数据库存储系列(2)事务隔离级别

186 阅读12分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情

开篇

今天是我们数据库存储系列第2篇,我们来聊聊事务隔离级别,这个面试中经常出现的问题。

谈到事务,我们经常想起 ACID 的经典组合,其中 I 就代表了隔离性,Isolation,相对来说也是 ACID 中最难理解的。

  • A 代表原子性,用 DDIA 中的经典总结,叫做 abortability 更合理,即是否能中断退出。如果一切正常,一组操作都会成功,这个不需要事务也是一样。关键在于如果存在失败,要能够做到 abort 所有操作,不提交。
  • C 代表一致性,坦率地讲这个更多是个业务层面的概念,比如银行内部转账,我们需要保证在清算的时候,所有的转出和转入是能匹配上的。
  • D 代表持久性,具体到 MySQL 其实可以理解为只要写了 redo log 就算存进去了,就算断电,关机,也是完全可以复原的。

这三点都不复杂,而隔离性就没那么容易表述了。

隔离性

隔离性描述的是【多个事务眼中的彼此】,比如,如果存在两个并发事务,读或写到同一行,那么他们能否感知到彼此带来的数据变化。在 MySQL 中,你可以使用 transaction-isolation 参数来设置隔离级别。


mysql> show variables like 'transaction_isolation';

+-----------------------+----------------+

| Variable_name | Value |

+-----------------------+----------------+

| transaction_isolation | READ-COMMITTED |

+-----------------------+----------------+

一个最朴素,也是最理想的 idea 是为什么要感知?我明明是两个独立的事务,不希望他们存在互相影响,就要完全隔离开。这其实就是【加锁防并发】的思路,导致的产物就是最强的隔离级别:串行化 Serializable。这种场景下我们会把所有并发的事务都串行化,看起来就是一个接一个执行,不存在并发。

的确,这样是最稳,最安全的方案,什么幻读,不可重复读都不用担心。但是,总有一个但是。加锁的方案我们在日常开发中也经常见到,Golang 中一把 Mutex 控制住共享资源,带来的后果是什么呢?没有多线程并发的坑了,但程序性能下降了很多。

类似的道理,串行化的隔离完全没有用到任何并发能力,对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。这会导致你的 SQL 查询语句运行效率骤降。你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。

下面我们来依次看看剩下的三种常见的隔离级别,以及他们可能出现的问题。

  • 读未提交(read uncommitted)

一个事务还没提交时,它做的变更就能被别的事务看到。

  • 读提交(read committed)

一个事务提交之后,它做的变更才会被其他事务看到。

  • 可重复读(repeatable read)

一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。未提交变更对其他事务也是不可见的。

可重复读的经典场景是【数据校对】,比如计算一个上班族的收入,支出,存款是否能对上。在计算的过程中,显然不希望存在一些并发请求,影响你的判断。可重复读的视图是事务开始时创建的,在你计算完成前都不会变,所以我们不必担心受到其他事务更新的影响。

常见的事务隔离异常

最常见的异常包括脏读,脏写,不可重复读,幻读等。

下图是 SQL 规范中对于四种隔离级别和四种异常状况下的评估。对勾表示有可能出现,叉号表示不可能出现。

image.png

这里其实也能看到,【读未提交】这个级别太弱,事务之间并发处理对于各种异常情况都无法防御,而【串行化】又避免了并发,所以不会有问题。所以通常我们讨论的是去除了两个极端场景,只看 RC(读提交) 和 RR(可重复读)。

MySQL 中默认的隔离级别是 RR,而且和 ANSI SQL 上面的标准不同,其实 MySQL 的 RR 是可以做到避免【幻读】的。

下面我们先来看看各个异常是什么含义,后续会具体看看 RC 和 RR 是怎样基于 MVCC 视图实现隔离的。

脏读【读取未提交数据】

一个事务读到了另一个未提交事务修改过的数据。

脏写【修改未提交数据】

一个事务修改了另一个未提交事务修改过的数据。人家这个事务刚写入一些操作,还没提交,因为你的回滚或是别的什么操作,直接把人家给影响了,那边之后没法提交,持久化了。

脏写代表着事务之间的写入直接受到影响,这也是4中异常情况下最严重的。因为数据直接丢掉了。

不可重复读【前后多次读取,数据内容不一致】

一个事务能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值。

幻读【前后多次读取,数据总量不一致】

如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。

其中,【不可重复读】和【幻读】两个概念经常有同学弄混,这两个异常描述的都是在同一个事务中,多次查询,数据不一致。但怎么不一致,这是关键!

【不可重复读】描述的是数据内容被修改。我刚读完数据,而且要基于这个数据来做一些操作,结果后续发现数据的值都不一样了(因为在这个事务执行过程中,存在其他事务对数据值的修改),对应的是 Update,只是一个数据项;

【幻读】描述的是数据量的变化。我读完数据,发现有 n 行,准备做下一个操作,结果后续发现数据量都不一样了,冒出来一些不在我预期内的行,总行数大于 n(因为在这个事务执行过程中,存在其他事务新写入记录),对应的是 Insert,一批数据整体。

