数据库的事务包含原子性、一致性、隔离性、持久性四个特性。隔离性与一致性紧密相连,它们也容易让人迷惑。SQL标准定义了4个隔离级别,但由于定义使用的是自然语言,而非形式化语言,导致人们对隔离级别的理解有所差异,各个数据库系统的实现方式也有所不同。然而在分布式的场景下,又面临新的问题。
探索前沿研究,聚焦技术创新。本期由腾讯云数据库高级工程师孟庆钟为大家介绍数据库事务一致性的实现,内容包括事务的基本概念以及特性、主要的隔离级别及实现、TDSQL事务一致性的实现。
事务的基本概念及特性
1.1 事务的基本概念及特性
事务是用户定义的一个数据库操作序列,这些操作要么全做,要么全不做,是一个不可分割的工作单位。
事务具有四个特性即ACID:
- 原子性(A):事务中包括的操作要么都做,要么都不做;
- 一致性(C):事务执行的结果必须使数据库从一个一致性状态转移到另一个一致性状态;
- 隔离性(I ):并发执行的事务之间不能互相干扰;
- 持久性(D):事务一旦提交,它对数据库的改变应该是永久性的。
原子性和持久性比较直观易懂,但是一致性和隔离性则较为复杂,不同人有不同的理解。
1.2 一致性的理解
一致性是偏应用角度的特性,每个应用程序需要自己保证现实意义上一致。
数据库在一致性方面对应用程序能作出的保证是:只要事务执行成功,都不会违反用户定义的完整性约束。在执行事务的过程中,只要没有违反约束,那么数据库内核就认为是一致的。
常见的完整性约束有主键约束、外键约束、唯一约束、Not-NULL约束、Check约束。只要定义了这些约束,数据库系统在运行时就不会违反;只要没有违反,数据库内核就认为数据库是一致的。至于现实意义上是否一致,需要由应用程序自行判断。
1.3 导致不一致的原因
为什么数据库可能会不一致呢?其实是由冲突所导致的。应用程序对数据的读写操作,最终体现为数据库内核中的事务对数据库对象的读写操作。如果不同事务对相同的数据进行操作,并且其中一个操作是写操作,则这两个操作就会出现冲突。如果不能正确处理这些冲突,就会出现某些异常。常见的异常主要有脏写、脏读、不可重复读、幻读等。
并发执行的事务产生冲突,其实可以理解为科幻小说里两个不相容的物体进入了同一时空。因为是在时空上产生冲突,所以我们可以从时间和空间两个维度解决:
- 时间维度:把两个操作从时间维度隔开,禁止同时访问。这其实是基于锁实现的并发控制思想。
- 空间维度:把这两个操作从空间维度隔开,禁止访问同一份数据。这其实是基于多版本实现的并发控制思想。
1.4 隔离性的理解
有些应用程序的执行逻辑,永远不会导致某些异常的产生。在这种前提下,即使数据库允许某些异常,实际上永远也不会产生这些异常,数据库仍然是一致的。
我们用一个简单的比喻来进行理解。底层模块看作是数据库,上层模块看作是应用软件,当上层软件模块调用底层模块时,即使底层模块有BUG,但如果不踩这个坑就永远不会触发BUG,则应用软件和数据库组成的成体看起来并没有BUG,数据库则会一致。
隔离性是指并发执行的事务之间不能互相干扰。为了提高系统运行效率,SQL标准允许数据库在隔离性上进行妥协,即允许数据库产生某些异常。那到底需要隔离到什么程度呢?这需要由隔离级别来确定。根据需求的不同,我们可以选择不同的隔离级别。
主要的隔离级别及实现
2.1 SQL标准定义的隔离级别
我们所理解的隔离级别是指并发执行的事务能看到对方的多少。下图是SQL标准给出的定义,根据数据库禁止哪些异常来进行划分。SQL标准定义了四种隔离级别:Read Uncommitted、Read Committed、Repeatable Read、Serializable。
Read Uncommitted为读未提交,所以三种异常都有可能发生。比Read Uncommitted更严格一级的是Read Committed,即不能读未提交的事务,但可以出现不可重复读和幻读。更严格的是Repeatable Read,只允许出现一种异常。最严格的是Serializable,这三种异常都不允许发生。
但该定义也有不足。一方面,上述定义来源于锁实现的并发控制,但是又想摆脱对锁实现的依赖,所以根据数据库不允许哪些异常来定义数据库的隔离级别。基于锁实现的并发控制可以完美匹配上面的定义,但是其它实现方式不一定匹配这个定义。比如常见的快照隔离(Serializable Isolation),不会出现这三种异常,按定义属Serializable,但它可能出现其它异常(写倾斜),所以快照隔离并非真正的Serializable。另一方面,该定义使用的是自然语言,而非形式化的语言,导致人们理解有差异,有些系统因此直接把快照隔离称为可串行化。
2.2 基于锁实现的并发控制
锁可以分为多种类型,包括读锁、写锁和谓词锁。读锁、写锁锁单个数据对象,谓词锁锁一个范围。持锁的时长也不相同,可以操作完数据直接放锁,也可以等事务结束才放锁,比如读数据或写数据前先把锁拿到手,一直持着不放直到事务结束。下图中的Well-formed Reads/Writes是指读/写数据之前都要加锁,非Well-formed Reads/Writes就是不加锁而直接对数据进行读/写。
基于上述三个前提,我们可以看到视图中的隔离级别,关于写的操作基本相同,都是写数据前要先拿到写锁,写锁等事务结束后再放,主要区别在于读锁。
Read Uncommitted的读不需要加锁。事务写数据要加写锁,事务结束后才放锁。虽然读锁和写锁互斥,写加写锁,但读时不加锁就可以直接读到。
比Read Uncommitted更严格一级的是Read Committed,读时需要加读锁。只要能加读锁就代表没有其它事务在写,写该数据的事务一定已经提交。因为未提交的写事务其写锁还没有放,读锁和写锁互斥,读事务无法加读锁,因此用该隔离级别读到的都已提交事务的数据。读事务的读锁在读完后就放锁,下次再读该数据时要重新加读锁。放锁后再加锁,该数据可能在读事务未持读锁的期间被其它事务修改,因此读到的数据可能有变化。
在Serializable下,读要加读锁,到事务提交时才放。这就保证了数据不会在读事务执行期间被修改。因为如果其它事务要改就需要加写锁,写锁读锁互斥,因此其它事务的写锁加不上。括号中的Both指的是谓词锁和一般的读锁都到最后才放。
Repeatable Read(RR)比Read Committed(RC)强,比Serializable弱。RR比RC强在读锁,RR比RC多一个读锁最后放。RR比Serializable弱在谓词锁,RR比Serializable少一个谓词锁最后放,所以RR可能出现幻读。
2.3 基于多版本实现的并发控制
一个数据库对象有多个不同的版本,每个版本关联一个时间戳。事务在访问数据时,会使用一个快照选出合适的版本。这就是多版本并发控制(MVCC),好处是读写互不堵塞,读时可在多版本中读合适的版本,写时追加一个版本。
时间戳的选择有两种主流的方式:
- 使用事务的开始时间:PostgreSQL属于这类系统。大多数情况下,事务开始的时间越晚,则产生的版本越新,但是存在特例。为了排除这些特例,PostgreSQL的快照中有一个活跃事务列表,列表中的事务对快照不可见。
- 使用事务的结束时间:TDSQL属于这类系统,事务结束的早,就可以对后续事务可见,所以这类系统的快照只需要一个时间戳,通常只是一个整数。
典型的隔离级别有三种,Read Committed(RC)、Snapshot lsolation(SI)以及 Serializable Snapshot Isolation(SSI)。
2.3.1 Read committed的实现
理论上,Read Committed有三种实现方式:
- 对每行数据来说,随意读取一个已经提交的版本;
- 对每行数据来说,读取数据最后提交的版本;
- 每条SQL语句开始时,获取一个最新的快照,使用这个快照选择数据合适的版本,修改数据时修改最新版本。
第一种实现方式满足定义,但可能因为读取的数据太老,导致现实中无意义,因此实际系统里基本不用这种实现方式。
第二种实现方式也满足RC定义,但会存在读偏序问题。读偏序是指只读取到某个事务的部分结果,比如T1更新了两行数据,但是T2只读到其中一行的更新。如果对每行数据都只读最新提交的版本,就会存在读偏序问题,实际系统中也较少使用这种实现方式。
第三种则是实际系统里较为常用的实现方式。具体实现方式为:每条SQL语句开始时都会获取一个最新的快照,用该快照选择合适的版本,用同一快照选择就不存在读偏序问题。
2.3.2 Snapshot lsolation的实现
第二种比较典型的隔离级别是Snapshot lsolation(SI),它并不在SQL标准定义的四种隔离级别中。其实现方式是:事务开始时获取一个最新的快照,事务的整个执行过程中使用同一个快照,保证可重复读。修改数据时,如果发现数据已被其它事务修改,则abort。
在SI中,上述提及的三个异常即脏读、不可重复读、幻读都不存在,但存在写偏序问题。如果两个事务读取了相同的数据,但是修改了这些数据中的不同部分,就可能导致异常,这种异常叫写偏序。
2.3.3 Serializable Snapshot Isolation的实现
第三种比较典型的隔离级别是Serializable Snapshot Isolation(SSI),其实现方式为:事务开始时,获取一个快照(通常是最新的。为了降低事务abort的概率,某些只读事务可能拿到非最新快照)。修改数据时,如果发现数据已经被其它事务修改,则abort。
SSI跟SI的不同在于:在读数据时,SSI记录事务读取数据的集合,再使用算法进行检测,如果检测到可能会有不可串行化的发生,则abort。这种算法可能会有误判,但不会有遗漏,因此SSI不存在写偏序问题。
SSI是真正的Serializable隔离级别。
2.3.4 写写冲突的处理
对于写写冲突的处理,基于多版本实现有两种实现方式:
- First commit win,谁先提交谁赢。谁最先把数据提交到数据库里谁就胜出。
- First write win,谁先写谁赢。谁先往数据库里写(不一定提交),就会阻塞后面的写事务,从而更有可能赢。
2.4 MySQL的隔离级别
在分析MySQL的隔离级别之前,我们需要先了解两个概念:当前读和快照读。当前读是指读取数据的最新版本,读写时都需要加锁。快照读是指使用快照读取合适的版本,快照读不加锁。MySQL支持SQL标准定义的四种隔离级别,具体实现方式如下:
MySQL在Read Uncommitted直接读,不加锁,可能出现脏读。
MySQL在Read Committed下,对于select(非for update、 非in share mode)使用快照读,每个SQL语句获取一个快照;对于insert、update、delete、select for update、select in share mode则使用当前读,读写都加锁,但不使用gap锁,读锁用完就释放,写锁等到事务提交再释放。
MySQL的Serializable属于纯两阶段锁实现,所有DML都使用当前读,都读最新版本,读写都加锁,使用gap锁,锁都到最后再放。
MySQL的Repeatable Read在Serializable的基础上,放松了对select的限制,select(非for update、非in share mode)使用快照读,其它场景则与Serializable相同。MySQL在Repeatable Read下,混用了当前读和快照读,可能会导致看起来比较奇怪的现象,但只要理解它的实现方式,就可以对这些行为做出分析,这些都是可解释的。
2.5 PostgreSQL的隔离级别
MySQL更像是基于锁和多版本的结合。而PostgreSQL则是基于多版本的实现,写时有行锁。PostgreSQL支持三种隔离级别,即Read Committed、SI、SSI,PostgreSQL里没有当前读,都是快照读,三种隔离级别的实现方式如下:
在Read Committed中,每条SQL语句都会使用一个最新的快照。对于update,如果发现本事务将要修改的行已经被其它事务修改了,则使用数据最新的版本重新跑一遍SQL语句,重新计算过滤条件、计算投影结果等,再尝试更新最新的行,如果不满足过滤条件则直接放弃更新。这个过程在PostgreSQL中被称为EPQ(EvalPlanQual)。
在SI中,整个事务使用同一个快照,更新时如果发现数据已经被其他事务修改,则直接abort。这在PostgreSQL代码里有较为直观的呈现,发现数据被改后,判断当前隔离级别是否大于等于SI,如果是则直接abort,如果小于则会跑EPQ。
SSI在SI的基础上,使用SIREAD锁(PG内部也称为Predicate Locking),记录本事务的读集合。如果检测到可能导致非可串行化,则将事务abort。实际加锁的数据范围,与执行计划相关,比如seqscan对整个表加锁,index scan只需要对部分行加锁。需要注意的是,SIREAD锁是一套独立于PG读写锁之外的机制,与PG中读写锁均不冲突,它只是起到记录作用,用于写倾斜的检测。
PostgreSQL里面关于写写冲突的处理方式是谁先写谁胜出,具体实现机制为给行加上行锁,这时其它事务就无法修改。PG的行锁几乎不占内存,本文不详细展开。
下面介绍下PostgreSQL中SSI的检测原理。SSI检测是为了消除SI里的写偏序异常,主要检测系统里是否存在下图中的危险结构。
PostgreSQL把下图中的结构称为危险结构,具体如图所示。T1是第一个事务,T1读取数据后,T2对读取的数据进行修改,这就形成了一个读写依赖。同理,T2和T3也形成了读写依赖关系。在所有可能导致不可串行化的调度里,必定会存在该结构,PostgreSQL通过检测这种危险结构从而避免写偏序。
TDSQL事务一致性的实现
3.1 TDSQL的架构
TDSQL的目标是让业务能够像使用单机数据库一样使用分布式数据库。其功能特性主要有MySQL完全兼容、全局一致性、扩缩容业务无感知、完全原生的在线表结构变更,其存储引擎为分布式的KV系统,提供事务和自动扩缩容能力。
TDSQL是存储计算分离架构,有三个组件,分别是计算层SQLEngine,分布式存储层TDStore、元数据管理层TDMetaCluster。计算层完全无状态,所有数据都存储在存储层。业务通过MySQL协议接到SQLEngine,SQLEngine在计算时如果需要数据就从存储层里读取。存储层是分布式的KV系统,用Region进行划分,一个Region代表一个Key的范围。以下图为例,图中有四个Region,分别是Region1、Region2、Region3、Region4,每个Region都有三个副本,副本之间用Raft协议保证一致性。
3.2 多副本的一致性
TDSQL使用Raft协议保证高可用以及多副本间的一致性。在Raft协议中,比较重要的内容主要是选主、日志复制、安全和配置变更。
- 选主。Raft协议是一个强主的协议,集群中必须要有一个leader,系统才能对外提供服务,要保证选出来的leader唯一。
- 日志复制。日志只会从leader到follower单向复制,日志没有其它流动方向。
- 安全。保证选出来的leader包含最多的日志,避免leader因为日志不全而需要到其它节点上拉取日志。如果日志不够多,就不可能成为leader。
- 配置变更。Raft协议中,系统只能有唯一的leader,不能产生双主。为了避免产生双主,增减节点时使用两阶段完成。整体流程为:先是旧配置生效,再到新配置和旧配置同时生效,最后新配置生效。
3.3 TDSQL的并发控制
TDSQL的并发控制是基于时间戳的多版本变化控制。通过提供全局时间戳服务的TDMetaCluster,保证时间戳全局单调递增。
每个事务开始时会拿一个start-ts,即快照。读数据时,因为数据项上有关联时间戳,我们就读取数据所有版本中关联时间戳小于等于start-ts且最大的那个版本。start-ts是事务开始时拿的时间戳,只要数据版本关联的时间戳比start-ts大,就代表该版本是后面事务产生的,不应该可见。
TDSQL使用write on commit。事务对数据的修改,会先缓存到本地的事务空间,在事务运行过程中只要不提交,就不会往存储上面写。事务的commit-ts写到数据项上,这是关联数据项的时间戳。
我们以update A=A+5为例。事务开始后先拿时间戳为4,再选择应该读取哪一行。这个例子中有两个key但有三个版本,A有两个版本,时间戳分别为1和3。我们用start-ts=4的时间戳去取,因为要读最新版本的值,1为旧版本,所以读取到的是时间戳为3的版本即A=10。再进行计算10+5=15,所以A=15。
update A=A+5事务对数据的修改,会先缓存到本地的事务空间而非存到公共存储,等到提交后拿到时间戳,关联新产生的版本后再进行存储。
我们以下图中的例子来说明TDSQL的并发控制。左边事务读到时间戳为3的版本即A=10,通过计算A=A+5得到15,缓存到本地事务空间再开始提交。右边事务在完成后准备提交,会先到存储里检查是否有其它事务先于自己往里面插入时间戳大于4的版本,读取后发现最新版本关联的时间戳为3,因为3<4因此可以把A=15进行提交。左边事务在put A=15成功后,在提交前也要进行检测,但在检测时发现存储上A的最新版本关联的时间戳为6(右边事务提交版本),刚刚是3现在是6,说明在事务运行的过程中其它事务修改了数据,因此就不能再提交,提交失败。
TDSQL的并发控制采用乐观事务模型,在提交前需要做冲突检测。这个过程不需要逐个比对最新数据与已读取的数据,耗时较短,它将之前读到的所有key的时间戳与start-ts比较,如果都小于start-ts则允许提交,否则就不允许提交。如果两个事务同时进行检测,只有1个事务可以检测成功,并不存在都检测成功的情况。
这种方式其实采用的是快照隔离。事务开始时先获取唯一快照,提交时做检查,谁先提交谁就胜出。事务的写,最初都是写在事务的本地空间中,没有写到公共的存储上,对其它事务不可见。TDSQL采用谁先提交谁胜出的策略,谁最先提交到系统里就胜出。
3.4 TDSQL的分布式提交协议
分布式事务有多节点参与,为了保证事务的原子性,最后提交时要么全部提交,要么都不提交。TDSQL使用两阶段提交(2PC)来完成,两阶段提交是共识算法的特例,它对环境做了下面的假设:
- 每个节点都有带预写式日志的持久化储存;
- 没有节点发生永久故障;
- 预写式日志永远不会丢失;
- 任何节点之间都可以相互通信。
2PC是一个阻塞性的协议,其中的角色分为协调者和参与者,协调者负责整个事务状态的推进。如果协调者发生永久性故障,分布式事务则无法继续推进。事务持有的其它资源也无法释放。
TDSQL中的计算层SQLEngine完全无状态。在TDSQL把协调者下沉到存储层TDStore上、SQLEngine发出commit命令之后,提交工作全部由TDStore自行完成。因此在协调者发生故障后,TDStore会自动让第一个参与者成为协调者。协调者一定是某个Raft组的leader,如果协调者故障,Raft会选出新的leader,新leader通过回放日志,构造出旧leader上事务的上下文,继续推进旧协调者上未完成的事务,根据具体场景执行commit或abort。
3.5 TDSQL的一致性读
大部分使用2PC提交的系统,都会先让所有节点完成prepare,再获取提交时间戳,TDSQL也不例外。为了保证一致性读(不发生读偏序),读取数据时,如果遇到prepare成功(但还没有commit)的事务,则需要等待已经prepare的事务结束(commit或abort)之后,才能确定这条记录是否对本事务可见。TDSQL在这一环节进行了优化。为了减少等待,TDSQL在prepare时,会给事务分配一个临时的时间戳即prepare_ts,等到提交时再分配一个commit_ts。因为它们都是从同一序列中产生,因此commit_ts一定大于等于prepare_ts。这样设计之后,其它事务遇到刚刚完成prepare的事务产生的记录时,如果事务start_ts小于prepare_ts,则无需等待事务结束,直接不可见这条记录即可,从而避免不必要的等待。
关于作者
孟庆钟,腾讯云数据库高级工程师,中国人民大学博士,深耕存储和优化器设计研发领域多年,熟悉PostgreSQL源代码、Linux操作系统内核代码,目前在腾讯从事TDSQL存储引擎的开发。研究领域包含数据库存储引擎、查询优化等。
参考文献
[1]《数据库系统概论》王珊, 萨师煊.
[2]《PostgreSQL技术内幕:事务处理深度探索》张树杰
[3] Berenson H, Bernstein P, Gray J, et al. A critique of ANSI SQL isolation levels[J]. ACM SIGMOD Record, 1995, 24(2): 1-10.
[4] Cahill M J, Röhm U, Fekete A D. Serializable isolation for snapshot databases[J]. ACM Transactions on Database Systems (TODS), 2009, 34(4): 1-42.
[5] Ports D R K, Grittner K. Serializable snapshot isolation in PostgreSQL[J]. arXiv preprint arXiv:1208.4179, 2012.
[6] PostgreSQL中与EvalPlanQual相关的代码
[7] MySQL中与加锁相关的代码
[8] MySQL中与隔离级别、锁相关的文档
[9] Gray J, Lamport L. Consensus on transaction commit[J]. ACM Transactions on Database Systems (TODS), 2006, 31(1): 133-160.
[10] https://en.wikipedia.org/wiki/Two-phase_commit_protocol
﹀
﹀
﹀
-- 更多精彩 --
还在为数据库事务一致性检测而苦恼?让Elle帮帮你 | DB·洞见
↓↓点击阅读原文,了解更多优惠