对事务隔离级别的新思考(InnoDB & MVCC & 间隙锁)

事物的ACID概念应该大家都懂, 它的隔离级别以及并发问题也耳熟能详, 网上有很多非常非常好的比喻和描述
我就简单回顾一下吧, 用大白话的方式

  1. Read Uncommited 读未提交
    顾名思义, 一个事务可以读取另一个未提交事务的数据. 那个事务要是提交了倒也没事, 顶多就是提前得到了点信息, 问题是万一回滚了不提交呢? 中途看到的数据怎么算?
    并发问题也比较明显: 如果修改数据的事务回滚了, 会出现"脏读"
    不多说了, 属实用不得啊
  2. Read Committed 读已提交(Oracle默认隔离级别)
    也顾名思义, 一个事务要等另一个事务提交后才能读取数据. 这样吧, 肯定就解决了脏读的问题了, 等要更新的事务提交掉再读总没问题吧? 要是就那一个事务要更新, 那肯定没事, 问题是有其他新事务要进来更新怎么办? 虽然它们插队了, 但规矩就是要等它们完成的啊, 万一修改了已经读出来了的数据呢? 这个事务再读一次这个数据作其他用途的时候, 前后数据可就不一致了
    并发问题也就来了: 同一个事务前后两次进行查询, 可能出现不同的结果, 即"不可重复读"
  3. Repeatable Read 重复读(MySQL默认隔离级别)
    好像不太能顾名思义了, 直说就是: 开始读取数据(事务开启)时, 不再允许修改操作. 这个规矩就是: update操作不允许插队了.
    这个隔离级别的并发问题有点脑筋急转弯, 那就是: 只限制update和delete, 但是不限制insert(即不允许去动那些现有的数据, 但是不限制新数据的加入). 这样的话有可能会产生"幻读"
  4. Serializable 序列化/串行化
    这个隔离级别下, 事务串行化顺序执行, 意思就是"好, 都别闹了, 一个个排好队, 别插队". 解决所有的并发数据问题
    并发问题? 没了. 不过吧, 效率低下, 也比较耗数据库性能, 这个虽然不是问题, 但也得算是缺陷吧? 不然干嘛不用它呢?

我之前觉得好像就这么回事儿吧, 但是凌晨的时候读到一篇关于"MVCC究竟能否解决幻读"的文章, 秉着好奇心读了读, 发现之前的理解属实有点浅薄
就是一个其实挺明显的问题: 既然串行化是低性能的, 而可重复读是有幻读这个风险的, 难道就放任这样一个尴尬状态在并发环境中吗? 当时学习事务隔离级别及其并发问题的时候, 我有想到这个问题, 但只是觉得"可能这个问题是允许发生的吧…", 就没有深凿, 看来学习观念属实还需要提高啊

MVCC(Multi-Version Concurrency Control, 多版本并发控制), 具体细节挺多的, 可以去搜一下看看

简单来说, 是一种解决了乐观锁ABA问题的机制, 通过版本号(三个额外的隐藏version值)来实现
(乐观锁ABA问题如果不知道的话可以去搜一下, 5分钟肯定能明白, 我这儿就不多说这一段了. 乐观锁的思想在ConcurrentHashMap中也有体现, put()方法的CAS+synchronized)

不过有一个重点, 网上的大多数说法是"通过两个额外的隐藏version值来实现多版本", 但官网是这么说的, 注意红字部分:
在这里插入图片描述
链接在这: https://dev.mysql.com/doc/refman/5.6/en/innodb-multi-versioning.html

接着我们的内容

InnoDB默认隔离级别是Repeatable Read(也即MySQL的默认事务隔离级别), 解决了不可重复读的问题, 是通过MVCC来实现的. 但数据库中读的方式有两种, 快照读和当前读
(注: InnoDB是一种表级别的存储引擎, 还有一个叫MyISAM, 两者常常拿来对比, 它们的底层数据结构也很有学问, 如果要理解清楚的话, 至少N小时吧, 因人而异)

