数据库内核杂谈(十):事务、隔离、并发(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 * FROM students WHERE class_id = 1;  // (1)
... 
SELECT * FROM students WHERE class_id = 1;  // (2)
...
COMMIT;

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

T2:
BEGIN;
INSERT INTO students (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属性以及对于隔离性,数据库推出的不同隔离级别。虽然并没有提到很多的实现,不过,理清这些概念对于理解和学习事务的实现是很有必要的。预告一下,下篇文章我们会分享事务的实现。

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

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/teJA7X43BO2alp6rLCWk
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券