数据库内核杂谈 (十):事务、隔离、并发(1)

本篇文章选自 数据库内核杂谈 系列文章。

在之前的文章,我们和大家分享了基本的数据库优化器和执行器。这篇文章,我们要分享一个很重要的概念:事务及其相关实现。

事务 (transaction) 和 ACID

事务的定义是:一个事务是一组对数据库中数据操作的集合。无论集合中有多少操作,对于用户来说,只是对数据库状态的一个原子改变。

单从概念定义来理解,可能有些晦涩难懂,我们举个例子来讲解:数据库中有两个用户的银行账户 A:100 元 ; B:200 元。假设事务是 A 转账 50 元到 B,可以理解为这个事务由两个操作组成:1) A-= 50; 2) B+=50。对于用户来说,数据库对于这个事务只有两个状态:执行事务前的初始状态,即 A:100 元 ; B:200 元,以及执行事务后的转账成功状态:A:50 元 ;B:250 元,不会有中间状态,比如钱从 A 已经扣除,却还没转到 B 上:A:50 元 ; B:200 元。

一个事务的所有操作要么全部执行,要么一个都不执行。如果在执行事务的过程中,因为任何原因导致事务失败,已经执行的操作都要被回滚 (rollback)。这种“all-or-none” 的属性就是所谓的事务的原子性 (atomicity)。

当一个事务被认定执行成功后,即代表这个事务的操作被数据库持久化。因此,即使数据库在此时奔溃了,比如进程被杀死了,甚至是服务器断电了,这个事务的操作依然有效,这就是事务的另一个属性,持久性 (durability)。

假定数据库的初始状态是稳定的,或者说对用户来说是一致的。由于事务执行的原子性,即执行失败就回滚到执行前的状态,执行成功就变成一个新的稳定状态。因此,事务的执行会保持数据库状态的一致性 (consistency)。

数据库系统是多用户系统。多个用户可能在同一时间执行不同的事务,称为并发。如果想要做到事务的原子性,那么数据库就必须做到并发的事务互不影响。从事务的角度出发,在执行它本身的过程中,不会感知到其他事务的存在。从数据库的角度出发,即使同一时间有多个事务并发,从微观尺度上看,它们之间也有先来后到,必须等一个事务完成后,另一个事务才开始。这种并发事务之间的不感知就是所谓的事务隔离性 (isolation)。

总之,一个事务是一组对数据库中数据操作的集合。事务,对于数据库系统,具有原子性 (atomicity),一致性 (consistency),隔离性 (isolation),以及持久性 (durability)。曾经听过这样一个观点,事务的出现主要是针对并发。其实不然,ACID 属性中只有隔离性是针对并发事务的。所以,即使数据库系统是一个单用户系统,我们依然希望事务具有原子性、一致性和持久性。

隔离级别 (Isolation Level)

如果让你来实现事务的隔离性,最容易的办法,你会想到什么?我想绝大部分的读者都会想到,给数据库加一个全局的操作锁,在同一时间里只允许一个用户对数据库进行操作,这就保证了隔离性。

的确,这样可以保证隔离性,但也限制了并发性,对数据库的性能产生了极大的影响。在实际情况中,没有数据库会这么去实现。并且这个世界并非非黑即白,隔离性也并不是有或者没有。数据库一般会提供多种隔离性的级别,供用户选择:越严格的隔离级别越接近全局锁,越宽松的隔离级别越能提高并发。天下没有免费的午餐,宽松的隔离级别也会随之带来一些问题。

我们结合并发事务可能带来的问题,来讲述一下不同的隔离级别。

首先,我们定义一个相对简单的事务模型,方便后续讨论各种隔离级别和可能遇到的数据问题。虽然数据库支持各种复杂的操作,但归根到底就是对数据基本单元的读写操作,对于任一给定数据单元 A,我们定义 read(A),write(A, val) 分别为读取和写入操作。 同时,对于事务,提供 begin(开启事务), commit(提交事务), rollback(回滚事务) 操作。

先从最宽松的隔离级别开始,read uncommitted(读未提交)。顾名思义,读未提交就是在一个事务中,允许读取其他事务未提交的数据。下图示例很清晰地诠释了读未提交:

在事务 T1 中,读取 A 得到结果是 5,是因为事务 T2 修改了 A 的值,虽然当时 T2 还未提交,甚至最后 T2 回滚了。读未提交导致的问题就是 dirty read(脏读)。脏读的定义就是,一个事务读取了另一个事务还未提交的修改。虽然可能大多数情况下,我们都会认为脏读产生了不正确的结果。但是,抛开业务谈正确性都是耍流氓。或许,某些用户的某些业务,为了支持更大地并发,允许脏读的出现。因为,对于读未提交,完全不需要对操作进行加锁,自然并发性更高。

