Back
Featured image of post Innodb 是如何解决脏读 / 幻读 / 不可重复读的

Innodb 是如何解决脏读 / 幻读 / 不可重复读的

MVCC 与 间隙锁

pixiv:92080599

Innodb 是如何解决脏读 / 幻读 / 不可重复读的

概念

脏读:在当前事务读到其他事务修改并且未提交的数据

幻读比较容易与不可重复读搞混:

幻读比较容易与不可重复读搞混,先看看他们的定义:

  • 不可重复读:在当前事务多次读时,读到因其他事务提交而前后不一致的数据

  • 幻读:在同一事务中,相同查询条件的多次查询读取的数据总量不一致

提出并解决这些事务问题,都是为了保证在单一事务中不受其他事务的影响。

解决不可重复读是为了保证,在本事务中 SELECT 得到的结果不会被其他事务影响而改变。一种解决思路是,只需要对 SELECT 的结果行加锁即可。

但在此之外还会有其他的干扰情况:就算锁住了查找结果,相同的 SELECT 语句仍然可能出现不一样的结果。因为其他事务的 INSERT 操作可能会插入满足上次 SELECT 条件的新数据行。尽管原 SELECT 语句的查询结果行数据不变,但再次查找就可能多出了几行,如同幻觉一般。这就是与不可重复读不同的另一种事务问题,幻读。

可重复读 Repeatable Read

显然,可重复读针对的就是『不可重复读』问题,也是 Innodb 默认的隔离级别

其实可以通过一个最简单的例子整体思考下事务的目的以及问题:

事务 T1 查找结果为行 A ,然后事务 T2 想更新行 A 中的数据

如果要解决『不可重复读』,那么有两种解决思路:

  1. 直接阻止 T2 的操作。

    在『串行化』隔离级别中,事务中的查找操作也会上锁,因此有着最好的隔离性。在『串行化』以外的隔离级别,也可以使用 select … for update / lock in share mode 手动对查找操作上锁。但缺点也显而易见,这么做会导致并发性能极差。

  2. 在不阻止 T2 操作的情况下,有没有方法阻止『不可重复读』?

    有。其实可以关注到,『不可重复读』要求事务多次读到的数据不变,而不是该数据本身不能变。如果为数据建立快照,就可以同时满足『不变』与『修改』。这种思路可以极大提高并发性能,但尽管符合了『可重复读』的要求,但读到的非实时数据也会成为一个隐患,尤其是在对数据实时性要求高的场合

这个例子主要表明:在实现事务隔离的情况下,也可以用一定的妥协来换取性能。毕竟『串行化』的实现最简单也最安全,但并发性能需求在大多数场合中,比保证数据的绝对实时性更重要。因此多版本并发控制 MVCC 应运而生

多版本并发控制 MVCC

MVCC 就是 Innodb 对于通过为数据建立快照思路的具体实现。由于事务是可以并发的,同一时间自然可能遇到多组事务间需要解决冲突,所以需要的也就是 MVCC 本身:多版本并发控制

MVCC 不仅用在 RR 级别解决『不可重复读』与『幻读』,也在 READ COMMITED 读已提交级别中解决了『脏读』问题

实现

MVCC 有不止一种实现,这里介绍的是 Innodb 的

  1. 每个事务在开启时都会获得一个递增的事务版本 id,记录了每个事务开启的先后顺序

  2. MVCC 为表创建两个隐藏列,trx_idroll_pointer .前者记录了本行数据最新被创建/修改的事务 id,后者是由 undo log 实现的回滚指针,指向该行的上一个历史版本,形成链表串联成该行完整的历史快照

  3. 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 在整个事务生命期间都不会更新

  4. 在事务期间的查询操作,会进行以下检查:

    • 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 事务的更新操作无法执行

参考文章

Innodb中的事务隔离级别和锁的关系

MySQL 实战45讲

文中如有错误,欢迎在评论区指出 Orzzzz