本文想用大白话和大家来聊聊Innodb存储引擎的锁机制实现,主要参考Innodb技术内幕这本书,同时混合笔者个人理解,可能会存在一定偏差,如果发现了问题,欢迎各位在评论区指出,以防误导他人。
DBMS中的锁通常分为两种类型: Lock 和 Latch
Lock | Latch | |
---|---|---|
对象 | 事务 | 线程 |
保护 | 数据库中的共享资源 | 内存中的共享数据结构 |
持续时间 | 整个事务过程 | 临界区 |
模式 | 行锁,表锁,意向锁 | 读写锁,互斥量 |
死锁 | 通过等待图,超时等机制进行死锁检测与处理 | 无死锁检测,通过应用程序按序加锁来确保无死锁情况发生 |
存在于 | Lock Manager的哈希表中 | 每个数据结构的对象中 |
从锁的兼容性角度进行分类:
按照锁的粒度范围进行分类:
Innodb支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在,那么该如何实现多粒度锁定呢?
那为什么给表上加读锁时,需要确保当前表下不存在行级排他锁呢?
如何避免通过遍历来判断当前表是否加了行锁呢?
因为Innodb不支持页级锁,所以Innodb的意向锁也只存在于表级别,根据表内所加行级锁的不同类型,意向锁分为以下两个类型:
意向锁本身是用于帮助快速判断是否能够获取指定类型的粗粒度锁的一种标识信息,避免通过全表扫描的方式来判断是否存在指定类型的细粒度锁。
意向锁并非原创,而是针对需要多粒度加锁场景下的一种成熟的解决方案。
上面重点介绍了一下意向锁的概念,下面我们来简单看看表级锁的兼容性问题:
表级共享锁(S) | 表级排他锁(X) | |
---|---|---|
意向共享锁(IS) | 兼容 | 互斥 |
意向排它锁(IX) | 互斥 | 互斥 |
这里简单举一个例子解释一下上面兼容性问题:
一致性非锁定读是指InnoDB通过读取行的快照数据实现读写操作并发执行。
对于一致性非锁定读而言,之所以称其为非锁定读,是因为其不需要等待访问行上的X锁释放。快照数据是当前行之前版本的数据,通过undo段实现,而undo段本身也用来在事务中回滚数据,因此读取快照数据本身是没有额外开销的。
快照数据其实就是当前行的历史版本,每行可能有多个历史版本,因此也称其为多版本并发控制(MVCC)。
在READ COMMITED和REPEATABLE READ隔离级别下,如果select查询语句中不主动添加上for update 或者 lock in share mode 告知innodb采用加锁读取,默认都是采用非锁定的一致性读。但是这两个隔离级别下对于快照数据的定义确不相同:
innodb在可重复读隔离级别下,快照数据是在第一次select时拍摄。
那么MVCC是如何根据版本链判断是否某条数据是否对当前事务可见的呢?
当事务隔离级别处于读提交和可重复读级别下时,Innodb的select操作默认使用非锁定读,但是某些情况下,我们必须显式要求数据库读取操作加锁以保证数据逻辑一致性。 因此数据库必须支持加锁语句,即使是SELECT只读操作,Innodb支持两种类型的锁定读:
前者会在行记录上加上一个排他锁,后者会加上共享锁。
对于非锁定读而言,即使读取的行上加上了排他锁,其也是可以进行读取的,这一点大家不要混淆。
这里要注意Innodb采用的是2PL两阶段锁协议,也就是说分为两个阶段:
在Innodb实现中,锁是在事务提交或者回滚时才会被释放。
在Innodb中对于每个含有自增长值的表来说,其都会对应一个自增长计数器,如果多个线程同时尝试插入记录,那么该计数器就会存在竞态,因此需要锁来确保自增过程的原子性,这种锁被称为AUTO-INC Locking 。
这里说的自增长锁属于互斥锁类型,因此在大批量并发插入的场景下,存在很大的性能问题 , 例如:
这里说的互斥锁属于睡眠锁实现,也就是说抢不到锁的时候,线程会被挂起等待,因此互斥锁最大的问题就是会产生大量上下文切换开销。
考虑到计数器自增的过程其实是一个非常短的过程,如果采用重量级的互斥锁实现,那么会产生大量的上下文切换开销,因此MySQL 5.1.22版本引入了一种轻量级互斥量的自增长实现机制,说人话就是采用CAS+自旋替代原有的互斥锁实现。
当然,并非所有场景都会使用CAS+自旋替代原有的互斥锁,Innodb通过innodb_autoinc_lock_mode来控制自增长模式,该参数默认值为1 , 下面来简单看看不同模式下自增长锁的实现,首先我们需要对自增长插入操作进行一下分类:
接着来分析参数innodb_autoinc_lock_mode以及各个设置下对自增的影响,其总共有三个有效值可供设定,即0、1、2,具体说明如下:
这里简单聊聊默认模式下的加锁抉择:
外键主要用于引用完整性的约束检查,在Innodb中,对于一个外键列而言,如果没有显式对这个类加索引,Innodb会自动加一个索引,因为这样可以避免加表锁。
对于外键值的插入或者更新,首先需要查询父表中的记录,即SELECT父表,但是此时的SELECT操作必须使用锁定读的方式,如果采用非一致性读取则可能会发生数据不一致的问题,因此这里使用的是SELECT … LOCK IN SHARE MODE方式,即主动对父表加一个S锁,如果父表上已经存在X锁了,那么子表的操作会被阻塞。
Innodb提供了3种行锁算法:
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 , 仅仅锁住索引本身 ,为什么可以这样做呢 ?
当精确查询非唯一的二级索引列时,情况则会不同:
select * from test where age=21 for update
假设我们在test表的age列上建立了非唯一的二级索引,那么此时SQL语句通过索引列age进行查询会使用Next-Key Locking技术加锁 ,并且由于有两个索引,其需要分别进行锁定:
为什么针对非唯一二级索引列的精确查询需要锁住当前记录本身的同时,还要使用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语句可能会返回之前不存在的行。
下面所讨论的幻读问题的解决均基于锁定读方式
以下图为例,简单看看插入操作导致的幻读问题:
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做了一些优化:
可重复读隔离级别下,Mysql针对上述情况同样进行了优化 ,即semi-consistent read
:
semi-consistent read
是如何触发的呢:要么在Read Committed
隔离级别下;要么在Repeatable Read
隔离级别下,设置了innodb_locks_unsafe_for_binlog
参数。但是semi-consistent read
本身也会带来其他的问题,不建议使用。当然,这边还有一个小优化就是查询语句中尽量使用limit语句来减少加锁范围:
我们可以通过锁或者MVCC机制实现事务的隔离性要求,使得事务可以并发工作。尽管锁提高了并发,但是却会带来一些潜在的问题,具体有以下四种情况:
脏读就是说在不同的事务下,当前事务可以读取到其他事务未提交的数据,但是一旦该事务回滚,那么先前读取到的数据就会变成脏数据。
读未提交隔离级别通常会应用在主从副本同步的场景中,通常会将slave节点的隔离级别设置为读未提交,因此从节点可以及时获取到主节点的数据变更,以保持数据的同步和一致性。
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些DML操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的情况,这种情况称为不可重复读。
不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了数据库事务一致性的要求。
一般来说,不可重复读的问题是可以接受的,因为其读到的是已经提交的数据,本身并不会带来很大的问题。因此,很多数据库厂商(如Oracle、Microsoft SQL Server)将其数据库事务的默认隔离级别设置为READ COMMITTED,在这种隔离级别下允许不可重复读的现象。
Innodb在锁定读场景下使用Next-Key Lock算法避免不可重复的问题,Mysql官方文档中将不可重复读的问题定义为幻读,但是个人认为幻读算是不可重复读的一个子集。
在Next - Key Lock算法下,对于索引的扫描,不仅是锁住扫描到的索引记录本身,而且还会锁住这些索引覆盖的范围(gap) 。 因此在这个范围内的插入操作都是不允许的,这样就避免了其他事务在这个范围内插入数据导致的不可重复读问题。
因此,innodb默认的事务隔离级别是可重复读,采用Next-Key Lock算法,避免了不可重复的的现象。
丢失更新是并发场景下都会遇到的一个问题,因为修改过程通常都分三步走:
在多线程情况下,可能会存在下面的情况:
在语言层面要解决丢失更新的问题,通常有以下一些思路:
在数据库层面的数据更新丢失场景如下所示:
在innodb数据库的任何隔离级别下,都不会导致数据库理论上的丢失更新问题,因为即使是读未提交隔离级别,对于行的DML操作,都需要对行或者其他粗粒度级别的对象加锁。因此在步骤2中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。
虽然数据库能够阻止丢失更新问题的发生,但是如果站在应用层面来看,还是可能会发生逻辑意义的丢失更新问题,例如:
由上图可知,由于线程2最后提交事务,所以最终记录r的值是V3 ,此时线程1的修改更新操作丢失了,在某些场景下这会发生非常恐怖的后果,比如银行转账场景下:
发生上述问题的本质原因还是: 读 - 修改 - 写回 的操作流程不是原子性的,要解决这个问题,需要让事务在这种情况下的操作变成串行化,而不是并行的操作。
解决思路就如最开始所讲,有两种方式:
如果不使用数据库层面提供的锁定读方式实现,还可以考虑在应用层采用分布式锁方案实现。
丢失更新是程序员最容易犯的错误,也是最不易发现的一个错误,因为这种现象只是随机的、零星出现的,不过其可能造成的后果却十分严重。
因为不同锁之间的兼容性关系,在有些时刻一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。阻塞并不是一件坏事,其是为了确保事务可以并发且正常地运行。
在InnoDB存储引擎中,参数innodb_lock_wait_timeout
用来控制等待的时间(默认是50秒),innodb_rollback_on_timeout
用来设定是否在等待超时时对进行中的事务进行回滚操作(默认是OFF,代表不回滚)。
mysql> SET@@innodb_lock_wait_timeout=60;
Query OK,0 rows affected(0.00 sec)
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在大部分情况下都不会对异常进行回滚。
这里简单举个例子:
mysql> BEGIN;
mysql> SELECT*FROM t WHERE a<4 FOR UPDATE;
结果: 1,2
#会话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
),当前事务进行回滚 在等待图算法中,通过上述链表可以构造一张图,如果该图存在回路,就代表存在死锁 , 在wait-for graph中,事务为图中的节点。而在图中,事务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是否被加了锁,以及被哪些事务加了锁,那么我们又该如何组织上面的锁结构呢?
假设一张表有3 000 000个数据页,每个页大约有100条记录,那么总共有300 000 000条记录。若有一个事务执行全表更新的SQL语句,则需要对所有记录加X锁。若根据每行记录产生锁对象进行加锁,并且每个锁占用10字节,则仅对锁管理就需要差不多需要3GB的内存。而InnoDB存储引擎根据页进行加锁,并采用位图方式,假设每个页存储的锁信息占用30个字节,则锁对象仅需90MB的内存。由此可见两者对于锁资源开销的差距之大。
本文依据Innodb技术内幕这本书简单聊了聊Innodb的锁实现机制,由于笔者目前还没开始研究Mysql源码实现,所以部分理解未必完全正确,当然数据库设计思想都是想通的,因此大家也可以和其他数据库的并发实现相互对比学习,推荐阅读一下著名的帆船书 和 CMU 15-445 的数据库基础课程。