如何避免脏读呢?数据库引入了第二层的隔离级别,read committed(读提交)。读提交就是指在一个事务中,只能够读取到其他事务已经提交的数据。

在读提交的隔离级别下,再回看上面的例子,T1 中读取 A 的值就应该还是 10,因为当时 T2 还没有提交。沿着上面的例子,接着往下看,如果最后 T2 提交了事务,而 T1 在之后又读取了一次 A,这时候的值就变为 5 了。

这又出现了什么问题呢?在 T1 事务中,先后读取了两次 A,两次的值不一样了。回顾最早提及的事务的隔离性,两次读取同一数据的值不一样,其实违反了隔离性。因为隔离性定义了一个事务不需要感知其他事务的存在,但显然,由于值不同,说明在这个过程中另一个事务提交了数据。这类问题就被定义为 nonrepeatable read(不可重复度读):在一个事务过程中,可能出现多次读取同一数据但得到的值不同的现象。

如何避免不可重复度这个问题呢?数据库引入了第三层隔离级别,根据上面的经验,你可能已经猜出来了,名称就叫做 repeatable read(可重复读)。可重复读指的是在一个事务中,只能读取已经提交的数据,且可以重复查询这些数据,并且,在重复查询之间,不允许其他事务对这些数据进行写操作。虽然我们还没讲到实现,但不难想象,对读数据加读锁锁就能实现。

对于可重复读级别来说,上述例子中的两次读取都会得到数据是 10。读者可能会有疑问,那彼时 T2 的 commit 会失败吗?如果是加锁实现的可重复读,那 T2 的 commit 就会 hold 在那,直至 T1 结束,取决于 T1 最后有没有更新 A,如果有,T2 就会失败。

可重复读,似乎看上去很完美,解决了所有并行事务带来的不确定性。其实不然,我们通过下面这个 SQL 语句的例子来看:

复制代码

T1:
BEGIN;
SELECT*FROMstudentsWHEREclass_id =1; // (1)
...
SELECT*FROMstudentsWHEREclass_id =1; // (2)
...
COMMIT;

上面示例中的查询语句 (1) 和 (2),在可重复读隔离级别下,应该返回相同的结果吗?乍一看,应该觉得,没错啊。但可重复读隔离级别只是规定对被已经读取的数据,禁止其他事务进行修改。那如果是下面这个事务呢?

复制代码

T2:
BEGIN;
INSERTINTOstudents (1/* class_id */, ...);
COMMIT;

T2 事务并没有修改现有数据,而是新增了一条新数据,恰巧 class_id = 1。如果这条插入介于 (1) 和 (2) 之间,(2) 的结果会改变吗?答案是,会的。语句 (2) 会比 (1) 多显示一条记录,即 T2 插入的。这个问题被称为 phantom read(幻读),指的是,在一个事务中,当查询了一组数据后,再次发起相同查询,却发现满足条件的数据被另一个提交的事务改变了。

如何才能避免幻读呢?数据库系统只能推出最保守的隔离机制,serializable(可有序化),即所有的事务必须按照一定顺序执行,直接避免了不同事务并发带来的各种问题。

数据库系统针对不同需求,推出了不同的隔离级别,由宽到紧分别是:

1)读未提交:在一个事务中,允许读取其他事务未提交的数据。

2)读提交:在一个事务中,只能够读取到其他事务已经提交的数据。

3)可重复读:在一个事务中,只能读取已经提交的数据,且可以重复查询这些数据,并且,在重复查询之间,不允许其他事务对这些数据进行写操作。

4)可有序化:所有的事务必须按照一定顺序执行。

而后三种隔离级别分别为了解决前一种隔离级别遇到的问题:

1)脏读:一个事务读取了另一个事务还未提交的修改。

2)不可重复度:在一个事务过程中,可能出现多次读取同一数据但得到不同值的现象。

3)幻读:在一个事务中,当查询了一组数据后,再次发起相同查询,却发现满足条件的数据被另一个提交的事务改变了。

下方列出了一张表格,更直观地展现它们之间的关系。

隔离级别 脏读 不可重复度 幻读
读未提交 可能出现 可能出现 可能出现
读提交 不能 可能出现 可能出现
可重复读 不能 不能 可能出现
可有序化 不能 不能 不能

总结

这篇文章主要覆盖了事务的定义、ACID 属性以及对于隔离性,数据库推出的不同隔离级别。虽然并没有提到很多的实现,不过,理清这些概念对于理解和学习事务的实现是很有必要的。预告一下,下篇文章我们会分享事务的实现。