很久没有发文了,搞了一个月事务相关的资料和分享,今天用这篇文章做个小节。希望能给大家一些启发。
说起数据库的事务,大家第一反应多是 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 通常是一行数据。因此,之后行文,事务修改数据都可以理解为:
- 单个对象。可以理解为一个 KV 条目。
- 一组对象。如
where x > 5 and y < 6
表达式,会确定一个 KV 条目子集。
因此,下面讨论数据的粒度,都是基于 KV 模型。
弱隔离级别和相应问题
性能最好的隔离级别就是不上任何锁。但会存在脏读(Dirty Read,一个事务读到另一个事务未提交的更改)和脏写(Dirty Write,一个事务覆盖了另外一个事务未提交的更改)的问题。这两种现象会造成什么后果呢?举个例子:
1 | 脏读: |
为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为读未提交(RU,Read Uncommitted)。
但此时仍然会有脏读:
1 | 脏读: |
为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是读已提交(RC,Read Committed)。
但由于加的是短时读锁,一个事务先后两次读 x,而在中间空挡,另一个修改 x 的事务提交,则会出现两次读取不一致的问题,此种问题称为不可重复读(non Repeatable Read)。
1 | 初始 x = 4 |
为了解决此问题,需要在读取数据的时候也加长时读锁。解决了不可重复读的隔离级别称为可重复读(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 第七章医生值班问题。
我们通常使用两种思想,来维护这种不变性:
- 悲观的方式。加锁,使通一个数据子集不能同时为多个事务所访问。
- 乐观的方式。MVCC,每个数据对象存多个版本,每个版本都是不可变的,修改对象即追加一个新的版本。每个事务可以放心的读写,在提交时检测到冲突后进行重试即可。
这部分隐去了很多细节,感兴趣的可以去看看我录的视频。
The Last Thing
上面说的四种隔离级别没有覆盖到另一个常见的隔离级别——快照隔离(Snapshot Isolation)。因为它引出了另上面提到的乐观派实现族——MVCC。由于属于不同的实现思想,快照隔离和可重复读在隔离级别强弱的光谱上属于一个偏序关系,不能说谁强于谁,有机会再展开讲。
本文源于本月分享 DDIA 第七章事务的一个小结。篇幅所限,很多概念没有太展开,感兴趣的可以看看我的读书笔记,也可以叫翻译,因为实在太长了,毕竟一共录了三期视频(阅读原文可达)。