前言
数据库管理系统(Database Management System,简称DBMS)承担着数据库系统中最主要的数据处理任务,这些任务可大致分为两种类型:
- Online Transaction Processing(联机事务处理,简称OLTP):主要面向业务处理场景,强调高频率、高并发的事务请求问题。
- Online Analytical Processing(联机分析处理,简称OLAP):主要面向分析决策场景,强调高复杂度、大数据量的复杂查询问题。
其中,对于OLTP场景,DBMS以 事务(Transaction) 作为处理业务数据的基本逻辑单元,其主要的性能指标为吞吐量,该场景下最主要的课题之一是如何在多并发环境下处理事务一致性与并发吞吐量之间的权衡,而实现这种权衡的技术即 并发控制技术(Concurrency Control,简称CC)。
对于并发控制中的经典问题(如隔离级别、异象等),现有文章大多是基于MySQL中InnoDB引擎的实现进行介绍,并非解决问题的唯一思路,也不利于充分理解并发控制理论。
本文将尽可能以实现无关的角度描述数据库事务的概念、特性以及并发控制相关的理论、技术,并适当结合MySQL、PostgreSQL等数据库的具体实现进行介绍。
部分原文在知乎,个人理解如有误,欢迎指正!
事务
为什么要有事务?
SQL语句是数据库层面基本的执行单元,但并不适合作为实际业务场景中的最小基本单元。
以一个简单的转账任务(A向B转账100元)为例,该任务至少应当包含以下两步SQL操作:
# 第一步:A的余额扣除100元
UPDATE balance
SET customer_balance = customer_balance - 100
WHERE customer_id = 'A的账户ID';
# 第二步:B的余额增加100元
UPDATE balance
SET customer_balance = customer_balance + 100
WHERE customer_id = 'B的账户ID';
若我们把这两步SQL操作视为互相独立的两个单元的话,可能会存在【一步成功执行,而另一步执行失败】的情况,这就会导致【A明明扣了钱,但B的余额没有增加】或者【B的余额增加了,但A没有扣钱】。很明显,我们不能容忍这种余额总额凭空减少或增加的情况,
为避免这种情况出现,我们必须明确,两步SQL操作对应同一个任务,它们作为一个不可分割的整体,要么都成功执行,要么都执行失败,这样一个整体,就是事务(Transaction)。
事务的ACID特性
通常而言,事务都要满足四大特性(ACID),大多数关系型数据库都提供了ACID事务支持,如Oracle、PostgreSQL、MySQL的InnoDB引擎等,但这并不绝对,一些数据库系统会为了更高的执行效率而放弃某些特性,或者放宽某些特性的要求。
A:Atomicity(原子性)
原子性(Atomicity) 描述了事务整体不可分割的特性,一个事务内的全部操作最终要么全都做,要么全不做(ALL OR NOTHING)。
想要让事务操作“全都做”比较简单,只要让事务正常执行并提交(Commit) 即可;但是想让正在执行的事务“全不做”就有些复杂了,这需要我们在事务执行出现问题时能够重置所有已执行的操作,回到事务尚未执行前的状态,这种操作就是 “回滚(Rollback)”。更确切来说,我们需要实现一种事务的 中止(Abort) 机制,中止会让当前事务停止继续执行并回滚之前的操作(回滚一定中止,中止不一定回滚,例如只读事务就不需要回滚)。
原子性决定了事务最终只有两种可能的状态:提交或中止,没有任何其他的中间状态。
值得一提的是,MySQL是允许用户主动破坏原子性的,即当一个事务内某些操作出错时,MySQL允许将其他成功的操作部分提交(也可以回滚),这实际上违背了原子性的要求,但给了用户更高的自由度;相比之下,PostgreSQL提供了严格的原子性保障,只要事务内的操作出错,该事务之后的所有操作将被忽略,即使该事务显式提交也会被自动回滚。
C:Consistency(一致性)
一致性(Consistency) 强调事务只会让数据库从一个有效状态转移到另一个有效状态,它是四大特性中最核心的特性,其他三个特性归根结底是为实现一致性而服务的。
一致性描述的是结果的正确性,而与过程无关,它表明一个事务应该能得到用户希望的结果,维基百科的定义如下:
Consistency ensures that a transaction can only bring the database from one valid state to another, maintaining database invariants: any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof. This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct.
定义中提到何为“有效状态”:写入数据库的所有数据都应该遵循全部预设的规则,这些规则包括由约束、级联、触发器等一切规则共同组成。
这里提到的预设规则大多是由用户定义并设置的,数据库系统只能提供最基本的执行条件,无法绝对保证结果的正确,因此,一致性的主要承担者是上层应用程序,而非数据库系统,后者只能通过保证原子性、隔离性、持久性等来为一致性的实现提供条件。
还是以一个转账事务(A向B转账100元,二人原余额均为500元)为例:
# 显式开启事务
BEGIN;
# A的余额扣除100,剩余余额500-100=400元
UPDATE balance
SET customer_balance = customer_balance - 100
WHERE customer_id = 'A的账户ID';
# B的余额增加100元,剩余余额500+100=600元
UPDATE balance
SET customer_balance = customer_balance + 100
WHERE customer_id = 'B的账户ID';
# 显式提交事务
COMMIT;
转账前,A和B余额之和为500+500=1000元;转账后,A和B余额之和为400+600=1000元。
对于这个事务而言,【A+B=1000元】就是我们需要保证的有效状态。然而,数据库层面能做的事非常有限,这还需要上层应用程序为数据库设置合理的约束或判断条件,并设计合适的事务内容予以保障,如果某个上层应用在设计事务时执意要破坏这种所谓的有效状态,那么无论数据库系统的实现有多完美,它本身也是无能为力的。
因此,我们通常所说的保证事务一致性只是指从数据库层面去保证事务的原子性、隔离性、持久性,并保障数据库的完整性约束,为上层应用层面提供合适的条件。
还有一点值得注意,数据库中的 一致性(Consistency) 是有不同定义的,这里我们介绍的只是事务ACID特性中的C。在分布式系统领域,还有一套CAP理论,其中的C也表示一致性(Consistency),但与上述事务一致性的定义是不同的,它表示分布式系统中不同副本数据的一致性,与事务无关。
I:Isolation(隔离性)
隔离性(Isolation) 是指事务之间互不干扰、彼此独立的一种特性。当我们谈论隔离性时,通常默认是在多个事务场景下,我们希望这些事务共同执行但不会影响彼此。
- 并发:多个事务交替地执行,但同一时刻只有一个事务被执行
- 串行:多个事务依次地执行,只有一个事务执行完毕才能执行下一个事务
最简单粗暴的隔离性方案是串行化(Serialization), 即完全限制并发,只有在完整地执行完一个事务(提交或中止)后才能执行下一个事务,但这不符合实际业务需求——假如目前有10000个用户的事务,那第10001个用户的事务要等前面所有事务执行完才能开始执行,这样的响应速度显然不符合实际需求。因此,让多事务能够并发地执行,是很有必要的。
同一时间可能存在多个事务正在被执行(并发或串行),则它们的实际执行顺序被称为一个 调度(Schedule) 。
如果这些事务是按顺序一个一个地被执行,则这种调度被称为 串行调度(Serial Schedule)。
如果存在 n 个事务,则它们可能的串行调度一共有 n! 种,而非串行调度的数目远大于 n! 种,具体取决于事务内的操作数量。
在并发场景下,DBMS需要对多个事务进行控制管理以保证隔离性,这是一个很复杂的问题,会涉及到多种可能存在的并发异常,而且需要考虑到实际业务场景的需求,进行适当的权衡,我们在之后的章节详细讲解。
D:Duration(持久性)
持久性(Duration) 是指事务一旦提交,它对数据库的改变是永久性的,即这些数据修改应当能够持久化地反映到数据库中,且不会因系统宕机、网络故障等不可抗力因素而丢失。
并发控制
概述
上文提到,原子性决定了事务最终只有两种可能的状态:提交或中止,没有其他中间状态,而一致性要求事务只能让数据库从一个有效状态转移到另一个有效状态,这两个特性要求的其实都是 “终态” ,即只对事务执行完毕之后的最终状态提出了要求。但是,在事务执行过程中,数据库有可能暂时处于一种不一致的状态,隔离性 则针对这种中间态 提出了一定的要求——一个事务执行过程中导致的不一致状态不应该被其他并发事务感知到,否则可能会对其他并发事务的执行造成干扰。
为了实现多并发场景下隔离不同事务导致的不一致状态,我们需要 并发控制(Concurrency Control) 机制对事务的并发过程进行管理和控制,避免因事务并发而产生的一致性问题,从而保障事务隔离性。这种一致性问题,往往是由并发冲突导致的。
冲突(Conflict) 是指并发执行中的事务同时访问了相同数据项的现象:
-
在冲突关系中,至少要有一个事务对共同的数据项执行了写操作,两个读操作之间是不会产生冲突的
-
因冲突而导致数据库一致性被破坏的现象,我们通常称之为异常(Anomaly)
-
在事务中,冲突只是描述并发事务访问了相同的数据项的现象,它并不代表一种错误,也就是说,即使存在冲突,也未必会存在异常,它只是一种必要不充分条件
-
通常,事务冲突描述的是一种逻辑上的冲突,并非物理上的冲突,即使两个事务访问的并不是同一块物理地址,若两块地址在逻辑上是同一个数据项,也同样视为冲突
-
在某些学术论文中,冲突也被称为依赖(Dependency),但这二者的定义其实略有不同,后面会讨论到
并发控制方法的核心任务,就是处理并发冲突,避免出现某些异常,保障事务的隔离性,并在此基础上尽可能提高并发性能。
隔离级别与异象
ANSI SQL-92标准
在讨论并发控制方法之前,我们首先要确定标准,即究竟如何衡量隔离性?
为了处理事务冲突、保障隔离性,并发控制方法引入了额外的控制开销,这必然会在一定程度上影响整体的并发性能。然而,现实很多业务场景中可能对实时性和效率的要求很高,而对安全性并没有那么高的需求,因此可以容忍一定的并发异常,以此换取更高的并发性能。
为了划分不同级别的隔离性要求,我们提出了 隔离级别(Isolation Level) 的概念:通常而言,隔离级别越高,隔离性越强,可容忍的异常越少,并发性能越差。最经典、最流行的隔离级别划分标准,出自1992年修订的美国国家标准的SQL语言标准(ANSI SQL-92标准),该标准提出了基于三种异象定义的四大隔离级别。
这四个隔离级别分别是:
- 读未提交(Read Uncommitted,简称 RU)
- 读已提交(Read Committed,简称 RC)
- 可重复读(Repeatable Read,简称 RR)
- 可串行化(Serializable,简称 SER)
这三种异象分别是:
- 脏读(Dirty Read)
- 不可重复读(Non-repeatable Read 或 Fuzzy Read)
- 幻读(Phantom Read)
三种异象在四种隔离级别下的存在情况如下表所示:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ UNCOMMITTED | Possible | Possible | Possible |
READ COMMITTED | Not Possible | Possible | Possible |
REPEATABLE READ | Not Possible | Not Possible | Possible |
SERIALIZABLE | Not Possible | Not Possible | Not Possible |
隔离级别只是一种标准,与具体的技术或实现无关,现实中的数据库系统未必严格按照SQL-92标准实现,比如PostgreSQL实际没有实现RU隔离级别且RR隔离级别不会出现幻读,又比如Oracle没有RU和RR隔离级别,但增加了只读隔离级别。
异象
上面提到了一个新的名词——异象(Phenamenon),它实际上可以视为对并发冲突的细分,表示某种具体的事务冲突或异常场景。
为了方便表示,我们这里规定一下相关符号:
符号形式 | 符号含义 |
---|---|
r1[x] | 事务 T1 对数据项 x 进行了读操作 |
w1[x] | 事务 T1 对数据项 x 进行了写操作 |
r1[P] | 事务 T1 对谓词 P 进行了读操作(即范围查询,读取了一组数据项集合) |
w1[y in P] | 事务 T1 写入符合谓词 P 的数据项 y(即对谓词范围插入了新的数据) |
c1 | 事务 T1 提交 |
a1 | 事务 T1 中止(回滚) |
脏读:w1[x] … r2[x] … (a1 and c2 in either order)
脏读,表示无效数据的读取,例如:事务T1修改了某数据项的值,此时事务T2读取该值,之后T1因某些原因撤销对该值的修改(回滚),这导致T2所读取到的数据是无效的。
脏读的本质是事务读取到了其他事务尚未提交的数据,因此允许脏读存在的隔离级别被称为 读未提交(RU) 隔离级别,不允许脏读的隔离级别被称为 读已提交(RC) 隔离级别(只能读取到其他事务已提交的数据)。
实际业务场景通常都不会允许脏读存在,很多数据库系统已经不支持RU隔离级别,如PostgreSQL、Oracle、CockroachDB等。
不可重复读:r1[x] … w2[x] … c2 … r1[x] … c1
不可重复读,表示一种读不一致的现象,即事务对某数据项进行读取的过程中,其他事务修改了该数据(UPDATE、DELETE),导致前后两次读取的结果不同,这里强调数值的不同。
不可重复读的本质是事务读取到的记录被其他事务修改并提交,不允许不可重复读的隔离级别被称为 可重复读(RR) 隔离级别。
幻读:r1[P] … w2[y in P] … c2 … r1[P] … c1
幻读,也表示一种读不一致的现象,即事务在范围查询过程中,其他事务在该范围内新增或删除了记录(INSERT、DELETE),导致范围查询的结果条数不一致,这里强调数目的不同。
不可重复读的本质是事务读取到的谓词被其他事务修改并提交。不可重复读强调读取的记录被修改(数值不同),而幻读强调读取的谓词被修改(数量不同),之所以这么区分,是因为二者的原理和解决方法都不同。
按照 ANSI SQL-92 标准的描述,异象似乎应该是一种实际产生的异常,我们上文描述的也是异常。但是,有研究表明,该标准的描述其实存在歧义和不完备的问题。
在1995年,微软研究者在 《A Critique of ANSI SQL Isolation Levels | ACM SIGMOD Record》 一文中对几种异象进行了形式化定义,将其分为宽泛版和严格版的异象,其中宽泛版表示一种冲突(用P表示),严格版表示一种异常(用A表示)。
以脏读为例,在上述严格版的定义A1中,我们认为【读取到的未提交数据被回滚,导致数据无效化】才算是脏读;但在宽泛版的定义P1下,只要【读取到了未提交的数据】,无论这个数据之后有没有被回滚,都应该被视为一种脏读。
宽泛版异象才是更直指本质的描述,该文中完善的隔离级别标准主要以宽泛版异象来定义,而我们上面介绍的SQL-92标准实际是以严格版异象来定义的,可见SQL-92标准实际上是并不完善的,这里不再赘述,详细可参考该论文。
可串行化
概念
可串行化(Serializable) 是单点式数据库中最高的隔离级别。不同于其他隔离级别,可串行化并非基于异象定义的,例如:我们可以说解决了脏读问题就实现了读已提交级别,但即使我们解决了脏读、不可重复读、幻读异象,也并不能说我们实现了可串行化级别。
参考:《The Notions of Consistency and Predicate Locks in a Database System》
可串行化隔离级别下不存在任何异象(不只包括脏读、不可重复读、幻读),其定义要求多事务的并发调度等价于其某个串行调度,这种等价分为不同层面的等价,从大到小依次为:
-
终态可串行化:调度的最终结果等价于某个串行调度,是真正意义上的可串行化
-
视图可串行化:调度的视图等价于某个串行调度,是终态可串行化的一个子集
-
冲突可串行化:调度经过有限次冲突等价的调换后可以等价于某个串行调度,是视图可串行化的一个子集
我们讨论的可串行化通常是指终态可串行化,但数据库系统中实现的可串行化通常是冲突可串行化,前者是后者的必要不充分条件,这意味着一些实际不违背可串行化原则的调度可能会被数据库系统视为不可串行化的异常。这样做的原因在于:在计算机层面实现终态可串行化和视图可串行化都有很高的算法复杂度,实现难度很高,且会带来很大的性能开销,只有冲突可串行化在工程实践层面有实现的价值。
分析方法
了解冲突可串行化的概念后,我们还需要进一步研究:我们该如何分析一个调度是否是冲突可串行化的呢?
基于冲突等价的分析
冲突等价: 并发调度中两个事务之间互不冲突的操作,可以等效地交换位置(不改变同一个事务内不同操作的相对顺序),不会改变执行结果
例如,下述两种并发调度可以认为是冲突等价的:
事务 T1 | 事务 T2 |
---|---|
BEGIN | BEGIN |
r[x] | |
w[x] | |
w[y] | |
r[y] | |
COMMIT | |
COMMIT |
事务 T1 | 事务 T2 |
---|---|
BEGIN | BEGIN |
r[x] | |
w[y] | |
w[x] | |
r[y] | |
COMMIT | |
COMMIT |
冲突可串行化的定义表明,在经过有限次冲突等价交换之后,能够达成 SERIAL (串行)的并发事务调度,可以视为冲突可串行化。
例如,下面左侧的调度经过交换后等效于右侧的调度(串行调度),因此它是冲突可串行化:
事务 T1 | 事务 T2 |
---|---|
BEGIN | BEGIN |
r[x] | |
w[x] | |
w[y] | |
r[y] | |
COMMIT | |
COMMIT |
事务 T1 | 事务 T2 |
---|---|
BEGIN | |
r[x] | |
w[y] | |
COMMIT | BEGIN |
w[x] | |
r[y] | |
COMMIT |
基于可串行化图的分析
直接的等价分析很难在计算机中实现,为此,1999年,麻省理工学院的 Atul Adya 在其博士论文 《Weak Consistency A Generalized Theory and Optimistic》中提出了一种基于图的事务冲突分析方法,该方法将事务抽象为图的节点,冲突关系抽象为图的有向边,构成了一种描述事务依赖关系的有向图(称为可串行化图)。
作者提出了导致事务异常的三种冲突(依赖),用于表示可串行化图中的有向边,其本质是对异象的进一步泛化:
-
读写冲突(Read-write Conflicts):事务 T1 读取到数据项 x 的旧值,随后 T2 修改了 x 的值
-
写读冲突(Write-read Conflicts):事务 T1 修改了 x 的值,随后 T2 读取了 x 的新值
-
写写冲突(Write-write Conflicts):事务 T1 修改了数据项 x 的值,随后 T2 又修改了 x 的新值
该分析方法指出并证明,在可串行化图中,若存在环结构,则不符合可串行化;若不存在环结构,则符合可串行化。
基于这种方法,我们可以较为方便地分析某个并发控制方法中是否存在不可串行化的异常。为了实现可串行化,我们需要主动破坏其中的环结构,从而保证可串行化隔离级别。
上述这两种方法都具有较高的开销,一般不用于实际数据库系统的并发控制方法中,只作为一种普适的理论模型用于分析并发控制方法是否符合冲突可串行化。
基于冲突等价或基于可串行化图的分析方法,都是存在错杀的情况的,因为它们都只是分析冲突可串行化,而非终态可串行化,即使不符合冲突可串行化,未必就会导致并发异常。
例如:r1[x] w2[x] w1[x] w3[x] … (c1 and c2 and c3 in any order)
上述调度不符合冲突可串行化(成环),但若事务 T2 和 T3 为盲写(不读只写),则事务 T2 对 x 的修改无论如何都会被 T3 覆盖,因此其终态实际等效于 T1 → T2 → T3,符合终态可串行化。
但是,错杀只会影响效率,并不影响数据库的正确性(一致性),出于对性能的考虑,我们允许一定的错杀存在。
下一篇我们介绍具体的并发控制方法。未完待续......