MySQL中的事务原理和锁机制
本文主要总结 MySQL 事务几种隔离级别的实现和其中锁的使用情况。因为 Mysql 几种存储引擎中 InnoDB 使用的最多,同时也支持事务和锁,所以这篇主要说得是 InnoDB 引擎下的锁机制与事务。
在开始前先简单回顾事务几种隔离级别以及带来的问题。
四种隔离级别:读未提交、读已提交、可重复读、可串行化。
带来的问题:脏读、不可重复读、幻读。分别是由读未提交、读已提交、可重复读引起的。
脏读:一个事务读取到在另一个事务还未提交时的修改。
不可重复读:一个事务在另一个事务提交前后读取到了不同数据。
幻读:一个事务在另一个事务提交前后察觉到了不同数据。(侧重于多了或是少了一条数据)。
在 Mysql 中,默认隔离级别是可重复读,在默认时却一定程度上解决了幻读,为什么这么说呢?请看下面这个例子。
同时我们查看数据库中的数据:
可以看到并没有发生 “幻读”,这是为什么?难道可重复读级别已经解决了“幻读”?后面会详细解释。
Mysql 中的锁
对于存储引擎 MyISAM ,只支持表级锁,对于 InnoDB 来说,既支持表级锁、也支持行级锁。所以 InnoDB 可以用于高并发的场景下而 MyISAM 不行。
按颗粒度划分
1、行级锁
只对一行数据加锁,当一个事务操作某一行事务时,只对该行数据加排他锁时,其他事务对其他行数据操作时不会影响,并发性好。缺点是在加多条数据时加锁会比较耗时。一个事务获取到锁后直到事务提交才会释放锁。
使用场景:可串行化隔离级别
2、表级锁
包含两种。1、MDL,是 DDL 操作与 DML、DQL 操作冲突的加锁。下面会讲解。
2、对整张表进行加读写锁,仅针对 DML、DQL 操作。加锁快但是可承受的并发量低。 加读锁:lock table 表名 read ; 加写锁:lock tables 表名 write ; 释放读写锁:unlock tables;
3、全局锁
对所有表所有数据进行加锁。这个锁是读锁,也就是加锁后当前数据库只能处理读操作,不能处理写操作。一般在将整个库的数据进行逻辑备份时使用(在 InnoDB 中可以使用 mysqldump 进行非阻塞式备份,原理就是通过 隔离级别MVCC数据一致性实现的)。 加锁: Flush tables with read lock (FTWRL)
这里不建议使用 set global readonly = true 来代替 FTWRL,因为 1、readonly 参数在某些系统业务中是用来判断当前库是主库还是从库,如果修改会对业务操作影响 2、在发生异常时 readonly 不会改变而 FTWRL 会自动释放锁,未改变的话在恢复后就无法处理写操作,导致系统出错。
4、页级锁(存储引擎BDB,不常使用)
对一页数据进行加锁,介于行级锁与表级锁之间。
按种类划分
1、共享锁(读锁)
共享锁是对于MySQL中的读操作的,所以共享锁也叫读锁,一个事务进行读操作时,会对读取的数据添加读锁(可串行化下的读操作是自动加锁的,其他隔离级别需要在查询语句后面添加 lock in share mode),加锁后其他事务也可以对加锁的数据进行读取。获取了某记录的共享锁后只能对其进行读取,不能修改,也不能去读取其他表的数据
2、排他锁(写锁)
排它锁是对于 MySQL 中的写操作的,所以排它锁也叫写锁。添加排它锁的数据其他事务就不能进行操作,同时共享锁与排它锁也是互斥的,也就是一个事务对某数据添加了共享锁,那么其他事务就不能对其再添加排它锁。在所有隔离级别级别中的修改操作(insert、update、delete)都会添加排他锁,而读操作可以通过在语句后面添加 for update 来对读取的数据添加排它锁。
其他种类
1、Record Lock
记录锁。record lock 是加在具体记录对应聚簇索引上的锁,它是锁住的是索引本身而不是记录,如果该表没有聚簇索引,也会创建一个聚簇索引来代替。换句话说 record lock 属于行级锁。它既可以是共享锁也可以是排它锁(究竟是共享锁还是排他锁上面已经分析了)。任何级别都会存在。
2、Gap Lock
间隙锁,就是加在两条数据索引之间的锁,比如数据表student(id,name),id 是主键,有数据(5,"aa"),(7,"bb"),隔离级别是可串行化。此时事务1执行select * from student where id>5 and id<7,那么就会对 (4,7) 添加间隙锁,锁住中间的间隙。比如说事务2执行insert into(6,"cc"),那么次操作就会被阻塞。在可重复读及以上级别才会有。间隙锁是一种共享锁,多个事务可以对同一个间隙添加间隙锁。
3、Next-Key Lock
指的是 Record Lock 与 Gap Lock 的结合。针对 Gap Lock 中的例子,如果事务1执行的是 select * from dept where id>4 and id<8,那么对数据(5,"aa")、(7,"bb")对应的聚簇索引上也会添加 Record Lock。同时(4,5),(5,7),(7,8)也会加上间隙锁。同 Gap Lock 一样,只有可重复读以以上级别才会出现。next-key lock 都是 “左开右闭” 的,也就是以前面的事务1来举例,会添加的锁依次是:net-key lock (4,5]、(5,6]、(6,7],间隙锁(7,8)。
4、MDL(MetaData Lock)
后面补充的 Lock,其本质属于表级锁,在数据增删改和表结构变化时自动进行加锁、解锁,增删改操作会加读锁,修改表结构会加写锁。详情可见 Mysql 中的MDL锁。
5、自增锁
详情可见 MySQL中的自增主键。
四种隔离级别的实现
在说明原理前,先了解一下什么是快照读和当前读。
快照读:Mysql 默认的隔离级别是“可重复读”。通过文章开头的例子可以看出左边事务在右边事务执行修改提交前后查询的数据都一样,左边事务的查询就是一个快照读。快照读的数据可以看作一个快照,其他事务的修改不会改变这个快照值。也就是说快照读的数据不一定是最新值,可重复读级别也因此才保证了 “可重复读”。快照读的优势是不用加锁,并发效率高。
使用场景:在 Mysql 的隔离级别中,除了可串行化级别的读外,其他隔离级别中事务的读都是快照读。
当前读:当前读指的就是读的是最新值。既然是要求是最新值,那么就需要进行加锁限制,所以当前读是需要加锁的,同时因为当前读一定是最新的数据,所以就无法保证 “可重复读”。
使用场景:首先是可串行化中事务的读操作是当前读,而四种隔离级别中的所有修改(insert、update、delete)操作都属于当前读。可能你觉得读操作和修改操作没有关系,但是事实是这些修改操作是先 “读” 找到数据具体的位置才能进行 “修改”。
读已提交和可重复读的实现
这两种隔离级别的实现归功于 MVCC 机制。
MVCC机制
MVCC机制也叫多版本并发控制,用于控制数据库的并发访问。在 Mysql 的 InnoDB 存储引擎中主要作用于实现读已提交和可重复读隔离级别。实现原理是通过 undo日志版本链和 Read View 。
1、undo日志版本链。在 InnoDB 聚簇索引记录的行数据中有两个隐藏列,trx_id 和 roll_pointer,trx_id 表示当前行数据上次被修改的事务 id (事务 ID 是自增的,越新的事务 ID 越大),roll_pointer 是每次在修改完数据前,都会将修改前的数据存入undo log(专门用于记录事务修改前数据的日志系统,用于进行事务的回滚和生成数据快照),roll_pointer 就是当前行数据修改前在 undo 日志中的存储位置。
2、Read View。内部主要有四个部分组成,第一个是创建当前 Read View 的事务 id creator_trx_id,第二个是创建 Read View 时还未提交的事务 id 集合trx_ids,第三个是未提交事务 id 集合中的最大值up_limit_id,第四个是未提交事务 id 集合中的最小值low_limit_id。
当执行查询操作时会先找磁盘上的数据,然后根据 Read View 里的各个值进行判断,
1)如果该数据的 trx_id 等于 creator_trx_id,那么就说明这条数据是创建 Read View的事务修改的,那么就直接返回;
2)如果 trx_id 大于等于 up_limit_id,说明是新事务修改的,那么会根据 roll_pointer 找到上一个版本的数据重新比较;
3)如果 trx_id 小于 low_limit_id,那么说明是之前的事务修改的数据,那么就直接返回;
4)如果 trx_id 是在 low_limit_id 与 up_limit_id 中间,那么需要去 trx_ids 中对各个元素逐个判断,如果存在相同值的元素,就根据 roll_pointer 找到上一个版本的数据,然后再重复判断;如果不存在就说明该数据是创建当前 Read View 时就已经修改好的了,可以返回。
而读已提交和可重复读之所以不同就是它们 Read View 生成机制不同,读已提交是每次 select 都会重新生成一次,而可重复读是一次事务只会创建一次且在第一次查询时创建 Read View。事务启动命令begin/start transaction不会创建Read View,但是通过 start transaction with consistent snapshot 开启事务就会在开始时就创建一次 Read View。
举个网上的例子,启动事务的方式是通过 start transaction with consistent 。首先创建事务1,假设此时事务1 id 是60,事务1先修改 name 为小明1,那么就会在修改前将之前的记录写入 undo log,同时在修改时将生成的undo log 行数据地址写入 roll_pointer,然后暂不提交事务1。开一个事务2,事务 id 为 65,进行查询操作,此时生成的 Read View 的trx_ids是[60],creator_trx_id 为 65,对应的数据状态就是下图,首先先得到磁盘数据的 trx_id ,为60,然后判断,不等于 creator_trx_id,然后检查,最大值和最小值都是 60,也就是属于上面 2)的情况,所以通过 roll_pointer 从 undo log 中找到 “小明” 那条数据,再次判断,发现 50 是小于 60的,满足上面 3)的情况,所以返回数据。
然后提交事务1,再开一个事务3,将name改成小明2,假设此时的事务3 id 是100,那么在修改前又会将 trx_id 为 60 拷贝进 undo log,同时修改时将 trx_id 改为100,然后事务3暂不提交,此时事务1再进行select。如果隔离级别是读已提交,那么就会重新生成 Read View,trx_ids是[100],creator_trx_id 为65,判断过程和上面相似,最终返回的是小明1那条数据;而如果是可重复读,那么还是一开始的 Read View,trx_ids 还是[60],creator_trx_id 还是 65,那么还是从小明2 的 trx_id 进行判断,发现不等于 65,且大于60,为情况 2),跳到 小明1 ,对 trx_id判断,还是大于,还是情况 2),跳转到 “小明” 那条数据,判断 trx_id < low_mimit_id,为情况 3),所以返回 "小明"。下面是这个例子最终的示意图
读未提交和可串行化实现
这两个实现比较简单。读未提交就是每次事务执行的修改都更新到对应的数据上,然后读取直接读取这个数据就可以了。而可串行化则是使用了读锁和写锁以及间隙锁来实现的,对会造成“幻读”、“脏读”、“不可重复读” 的操作会进行阻塞,也正因为这样,极易任意造成阻塞,所以不建议使用可串行化级别。
不同隔离级别下加锁情况
锁是加载索引上的。对于不同的隔离级别,不同的列情况,加锁情况都各不不同,下面会列举各个场景下加锁的情况。
1、读未提交级别
读操作不会加锁,写操作会添加排它锁。因为会发生脏读,所以 MVCC并不会发生效果。可以手动添加 for update 、lock in share mode 来加锁,不会产生间隙锁,只有记录锁。
无论是否使用索引,是否是手动添加锁,只会对最终操作的数据加 Record Lock。
2、读已提交级别
读操作不会加锁,写操作会添加排它锁。MVCC 会在每次查询时生成 Read View,可以手动添加 for update 、lock in share mode 来加锁,不会产生间隙锁,只有记录锁。
无论是否使用索引,是否是手动添加锁,只会对最终操作的数据加 Record Lock。(在未使用到索引时数据库会对所有数据加锁,当加载到 Server 层筛选后会将不符合条件的数据进行解锁,所以我们会认为只对最终操作的数据加锁,读未提交级别的未使用索引情况也相同)
3、可重复读级别
可重复读是一个特殊的隔离级别,为什么这么说呢?因为它是 mysql 默认的隔离级别,因为 "可串行化" 级别默认对读操作加锁,导致程序的并发性不高,所以不建议使用,而可重复读因为使用的是快照读,所以并发性很好,并且解决了不可重复读、脏读以及 "快照读" 幻读,但同时会有 "当前读"幻读的问题产生(下面"MySQL 对幻读的解决" 会详细解释),所以针对这个问题引入了间隙锁来解决。
读操作不会加锁,写操作会添加排它锁。MVCC 会在事务开始第一次查询时生成 Read View,可以使用 for update、lock in share mode 来手动加锁,可能会产生间隙锁。
在默认加锁情况下,加锁的规律可以概括为下面几点:
原则1:加锁的基本单位是 next-key lock。需要注意的是,next-key lock 都是前开后闭区间。
原则2:查找过程中访问到的对象才会加锁。
原则3:非唯一索引会向排序方向一直匹配,直到不满足条件为止。唯一索引不需要。
优化1:索引上的等值查询(符号是=。或者是首个判断条件的常量值等于第一个确定的值,比如有唯一索引列 a 值5、10,条件为 a>=5(a>5 不行,必须包含等于的符号),那么根据条件找到第一个满足的记录值5正好等于条件的边界值 5,那么也可以优化成行锁),给唯一索引加锁的时候,next-key lock 会退化成行锁。
优化2:索引上的等值查询(符号必须是=),向右遍历时且最后一个值不满足等值条件的时候,next-key lock 会退化成间隙锁。
一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
除了上面五种情况外,还有一些场景需要注意:1、因为默认的排序方式是升序,如果声明为 desc 那么就需要从高向低匹配。 2、上面说的都是等值,如果是不等值,如 >、<、>=、<=,那么会先匹配到最先满足条件的值(其实就是找等值没有找到所以按着结点值向条件方向找到一个返回),然后根据上面的加锁规则加锁。 3、使用 limit x 会在加锁的元素达到 x 后就会停止加锁。
下面就举几个例子来说明。首先是表结构和数据。
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
1、等值查询未命中。
sessionA 首先根据原则1,添加 next-key lock ( 5, 10 ],然后因为优化2, 10 不满足等值条件,降为间隙锁,所以最终加锁为间隙锁 (5,10)。
2、倒序唯一索引查询加锁情况。执行语句
begin; select * from t where id>9 and id<12 order by id desc for update;
这条语句加锁情况是 next-key lock(0,5 ]、( 5,10 ],间隙锁(10,15 )。加锁过程:因为是按 id 降序,所以先根据 id<12 来查找第一个符合条件的值,也就是 c=10,然后根据原则1直接添加 next-key lock(5,10 ],同时因为还未达到 id<12 的条件,所以还需要先添加next-key lock(10,15 ],但是因为优化2,所以这个 next-key lock 会退化成间隙锁,所以变成(10,15)。然后开始向前匹配,因为前置条件是 id>9 ,所以在找到 id=5这条记录后才确定遍历完所有满足的记录,这时因为原则2,所以需要对 c=5 这行添加next-key lock(0,5](这里的条件不是等值查询条件(必须为=),所以优化1,2不会生效)。
3、非唯一索引发生覆盖索引不需要加锁。执行
sessionA 加锁情况是间隙锁(0,5)、(5,10)。加锁过程:首先会匹配 c=5 这行,因为原则1,所以加 next-key lock(0,5 ]。又因为 c 不是唯一索引,必须继续向右匹配,直到不符合条件的值结束(这里不向前匹配是因为在初始确定第一个值时就一定是边缘值,所以在 c=5 前面的记录一定不是 c=5,不需要匹配)。向右匹配找到 c=10 后发现不满足,结束。因为原则2,所以会添加一个 next-key lock(5,10 ]。但是因为优化2,c=10不满足条件,所以退化成间隙锁(5,10)。同时由于 sessionA 是覆盖索引,并没有访问主键索引,所以并不需要加锁(因为锁是加载聚簇索引上的)。最终加锁情况就是两个间隙锁(0,5)、(5,10)。
4、唯一索引范围查询。执行
sessionA 加锁情况是[ 10,15 ]。加锁过程:首先根据 id>=10 找到 id=10 这一行,因为原则1加锁 next-key lock(5,10 ],但是因为优化1,所以这里的 next-key lock 被优化成行锁 c=10。然后向右遍历,遍历到 id=15 后发现不满足条件所以加锁 next-key lock(10,15 ](条件不是=,所以优化1,2不生效)。所以最终的加锁区间就是 [10,15 ]。
5、唯一索引 bug。该 bug 在 8.0.18 版本中修复。执行
如果不看 bug,只根据前面的加锁规律来看:首先根据条件 id>10 ,找到满足的第一个值,也就是 id=15,这时因为原则1,添加 next-key lock(10,15 ],然后向右遍历,找到 id=15 后就已经满足了后置条件,所以最终的锁就是(10,15 ]。但是实际并不是如此。sessionB、sessionC都被锁住了。
实际的加锁:在添加完 next-key lock(10,15 ] 后,还会匹配直到不满足条件为止,也就是将非唯一索引的加锁规则弄到了唯一索引上。所以在遍历到 id=20 后才停止,所以相对应的又添加了一个 mext-key lock(15,20 ]。
6、使用 limit x 会在加锁的元素达到 x 后就会停止加锁。加入增加了一行记录 (30,10,10),执行 SQL :delete from t where c=10 limit 2; c是索引列,那么其加锁情况如下:
加锁过程:首先匹配到(10,10,10)这一行记录,然后添加 next-key lock((5,5),(10,10) ],这是第一个值,然后向右匹配,找到 (30,10,10),添加 next-key lock((10,10),(10,30) ]。到这两个值都已经匹配完成,所以即使是非唯一索引,也不会再向右边匹配。
7、使用 in 就相当于三条等值查询。执行SQL:select id from t where c in(5,20,10) lock in share mode;
加锁情况:因为是默认升序的,所以执行是先执行 c=5,然后 c=10,最后 c=20。c=5加锁是(0,5 ]、(5,10)(因为优化2);c=10加锁是(5,10 ]、(10,15);c=20 加锁是(15,20 ]、(20,25)。
8、特殊情况1。删除操作可能会使间隙锁范围扩散。
sessionA 原本添加的锁是一个 next-key lock(10,15 ]。所以 sessionB 删除是不会被阻塞的,但是在删除后因为间隙失去了,就会转移到前一个点上,也就是间隙扩大,所以锁就变成了(5,15 ]。
9、特殊情况2。更新操作也可能会使间隙锁范围扩散。
更新操作可以看成两步:先新增修改后的记录,然后再删除要修改的记录。
分析:sessionA 加锁区间是(5,+∞)所以这两步过程如下:
第一步:先增加c=1的记录,删除c=5的记录,锁区间因为删除扩大变成(1,+∞);
第二步:增加 c=5的记录,被锁阻塞。
10、空表的加锁情况是 next-key lock (-∞, supremum]。
4、可串行化级别
读操作会加读锁,写操作会加写锁,读写锁互斥。也会有间隙锁。
1)用到主键索引和唯一索引,会对操作数据添加 Record Lock。
2)普通索引,会对操作数据以及间隙添加 Next-key Lock。
3)未使用索引,会对所有数据以及两边间隙添加 Next-key Lock。
MySQL 对幻读的解决
“快照读” 幻读
通过上面对 MVCC 原理的解释,可以知道文章开头的例子为什么“解决了” 幻读。如果假设左边的事务1 id 是50,右边事务2 id 是55,其他数据创建时的事务是10,那么在事务1第一次查询时生成的 Read View 的 trx_ids 为[55],对应的数据如下
那么在判断其他数据时 trx_id 的10小于 trx_ids 的最小值55,所以通过,而 id 为6的数据发现 trx_id 正好等于 55,所以获取 roll_point 从 undo log中找到之前的数据快照,但是发现该列值为空,所以放弃跳到下一条数据。没有出现文章开头所说的 “幻读” 情况,开头所说的幻读叫做 “快照读” 幻读。 由此我们可以知道, MVCC 可以解决 “快照度” 幻读。
这里可以再插入一下题外话,其实对于 MVCC 中可重复读级别 Read View 创建时机为什么是第一次查询时生成而不是事务启动时就生成,可以通过下面的测试来证明。
可以在事务2提交后再查询就会查出提交后的数据。
“当前读” 幻读
这样看来 MVCC 已经解决了幻读问题,而在一开始也说过在默认时在一定程度上解决了幻读,为什么这么说?请看下面这个例子
如果单看左边的事务,会发现明明表中没有id为6的记录,但是就是无法执行 insert 操作,显示主键已存在。这就是 “当前读”幻读,而 MVCC只能解决 “快照读” 幻读。由于前面对 “当前读”、“快照读” 的解释可以知道这两种读是互斥的,那么如何解决 “当前读” 幻读。第一种方式是直接切换成 “可串行化” 级别,这种因为默认对数据加锁,不利于项目的并发执行,所以不建议;第二种就是手动添加锁,在开始查询操作后添加 for update 或 lock in share mode,来强制加锁(也对间隙加上了间隙锁),这样右边的插入操作就会被阻塞。这样就可以实现 "当前读"了。
死锁
MySQL 的死锁与多线程中的死锁本质上一样,其核心思想就是 “两个及以上的事务互相获取对方事务添加的锁记录(排它锁)”,
解决死锁的方式有两种:
1、设置事务超时时间。通过修改 innodb_lock_wait_timeout 参数来修改事务的超时时间,默认为 50s。这种方式不推荐使用,因为并不能根本上解决问题,缩短时间虽然能解决死锁,但是对于执行时间长的事务来说就永远无法完成。
2、开启死锁检测,在事务获取锁资源时阻塞就会开始检测,如果发生死锁就会回滚其中一个事务。这是默认的方式,死锁检测可以通过将 innodb_deadlock_detect 设为 on 来开启(默认开启)。但是死锁检测会消耗大量的 CPU。所以可以进行一些优化。
优化方式:
1、在保证当前系统不会发生死锁后可以直接关闭搜索检测。
2、控制并发度,通过使用中间件来减少数据库操作的并发量来提高效率。
3、将操作的数据拆分成多个数据,然后依次获取,这样就减小了资源冲突的概率,但是需要结合业务判断是否允许拆分数据。
Next-key Lock 是 Record Lock 与 Gap Lock 结合的验证
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
可重复读隔离级别下
Gap Lock 是共享锁,但是行锁、Next-key Lock不是,如果这样的话因为 sessionA 开始加的锁就是 Next-key Lock( (5,5,5) , (10,10,10) ],间隙锁 ( (10,10,10) , (15,15,15) )。所以 sessionB 应该是无法获取 Next-key Lock( (5,5,5) , (10,10,10) ],所以 sessionA 的 insert 语句就不应该被阻塞,也就不会造成死锁,但为什么会这样呢?这是因为 Next-key Lock 并不是一把原子性的锁,它是由 Gap Lock 与 Record Lock 组成的,在 sessionB 的 update 语句执行时首先添加的是间隙锁( (5,5,5) , (10,10,10) ),然后再尝试添加(10,10,10) 的行锁,然后失败,随后后面的间隙锁也会被阻塞加锁。所以最终加锁范围就是 ( (5,5,5) , (10,10,10) )。所以 sessionA 在执行 insert 时也会等待 sessionB 释放间隙锁后才能执行,导致死锁。
而如果把 sessionA 的insert 操作改成 update t set d=d+1 where c=10; 会发现还是阻塞发生死锁,这是因为 sessionA 开始加的是共享锁,只能读不能写,如果改成 select * from t where c=10 for update 就不会发生死锁了。
博客总结来源于
https://blog.csdn.net/cug_jiang126com/article/details/50596729
https://www.cnblogs.com/crazylqy/p/7611069.html,其中一些图片和加锁情况来源于第二个
以及极客时间的<<mysql实战45讲>>