一、前言
在巍峨的数据库大厦体系中,查询优化器和事务体系是两堵重要的承重墙,二者是如此重要以至于整个数据库体系结构设计中大量的数据结构、机制和特性都是围绕着二者搭建起来的。他们一个负责如何更快的查询到数据,更有效的组织起底层数据体系;一个负责安全、稳定、持久的存储数据,为用户的读写并发提供逻辑实现。我们今天探索的主题是事务体系,然而事务体系太过庞大,我们需要分成若干次的内容。本文就针对PolarDB事务体系中的原子性进行剖析。
二、问题
在阅读本文之前,首先提出几个重要的问题,这几个问题或许在接触数据库之前你也曾经疑惑过。但是曾经这些问题的答案可能只是简单的被诸如“预写日志”,“崩溃恢复机制”等简单的答案回答过了,本文希望能够更深一步的讨论这些机制的实现及内在原理。
数据库原子性到底是如何保证的?使用了哪些特殊的数据结构?为什么要用?
为什么我写入成功的数据能够被保证不丢失?
为什么数据库崩溃后可以完整的恢复出来逻辑上我已经提交的数据?
更进一步,什么是逻辑上已提交的数据?哪一个步骤才算是真正的提交?
三、背景
1.原子性在ACID中的位置
大名鼎鼎的ACID特性被提出后这个概念不断的被引用(最初被写入SQL92标准),这四种特性可以大概概括出人们心中对于数据库最核心的诉求。本文要讲的原子性便是其中第一个特性,我们先关注原子性在事务ACID中的位置。
这是个人对于数据库ACID特性关系的理解,我认为数据库ACID特性其实可以分为两个视角去定义,其中AID(原子、持久、隔离)特性是从事务本身的视角去定义,而C(一致)特性是从用户的视角去定义。下面我会分别谈下自己的理解。
- 原子性:我们还是从这些特性的概念出发去讨论,原子性的概念是一个事务要么执行成功,要么执行失败,即All or nothing。这种特质我们可以用一个最小的事务模型去定义出来,我们假设有一个事务,我们通过一套机制能够实现它真正的提交或回滚,这个目的就达成了,用户只是通过我们的系统进行了一次提交,而原子性的重心不在于事务成功或失败本身;而是保证了事务体系只接受成功或失败两种状态,而且有相应的策略来保证成功或失败的物理结果和逻辑结果是一致的。原子性可以通过最小事务单元的特性定义出来,是整个事务体系的基石。
- 持久性:而持久性指的是事务一旦提交后就可以永久的保存在数据库中。持久性的范围与视角几乎与原子性是一致的,其实也导致了二者在概念和实现上也是紧密相连的。二者都一定意义上保证了数据的一致和可恢复性,而界限便是事务提交的时刻。举例来说,一个数据目前的状态是T,如果某个事务A试图将状态更新到T+1,如果这个事务A失败了,那么数据库状态回到T,这是原子性保证的;如果事务A提交成功了,那么事务状态变成T+1的那一刻,这个是原子性保证的;而一旦事务状态变成T+1且事务成功提交,事务已经结束不再存在原子性,这个T+1的状态就是由持久性负责保证。从这个角度可以推断原子性保证了事务提交前数据的崩溃恢复,而持久性保证了事务提交后的崩溃恢复。
- 隔离性:隔离性同样是定义在事务层面的一个机制,给事务并发提供了某种程度的隔离保证。隔离性的本质是防止事务并发会导致不一致的状态。由于不是本文的重点这里不做详述。
- 一致性:相较于其他几个特性很特殊,一致性的概念是数据库在经过一个或多个事务后,数据库必须保持在一致性的状态。如果从事务的角度去理解,保证了AID就可以保证事务是可串行、可恢复、原子性的,但是这种事务状态的一致性就是真正的一致性吗?破坏了AID就一定破坏C,但是反之AID都保证了C一定会被保证吗?如果答案是是的话那这个概念就会失去它的意义。我们可以保证AID来保证事务是一致的,但是是否能够证明事务的一致一定保证数据的一致呢?另外数据一致这个概念通过事务很难去准确定义,而如果通过用户层面就很好定义。数据一致就是用户认为数据库中数据任何时候的状态是满足其业务逻辑的。比如银行存款不能是负数,所以用户定义了一个非负约束。我认为这是概念设计者的一个留白,倾向于将一致性视为一种高阶目标。
本文主要还是围绕原子性进行,而中间涉及到崩溃恢复的话题可能会涉及到持久性。隔离性和一致性本文不讨论,在可见性的部分我们默认数据库具有完成的隔离性,即可串行化的隔离级别。
2.原子性的内在要求
上面讲了很多对于数据库事务特性的理解,下面进入我们的主题原子性。我们还是需要拿刚才的例子来继续阐述原子性。目前数据库的状态是T,现在希望通过一个事务A将数据状态升级为T+1。我们讨论这个过程的原子性。
如果我们要保证这个事务是原子的,那么我们可以定义三个要求,只有满足了下者,才可以说这个事务是原子性的:
- 数据库存在一个事务真正成功提交的时间点。
- 在这个时间点之前开启的事务(或者获取的快照)只应该看到T状态,这个时间点之后开启的事务(或者获取的快照),只应该看到T+1状态。
- 在这个时间点之前任何时候的崩溃,数据库都应该能够回到T状态;在这个时间点之后任何时候崩溃,数据库都应该能回到T+1状态。
注意这个时间点我们并没有定义出来,甚至我们都不能确定2/3中的这个时间点是不是同一个时间点。我们能确定的是这个时间点一定存在,否则就没办法说事务是原子性的,原子性确定了提交/回滚必须有一个确定的时间点。另外根据我们刚才的描述,可以推测出2中的时间点,我们可以定义为原子性位点。由于原子性位点之前的提交我们不可见,之后可见,那么这个原子性位点对于数据库中其他事务来说就是该事务提交的时间点;而3中的位点可以定位为持久性位点,由于这符合持久性对于崩溃恢复的定义。即对于持久性来说,3这个位点后事务已经提交了。
四、原子性方案讨论
1.从两种简单的方案说起
首先我们从两个简单的方案来谈起原子性,这一步的目的是试图说明为什么我们接下来每一步介绍的数据结构都是为了实现原子性必不可少的。
简单Direct IO
设想我们存在这样一个数据库,每次用户操作都会把数据写到磁盘中。我们把这种方式叫做简单Direct IO,简单的意思是指我们没有记录任何数据日志而只记录了数据本身。假设初始的数据版本是T,这样当我们插入了一些数据之后如果发生了数据崩溃,磁盘上会写着一个T+0.5版本的数据页,并且我们没有任何办法去回滚或继续进行后续的操作。这样失败的CASE无疑打破了原子性,因为目前的状态既不是提交也不是回滚而是一个介于中间的状态,所以这是一次失败的尝试。
简单Buffer IO
接下来我们有了一种新的方案,这种方案叫做简单Buffer IO。同样我们没有日志,但是我们加入了一个新的数据结构叫做“共享缓存池”。这样当我们每次写数据页的时候并不是直接把数据写到数据库上,而是写到了shared buffer pool 中;这样会有显而易见的优势,首先读写效率会大大的提高,我们每次写都不必等待数据页真实的写入磁盘,而可以异步的进行;其次如果数据库在事务未提交前回滚或者崩溃掉了,我们只需要丢弃掉shared buffer pool中的数据,只有当数据库成功提交时,它才可以真正的把数据刷到磁盘上,这样从可见性和崩溃恢复性上看,我们看似已经满足了要求。
但是上述方案还是有一个难以解决的问题,即数据落盘这件事并不像我们想象的这么简单。比如shared buffer pool中有10个脏页,我们可以通过存储技术来保证单个页面的刷盘是原子的,但是在这10个页面的中间任何时候数据库都可能崩溃。继而不论我们何时决定数据落盘,只要数据落盘的过程中机器发生了崩溃,这个数据都可能在磁盘上产生一个T+0.5的版本,并且我们在重启后还是没办法去重做或者回滚。
上面两个例子的阐述似乎注定了数据库没有办法通过不依赖其他结构的情况下保证数据的一致性(还有一种流行的方案是SQLite数据库的Shadow Paging技术,这里不讨论),所以如果想解决这些问题,我们需要引入下一个重要的数据结构,数据日志。
2.预写日志 + Buffer IO方案
方案总览
我们在Buffer IO的基础上引入了数据日志这样的数据结构,用来解决数据不一致的问题。
在数据缓存的部分与之前的想法一样,不同的是我们在写数据之前会额外记录一个xlog buffer。这些xlog buffer是一个有序列的日志,他的序列号被称为lsn,我们会把这个数据对应的日志lsn记录在数据页面上。每一个数据页页面都记录了更新它最新的日志序号。这一特性是为了保证日志与数据的一致性。
设想一下,如果我们能够引入的日志与数据版本是完全一致的,并且保证数据日志先于日志持久化,那么不论何时数据崩溃我们都可以通过这个一致的日志页恢复出来。这样就可以解决之前说的数据崩溃问题。不论事务提交前或者提交后崩溃,我们都可以通过回放日志的方案来回放出正确的数据版本,这样就可以实现崩溃恢复的原子性。另外关于可见性的部分我们可以通过多版本快照的方式实现。保证数据日志和数据一致并不容易,下面我们详细讲下如何保证,还有崩溃时数据如何恢复。
事务提交与控制刷脏
WAL日志被设计出来的目的是为了保证数据的可恢复性,而为了保证WAL日志与数据的一致性,当数据缓存被持久化到磁盘时,持久化的数据页对应的WAL日志必须先一步被持久化到磁盘中,这句话阐述了控制刷脏的本质含义。
- 数据库后台存在这样一个进程叫做checkpoint进程,其周期性的进行checkpoint操作。当checkpoint发生的时候,它会向xlog日志中写入一条checkpoint日志,这条checkpoint日志包含了当前的REDO位点。checkpoint保证了当前所有脏数据已经被刷到了磁盘当中。
- 进行第一次插入操作,此时共享内存找不到这个页面,它会把这个页面从磁盘加载到共享内存中,之后写入本次插入的输入,并且插入一条写数据的xlog到xlog buffer中,将这个表的日志标记从LSN0升级到LSN1。
- 在事务提交的时刻,事务会写入一条事务提交日志,之后wal buffer pool上所有本次事务提交的WAL日志会一并被刷到磁盘上。
- 之后插入第二条数据B,他会插入一条写数据的xlog到xlog buffer中,将这个表的日志标记从LSN1升级到LSN2。
- 同3一样的操作。
之后如果数据库正常运行,接下来的bgwriter/checkpoint进程会把数据页异步的刷到磁盘上;而一旦数据库发生崩溃,由于A、B两条日志对应的数据日志与事务提交日志都已经被刷到了磁盘上,所以可以通过日志回放在shared buffer pool中重新回放出这些数据,之后异步写入磁盘。
fullpage机制保证可恢复性
WAL日志的恢复似乎是完美无缺的,但不幸的是刚才的方案还是存在一些瑕疵。设想当一个bgwriter进程在异步的写数据时遇到了数据库的CRASH,这时一部分脏页写到了磁盘上,磁盘上可能存在坏页。(PolarDB数据页是8k,极端情况下磁盘的4k写是有可能写出坏页面的)然而WAL日志是没办法在坏页上回放数据的。这时就需要用到另外一个机制来保证极端情况下数据库能够找到原始数据,这就涉及到了一个重要的机制fullpage机制。
在每一个checkpoin动作之后的第一次修改数据时,PolarDB会将这条修改的数据连同整个数据页写入到wal buffer中之后再刷入磁盘,这种包含整个数据页的WAL日志被称为备份块。备份块的存在使得在任何情况下WAL日志都可以将完整的数据页给回放出来。下面是一个完整的过程。
- checkpoint动作
- 进行第一次插入操作,此时共享内存找不到这个页面,它会把这个页面从磁盘加载到共享内存中,之后写入本次插入的输入。这时不同于上一节的操作,PolarDB序号为LSN1的这条WAL日志会把从磁盘上读上来标记为LSN 0的整个页面写入到wal buffer pool中。
- 事务提交,此时整个WAL日志被强制刷入磁盘上的WAL段中。
- 同上节
- 同上节
这时如果数据库发生了崩溃,在数据库重新拉起恢复时,一旦它遇到了坏掉的页面,便可以通过最初的WAL日志中记录的最初版本的页面一步一步的把正确的数据给回放出来。
基于WAL日志的崩溃恢复机制
有了前两节的基础上,我们可以继续演示如果数据库崩溃后,数据是如何被回放出来的。我们演示一种数据页被写坏的回放。
- 当数据库回放到写入数据A的这条WAL日志时,它会从磁盘中读出TABLE A这个页面。这里的这条WAL日志是一条备份日志,这是由于CHECKPOINT后,每个回放页面的第一条WAL日志都是备份日志。
- 当这条日志被回放时,备份日志有特殊的回放规则:它总是将自己页面覆盖掉原来的页面,并将原来页面的LSN升级为这个页面的LSN。(为了保证数据一致性,正常回放页面只会回放大于自己LSN号码的WAL日志)。在这个例子中,由于备份块的存在,写坏的页面被成功恢复了出来。
- 接下来PolarDB会按照正常的回放规则去回放后续的日志。
最后数据回放成功后,shared buffer pool中的数据便可以异步的被刷到磁盘上去替换之前损坏的数据。
我们花了很大的篇幅来说明数据库是如何通过预写日志而进行崩溃恢复的,这似乎可以解释持久性位点的含义;下面我我们还需要再解释可见性的问题。
3.可见性机制
由于我们对于原子性的说明中会涉及可见性的概念,这个概念在PolarDB中由一套复杂的MVCC机制来实现,且大多属于隔离性的范畴。这里会对可见性进行一个简单的说明,而更详细的说明会放到隔离性的文章中继续阐述。
事务元组
第一个要说到的是事务元组。他是一条数据的最小单元,真正存放了数据,这里我们只关注几个字段就好了。
- t_xmin:生成该数据的事务ID
- t_xmax:修改该事务数据的事务ID(删除或锁定数据的事务ID)
- t_cid:同一事务中对该元组操作的一个序号
- t_ctid:一个由段号/偏移量组成的指针,指向最新版本的数据
快照
第二个要说到的是快照。快照记录了某一个时间点数据库中事务的状态。
关于快照我们依旧不展开,我们知道通过快照可以从procArray中获取到某一个时间点数据库中所有可能事务的状态即可。
当前事务状态
第三点要说的到的是当前事务状态,事务状态是指数据库中决定事务运行状态的的机制。在并发的环境中,决定看到的事务状态是非常重要的一件事。
在查看一个tuple中的事务状态时,可能会涉及到三个数据结构t_infomask、procArray、clog:
- infomask:位于tuple头部的缓存标志位,标志了该元组xmin/xmax两个事务的运行状态,这个状态可以看作是clog的一层异步缓存,用来加速事务状态的获取;其状态设置是异步设置,在事务提交时并不将所有事务相关的元组都立即升级,而是等待当第一个足够新的能够看到本次更新的快照设置时再去设置。
- procArray快照:快照中的事务状态,快照的获取实际上就是在procArray中拿到这一瞬间数据库中所有事务的状态,快照一旦获取状态恒定,除非再次获取(同一事务中获取内容是否改变取决于事务隔离级别)。
- clog:事务的实际状态,分为clog buffer和clog文件两部分。clog buffer中实时的记录了所有的事务状态。
在一个可见性判断过程中,三者访问的顺序是[infomask -> 快照,clog],而三者的决定性顺序是[快照 -> clog -> infomask] 。
infomask是最容易获取的信息,就记录在元组的头部,在部分条件下通过infomask就可以明确当前事务的可见性,不需要涉及到后面的数据结构;快照拥有最高级的决定权,最终决定xmin/xmax事务的状态是运行/未运行;而clog用来辅助可见性的判断,并且辅助设置infomask的值。举例而言,如果这个判断xmin事务可见性时发现在快照/clog中都已经提交,那么会把t_infomask置为已提交;而如果xmin事务可见性时发现在快照提交,而clog未提交,则系统判断发生了崩溃或回滚,将infomask设置为事务非法。
事务快照可见性
在介绍元组和快照后,我们就可以继续讨论快照可见性的话题。PolarDB的可见性有一套复杂的定义体系,需要通过许多信息组合定义出来,但是其中最直接的就是快照和元组头。下面通过一个数据插入和更新的示例来说明元组头和快照的可见性。
本文不讨论隔离性,我们假设隔离级别是可串行化:
- Snapshot1时刻:此时事务1184/1187都未开始,元组中也没有记录,student表是一张空表;通过Snapshot1快照可以得到的数据是空,我们把这个版本记做T。
- Snapshot1 - Snapshot2时,此刻我们获取快照那么拿到的还是Snapshot1,那么他看到的数据应该还是T。
- Snapshot2时刻:此时事务1184已经结束,1187还未开始。所以1184的修改对用户可见,1187仍旧不可见。具体到元组中可以看到 (1184/0) 这样的元组头,所以看到的是数据版本Tom,我们把这个版本记做T+1。
- Snapshot2 - Snapshot3时,此刻我们获取快照那么拿到的还是Snapshot2,那么他看到的数据应该还是T+1。
- Snapshot3时刻:此刻事务1184/1187都已经结束,二者都可见,所以我们可以看到元组中(1184,1187)和(1187,1187)二者都不可见,而(1187,0)即Susan是可见的。我们把这个版本记做T+2。
通过上述分析我们可以得到一个简单的结论,数据库的可见性取决于快照的时机。我们原子性中所谓的可见性版本不同其实是指拿到的快照不同,快照决定了一个正在执行中的事务是否已经提交。这种提交与事务标记提交状态甚至是记录clog提交都没有关系,我们可以通过这种方法来使得我们拿到的快照与事务提交具有一致性。
事务原子性中的可见性
上文中我们已经简述了PolarDB快照可见性的问题,这里补充下事务提交时的具体实现问题。
我们设计可见性机制的核心思想是:“事务只应该看到它应该看到的数据版本”。如何定义应该看到,这里只举一个简单的例子,如果一个元组的xmin事务没有提交,其他事务大概率是看不到的;而如果一个元组的xmin事务已经提交,其他事务就可能会看到。如何知道这个xmin有没有提交,上文已经提到了我们通过快照来决定,所以我们事务提交时的关键机制就是新快照的更新机制。
可见性在事务提交时涉及到两个重要的数据结构clog buffer和procArray 。二者的关系在上文已经给出了解释,他们在判断事务可见性时发挥一定的作用,当然procArray起到了决定性的作用。这是因为快照的获取实际上就是一个遍历ProcArray的过程。
在实际第三步会将本事务提交的信息写入clog buffer,此时事务标记clog是已提交,但实际上仍旧没有提交。之后事务标记ProcArray已提交,这一步事务完成了真正的提交,这个时间点之后重新获取的快照会更新数据版本。
五、PolarDB 中原子性的实现
在完成了PolarDB崩溃恢复及可见性理论的说明之后,我们可以知道PolarDB可以通过这样一套预写日志+BufferIO的方案来保证事务的崩溃恢复和可见一致性,从而实现原子性。下面我们将针对事务提交中最重要的环节进行探究,找出我们最初提到的原子性位点到底指什么。
1.事务崩溃恢复一致——持久性位点
简单来说事务提交中有这样四个操作对于事务的原子性来说是最为核心和重要的。本节我们先考虑前两个操作。
- 提交事务的Commit日志(即Commit 的WAL日志)。
- 将本次事务所有的提交的WAL日志全部强制刷盘,持久化到存储。
我们标记这个xlog(WAL日志)落盘的位点,我们设想两种情况:
- 如果在这个位点前事务崩溃或者回滚了,那么不管数据日志有没有刷盘,Commit日志一定没有刷盘,由于WAL日志具有顺序性,Commit日志一定是最后一个持久化到磁盘中。此时如果我们对数据进行回放,我们发现缺少Commit日志的事务无法被标记为已提交状态,而根据可见性这种状态相关的数据一定是不可见。这些数据之后会被视为脏数据给清理掉。所以我们可以得出结论,在这个节点前崩溃,事务实际上就是没有提交。数据库实质上是恢复到了状态T。
- 如果在这个位点后崩溃或回滚了,此时我们不论它在哪一步崩溃或回滚,我们都可以确定Commit日志一定刷到了磁盘上。而一旦Commit日志被刷到了磁盘上,那么这个事务所写的数据一定可以被回放出来且标记为已提交。那么这个数据就是可见的。这个事务实际上已经提交了,数据库被恢复到了T+1。
这个现象表明,2号位点似乎就是崩溃恢复的临界点,它标注了数据库崩溃恢复可以回到T或者T+1状态。那么我们如何称呼这个位点?回想持久性的概念:事务一旦提交,该事务对于数据库的修改就永久的保留在了数据库中。二者实际上是吻合的。所以我们将这个2号位点称为持久性位点。
另外关于xlog刷盘还有一点需要说明的是xlog刷盘和回放具有单个文件的原子性;WAL日志头部的CRC校验提供了单个WAL日志文件的合法性校验,如果WAL日志写磁盘损坏,这条WAL日志的内容无效,确保不会出现数据的部分回放。
2.事务的可见性一致——原子性位点
接下来我们继续看3、4号操作:
- 将本次事务提交写入到Clog buffer中。
- 将本次事务提交的结果写入到ProcArray中。
3号操作是在Clog buffer中记录了事务的当前状态,可以看作是一层日志缓存。4号操作将提交操作写入到了ProcArray中,这是非常重要的一步操作,通过刚才的说明我们知道快照判断事务状态是通过ProcArray进行的。即这一步决定了其他事务看到的该事务状态。
如果在4号操作前事务崩溃或回滚,那么数据库中所有其他事务看到的数据版本都是T,相当于事务没有真正的提交。这个判断即通过可见性 -> 快照 -> Procarray这个顺序决定的。
而当4号操作后,针对所有观察者来说这个事务已经提交了,因为所有在这个时间点之后拿到的快照数据版本都是T+1。
从这一点考虑,4号操作完全切合原子性操作的含义。因为4号操作的进行与否影响了事务能否成功提交。4号操作前事务总是允许回滚的,因为没有其他事务看到该事务的T+1状态;但是4号操作过后,事务便不允许回滚,不然一旦存在读到T+1版本的其他事务就会造成数据的不一致。而原子性的概念即是,事务成功提交或失败回滚。由于4号操作后不允许回滚,那4号操作就完全可以作为事务成功提交的标志。
综上所述,我们可以将4号操作定义为事务的原子性位点。
3.持久性位点与原子性位点
原子性与持久性的要求
再次给出原子性与持久性的概念:
- 原子性:一个事务要么执行成功,要么执行失败。
- 持久性:一个事务一旦执行成功,就可以永久的保存在数据库中。
我们把4号操作标记为原子性位点,是因为在4号操作的时刻,客观上所有的观察者都认为这个事务已经提交了,快照的版本从T升级为T+1,事务不再可回滚。那么事务一旦提交,原子性是否就不生效了,我认为是的,原子性至多只保证事务成功提交那一刻的数据一致性,事务已经结束了我们就没办法再说原子性。所以原子性在原子性位点前保证了事务的可见、可恢复。
我们把2号位点标记为持久性位点,是因为持久性认为事务成功后就可以永久的保留。根据上述的推测,这个位点无疑就是2号这个持久性位点。所以从2号位点开始后的所有时间我们都应该保证持久性。
如何理解两个位点
在解释完2、4号两个位点之后,我们最终可以把事务提交时涉及到的两个最重要概念定义出来,我们现在可以回答第一个问题,到底在哪个时刻事务真正的提交?答案是持久性位点后事务可以被完整的恢复出来;而原子性位点后事务真正的被其他事务视作提交。但是二者却并不是分离性的,这如何理解呢?
我认为这其实是原子性实现的一种妥协,因为我们没有必要把二者统一,我们只需要保证关键性的一点,只要两个位点的顺序能够使得在不同状态下的数据具有一致性,那么就可以认为它符合我们原子性的定义。
- 在持久性位点前崩溃或回滚,此时事务失败,崩溃前或恢复后数据版本都是T。
- 在持久性位点后原子性位点间崩溃或回滚,此时事务的可见性版本是T,也就是说对于数据库中的所有事务来说,我们看到的都是T。回滚后,数据被重新回放到了T+1;而此时数据库重启后会发现,在数据库崩溃前的事务拿到快照看到的数据版本是T,崩溃后重启拿到快照看到的数据版本是T+1,仿佛事务被隐式的提交了。但是这并不违背数据的一致性。
- 在原子位点后崩溃。这个事务已经提交了,崩溃前崩溃后事务看到的都是T+1版本的数据。
最后我们考虑两个位点为什么没有选择合并。持久性位点的操作是WAL日志的刷盘,这个涉及到了磁盘IO的问题;另一方面原子性位点做的事情是写ProcArray,这就要拿到ProcArray上的一把争抢很严重的大锁,可以认为是一次高频的共享内存写行为;二者本身都关乎数据库事务的效率,如果绑定了二者成为一个原子操作,无疑会使得二者等待相当严重,可能会对事务的运行效率造成较大影响。从这个角度来说二者的行为分离是一个效率上的考虑。
二者顺序是否可以颠倒?
显然不可以,通过上述的示意图我们可以看到中间这一段时间可能出现既不满足原子性要求,也不满足持久性要求的区域。
具体而言,如果先进行原子性位点,再进行持久性位点,则设想二者中间崩溃的事务情形。其他事务在崩溃前会看到T+1版本的数据,崩溃后看到了T版本的数据,这样看到未来数据的行为显然是不被允许的。
如何定义真正的提交
真正的提交就是原子性位点提交。
还是最基本的道理,真正提交的标志就是数据版本从T升级为T+1。这个位点就是原子性位点。在这个点之前,其他事务看到的数据版本都是T,说真正的提交是不恰当的;在这个点之后事务无法被回滚。这足以说明这就是事务真正的提交点。
其他操作
我们最后关注1/3号操作:
- 1号操作是写wal commit日志到xlog buffer,这个写日志对于事务提交来说并不关键;因为如果它写入了没有刷到磁盘上,那么它其实还是毫无作用。
- 3号操作是在clog buffer 中标记本事务为已提交状态;这个操作对事务提交来说也不关键。因为如果数据库运行正常,它不影响本事务快照的可见性;如果数据库崩溃,这个clog状态不论是否已经持久化,事务状态都可以被xlog中的 Commmit/Abort日志给回放出来。
六、PolarDB的原子性过程
1.事务提交
本节我们回到事务提交函数中,看到这几个操作在函数调用栈中的位置。
- 事务提交流程是带有事务ID的事务,不带事务ID的事务没有这个过程。由于不带事务ID的事务大概率是只读操作,不会对数据库中数据一致性造成任何影响。
- 提交xlog前会开启严格模式,这个模式下任何错误都会是致命错误,数据库直接崩溃重启。
- xlog刷盘和CLOG写内存的顺序是在同步模式下进行的,异步模式下不保证xlog刷盘,所以可能会崩溃后丢失数据。
- 3/4中间有一步关键的操作,Replication等待。实际上此时数据xlog已经刷盘,但是还没有真正的提交,在同步模式下主库会等待被库将刷到磁盘上的xlog应用完毕,之后再进行下一步。
- 写ProcArray本事务提交,事务真正提交完成,事务不再可回滚。
- 清理资源状态,此时工作已和本事务没有任何关系。
2.事务回滚
- 没有事务ID的事务回滚会直接跳过。
- 回滚前会首先判断事务是否已提交,这个判断是基于CLOG进行的。一个事务怎么能又提交又回滚呢?这就是我们之前讨论的3-4之间的状态,如果CLOG记录了提交,那么遇到回滚命令数据库直接发生致命故障崩溃重启。
- 回滚中也会相应的写入xlog回滚日志,不过是异步刷到磁盘。可以设想其实回滚日志即使不写入,数据也是不可见的。
- 当事务在ProcArray中写入回滚日志后,事务在进程中真正的回滚了(其实这个状态对其他事务没有影响,之前后拿到的数据版本都是T)。
七、总结与展望
最后对全文做一个总结,本文主要围绕着“如何实现事务原子性”这个话题展开,分别从数据库的崩溃恢复特性和事务可见性来说明了PolarDB数据库实现原子性的底层原理。在介绍预写日志+buffer IO原理的过程中还谈到了shared buffer、WAL日志、clog、ProcArray、这些对原子性来说重要的数据结构。在事务这个整体下数据库的各个模块巧妙的搭接起来,充分利用磁盘、缓存、IO这些计算机资源组成了一套完整的数据库系统。
联想到计算机科学其他的模型,如ISO网络模型中传输层TCP协议在一个不可靠的信道上提供可靠的通信服务。数据库事务实现了类似的思想,即在一个不可靠的操作系统(随时可能崩溃)和磁盘存储(无法大量数据的原子写)上可靠的存储数据。这一简单而重要的思想可谓是数据库系统的基石,它如此重要以至于整个数据库中最核心的数据结构大多其有关。或许随着数据库的发展未来技术更迭出更先进的数据库架构体系,但是我们不能忘记是原子性、持久性仍旧应当是数据库设计的核心。
八、思考
到这里事务原子性的重点就结束了,最后针对本文提到的观点留下几个问题供大家思考。
如何理解事务提交的原子性和持久性位点?
思考单个事务原子性和多个事务原子性的关系?崩溃恢复和可见性是否是一体的?
PolarDB中存在异步提交的概念,即不要求事务提交时不要求xlog日志落盘。请思考在这个模式下可能违背事务的哪些特性?是否违背原子性和持久性?