快照读, 顾名思义, 像冲相片一样读数据, 执行普通select语句的时候都是快照读
事务A在第一次select以后, InnoDB会默认执行一个快照读(类似于"拍一张照片"), 之后事务A的select都会根据这个快照来返回数据, 这两个select返回的是一样的数据, 符合Repeatable Read, 就相当于"一张拍好的照片, 我怎么看都是一样的"
但是呢, 如果两个普通的select期间有事务B执行insert, 这张"照片"就不新了. 不过由于快照读的存在, 事务A并没有感知到, 只要不触发当前读, 事务A都不会感知到. 在这个情况下, 快照读是没有导致幻读的, 甚至是"防止"了, 只不过没有读到最新插入的数据(关于读不到最新插入的数据这个情况, 我还暂时没有找到好的描述, 可能也还需要再看看). 但是, 如果有其它操作触发了当前读, 就不一样了

当前读, 听起来比较没那么直白, 不过举几个实例就直白了, 它是由一些特定的操作所触发的:

  1. 加锁的读操作
    select … lock in share mode (这里是加了共享锁)
    select … for update (这里是加了排它锁)
  2. 数据修改的操作(在这里不会直接发现数据不对劲, 还需要一次select来显示数据的对吧)
    update / insert / delete (这里也是排它锁)

至于为什么这些操作会触发当前读呢? 可以联想一下Java中synchronized和volatile的区别, 有很多共性!

接着上面事务A和B的场景. 事务A已经select过了, 事务B也已经insert过了, 但事务A丝毫不知道事务B介入了这些数据. 如果此时事务A执行update, 再select, 就会触发当前读, 刚刚事务B所insert的数据就会一起出现. 但对于事务A来说, 它并没有执行过这个新出现数据的操作(因为是由事务B插入的, 事务A并不知道), 很懵, 就像幻觉一样, 即幻读了
举一个我在网上看到的例子:
对事务隔离级别的新思考(InnoDB & MVCC & 间隙锁)_第1张图片
所以, MVCC并不能根本上解决幻读的情况. 准确地说, 在"快照读"的场景下不会引起幻读, 但是在"当前读"场景下会引起幻读

那么怎么办呢? 先讲一个相关的概念吧, InnoDB存储引擎有三个锁算法:

  1. Record Lock: 单个行记录上的锁, 即常说的"InnoDB行锁", 也有别名叫"InnoDB事务锁"的. 说白了, 有哪些数据记录, 就把它们锁住, 不准动, 这样是不是就实现了"不允许update / delete已有的数据"?
  2. Gap Lock: 锁定一个范围, 不包括记录本身. 乍一看没什么用, 因为它需要结合上面的Record Lock才厉害
  3. Next-Key Lock: 锁定一个范围, 包含记录本身(相当于Record+Gap). 什么意思呢? 说白了, 这可以直接抑制"旧数据的update / delete"(Record Lock), 外加"新数据的insert"(靠Gap Lock)

举个例子就比较清晰了:
假如A事务正在查询id<10的所有数据, 只存在id为1~7的数据, 8、9并不存在. 此时B事务向数据库插入id为8的数据, 那么事务A即将在下一次"当前读"时出现幻读, 对吧?
这个时候, Next-Key Lock就派上用场了. 它相当于Record+Gap两个锁, 而Record Lock(行锁)会锁着1~7(已有数据)的位置, Gap Lock会锁着id为8、9的两个现在没有数据的位置. 这种情况下, 事务A读取数据的时候, 事务B向数据库插入数据(无论是1,2,3,4,5,6,7还是8,9)都会被阻止, 即防止了幻读

综上所述, 幻读的解决实际上有两种方式, 但MVCC解决幻读的这个情况我觉得有点"擦边球", Next-Key Lock这个解决方案是更加直接的
有了Next-Key Lock这个解决方案, Serializable这个低效率的事务隔离级别就可以不去使用了, 兼顾了实现和性能, 属实厉害

顺口一提, 这其实很像在ConcurrentHashMap中使用volatile在get()方法中替代synchronized的设计思想, 都是并发访问的轻量级实现方式, 都在实现功能的前提下大幅提高了性能, 可能这就是计算机科学吧

以上内容如有错误, 恳请指正. 有时间再更

你可能感兴趣的:(对事务隔离级别的新思考(InnoDB & MVCC & 间隙锁))