前言
年前,线上某个业务模块报了一个死锁问题。详细解析点击这里,为了定位相关问题。看了《MySQL技术内幕–InnoDB存储引擎》一书。这里记录一下学习的过程,方便以后回过头来看。
这一篇幅讲的是锁。
目录
1、锁概念及innodb中的锁
3、innodb中锁的相关算法
4、锁问题
5、死锁及解决方法
锁概念及innodb中的锁
锁是用于管理对共享资源(这里的共享资源不仅仅是行记录)的并发访问。lock和latch都被成为锁。但是我们常说的锁,指的是lock。以下是两者的区别:
lock | latch | |
---|---|---|
对象 | 事务 | 线程 |
保护 | 数据库内容 | 内存数据结构 |
持续时间 | 整个事务过程 | 临界资源 |
模式 | 行锁、表锁、意向锁 | 读写锁、互斥锁 |
死锁 | 通过waits-for graph、time out等机制 进行死锁检测 |
无死锁检测机制。仅通过应用程序加锁的顺序(lock leveling) 保证无死锁的情况发生 |
存在于 | Lock Manager的哈希表中 | 每个数据结构的对象中 |
从上面表格中可以看出,数据库中的锁概念和我们日常开发中的说的锁是有点区别的。开发中说的锁更像是数据库中的latch。这个需要分清。
在innodb中有如下两个标准行级锁:
- 共享锁(S Lock),允许事务读一行数据。
- 排他锁(X Lock),允许事务删除或更新一行数据。
以下表格是排他锁和共享锁的兼容性:
X | S | |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
可以发现,X锁与任何的锁都不兼容,而S锁仅与S锁兼容。需要注意的是S锁和X锁都是行锁,兼容是指对同一记录锁的兼容性情况。
- 意向锁:将锁定的对象分为多个层次。表级别锁。主要是为了在一个事务中揭示下一行将被请求的锁类型。
意向锁有可以分为一下两种意向锁:
- 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁
- 意向排他锁(IX Lock),事务想要获得一张表中某几行的排他锁
由于InnoDB存储引擎支持行级别的锁,因此意向锁其实不会阻塞出全表扫以外的任务请求。表级意向锁和行级锁兼容性如下:
IS | IX | S | X | |
---|---|---|---|---|
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
innodb 中的锁主要涉及INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS这三个表。在这个三个表中,innodb记录了每一个锁,可以分析这三个表的参数监控并分析锁问题。现在来看一下这三个表的结构。
- 表INNODB_TRX结构说明
字段名 | 说明 |
---|---|
trx_id | innodb存储引擎内部唯一的事务ID |
trx_state | 当前事务的状态 |
trx_started | 事务的开始时间 |
trx_requested_lock_id | 等待事务的锁ID.如trx_state的状态为LOCK WAIT,那么该值代表当前的事务等待之前事务占用锁资源的ID.若trx_state不是LOCK WAIT,则该值为NULL |
trx_wait_started | 事务等待开始的时间 |
trx_weight | 事务的权重,反映了一个事务修改和锁住的行数。在innodb存储引擎中,当发生死锁需要回滚是,innodb存储引擎会选择该值最小的进行回滚 |
trx_mysql_thread_id | mysql中的线程ID,SHOW PROCESSLIST显示的结果 |
trx_query | 事务运行的sql语句 |
- 表INNODB_LOCKS结构说明
字段名 | 说明 |
---|---|
lock_id | 锁的ID |
lock_trx_id | 事务ID |
lock_mode | 锁的模式 |
lock_type | 锁的类型,表锁还是行锁 |
lock_table | 要加锁的表 |
lock_index | 锁住的索引 |
lock_space | 锁对象的space id |
lock_page | 事务锁定页的数量。若是表锁,则该值为NULL |
lock_rec | 事务锁定行的数量。若是表锁,则该值为NULL |
lock_data | 事务锁定记录的主键值,若是表锁,则该值为NULL |
- 表INNOB_LOCK_WAITS结构说明
字段名 | 说明 |
---|---|
requesting_trx_id | 申请锁资源的事务ID |
requesting_lock_id | 申请的锁的ID |
blocking_trx_id | 阻塞的事务ID |
blocking_lock_id | 阻塞的锁的ID |
- 一致性非锁定读
一致性的非锁定读(consistent nonlocking read)是指innodb存储引擎通过多版本控制的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行行DELETE或UPDATE操作,这时读取操作不会因此去等待行锁的释放。相反地,innodb存储引擎会去读取行的一个快照数据。
以上是innodb非锁定的一致性读的简易流程图,当innodb对某一行数据加了X锁,这时候如果有某一查询需要读取数据,这时候innodb并不会去读取已经上锁的数据,而是读取历史的快照数据,快照本身是没有占用内存开销的。读取本身也不会进行加锁,因为读取操作并不会对历史数据进行修改。
可以看到,这种机制可以很大提高数据库的并发性。innodb默认读取方式就是采用了这种机制,在进行读操作的时候并不会去加锁或者等待当前的锁释放。值得注意的是,并不是每个事务的隔离级别下都是采用非锁定的一致性读。即使都是使用非锁定的一致性读,快照的定义也是不一样的。
innodb中,READ COMMITTED 和 REPEATABLE READ(innodb存储引擎的默认事务隔离级别)下都是使用非锁定的一致性读。但是这两种隔离级别对于快照的定义也不一样。READ COMMITTED下,总是读取被锁定行的最新一份快照数据。REPEATABLE READ总是读取事务开始时的行数据版本。
- 一致性锁定读
一致性锁定读是指在读取数据的时候加上锁保证数据逻辑的一致性。innodb提供了以下的一致性的锁定读操作:
select ... for update
select lock in share mode
复制代码
select … for update 是对读取的行记录加一个X锁,其他事务不能对已锁定的行加任何锁。select … lock in share mode对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是加X锁则是被阻塞。
- 自增长锁
在开发过程中,经常会指定某一个字段AUTO_INCREMENT,在innodb中,对每个含有自增长的表都有一个自增长计数器。当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化,并且执行一下语句来得到计数器的值:
select max(auto_inc_col) from t for update;
复制代码
这个操作会对表加上一个锁,但是这个锁机制和其他锁不一样,这个锁不是等待整个事务完成之后释放,而是在完成对自增长值插入的sql语句后立即释放。所以对于并发的插入,还是会有一些性能问题,因为在插入过程中,事务需要等待前一个插入完成才能执行下一个插入操作。
在MySQL 5.1.22版本开始,innodb提供了一个互斥量实现自增长。在innodb中提供了一个参数innodb_autoinc_lock_mode来控制自增长模式,参数默认值是1。不同的值,会根据插入类型进行判断需要加什么类型的锁。对于插入语句的分类和innodb_autoinc_lock_mode参数的说明可以参考官方介绍。
innodb中锁的相关算法
innodb中有3种行锁算法:
- Record Lock: 单个行记录上的锁
- Gap Lock: 间隙锁,锁定一个范围,但不包含记录本身
- Next-Key Lock: Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身
Gap Lock的作用是为了阻止多个事务将记录插入到同一个范围内,而这会导致Phantom Problem问题的产生。
可以使用以下方式来关闭Gap Lock:
- 将事务的隔离级别设置为READ COMMITTED
- 将参数innodb_locks_unsafe_for_binlog设置为1
在上述配置下,除了外键约束和唯一性检查依然需要的Gap Lock,其余情况仅使用Record Lock进行锁定。但是上面的设置破坏了事务的隔离性,并且对于replication,可能会导致主从数据的不一致。因为innodb默认事务隔离级别是REPEATABLE READ,在该隔离级别下,采用的是Next-Key Locking,在READ COMMITTED下,其金财涌Record Lock。
Record Lock总是会去锁住索引记录,如果innodb在建立的时候没有设置任何一个索引,那么这时innodb存储引擎会使用隐式的主键来进行锁定。
Next-Key Lock是结合了Gap Lock和Record Lock的一种锁定算法,在Next-Key Lock算法下,innodb对于行的查询都是采用这种锁定算法,使用这种锁定方式是为了解决Phantom Problem。Phantom Problem是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。例如一个索引有10,11,13和20这四个值,那么锁住的区间可能为:
(-, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +)
需要注意的是,当查询中含有唯一属性是,innodb会对Next-Key Lock进行优化,将其降级为Record Lock, 即仅锁住索引本身,而不是范围。值得注意的是,Next-Key Lock并不适合REPEATABLE READ或者SERIALZABLE的事务类型,因为innodb无法判断下一下key是哪一个。
锁问题
- 脏读
脏数据是指事务对缓冲池中行记录的修改,并且还没有被提交。这和脏页是两种不同的概念。脏页指的是在缓冲池中已经被修改的页,但是还没有刷新到磁盘中,即数据库实例内存中的页和磁盘中的页的数据是不一致的,当然在刷新到磁盘之前,日志都已经被写入到了重做日志文件中。
脏页是以内数据库实例内存和磁盘的异步造成的,这并不影响数据的一致性(或者说两者最终会达到一致性)。脏数据却不一样,脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另一个事务中未提交的数据,这就违背了数据库的隔离性。
脏读发生的条件是需要事务的隔离级别为READ UNCOMMITTED,而innodb默认隔离事务级别为READ REPEATABLE。
- 不可重复读(Phantom Problem)
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束是,另外一个事务也访问改同一数据集合,并做了一些DML操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。
不可重复读和脏读的区别是:脏读是读到为提交的数据,而不可重复读读到的确实已经提交的数据,但是其违反了数据库一致性的要求。
一般来说,不可重复读的问题时可以接受的,因为其读到的是已经提交的数据,本身不回带来很大的问题。在innodb中,通过使用Next-Key Lock算法来避免不可重复读的问题。在Next-Key Lock算法下,对于索引扫描,不进锁住扫描到的索引,而且还锁住这些索引覆盖的范围。因此在这个范围内插入都是不允许的。这样就避免了另外的事务在这个范围内插入数据导致的不可重复读的问题。因此,innodb默认事务隔离界别是READ REPEATABLE,采用Next-Key Lock算法,避免了不可重复读的现象。
- 丢失更新
丢失更新是另一个锁导致的问题,简单来说就是一个事务的更新操作会被另一个事务的更新操作锁覆盖,从而导致数据的不一致。例如:
- 事务T1将行记录r更新为v1,但是事务T1并未提交。
- 与此同时,事务T2将行记录r更新为v2,事务T2为提交。
- 事务T1提交。
- 事务T2提交。
当前,任何隔离级别都可以阻止此类问题的产生。这是因为即使在READ UNCOMMITTED的事务隔离级别下,对于行的DML操作,也需要对行货其他粗粒级别的对象加锁。所以,在上述说的例子中,T2是无法执行更新操作的,其会被阻塞,直到事务T1提交。
出现这个这种问题的主要是生产应用中。看一下一个例子:
a) 事务T1查询一行数据,放入本地内存,并显示给一个终端用户User1。
b) 事务T2也查询该行数据,并将取得的数据显示给终端用户User2。
c) User1修改这行记录,更新数据库并提交。
d) User2修改这行记录,更新数据库并提交。
要避免这种情况的发生,需要让事务在这种情况下的操作变为串行,而不是并行。即在步骤a)中,对用户读取的记录加上一个X锁。同样在步骤b)中,也加一个X锁。通过这种方式,步骤b)就必须等待步骤c)完成,才能进行步骤d)。
死锁及解决方法
死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。解决死锁问题最简单的方式是不要有等待,将任何等待都转化为回滚,并且事务重新开始。但是这对于生产环境却是灾难性的,因为这样会导致并发性能下降,甚至会造成一个事务都无法进行。
另一种解决死锁的方法是超时机制。即两个事务互相等待是,当一个等待时间超过设置的某一个阈值是,其中一个事务进行回滚,另一个等待的事务就能继续进行。innodb中是通过innodb_lock_wait来设置超时时间的。
超时机制虽然简单,但是其通过超时后对事务进行回滚的方式来处理,或者说其是根据FIFO的顺序选择回滚对象。但如超时的事务所占权重比较大,如事务操作更新了很多行,占用了较多的undo log,这时采用FIFO的方式,就显得不合适了,因为回滚这个事务的时间相对另一个事务占用的时间可能会很多。
因此,除了上述方法,数据库还会通过wait-for graph(等待图)的方式来进行死锁检测。innodb也是采用这种方式。wait-for graph要求数据库保存一下两种信息:
- 锁的信息链表
- 事务等待链表
然后构造出一张图,若这个图存在回路,就代表存在死锁,因为资源存在相互等待。在wait-for graph中,事务为图中的节点。而在图中,事务T1指向T2边的定义为:
- 事务T1等待事务T2所占用的资源
- 事务T1最终等待T2所占用的资源,也就是事务之间在等待相同的资源,而事务T1发生在事务T2的后面
在示例中有t1、t2、t3、t4四个事务,所以在wait-for graph中有4个节点。而事务t2对row1占用x锁,事务t1对row2占用s锁。事务t1需要等待事务t2中row1的资源,因此在wait-for graph中有条边从节点t1指向t2。事务t2需要等待事务t1、t4锁占用的row2对象,故而存在节点t2到节点t1、t4的边。同样,存在节点t3到t1、t2、t4的边,因此最终的wait-for graph如下所示:
通过wait-for graph发现存在回路(t1, t2),因此存在死锁。通过介绍可以发现wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说innodb存储引擎选择回滚undo量最小的事务。
总结
本文主要记录了在阅读《Mysql技术内幕–InnoDB存储引擎》中的一些理论知识,整体看下来可能会很无趣,很乏味。但是理解这些东西,可以帮助我们在开发过程中,避开一些坑。
目前为止,看这本书锁这一篇幅,陆陆续续看了两遍了。但是对于一些东西还是很模糊,还没有理清整个思路。后续,会相继更新一些实验,并且会结合实际开过程中遇到的问题来说明此类问题,这样可以加强记忆。
最后,对于死锁的案例可以看一下这个仓库
参考
《MySQL 技术内幕-InnoDB 存储引擎》第二版,作者:姜承尧