木鸟杂记

分布式系统,程序语言,算法设计

步步为营 剖析事务中最难的——隔离性

很久没有发文了,搞了一个月事务相关的资料和分享,今天用这篇文章做个小节。希望能给大家一些启发。

说起数据库的事务,大家第一反应多是 ACID,但这几个属性的重要性和复杂度并不等同。其中,最难理解的当属隔离性(I,Isolation)。造成这种理解困局的一个重要原因是,历史上对几种隔离级别的定义和实现耦合在了一块、不同厂商的的叫法和实现又常常挂羊头卖狗肉。本文试着从锁的角度来梳理下几种常见的隔离级别,用相对不精确的叙述给大家建立一个直观感性的认识。

作者:木鸟杂记 https://www.qtmuniao.com/2022/07/07/db-isolation 转载请注明出处

背景

数据库试图通过事务隔离(transaction isolation)来给用户提供一种隔离保证,从而降低应用侧的编程复杂度。最强的隔离性,可串行化(Serializability),可以对用户提供一种保证:任意时刻,可以认为只有一个事务在运行。

初学时对几种隔离级别的递进关系通常难以理解,是因为没有找到一个合适的角度(再次表明观点:没有理解不了的问题,只有不对的打开方式)。我的经验是,从实现的角度对几种隔离级别进行梳理,会建立一个一致性的认识。

ANSI SQL 定义的四种隔离级别,由弱到强分别是:读未提交(Read Uncommited)、读已提交(Read Commited)、可重复读(Repeatable Read)和可串行化(Serializability),可以从使用锁实现事务的角度来理解其递进关系。

以锁为媒

最强的隔离性——可串行化,可以理解为全局一把大互斥锁,每个事务在启动时获取锁,在结束(提交或者回滚)时释放锁。但这种隔离级别性能无疑最差。而其他几种弱隔离级别,可以理解为是为了提高性能,缩小了加锁的粒度(谓词锁->对象锁)、减短了加锁的时间(事务结束后释放->用完即释放)、降低了加锁的强度(互斥锁->共享锁),从而牺牲一致性换取性能。

换个角度来说,从上锁的强弱考虑,我们有互斥锁(Mutex Lock,又称写锁、独占锁、排它锁)和共享锁(Shared Lock,又称读锁);从上锁的长短来考虑,我们有长时锁(Long Period Lock,事务开始获取锁,到事务结束时释放)和短时锁(Short Period Lock,访问数据时获取,用完旋即释放);从上锁的粗细来考虑,我们有对象锁(关系型数据中的 Row Lock,锁一行)和谓词锁(Predicate Lock,锁一个数据集合)。

KV 模型

说到数据集合,由于数据库在存储层实现时都是基于 KV 模型,如 B+ 树中 Page 和 LSM-Tree 中的 Block 都是一组 KV 条目。对应到关系型数据库中,如果按行存储,则单条 KV 的 Key 通常是主键, Value 通常是一行数据。因此,之后行文,事务修改数据都可以理解为:

  1. 单个对象。可以理解为一个 KV 条目。
  2. 一组对象。如 where x > 5 and y < 6 表达式,会确定一个 KV 条目子集。

因此,下面讨论数据的粒度,都是基于 KV 模型。

弱隔离级别和相应问题

性能最好的隔离级别就是不上任何锁。但会存在脏读(Dirty Read,一个事务读到另一个事务未提交的更改)和脏写(Dirty Write,一个事务覆盖了另外一个事务未提交的更改)的问题。这两种现象会造成什么后果呢?举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
脏读:
初始 x=4
事务1:--W(x=5)--->rollback
事务2:----->R(x=5)--->commit

事务2 错误的读出了 x=5,但这个值不应该存在过。原因在于事务2读到了事务1未提交的值。

脏写:
初始 x=4, y=4
事务1:--W(x=5)--------W(y=5)--->commit
事务2:----W(x=6)--W(y=6)->commit
最后结果是 x=6, y=5。但如果两个事务先后执行的正确结果应该是 x 和 y 要么都为 5,要么都为 6。造成这种不一致的原因在于,事务1未提交的更改 x=5 被覆盖了,事务2未提交的更改 y=6 也被覆盖了。

为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为读未提交(RU,Read Uncommitted)。
但此时仍然会有脏读:

