事务是数据库系统提供的高级抽象,利用事务可以让应用层付出较少的努力就能提供较高的一致性保障,而不用过度关心类似于竞争条件、不完全写入、数据丢失等问题。
稍微学过用过数据库的同学,大都接触过事务这个概念,通常也知道事务有ACID四个特性。最早接触事务是在背八股时,阅读《MySQL技术内部 InnoDB存储引擎》这本书听说的,相信这本书也是很多同学入门数据库的书------但是它很多内容像流水账,很枯燥,而且由于覆盖的内容很广,像事务的概念这样的内容介绍不够深入,并没有让我看完能形成逻辑闭环。又在网上翻了很多文章,最终也是一知半解。所幸后来又拜读了《数据密集型应用系统设计》这本书,其中对后端中常用的数据存储和数据处理组件的原理进行了深入的剖析,解决了当初我的很多疑惑。这里列举出当年我的一些疑惑,这本书和这篇文章都会深入解释:
- 间隙锁(next-key locking)是什么?
- 事务的一致性到底指什么?
- 幻读是怎么产生的?
- 各种隔离级别解决了什么问题?为什么有这么多隔离级别?
事务的ACID特性
各种网络上的文章和书本对ACID都有各自的解释,同时各种数据库的实现也各有不同。由于ACD通常只是概念,实现上的区别和可调节的参数不大,所以用比较短的篇幅介绍;而隔离性的内容较多,我们放到最后详细介绍。
A, 原子性
原子性恐怕是ACID中最容易理解的概念了,它虽然在多种语境下(比如它常被使用的一种语境是,"并发环境下使用原子操作保证自增操作的正确性")都被广泛使用,但是含义都基本一致。原子性意味着多个(写)操作会作为一个整体执行,不然整体成功,不然整体失败,不会出现其中一部分操作成功而另一部分失败的情况。也就是说,原子性可以让事务执行的结果可预测。后面介绍的隔离性则主要关心事务执行过程中对其它事务展现的状态。
C, 一致性
一致性是一个被滥用的词,即使在用于解释事务一致性时都能有五花八门的说法,比如这个知乎讨论。总结一下大概有这些解释:
- 事务进行的过程中,反复读取记录,结果都相同
- 最终一致性,弱一致性等CAP中的一致性
- 数据库会从一个一致的状态,转变为另一个一致的状态(这也是最抽象的一种解释)
- 数据库的状态变化反映了真实世界的变化,是符合程序员、业务场景预期的变化。AID是数据库帮助程序员简化编程,实现C的承诺(保障)。也就是说,ACID中,C是目的,AID是手段。
- 数据库在事务执行的过程中会检查数据库的完整性约束(比如唯一键、数据类型、数据长度、非空约束等),只要有一个操作不满足约束,整个事务就会回滚
这些解释中,首先把1&2排除掉!1描述的是事务隔离性的要求,而2描述的是分布式系统下同一份数据多副本同步问题中的一致性。在剩下的说法中,3太抽象太笼统,对我们理解概念没有任何帮助;4是我比较认可的说法;5则是达成一致性过程中,数据库层面可以做的一些保证,毕竟触发器、唯一性约束等都可以算是数据库提供的,可供用户配置的功能。即使你不用事务,这些功能都也是生效的。
因此可以看到,ACID中的一致性更像是为了凑个像样的单词而提出来的概念,就像中文广告喜欢凑谐音梗,英文里也很喜欢把多个单词的首字母凑起来成为新概念。
D, 持久性
持久性意味着一旦事务被提交,它就不会丢失,即使这个时候出现了一些异常事件比如服务器断电。当然持久性也并不是万全的,对于单机数据库而言持久性通常意味着数据已经写入磁盘,但是这并不能保证数据不丢失,假如磁盘日后出现了坏道,或者出现了火灾、水灾等不可抗力,持久性也会被打破。对于分布式数据库来说,持久性意味着已经有一定数量的节点同步了最新的事务提交,这种情况下即使一个节点被不可抗力毁坏了,持久性依然可以被保证------不过可能会出现最新的事务只被部分节点同步,但没能正确返回客户端已提交的回复。因此并没有完美的持久性,持久性能做的就是保证几个9的情况下,数据都是完好的......这世上一切都是不确定的,对吧?
I, 隔离性
隔离性主要解决并发问题,也就是在一个事务中(大部分情况下不会只执行一条语句),假如出现了并发读写,那这些事务该看到什么样的数据的问题。理想情况下,事务应该让并发事务的执行过程和一个一个串行执行相同(这也是"隔离"的含义,无需在意其它事务的存在),但是真正串行隔离级别的性能很差,因此数据库通常支持3种更弱的隔离级别:未提交读、已提交读、可重复读(快照读)。随着技术的发展,也出现了基于乐观锁的串行快照隔离级别,在无锁条件下自动检测丢失更新等并发写问题。
未提交读、已提交读的概念很好理解,因此这里主要深入可重复读和串行快照这两种隔离级别。
可重复读(快照读)
简单地说,可重复读(快照读)是为了解决同一个事务内,多次读取同一行数据可能看到不同值的情况。这是因为,在第一次和第二次读取之间,可能有别的事务提交了更改,导致第二次读取的时候值被修改了。这很容易导致不一致问题,就像我们开发过程中,谁都不希望自己开发了一个月的代码突然要rebase一个重构的commit对吧?
尤其是在一些OLAP场景下,一次计算通常要持续很久,我们希望整个数据库的数据保持一致没有更改。否则,比方说我们需要计算用户最近3天是否登录和最近1天是否登录,计算第一个数据的时候,用户这三天都没有登录,我们得到false
;在计算第二个数据之前,突然来了个数据修正,把用户昨天的登陆状态改成了true
,因此第二个数据得到了true
,最终我们得到的结果是:用户三天内没有登录,但是昨天登录了------这明显不符合逻辑。错误的数据得到了错误的答案很正常,但是不符合逻辑的答案是不能接受的,开发人员可能debug好久都摸不着头脑。
可重复读可以通过两种方式来实现:
- 锁:最原始且保守的方式,当一行只有读事务时,就上一个读锁,读锁可以在多个事务中共享;当遇到写事务时,就上写锁,而写锁是排他的,这样就能保证读事务读取的数据不会被修改,而且读到的数据始终是最新的,但缺点是读写会互斥。
- MVCC快照读:为了让读写不相互阻塞,可重复读可以通过MVCC(Multi-Version Concurrency Control)来实现,大致思路是支持每行数据有多个版本,每当新事务提交时更新版本。每个事务开始前要记住当前未提交的有哪些版本号,读取数据时,事务只需遍历读取数据的版本,找到比当前事务id小,又不是记录的未提交事务(事务执行过程中这些事务可能有的已经被提交了)的最新数据即可。当然,那些目前已经没有被事务使用的版本会被时不时清理掉。相比于1#,它的缺点是读到的数据可能不是最新的,因此很会造成丢失更新问题
MVCC通常只对读语句生效,在处理写(UPDATE
和DELETE
)语句时,通常会打破"只读取事务开始前版本"的限制。比如我们写了如下的SQL:UPDATE table SET money=money-10 WHERE id == uid
,这时读到的money
字段就是最新的。因此这可能造成先SELECT再UPDATE
和直接UPDATE
的执行结果有差别!
丢失更新
已提交读和可重复读关注的是事务中的读语句遇到并发写事务时的问题,接下来我们看看多个写事务同时修改相同数据时会遇到的经典问题:丢失更新,之后再介绍串行隔离级别。
最容易理解的丢失更新就是自增操作:先读,再自增1,即使单机环境下也会有这种问题。如果考虑不阻塞读取的方法,单机条件下我们可以通过CAS操作,SQL中也有类似的语句可以利用,比如UPDATE table SET x = x + 1 WHERE id = ...
通常会隐含原子操作的语义(取决于数据库实现)。但是并非所有情况都可以用CAS一步到位地解决,比方说应用层代码想先把数据读出来,做一些操作之后再写回,因此在读和写之间就可能有别的事务提交,造成写回的数据丢失了更新。
解决单点查询丢失更新------加锁
为了解决丢失更新的问题,我们希望在读取可能产生并发写问题的数据时,先将其上锁,再进行读写操作。这样就可以确保事务进行的过程中该数据不会被修改,而且读到的始终是最新的。这样即使出现了并发问题,另一个事务开始前也得先拿到锁才能进行之后的读写,打破了丢失更新的产生条件。其实刚刚介绍的数据库中的执行的类CAS语句,很多时候也是数据库隐式加了锁实现的,也就是说很多数据库会默认给所有UPDATE语句修改的行都上锁。
行锁与间隙锁
刚刚看到,我们用上锁解决了丢失更新问题。现在我们来深入讨论一下锁的实现方式。
通常数据库都会实现行锁,行锁通常是对表的主键上锁,确保主键相同的数据不能被多个事务并发修改。但对主键上锁只能锁住已存在的行,行级锁没有办法对不存在的数据上锁(也就是没法阻止别的事务插入新行)。
假如某个岗位上一共有两名员工,因此它们俩人不能一起请假。假如我们有一个请假系统,分两步执行这个逻辑:
- 找到所有今天请假的人并上锁
SELECT * FROM oof WHERE timeStart <= today AND timeEnd > today FOR UPDATE
。 - 应用层代码判断如果人数为0,那就执行下一句SQL:
INSERT INTO oof (id = myId, timeStart=todayMorning)
在这两句代码执行的间隙,也许有另一个人也提交了请假请求并通过。如果1#中的上锁操作只依靠行锁(如果是不按主键的单点查询,也可以锁二级索引),那就会造成两个人的请假都被系统通过,违背了我们设定的逻辑,造成丢失更新问题------在发出请假请求之前,表中根本就没有数据,怎么能对不存在的行上锁呢?
没法对不存在的数据上锁,而这些新插入的数据最终影响了逻辑正确性的问题通常称为幻读。为了解决幻读问题,我们可以考虑记录目前哪些范围被上锁了,每次申请新的锁时,数据库需要一一判断时候与已有的范围锁冲突。但范围锁毕竟更偏向逻辑概念,需要遍历执行才能知道是否有冲突的结果,因此存在很多范围锁的时候,申请新锁时的消耗就比较大。
许多主流数据库的实现方法都是间隙锁,这是将"范围冲突"这一包含无限可能的冲突简化化为有限的"锁冲突"的方法。实现方法通常是在主键索引和二级索引上增加可供上锁的间隙锁(把相邻的两行之间的间隙锁起来),这样两行之中就不能插入别的行。负无穷到第一行、最后一行到正无穷是特殊的两个间隙,因此这种方式产生的间隙锁大概是索引中值的数量+1。举个例子,假设表中存在id=1,2
两条数据(id为主键),那主键上就有5把锁,分别是
- 行锁:
id=1
,id=2
- 间隙锁:
(-∞,1)
,(1,2)
,(2,+∞)
因此当想锁住id>=2
这一范围时,只需锁住id=2
和(2,+∞)
两把锁即可。
当然,间隙锁可能会造成锁的并发度降低,比方说有一个事务需要锁住id=[2,3]
这一范围,实际上也会锁住id=2
和(2,+∞)
这两把锁,因此如果另一个不与之冲突的事务需要锁住id=[5,6]
,也会被阻塞。
刚刚举的是主键的例子,如果上锁的范围是用二级索引查询的(类似SELECT XXX FOR UPDATE WHERE age>5
的语句,且age
不是主键,并建了索引),那也是类似的实现原理。那如果没有为这一字段建立索引呢?数据库会直接锁住整张表。通常在可重复读(快照读)隔离级别下,间隙锁是会生效的,也就是说通常可重复读隔离级别已经可以避免幻读。
可以认为间隙锁是利用已有的索引实现了范围锁。为什么一定要利用已有索引呢?因为每次插入数据都要更新已有索引,这样维护间隙锁的额外成本更可控。如果只限于索引,那数据库的字段这么多,查询的模式千变万化,数据库不可能维护所有字段下的范围锁,因为这样和给所有字段建索引没有任何区别。所以可以认为这是在锁维护成本和并发度之间取的平衡点,目标是保证数据库性能在多数情况下比较好。
串行快照读
可以看到为了性能问题,数据库付出了很多的努力来在无锁或者用较小粒度锁的方式解决并发问题,但是它们或多或少都需要应用代码层仔细地甄别竞争条件,对代码正确地加锁来避免错误出现。因此对开发者最友好的还是串行执行------有没有性能很好的串行隔离实现呢?确实有,随着技术的发展,出现了串行快照读这种能自动检测丢失更新(准确地说是写倾斜问题,也就是事务执行过程中读到了过时的数据,又将过时的数据的衍生数据写回数据库)的隔离级别。
串行快照读利用乐观锁来解决丢失更新问题。具体来说,数据库
- 每个事务开始后,记录事务读取的记录和写入的记录
- 每个事务提交时,
- 查看当前事务读取的记录是否被已提交的事务修改了(详见#2.2),是的话,就终止事务并回滚。
- 检查修改的记录与正在运行的事务的读取的记录是否有交集。如果有的话,就将被影响的事务做个标记,并让它们在提交的时候报错(详见#2.1)
因此串行快照隔离级别打破了竞争条件,确保事务执行过程中没有任何读取过时数据的情况发生。目前一些数据库如PostgreSQL的串行隔离级别实现已经支持这种实现,相信随着其应用扩大和性能提高,我们再也不用使用弱隔离级别,一切都和串行执行的结果一样!
串行快照读有什么缺点呢?主要问题在于"乐观锁"上。乐观锁是假定没有任何冲突存在,因此最怕的就是冲突频次高的情况,事务被打断的概率会很高,尤其是对于长时间执行的事务来说,几乎无法成功提交。所以在冲突频次高的情况下,最好的方式还是加锁,让需要竞争资源的请求一个一个进入,有序执行。举个红绿灯的例子,当十字路口几乎没有车时,可能不要红绿灯的交通吞吐量最大,因为过路口的车几乎不会和别的车有冲突;但当十字路口塞满了车开始排队时,最好还是有交通灯,否则乱行的车会相互阻挡,总体的行驶速度肯定更慢。