注意,如果是数据量变少,这个不叫【幻读】,幻读特指的是原先没有的数据,现在冒出来了。原先读到了,但后来没有了,这种 case 还是归纳为这些行出现了【不可重复读】。

下面我们来看看,MySQL 是怎样在不同的隔离级别,实现对这些异常表现的预防的。

MVCC 视图隔离

MySQL 底层依赖 MVCC 视图来实现不同隔离级别下不同的查询效果。

  • 读未提交:直接返回记录上的最新值,没有视图概念;
  • 读提交:MVCC视图会在每一个语句前创建一个,所以在这个级别下,一个事务是可以看到另外一个事务已经提交的内容,因为它在每一次查询之前都会重新给予最新的数据创建一个新的MVCC视图。
  • 可重复读:MVCC视图实在开始事务的时候就创建好了,这个视图会一直使用,直到该事务结束。 这里要注意不同的隔离级别他们的一致性事务视图创建的时间点是不同的。
  • 串行化:加锁解决,不依赖视图。

下面我们来看看 MVCC 具体是怎么实现的:

在 MySQL 的聚簇索引中,存在着两个隐藏列:trx_idroll_pointer

每个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给 trx_id 列,并把旧的版本写入到undo日志中,roll_pointer 列就相当于一个指针,可以通过它来找到该记录修改前的信息,指向 undo 日志。

每次我们对记录修改时,都会产生一条 undo 日志(本质上是为了事务回滚,一旦 MySQL 判断不需要回滚了就会删除这些日志,这也说明了长事务的危害,undo 日志会不断积累)。

注意,这里特指 update 和 delete,因为 insert 之前没有记录,所以不会有 undo日志。

undo 日志和聚簇索引类似,都包含了一个 roll_pointer 属性,这样多条undo日志和索引就形成了一个链表,通过 roll_pointer 指针链接起来。这里引用小孩子老师专栏里的图示:

image.png

可以看到,每次修改都会对应生成一个 undo 日志,进而我们可以通过这个链表去实现多版本存储。聚簇索引中的是最新值,剩下的 undo 日志里存储的是历史版本的值,以及对应的 trx_id。

ok,现在我们有多版本存储了。怎样支持 RC 和 RR 两个视图呢?

上面我们有提到,RC 这种场景下,我们会在每个语句执行前新创建一个视图,而 RR 场景下,我们会一直复用事务启动的时候的那个视图。

所谓视图,本质还是去控制我们构建出来的这个版本链的可见范围。我们知道当前事务id,又有版本链,每个节点的事务id我们也都知道,就可以基于此来做一些文章。

MySQL 解决这个问题的思路是 ReadView。

ReadView

ReadView 会记录 4 个属性:

  1. creator_trx_id: 当前事务的 id(只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0)
  2. m_ids: 当前系统中所有的活跃事务的 id,即当前系统中开启了事务,但是还没有提交;
  3. min_trx_id: 当前系统所有活跃事务中事务 id 最小的那个事务,也就是 m_id 数组中最小的事务 id;
  4. max_trx_id: 生成 ReadView 时系统中应该分配给下一个事务的 id值。(注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的)

有了 ReadView,我们在查询记录的时候就容易多了。ReadView 中有当前事务id,也知道自己的边界(能看到的最小事务id,最大事务id),同时也知道自己启动的时候有哪些活跃事务(简单讲就是跟自己是并发关系的有哪些)。有了这些上下文,我们就能判断当前事务查询 record 时可见的版本了。

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadViewmin_trx_idmax_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

若沿着【版本链】发现当前版本对 creator_trx 不可见,就继续往 undo 日志那边找,一级一级往老版本找,如果找到最后发现都没有可见的,就认为这个记录对当前事务完全不可见。

  • RC 会在每次读取数据前都生成一个ReadView(注意:事务执行过程中,只有在第一次真正修改记录时才会被分配一个单独的事务id,这个事务id是递增的)
  • RR 则是在第一次读取数据时生成一个ReadView,之后的查询就不会重复生成了。

结语

其实数据库领域里,SQL 的标准和实际业界的实现并不是很贴合。正如 Andy Pavlo 在 CMU 15445 课程上讲的那样,很多时候是厂商先做出功能,然后倒逼委员会加入标准。我们上面提到的4种隔离级别,在不同的厂商产品中表现也是不同的。在技术选型时一定要充分了解产品的 guarantee 到底是什么。

MySQL 的 MVCC 还是非常厉害的,通过 ReadView + roll_pointer 实现了 RR 级别不会出现幻读,为了保证 Multi Version,其实我们执行 Delete 或主键更新时,也不是直接删除记录,而是打标签。只读事务不分配trx_id(会分配一个假trx_id),只有涉及到数据更新才会分配事务ID。这样也更好保证了读写并发。

好了,今天就是这些,下来我们数据库系列文章还会继续,有问题可以发在评论区讨论,感谢您的阅读。