1
2
3
4
5
6
7
脏读:
初始 x = 4
事务1:----WL(x),W(x=5)-->rollback, UL(x)
事务2:---------------->R(x=5)--->commit

注:RL:ReadLock; WL:WriteLock;UL:Unlock

为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是读已提交(RC,Read Committed)。
但由于加的是短时读锁,一个事务先后两次读 x,而在中间空挡,另一个修改 x 的事务提交,则会出现两次读取不一致的问题,此种问题称为不可重复读(non Repeatable Read)。

1
2
3
4
初始 x = 4
事务1: -RL(x),R(x=4),UL(x)-------------------------->RL(x),R(x=5),UL(x)--->commit
事务2:-------------------WL(x),W(x=5)-->commit,UL(x)

为了解决此问题,需要在读取数据的时候也加长时读锁。解决了不可重复读的隔离级别称为可重复读(RR,Repeatable Read)。

到可重复读级别,都是针对单条数据上锁。在此隔离级别下,如果一个事务两次进行范围查询,比如说执行两次 select count(x) where x > 5;另一个事务在两次查询中间插入了一条 x = 6,这两次读到的结果并不一样,这种现象称为幻读(Phantom Read)。为了解决幻读问题,需要针对范围查询语句加长时谓词读锁。解决了幻读的隔离级别,我们称为可串行化(Serializability)。

可以看出,在可串行隔离级别,不管读锁还是写锁,都要拿到事务结束。这种用锁方式,我们称为两阶段锁(2PL,two Phase Lock)。

两阶段锁,即分为两个阶段,在第一个阶段(事务执行时)只允许上锁,在第二个阶段(事务提交时)只允许释放锁。当然,这其实是严格两阶段锁,SS2PL,感兴趣同学可以自行去查阅下其与 2PL 区别。

泛化一下

从更抽象角度来说,每个事务在执行过程中都要去(touch)一个数据对象子集,针对该数据子集可能会读,可能会写;如果两个并发的事务,摸到的数据子集并不相交,则不用做任何处理,可以放心并发执行。

如果数据子集有相交之处,就会形成事务之间的依赖关系。将事务抽象成一个点,依赖关系根据时间先后抽象成一条有向边,则可构造出一个有向无环图

事务对外提供的最理想抽象是:所有的事务在时间线上可以坍缩为一个点(瞬时完成,即 ACID 中的 A,原子性)。这样所有的事务即可在时间轴上将 DAG 进行拓扑排序,即可串行化。

但在实际执行过程中,事务都是要持续一段时间,即一个时间线段,执行时间有交叠的事务便有了各种并发问题和隔离性(或者说可见性)问题。那如何让物理上并发的事务,逻辑上看起来像顺序地、原子地执行呢?答曰:只需在事务执行前后维持某些不变性即可。

这些不变性,即为 ACID 中的 C,一致性。在应用层看来,也可以称为因果性。也即,前面所说的有数据交集的事务的依赖性能够被正确的处理。举个例子,事务一依赖某个读取内容进行决策后修改,其依赖的内容不能为并发的事务二所修改,不然做出的决策就有问题。更实际的例子可以参考 DDIA 第七章医生值班问题。

我们通常使用两种思想,来维护这种不变性:

  1. 悲观的方式。加锁,使通一个数据子集不能同时为多个事务所访问。
  2. 乐观的方式。MVCC,每个数据对象存多个版本,每个版本都是不可变的,修改对象即追加一个新的版本。每个事务可以放心的读写,在提交时检测到冲突后进行重试即可。

这部分隐去了很多细节,感兴趣的可以去看看我录的视频

The Last Thing

上面说的四种隔离级别没有覆盖到另一个常见的隔离级别——快照隔离(Snapshot Isolation)。因为它引出了另上面提到的乐观派实现族——MVCC。由于属于不同的实现思想,快照隔离和可重复读在隔离级别强弱的光谱上属于一个偏序关系,不能说谁强于谁,有机会再展开讲。

本文源于本月分享 DDIA 第七章事务的一个小结。篇幅所限,很多概念没有太展开,感兴趣的可以看看我的读书笔记,也可以叫翻译,因为实在太长了,毕竟一共录了三期视频(阅读原文可达)。