大白话聊聊Innodb的锁机制
- 引言
- 理清 "锁" 类型
- 锁
- 锁的类型
- 非锁定读(MVCC)
- 锁定读
- 用来保护 "自增长计数器" 的锁
- 外键和锁
- 加锁算法
- 精确匹配查询
- 如何关闭Gap Lock
- 小结
- 幻读问题
- 小结
- 锁问题
- 脏读
- 不可重复读
- 丢失更新
- 阻塞
- 死锁
- 死锁发生概率
- 死锁案例
- 锁升级
- 小结
引言
本文想用大白话和大家来聊聊Innodb存储引擎的锁机制实现,主要参考Innodb技术内幕这本书,同时混合笔者个人理解,可能会存在一定偏差,如果发现了问题,欢迎各位在评论区指出,以防误导他人。
理清 “锁” 类型
DBMS中的锁通常分为两种类型: Lock 和 Latch
- lock 用来控制事务对数据库中的共享资源,如表,行,列的并发访问
- latch 用来限制线程对内存中共享数据结构,如页,缓冲池的并发访问
Lock | Latch | |
---|---|---|
对象 | 事务 | 线程 |
保护 | 数据库中的共享资源 | 内存中的共享数据结构 |
持续时间 | 整个事务过程 | 临界区 |
模式 | 行锁,表锁,意向锁 | 读写锁,互斥量 |
死锁 | 通过等待图,超时等机制进行死锁检测与处理 | 无死锁检测,通过应用程序按序加锁来确保无死锁情况发生 |
存在于 | Lock Manager的哈希表中 | 每个数据结构的对象中 |
锁
锁的类型
从锁的兼容性角度进行分类:
- 共享锁 (S Lock)
- 排它锁 (X Lock)
按照锁的粒度范围进行分类:
- 行锁
- 表锁
Innodb支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在,那么该如何实现多粒度锁定呢?
- 最简单的思路就是当我们需要加粗粒度锁,如给表加共享锁时,我们需要遍历表中所有行,判断是否存在某一行上加了排他锁,如果是,那么想要加表锁的线程需要阻塞等待对应行锁释放
那为什么给表上加读锁时,需要确保当前表下不存在行级排他锁呢?
- 线程2执行全表扫描,线程1对表中记录进行了修改,然后在线程2读取完修改后的数据后,线程1执行回滚操作,或者线程1多次修改,但是线程2读取到的是中间某次修改的数据,那么这种情况下就会出现脏读问题
如何避免通过遍历来判断当前表是否加了行锁呢?
- 每次成功给表中记录加上行锁时,都对应在当前表的header中简单记录一下,这样下次只需要查看表的header就知道当前表上是否存在行级锁,以及行级锁的类型,这种记录方式也被称为意向锁
因为Innodb不支持页级锁,所以Innodb的意向锁也只存在于表级别,根据表内所加行级锁的不同类型,意向锁分为以下两个类型:
- 意向共享锁(IS Lock)
- 意向排它锁(IX Lock)
意向锁本身是用于帮助快速判断是否能够获取指定类型的粗粒度锁的一种标识信息,避免通过全表扫描的方式来判断是否存在指定类型的细粒度锁。
意向锁并非原创,而是针对需要多粒度加锁场景下的一种成熟的解决方案。
上面重点介绍了一下意向锁的概念,下面我们来简单看看表级锁的兼容性问题:
表级共享锁(S) | 表级排他锁(X) | |
---|---|---|
意向共享锁(IS) | 兼容 | 互斥 |
意向排它锁(IX) | 互斥 | 互斥 |
这里简单举一个例子解释一下上面兼容性问题:
- 表级S锁和IS锁兼容意味着当我们对表上加共享锁时,其他事务还是可以正常获取表中记录的共享锁的
- 表级S锁和IX锁互斥意味着当我们对表上加共享锁时,其他事务不能正常获取表中记录的互斥锁
非锁定读(MVCC)
一致性非锁定读是指InnoDB通过读取行的快照数据实现读写操作并发执行。
对于一致性非锁定读而言,之所以称其为非锁定读,是因为其不需要等待访问行上的X锁释放。快照数据是当前行之前版本的数据,通过undo段实现,而undo段本身也用来在事务中回滚数据,因此读取快照数据本身是没有额外开销的。
快照数据其实就是当前行的历史版本,每行可能有多个历史版本,因此也称其为多版本并发控制(MVCC)。
在READ COMMITED和REPEATABLE READ隔离级别下,如果select查询语句中不主动添加上for update 或者 lock in share mode 告知innodb采用加锁读取,默认都是采用非锁定的一致性读。但是这两个隔离级别下对于快照数据的定义确不相同:
- 在Read Commited隔离级别下,快照数据总是在每次select查询前拍摄,即非一致性锁定读总是读取被锁定行最新的一份快照数据
- 在Repeatable Read隔离级别下,快照数据总是在事务开始时拍摄,即非一致性锁定读总是读取开始时行的数据版本
innodb在可重复读隔离级别下,快照数据是在第一次select时拍摄。
那么MVCC是如何根据版本链判断是否某条数据是否对当前事务可见的呢?
- innodb在每次拍摄快照时,都会为当前事务生成一个ReadView,该ReadView中会记录下以下信息
- 每条记录都会记录创建当前记录的事务id , 包括undo段中记录的历史版本记录
- MVCC工作在读提交和可重复读隔离级别下,因此其必须确保不能读到还未提交事务产生的修改记录,也就是ReadView中记录的活跃事务集合,同样也不能读取到快照拍摄后才开启的事务产生的修改记录,因此判断可见性的规则如下:
- 当前事务自己产生的修改对自己是可见的
- 小于下一个自增事务id , 并且不处于活跃事务id集合的事务产生的修改对当前事务是可见的
- 位于活跃事务id集合中的事务产生的修改对当前事务是不可见的
- 事务id大于等于下一个自增事务id,那么其产生的修改对当前事务是不可见的
锁定读
当事务隔离级别处于读提交和可重复读级别下时,Innodb的select操作默认使用非锁定读,但是某些情况下,我们必须显式要求数据库读取操作加锁以保证数据逻辑一致性。 因此数据库必须支持加锁语句,即使是SELECT只读操作,Innodb支持两种类型的锁定读:
- SELECT … FOR UPDATE
- SELECT … LOCK IN SHARE MODE
前者会在行记录上加上一个排他锁,后者会加上共享锁。
对于非锁定读而言,即使读取的行上加上了排他锁,其也是可以进行读取的,这一点大家不要混淆。
这里要注意Innodb采用的是2PL两阶段锁协议,也就是说分为两个阶段:
- growing 增长阶段: 事务按需获取自己需要的锁
- shringking 缩小阶段: 事务释放掉之前获取的锁,且该阶段不能获得新的锁,即一旦开始释放锁,之后就只能释放锁
在Innodb实现中,锁是在事务提交或者回滚时才会被释放。
用来保护 “自增长计数器” 的锁
在Innodb中对于每个含有自增长值的表来说,其都会对应一个自增长计数器,如果多个线程同时尝试插入记录,那么该计数器就会存在竞态,因此需要锁来确保自增过程的原子性,这种锁被称为AUTO-INC Locking 。
这里说的自增长锁属于互斥锁类型,因此在大批量并发插入的场景下,存在很大的性能问题 , 例如:
- 对于INSERT … SELECT 的大数据量插入会阻塞其他事务的插入操作
这里说的互斥锁属于睡眠锁实现,也就是说抢不到锁的时候,线程会被挂起等待,因此互斥锁最大的问题就是会产生大量上下文切换开销。
考虑到计数器自增的过程其实是一个非常短的过程,如果采用重量级的互斥锁实现,那么会产生大量的上下文切换开销,因此MySQL 5.1.22版本引入了一种轻量级互斥量的自增长实现机制,说人话就是采用CAS+自旋替代原有的互斥锁实现。
当然,并非所有场景都会使用CAS+自旋替代原有的互斥锁,Innodb通过innodb_autoinc_lock_mode来控制自增长模式,该参数默认值为1 , 下面来简单看看不同模式下自增长锁的实现,首先我们需要对自增长插入操作进行一下分类:
接着来分析参数innodb_autoinc_lock_mode以及各个设置下对自增的影响,其总共有三个有效值可供设定,即0、1、2,具体说明如下:
这里简单聊聊默认模式下的加锁抉择:
- 对于可以预先知道插入行数的插入操作而言,都采用CAS+自旋的方式实现,注意: 如果可以知道某次批量插入操作需要插入的行数,那么可以一次性申请n个自增长id ,然后事务自己后面再慢慢进行分配
- 对于无法提前获知插入行数的批量插入操作来说,采用互斥锁的方式实现,因为此过程可能会比较漫长,如果采用CAS+自旋可能会导致长时间的空自旋,浪费CPU资源
外键和锁
外键主要用于引用完整性的约束检查,在Innodb中,对于一个外键列而言,如果没有显式对这个类加索引,Innodb会自动加一个索引,因为这样可以避免加表锁。
对于外键值的插入或者更新,首先需要查询父表中的记录,即SELECT父表,但是此时的SELECT操作必须使用锁定读的方式,如果采用非一致性读取则可能会发生数据不一致的问题,因此这里使用的是SELECT … LOCK IN SHARE MODE方式,即主动对父表加一个S锁,如果父表上已经存在X锁了,那么子表的操作会被阻塞。
加锁算法
Innodb提供了3种行锁算法:
- Record Lock : 单个行记录上的锁
- Gap Lock: 间隙锁,锁定一个范围,但是不包含记录本身
- Next Key Lock: 锁定一个范围,并且锁定记录本身,等同于Gap Lock + Record Lock ,锁定区间左开右闭: ( ]
Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么Innodb会使用隐式的主键来锁定。
Gap Lock 和 Next Key Lock的提出是为了解决幻读问题。
精确匹配查询
什么是精确匹配查询,如下所示:
CREATE TABLE test(id INT,name VARCHAR,age INT,PRIMARY KEY(id),KEY(age));
select * from test where id=1 lock in share mode
以下讨论均基于锁定读方式,不要和非锁定读MVCC实现搞混了。
当精确查询唯一索引列时,Innodb会对Next-Key Lock进行优化,将其降级为Record Lock , 仅仅锁住索引本身 ,为什么可以这样做呢 ?
- 间隙锁本身是为了解决幻读问题的,幻读是指同一事务下,连续两次执行同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行
- 对于唯一键的精确查询而言,因为其唯一性确定了不可能存在两个相同id的记录,所以同一事务下,多次精确查询,不可能会返回多个id相同的记录
- 因此此时只需要锁住当前记录本身,防止其被修改或者删除即可
当精确查询非唯一的二级索引列时,情况则会不同:
select * from test where age=21 for update
假设我们在test表的age列上建立了非唯一的二级索引,那么此时SQL语句通过索引列age进行查询会使用Next-Key Locking技术加锁 ,并且由于有两个索引,其需要分别进行锁定:
- 对于按id顺序组织的聚簇索引来说,其只对列id为21的索引记录加上Record Lock
- 对于非唯一二级索引,其加上的是Next-Key Lock,锁定的范围是(18,21] ,如下图所示
- 需要特别注意的是,Innodb还会对辅助索引的下一个键值加上gap lock , 即还有一个(21,22)区间锁
为什么针对非唯一二级索引列的精确查询需要锁住当前记录本身的同时,还要使用gap lock锁住其前后两个区间呢?
- 因为非唯一二级索引列的值是允许重复的,因此在精确查询场景下,为了避免同一事务下多次查询返回之前不存在的行,需要使用Gap Lock锁住其前后区间,防止插入相同值的记录,这里就是防止插入age=21的记录
- 同样还需要锁住当前记录本身,防止其被修改或者删除
如何关闭Gap Lock
Gap Lock主要是用来避免插入导致的幻读问题的,我们可以将事务隔离级别设置为读已提交,从而关闭Gap Lock ,或者将innodb_locks_unsafe_for_binlog参数设置为1。
即便进行了综上调整,在外键约束和唯一性检查场景下依然需要Gap Lock,其余情况仅使用Record Lock进行锁定。
小结
在Innodb中,对于Insert操作,其会检查插入记录所在区间是否存在Next-Key Lock 或者 Gap Lock , 如果存在,当前插入操作阻塞等待。
但是这边大家需要注意,只有在锁定读场景下才会按情况添加间隙锁,在默认的非锁定读情况下,是不会加任何锁的。
还有一点就是,对于唯一键值的锁定,Next-Key Lock降级为Record Lock仅存在于查询所有的唯一索引列,如果唯一索引列由多个列组成,也就是联合索引的情况下,查询仅是查找多个唯一索引列中的一个,那么查询其实是Range类型查询,而非point类型查询,故Innodb依然使用Next-Key Lock进行锁定。
幻读问题
在默认的可重复读隔离级别下,Innodb采用Next-Key Lock机制来避免锁定读情况下的幻读问题,非锁定读采用MVCC实现,在可重复隔离级别下通过在事务开始时拍摄快照,其本身就可以避免幻读问题的发生。
幻读问题是指同一事务下,连续执行两次同样的SQL语句可能会导致不同的结果,第二次的SQL语句可能会返回之前不存在的行。
下面所讨论的幻读问题的解决均基于锁定读方式
以下图为例,简单看看插入操作导致的幻读问题:
- 由于非锁定读场景下使用MVCC读取历史旧版本,所以即便事务B提交了自己的修改,事务A再次查询时也会忽略最新版本,返回旧的版本,因此在可重复读隔离级别下,其本身就不存在幻读问题
- 而对于锁定读场景而言,因为其加了锁,所以每次都会去读取记录的最新版本,那么如果不加处理,自然可以读取到事务B最新的修改操作,要解决这个问题,那么就需要让事务B的插入操作阻塞等待,直到事务A提交释放锁
Innodb在锁定读场景下才有Next-Key Locking算法避免幻读问题,对于上面事务A的select查询语句来说,其锁住的不是5这单个值,而是对(2,+00)这个范围加了X锁,因此任何对于这个范围的插入操作都是不被允许的,从而就避免了幻读问题的产生。
注意这里是范围查询,不是精确查询了,范围查询更简单直接一个Gap Lock就可以了,如果是含等于号的情况,可以把等于号分开来,看做是一次精确查询。
Innodb存储引擎默认的事务隔离级别是可重复读,在该隔离级别下,锁定读采用Gap Lock 或者 Next-Key Locking的方式来加锁,而在读提交隔离级别下,仅会采用Record Lock 。
我们通常会使用锁定读的方式来读取记录的最新值而非旧版本数据,当然我们还可以在可重复读隔离级别下利用Innodb提交的Next-Key Locking机制在应用层面实现唯一性检查,例如:
0. BEGIN
1. SELECT * FROM test WHERE age=21 LOCK IN SHARE MODE;
2. 如果返回结果为空: INSERT INTO table VALUES(...);
3. COMMIT
用户通过索引查询一个值,并对该行加上一个S锁,那么即使查询的值不存在,其锁定的也是一个范围,因此若没有返回任何行,那么新插入的值一定是唯一的。
如果第一步同时存在多个事务并发操作,那么这种唯一性检查机制会导致死锁发生,只有一个事务的插入操作会成功,其余的事务会抛出死锁错误,因此这种唯一性检查机制再该场景下不会存在问题:
小结
innodb可以通过两种方式实现读已提交隔离级别和可重复读隔离级别,一种是非锁定读MVCC,另一种是锁定读取;对于锁定读取而言,针对不同的场景,其加锁算法也算不同的,具体如下图所示:
对于读已提交隔离级别而言加锁思路就是当前对当前查询直接匹配到的所有记录加X锁。
对于可重复读隔离级别而言加锁思路不仅是对查询匹配到的所有记录加X锁,还需要对每条记录之间的间隙都加上Gap Lock , 防止插入导致的幻读问题,当然唯一索引列的精确匹配情况可以优化一下,只保留Record Lock 。
上图的范围匹配针对的是不包含等于号的情况,即 > 而非 >= , 如果是 >= 的情况则等于精确匹配锁住的记录集合 和 范围匹配锁住的记录集合 求并集。
针对非索引的查询,由于需要全表扫描,读已提交隔离级别下会给表中每条记录都加上X锁,效率很低,因此Mysql做了一些优化:
- 在扫描过程中,若记录不满足过滤条件,会进行解锁操作。同时优化违背了2PL原则。
可重复读隔离级别下,Mysql针对上述情况同样进行了优化 ,即semi-consistent read
:
- 对于不满足条件的记录,MySQL提前释放锁,同时Gap锁也会释放。
- 而
semi-consistent read
是如何触发的呢:要么在Read Committed
隔离级别下;要么在Repeatable Read
隔离级别下,设置了innodb_locks_unsafe_for_binlog
参数。但是semi-consistent read
本身也会带来其他的问题,不建议使用。
当然,这边还有一个小优化就是查询语句中尽量使用limit语句来减少加锁范围:
- 之所以limit语句可以减少加锁范围,是因为mysql在查找过程中访问到的对象才会加锁,如果遍历过程中mysql发现记录数已经满足limit要求了,那么就会直接返回。
锁问题
我们可以通过锁或者MVCC机制实现事务的隔离性要求,使得事务可以并发工作。尽管锁提高了并发,但是却会带来一些潜在的问题,具体有以下四种情况:
- 脏读
- 不可重复读
- 丢失更新
脏读
脏读就是说在不同的事务下,当前事务可以读取到其他事务未提交的数据,但是一旦该事务回滚,那么先前读取到的数据就会变成脏数据。
读未提交隔离级别通常会应用在主从副本同步的场景中,通常会将slave节点的隔离级别设置为读未提交,因此从节点可以及时获取到主节点的数据变更,以保持数据的同步和一致性。
不可重复读
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。
不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库事务一致性的要求。
一般来说,不可重复读的问题是可以接受的,因为其读到的是已经提交的数据,本身并不会带来很大的问题。因此,很多数据库厂商(如Oracle、Microsoft SQL Server)将其数据库事务的默认隔离级别设置为READ COMMITTED,在这种隔离级别下允许不可重复读的现象。
Innodb在锁定读场景下使用Next-Key Lock算法避免不可重复的问题,Mysql官方文档中将不可重复读的问题定义为幻读,但是个人认为幻读算是不可重复读的一个子集。
在Next - Key Lock算法下,对于索引的扫描,不仅是锁住扫描到的索引记录本身,而且还会锁住这些索引覆盖的范围(gap) 。 因此在这个范围内的插入操作都是不允许的,这样就避免了其他事务在这个范围内插入数据导致的不可重复读问题。
因此,innodb默认的事务隔离级别是可重复读,采用Next-Key Lock算法,避免了不可重复的的现象。
丢失更新
丢失更新是并发场景下都会遇到的一个问题,因为修改过程通常都分三步走:
- 读取共享数据
- 线程在自己本地空间修改数据
- 将修改后的数据写回
在多线程情况下,可能会存在下面的情况:
在语言层面要解决丢失更新的问题,通常有以下一些思路:
- 乐观并发控制: 基于版本号或者时间戳判断数据是否被其他线程修改,如果检测到冲突,系统会通知用户重试。其中一种实现方式就是我们常说的CAS ( Compare And Set )
- 悲观并发控制:执行任何修改操作前都加上排它锁
- 借助消息队列将并发操作顺序化执行
在数据库层面的数据更新丢失场景如下所示:
在innodb数据库的任何隔离级别下,都不会导致数据库理论上的丢失更新问题,因为即使是读未提交隔离级别,对于行的DML操作,都需要对行或者其他粗粒度级别的对象加锁。因此在步骤2中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。
虽然数据库能够阻止丢失更新问题的发生,但是如果站在应用层面来看,还是可能会发生逻辑意义的丢失更新问题,例如:
由上图可知,由于线程2最后提交事务,所以最终记录r的值是V3 ,此时线程1的修改更新操作丢失了,在某些场景下这会发生非常恐怖的后果,比如银行转账场景下:
- 你使用两个客户端分别进行转账,客户端1转账十万块,同时客户端2转账1块,如果网络延迟较大,两个客户端同时读取到当前剩余余额二十万,然后客户端1先提交,客户端2后提交,最终客户端1的操作丢失,但是客户端1的确成功转出了十万块到另一个账户上,这样会导致世界上凭空多出十万块
发生上述问题的本质原因还是: 读 - 修改 - 写回 的操作流程不是原子性的,要解决这个问题,需要让事务在这种情况下的操作变成串行化,而不是并行的操作。
解决思路就如最开始所讲,有两种方式:
- 采用乐观并发控制,通过版本号或者时间戳机制配合重试
- 采用悲观并发控制,通过对读取的记录加排它锁,即在步骤 3 中,对用户读取的记录加上一个排他X锁。同样,在步骤 5 的操作过程中,用户同样也需要加一个排他X锁。通过这种方式,步骤 5 就必须等待一步骤14 和步骤 7 完成,最后完成步骤 6 和 8 。
如果不使用数据库层面提供的锁定读方式实现,还可以考虑在应用层采用分布式锁方案实现。
丢失更新是程序员最容易犯的错误,也是最不易发现的一个错误,因为这种现象只是随机的、零星出现的,不过其可能造成的后果却十分严重。
阻塞
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。阻塞并不是一件坏事,其是为了确保事务可以并发且正常地运行。
在InnoDB存储引擎中,参数innodb_lock_wait_timeout
用来控制等待的时间(默认是50秒),innodb_rollback_on_timeout
用来设定是否在等待超时时对进行中的事务进行回滚操作(默认是OFF,代表不回滚)。
- 参数innodb_lock_wait_timeout是动态的,可以在MySQL数据库运行时进行调整:
mysql> SET@@innodb_lock_wait_timeout=60;
Query OK,0 rows affected(0.00 sec)
- 而innodb_rollback_on_timeout是静态的,不可在启动时进行修改,如:
mysql> SET@@innodb_rollback_on_timeout=on;
ERROR 1238(HY000):Variable'innodb_rollback_on_timeout'is a read only variable
当发生超时,MySQL数据库会抛出一个1205的错误,如:
mysql> BEGIN;
Query OK,0 rows affected(0.00 sec)
mysql> SELECT * FROM t WHERE a=1 FOR UPDATE;
ERROR 1205(HY000):Lock wait timeout exceeded;try restarting transaction
但是在默认情况下,Innodb不会回滚超时引发的错误异常,InnoDB在大部分情况下都不会对异常进行回滚。
这里简单举个例子:
- 会话1 : 开启事务并使用锁定读来锁定(-00, 4] 范围内的所有记录,但是一直不提交事务
mysql> BEGIN;
mysql> SELECT*FROM t WHERE a<4 FOR UPDATE;
结果: 1,2
- 会话2: 开启事务并依次尝试插入记录5和记录3,此时记录5时可以直接插入的,但是记录3由于Next-Key Lock算法关系,需要等待会话A中事务释放锁,但是会话A一直不提交事务,所以等待后产生超时。但是超时后再次Select后会发现,5这个记录依然存在:
#会话B
mysql> BEGIN;
mysql> INSERT INTO t SELECT 5;
mysql> INSERT INTO t SELECT 3;
ERROR 1205(HY000):Lock wait timeout exceeded;try restarting transaction
mysql>SELECT*FROM t;
结果: 1,2,5
这是因为这时会话B中的事务虽然抛出了异常,但是既没有进行COMMIT操作,也没有进行ROLLBACK。而这是十分危险的状态,因此用户必须判断是否需要COMMIT还是ROLLBACK,之后再进行下一步的操作。
侧面也说明了mysql中抛出超时异常错误并不会导致当前事务结束
死锁
产生死锁必须满足以下四个条件:
- 互斥
- 占有并等待
- 不可抢占
- 循环等待
解决死锁通常有以下几个思路:
- 死锁预防 – 破坏死锁出现的相关必要条件
- 一次性申请完所有资源 ,打破占有并等待条件
- 锁超时释放 ,打破不可抢占这个必要条件
- 给资源进行排序,资源按序申请,打破循环等待这个条件
- 死锁避免 – 检测每个资源请求,如果会造成死锁就拒绝
- 银行家算法
- 死锁检测+恢复 – 检测到死锁出现时,让一些请求回滚,让出资源
- 等待图
- 死锁忽略 – 比如操作系统如果因为死锁出现而死机,那么就重启一下呗
在数据库中死锁通常指的是两个或者两个以上的事务在执行过程中因为争夺资源而造成的一种互相等待的现象。在数据库层面解决死锁的思路通常有:
- 超时回滚: 如果某个事务等待锁的时间超过了指定阈值(
innodb_lock_wait_timeout
),当前事务进行回滚- 超时机制虽然简单,但是通过超时回滚的方式来处理,可能会让某些权重较大的事务回滚,如事务操作更新了很多行,占用了较多的undo log , 此时回滚该事务可能会比回滚另一个事务花费几倍长的时间
- 等待图: 相比于超时回滚,等待图是一种更为主动的死锁检测方式,innodb也采用这种方式
- 等待图中通常会保存一下几类信息
- 锁的信息链表
- 事务等待链表
- 等待图中通常会保存一下几类信息
在等待图算法中,通过上述链表可以构造一张图,如果该图存在回路,就代表存在死锁 , 在wait-for graph中,事务为图中的节点。而在图中,事务T1指向T2边的定义为:
- 事务T1等待事务T2所占用的资源
- 事务T1最终等待T2所占用的资源,也就是事务之间在等待相同的资源,而事务T1发生在事务T2的后面
如下图中这个例子所示:
等待图算法一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,如果存在说明有死锁,通过来说Innodb会选择回滚undo量最小的事务。
等待图算法通常采用DFS实现,Innodb 1.2版本之前都是采用递归方式实现,从1.2版本开始对该算法做了优化,采用非递归方式实现。
死锁发生概率
死锁发生的概率应该是很小的,这里就不展示数学推导验证过程了,感兴趣的可以去看原书或者帆船书上面的推导证明过程,这里简单介绍一下死锁的概率与哪些因素有关:
- 系统中事务数量,数量越多发生死锁概率越大
- 每个事务操作的数量越多,发生死锁概率越大
- 操作数据的集合越小,发生死锁概率越大
死锁案例
此处先展示最经典的死锁名场面,即A等待B,B等待A,这种问题也被称为AB-BD死锁:
a是主键列
在上述操作中,会话B中的事务抛出了1213这个错误提示,即表示事务发生了死锁。死锁的原因是会话A和B的资源在互相等待。大多数的死锁InnoDB存储引擎本身可以侦测到,不需要人为进行干预。
在上面的例子中,会话B中事务抛出死锁异常后,会话A中马上得到了记录为2的这个资源,这是因为会话B中的事务发生了回滚,否则会话A中的事务不可能得到该资源。innodb存储引擎不会回滚大部分的错误异常,但是死锁除外,发现死锁后,innodb会马上回滚一个事务。所以如果我们在应用程序中捕获了1213这个错误,是不需要对其进行回滚的。
Oracle数据库中产生死锁的常见原因是没有对外键添加索引,而InnoDB存储引擎会自动对其进行添加,因而能够很好地避免了这种情况的发生。而人为删除外键上的索引,MySQL数据库会抛出一个异常:
ERROR 1553(HY000):Cannot drop index'b':needed in a foreign key constraint
还有一类死锁现象,即当前事务持有了待插入记录的下一个记录的X锁,但是等待队列中存在一个S锁的请求,则会发生死锁:
a是主键列
会话A中已经持有了记录4的X锁,但是会话A中插入记录3会导致死锁发生,这是因为会话B中请记录4的锁而发生等待。但是由于会话B之前请求的锁对于主键值记录1、2都已经成功,若在事件点5能插入记录,那么会话B在获得记录4持有的S锁后,还需要向后获得记录3的记录,这样就显得有点不合理。因此InnoDB存储引擎在这里主动选择了死锁,而回滚的是undo log记录大的事务,这与AB-BA死锁的处理方式又有所不同。
锁升级
锁升级在数据库中指的是将当前锁的粒度降低,例如: 将表的1000个行锁升级为页锁,或者将页锁升级为表锁。
Java的读写锁中也存在锁升级和锁降级的概念,但是和这里所指的含义不太一样。
那么为什么要进行锁升级呢?
- 锁本身是一种稀有资源,如果对每行加锁都需要创建一把锁的话,就会产生大量的锁对象,占用大量内存,因此数据库中才会频繁出现锁升级现象
Innodb本身不存在锁升级问题,因为其不是根据每个记录来产生行锁的,相反,其根据每个事物访问的每个页对锁进行的管理,采用的是位图的方式,实现如下:
因此不管一个事务锁住页中一条还是多条记录,其开销基本没啥差别。
这边有两个问题大家可以思考一下: 事务期间可能会对多个页面进行加锁,那么意味着会创建多个锁结构,那么这多个锁结构应该采用什么数据结构组织起来比较好呢?如果想要快速查询某个page是否被加了锁,以及被哪些事务加了锁,那么我们又该如何组织上面的锁结构呢?
- 这部分内容我还没有进行过研究,我先给出我自己的一个猜测,第一个问题比较简单,大概率是组织为链表结构,而第二个问题应该会为Buffer Pool中每个Page关联一个锁队列,队列采用链表实现,其中节点类型为上面展示的锁结构
假设一张表有3 000 000个数据页,每个页大约有100条记录,那么总共有300 000 000条记录。若有一个事务执行全表更新的SQL语句,则需要对所有记录加X锁。若根据每行记录产生锁对象进行加锁,并且每个锁占用10字节,则仅对锁管理就需要差不多需要3GB的内存。而InnoDB存储引擎根据页进行加锁,并采用位图方式,假设每个页存储的锁信息占用30个字节,则锁对象仅需90MB的内存。由此可见两者对于锁资源开销的差距之大。
小结
本文依据Innodb技术内幕这本书简单聊了聊Innodb的锁实现机制,由于笔者目前还没开始研究Mysql源码实现,所以部分理解未必完全正确,当然数据库设计思想都是想通的,因此大家也可以和其他数据库的并发实现相互对比学习,推荐阅读一下著名的帆船书 和 CMU 15-445 的数据库基础课程。