pixiv:92080599
Innodb 是如何解决脏读 / 幻读 / 不可重复读的
概念
脏读:在当前事务读到其他事务修改并且未提交的数据
幻读比较容易与不可重复读搞混:
幻读比较容易与不可重复读搞混,先看看他们的定义:
-
不可重复读:在当前事务多次读时,读到因其他事务提交而前后不一致的数据
-
幻读:在同一事务中,相同查询条件的多次查询读取的数据总量不一致
提出并解决这些事务问题,都是为了保证在单一事务中不受其他事务的影响。
解决不可重复读是为了保证,在本事务中 SELECT 得到的结果不会被其他事务影响而改变。一种解决思路是,只需要对 SELECT 的结果行加锁即可。
但在此之外还会有其他的干扰情况:就算锁住了查找结果,相同的 SELECT 语句仍然可能出现不一样的结果。因为其他事务的 INSERT 操作可能会插入满足上次 SELECT 条件的新数据行。尽管原 SELECT 语句的查询结果行数据不变,但再次查找就可能多出了几行,如同幻觉一般。这就是与不可重复读不同的另一种事务问题,幻读。
可重复读 Repeatable Read
显然,可重复读针对的就是『不可重复读』问题,也是 Innodb 默认的隔离级别
其实可以通过一个最简单的例子整体思考下事务的目的以及问题:
事务 T1 查找结果为行 A ,然后事务 T2 想更新行 A 中的数据
如果要解决『不可重复读』,那么有两种解决思路:
-
直接阻止 T2 的操作。
在『串行化』隔离级别中,事务中的查找操作也会上锁,因此有着最好的隔离性。在『串行化』以外的隔离级别,也可以使用 select … for update / lock in share mode 手动对查找操作上锁。但缺点也显而易见,这么做会导致并发性能极差。
-
在不阻止 T2 操作的情况下,有没有方法阻止『不可重复读』?
有。其实可以关注到,『不可重复读』要求事务多次读到的数据不变,而不是该数据本身不能变。如果为数据建立快照,就可以同时满足『不变』与『修改』。这种思路可以极大提高并发性能,但尽管符合了『可重复读』的要求,但读到的非实时数据也会成为一个隐患,尤其是在对数据实时性要求高的场合
这个例子主要表明:在实现事务隔离的情况下,也可以用一定的妥协来换取性能。毕竟『串行化』的实现最简单也最安全,但并发性能需求在大多数场合中,比保证数据的绝对实时性更重要。因此多版本并发控制 MVCC 应运而生
多版本并发控制 MVCC
MVCC 就是 Innodb 对于通过为数据建立快照思路的具体实现。由于事务是可以并发的,同一时间自然可能遇到多组事务间需要解决冲突,所以需要的也就是 MVCC 本身:多版本并发控制
MVCC 不仅用在 RR 级别解决『不可重复读』与『幻读』,也在 READ COMMITED 读已提交级别中解决了『脏读』问题
实现
MVCC 有不止一种实现,这里介绍的是 Innodb 的
-
每个事务在开启时都会获得一个递增的事务版本 id,记录了每个事务开启的先后顺序
-
MVCC 为表创建两个隐藏列,
trx_id
与roll_pointer
.前者记录了本行数据最新被创建/修改的事务 id,后者是由 undo log 实现的回滚指针,指向该行的上一个历史版本,形成链表串联成该行完整的历史快照 -
read-view 一致性视图:第一次读时会记录下这些数据:
- trx_ids:当前未提交的所有事务 id 组成的数组
- up_limit_id(min):trx_ids 中的最小值
- low_limit_id(max):当前所有事务版本 id 的最大值+1,也就是未来下一个出现的事务 id
- creator_trx_id:本事务的 id (也查到称为 current_trx_id 的,暂时没求证)
在 RR 隔离级别下,read-view 在整个事务生命期间都不会更新
-
在事务期间的查询操作,会进行以下检查:
- 若
trx_id
< min,说明该行的最晚修改时间一定在本事务开启前,是可见数据 - 若
trx_id
> max,说明该行的最晚修改时间一定在本事务开启后,不可见,需要通过roll_pointer
找到可见的历史版本 - 若 min <
trx_id
< max,则无法直接判断,需要在 trx_ids 数组中进行二分查找,确认trx_id
是否存在其中:若存在则说明是不可见版本。反之亦然
Tips:其实第一、二步的判断只是为了提高性能,不然每次查询操作都需要在 trx_ids 数组中二分查找
- 若
MVCC 与 脏读
前文也提到了,MVCC 也在 RC 隔离级别中解决了『脏读』,其实与 MVCC 在 RR 中解决『幻读』的机制很相似,唯一的区别是 read-view 一致性视图变为了:在事务中每次 SELECT 操作中都会重新生成一次,推演一下其影响就能明白, RC 隔离级别为什么不存在脏读但是『不可重复读』了
快照读与当前读
在启用了 MVCC 后,事务中查找操作得到的数据不再一定是最新值,要确保得到最新值只能使用 SELECT ... FOR UPDATE / LOCK IN SHARE MODE
,因此 MVCC 让读操作分为了『快照读』与『当前读』两种,在《InnoDB 技术内幕》中称为『一致性锁定/非锁定读』
MVCC 可以确保在 RR 隔离级别下,『快照读』中不会出现幻读现象,但『快照读』和『当前读』在事务里同时出现时,就可能出现幻读
INSERT 与 UPDATE 实际上也算是一种『当前读』,重点在于这两个操作会使事务脱离『快照』,接触到『当前』,这点很重要,后文中还会提起
间隙锁与临键锁
简单重复一下概念
- 间隙锁:对索引的一段区间范围上锁,使得无法在对应区间插入新行,区间为左开右开
- 临键锁:对索引的一段区间范围上锁,区间为左开右闭。其本质实现就是间隙锁+行锁,作用也相当于就是将间隙锁与行锁组合在一起使用,从而做到『左开右闭』。临键锁是『当前读』时的默认加锁单位,因此主要会讨论临键锁,但基本可以将其视为间隙锁+行锁即可
间隙锁和临键锁只存在于 RR 隔离级别下,因为其本身只能用来解决幻读,在其他隔离级别也无意义
间隙锁的出现是为了实现 阻止一个范围内出现新行
,而之所以有这个需求是为了在『当前读』的情况下解决『幻读』。
间隙锁之间不冲突,因此两个事务可以同时申请同一个范围的间隙锁,间隙锁的唯一目的就是阻止范围内的插入操作
临键锁的加锁原则比较复杂和底层,网上也有很多对着源码分析的文章,因此我打算仅记录归纳后的总结。示例表 test :
id 主键 | uid 普通索引 | gid 唯一索引 |
---|---|---|
1 | 1 | 1 |
2 | 5 | 5 |
3 | 10 | 10 |
非唯一索引
也就是普通索引,被『当前读』显式查找的结果,及其前后间隙都会被临键锁锁定
注意!UPDATE INSERT DELETE 等『写操作』本身也需要『当前读』,因此它们也会导致临键锁锁定
T1 | T2 |
---|---|
select * from test where uid = 5 for update; | |
insert into test values (4,2,2); | |
临键锁锁定区间为 uid的 (1,10),也就是查找结果(2,5,5)
再加上它上下的间隙,是开区间。T2 事务被阻塞
可以思考一个问题,在此处场景中,select 并不是范围而是等值查询,只有可能查到 uid = 5的行,就算接下来 insert uid 为1~4,6~10之间的行,也不可能发生幻读,那为什么不只给
(2,5,5)
这一行上行锁呢?首先是由于此处为普通索引,没有唯一性,因此还可以插入 uid = 5 的行,这就会导致幻读。其次这仍然导致了本不应该受影响的 uid 范围被禁止 insert,为什么不设计另一种机制,仅禁止 uid = 5 行的 insert? 关于这个问题网上鲜有讨论(也可能是我孤陋寡闻=。=),目前我看到的有一定信服力的解释是:原因和索引使用的 B+ Tree 结构有关,只能实现到这种地步。如果有了解这个问题的欢迎评论指正 Orz
T1 | T2 |
---|---|
select * from test where uid >= 5 for update; | |
insert into test values (4,6,2); | |
临键锁锁定区间为 uid的 (1,+supernum) 。supernum 是数据库维护的最大的值
唯一索引
唯一索引最大的区别在于等值查找场景时,由于能确保唯一性所以可以降级到行锁即可,而普通索引的话只能维持在临键锁
总结
其实关于临键锁的锁区间范围规律,归根到底还是为了阻止幻读的可能而锁住可能产生幻读的空间。我认为不必过多关注其实现,因为实际锁住的范围一定是大于等于理论上需要锁住的范围,只要清楚这点,就能从另一种逆推的思维也能得到相近的结果。
而之所以是大于等于而不是等于,一方面是由于实现手段的不够理想(如普通索引的等值查询会锁住额外的范围),另一方面则是 Innodb 本身也存在 bug,丁奇老师在《Mysql 实战45讲》中就提出过一个临键锁范围的 bug,直到 8.0.18 版本才被修复。所以也不能一味地相信源码实现的正确性。
结语&QA
-
于是,
InnoDB 在 RR 下不会产生幻读
这句话是正确的吗?这个问题很难直接给出答案倘若在事务中没有产生『当前读』,那在 MVCC 机制的作用下,确实不可能会出现幻读;但『当前读』出现后,先『快照读』再『当前读』就可能产生幻读;倘若只使用『当前读』,那临键锁机制也可以确保阻止幻读,但实际上这种情况的并发性能已经下降到『串行化』级别了
只能说在 RR 下,InnoDB 解决了仅『快照读』与仅『当前读』情况下的『幻读』问题,但混合使用时则没有解决
-
RR 隔离级别下,read-view 一致性视图只在事务的第一次 select 操作后才建立
T1 T2 update test set uid = 2 where id = 1; update test set uid = 3 where id =1; select * from test where id = 1; 那有没有可能发生如上表,最终 T1 查找结果为 (1,3,1)而不是预期的(1,2,1)类似幻读的情况?
A:不可能,因为 update 操作也是『当前写』,会上锁,T2 事务的更新操作无法执行
参考文章
文中如有错误,欢迎在评论区指出 Orzzzz