在巍峨的数据库大厦体系中,查询优化器和事务体系是两堵重要的承重墙,二者是如此重要以至于整个数据库体系结构设计中大量的数据结构、机制和特性都是围绕着二者搭建起来的。他们一个负责如何更快的查询到数据,更有效的组织起底层数据体系;一个负责安全、稳定、持久的存储数据,为用户的读写并发提供逻辑实现。我们今天探索的主题是事务体系,然而事务体系太过庞大,我们需要分成若干次的内容。本文就针对PolarDB事务体系中的原子性进行剖析。
在阅读本文之前,首先提出几个重要的问题,这几个问题或许在接触数据库之前你也曾经疑惑过。但是曾经这些问题的答案可能只是简单的被诸如“预写日志”,“崩溃恢复机制”等简单的答案回答过了,本文希望能够更深一步的讨论这些机制的实现及内在原理。
1 原子性在ACID中的位置
大名鼎鼎的ACID特性被提出后这个概念不断的被引用(最初被写入SQL92标准),这四种特性可以大概概括出人们心中对于数据库最核心的诉求。本文要讲的原子性便是其中第一个特性,我们先关注原子性在事务ACID中的位置。
这是个人对于数据库ACID特性关系的理解,我认为数据库ACID特性其实可以分为两个视角去定义,其中AID(原子、持久、隔离)特性是从事务本身的视角去定义,而C(一致)特性是从用户的视角去定义。下面我会分别谈下自己的理解。
本文主要还是围绕原子性进行,而中间涉及到崩溃恢复的话题可能会涉及到持久性。隔离性和一致性本文不讨论,在可见性的部分我们默认数据库具有完成的隔离性,即可串行化的隔离级别。
2 原子性的内在要求
上面讲了很多对于数据库事务特性的理解,下面进入我们的主题原子性。我们还是需要拿刚才的例子来继续阐述原子性。目前数据库的状态是T,现在希望通过一个事务A将数据状态升级为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日志必须先一步被持久化到磁盘中,这句话阐述了控制刷脏的本质含义。
之后如果数据库正常运行,接下来的bgwriter/checkpoint进程会把数据页异步的刷到磁盘上;而一旦数据库发生崩溃,由于A、B两条日志对应的数据日志与事务提交日志都已经被刷到了磁盘上,所以可以通过日志回放在shared buffer pool中重新回放出这些数据,之后异步写入磁盘。
fullpage机制保证可恢复性
WAL日志的恢复似乎是完美无缺的,但不幸的是刚才的方案还是存在一些瑕疵。设想当一个bgwriter进程在异步的写数据时遇到了数据库的CRASH,这时一部分脏页写到了磁盘上,磁盘上可能存在坏页。(PolarDB数据页是8k,极端情况下磁盘的4k写是有可能写出坏页面的)然而WAL日志是没办法在坏页上回放数据的。这时就需要用到另外一个机制来保证极端情况下数据库能够找到原始数据,这就涉及到了一个重要的机制fullpage机制。
在每一个checkpoin动作之后的第一次修改数据时,PolarDB会将这条修改的数据连同整个数据页写入到wal buffer中之后再刷入磁盘,这种包含整个数据页的WAL日志被称为备份块。备份块的存在使得在任何情况下WAL日志都可以将完整的数据页给回放出来。下面是一个完整的过程。
这时如果数据库发生了崩溃,在数据库重新拉起恢复时,一旦它遇到了坏掉的页面,便可以通过最初的WAL日志中记录的最初版本的页面一步一步的把正确的数据给回放出来。
基于WAL日志的崩溃恢复机制
有了前两节的基础上,我们可以继续演示如果数据库崩溃后,数据是如何被回放出来的。我们演示一种数据页被写坏的回放。
最后数据回放成功后,shared buffer pool中的数据便可以异步的被刷到磁盘上去替换之前损坏的数据。
我们花了很大的篇幅来说明数据库是如何通过预写日志而进行崩溃恢复的,这似乎可以解释持久性位点的含义;下面我我们还需要再解释可见性的问题。
3 可见性机制
由于我们对于原子性的说明中会涉及可见性的概念,这个概念在PolarDB中由一套复杂的MVCC机制来实现,且大多属于隔离性的范畴。这里会对可见性进行一个简单的说明,而更详细的说明会放到隔离性的文章中继续阐述。
事务元组
第一个要说到的是事务元组。他是一条数据的最小单元,真正存放了数据,这里我们只关注几个字段就好了。
快照
第二个要说到的是快照。快照记录了某一个时间点数据库中事务的状态。
关于快照我们依旧不展开,我们知道通过快照可以从procArray中获取到某一个时间点数据库中所有可能事务的状态即可。
当前事务状态
第三点要说的到的是当前事务状态,事务状态是指数据库中决定事务运行状态的的机制。在并发的环境中,决定看到的事务状态是非常重要的一件事。
在查看一个tuple中的事务状态时,可能会涉及到三个数据结构t_infomask、procArray、clog:
在一个可见性判断过程中,三者访问的顺序是[infomask -> 快照,clog],而三者的决定性顺序是[快照 -> clog -> infomask] 。
infomask是最容易获取的信息,就记录在元组的头部,在部分条件下通过infomask就可以明确当前事务的可见性,不需要涉及到后面的数据结构;快照拥有最高级的决定权,最终决定xmin/xmax事务的状态是运行/未运行;而clog用来辅助可见性的判断,并且辅助设置infomask的值。举例而言,如果这个判断xmin事务可见性时发现在快照/clog中都已经提交,那么会把t_infomask置为已提交;而如果xmin事务可见性时发现在快照提交,而clog未提交,则系统判断发生了崩溃或回滚,将infomask设置为事务非法。
事务快照可见性
在介绍元组和快照后,我们就可以继续讨论快照可见性的话题。PolarDB的可见性有一套复杂的定义体系,需要通过许多信息组合定义出来,但是其中最直接的就是快照和元组头。下面通过一个数据插入和更新的示例来说明元组头和快照的可见性。
本文不讨论隔离性,我们假设隔离级别是可串行化:
通过上述分析我们可以得到一个简单的结论,数据库的可见性取决于快照的时机。我们原子性中所谓的可见性版本不同其实是指拿到的快照不同,快照决定了一个正在执行中的事务是否已经提交。这种提交与事务标记提交状态甚至是记录clog提交都没有关系,我们可以通过这种方法来使得我们拿到的快照与事务提交具有一致性。
事务原子性中的可见性
上文中我们已经简述了PolarDB快照可见性的问题,这里补充下事务提交时的具体实现问题。
我们设计可见性机制的核心思想是:“事务只应该看到它应该看到的数据版本”。如何定义应该看到,这里只举一个简单的例子,如果一个元组的xmin事务没有提交,其他事务大概率是看不到的;而如果一个元组的xmin事务已经提交,其他事务就可能会看到。如何知道这个xmin有没有提交,上文已经提到了我们通过快照来决定,所以我们事务提交时的关键机制就是新快照的更新机制。
可见性在事务提交时涉及到两个重要的数据结构clog buffer和procArray 。二者的关系在上文已经给出了解释,他们在判断事务可见性时发挥一定的作用,当然procArray起到了决定性的作用。这是因为快照的获取实际上就是一个遍历ProcArray的过程。
在实际第三步会将本事务提交的信息写入clog buffer,此时事务标记clog是已提交,但实际上仍旧没有提交。之后事务标记ProcArray已提交,这一步事务完成了真正的提交,这个时间点之后重新获取的快照会更新数据版本。
在完成了PolarDB崩溃恢复及可见性理论的说明之后,我们可以知道PolarDB可以通过这样一套预写日志+BufferIO的方案来保证事务的崩溃恢复和可见一致性,从而实现原子性。下面我们将针对事务提交中最重要的环节进行探究,找出我们最初提到的原子性位点到底指什么。
1 事务崩溃恢复一致——持久性位点
简单来说事务提交中有这样四个操作对于事务的原子性来说是最为核心和重要的。本节我们先考虑前两个操作。
我们标记这个xlog(WAL日志)落盘的位点,我们设想两种情况:
这个现象表明,2号位点似乎就是崩溃恢复的临界点,它标注了数据库崩溃恢复可以回到T或者T+1状态。那么我们如何称呼这个位点?回想持久性的概念:事务一旦提交,该事务对于数据库的修改就永久的保留在了数据库中。二者实际上是吻合的。所以我们将这个2号位点称为持久性位点。
另外关于xlog刷盘还有一点需要说明的是xlog刷盘和回放具有单个文件的原子性;WAL日志头部的CRC校验提供了单个WAL日志文件的合法性校验,如果WAL日志写磁盘损坏,这条WAL日志的内容无效,确保不会出现数据的部分回放。
2 事务的可见性一致——原子性位点
接下来我们继续看3、4号操作:
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号两个位点之后,我们最终可以把事务提交时涉及到的两个最重要概念定义出来,我们现在可以回答第一个问题,到底在哪个时刻事务真正的提交?答案是持久性位点后事务可以被完整的恢复出来;而原子性位点后事务真正的被其他事务视作提交。但是二者却并不是分离性的,这如何理解呢?
我认为这其实是原子性实现的一种妥协,因为我们没有必要把二者统一,我们只需要保证关键性的一点,只要两个位点的顺序能够使得在不同状态下的数据具有一致性,那么就可以认为它符合我们原子性的定义。
最后我们考虑两个位点为什么没有选择合并。持久性位点的操作是WAL日志的刷盘,这个涉及到了磁盘IO的问题;另一方面原子性位点做的事情是写ProcArray,这就要拿到ProcArray上的一把争抢很严重的大锁,可以认为是一次高频的共享内存写行为;二者本身都关乎数据库事务的效率,如果绑定了二者成为一个原子操作,无疑会使得二者等待相当严重,可能会对事务的运行效率造成较大影响。从这个角度来说二者的行为分离是一个效率上的考虑。
二者顺序是否可以颠倒?
显然不可以,通过上述的示意图我们可以看到中间这一段时间可能出现既不满足原子性要求,也不满足持久性要求的区域。
具体而言,如果先进行原子性位点,再进行持久性位点,则设想二者中间崩溃的事务情形。其他事务在崩溃前会看到T+1版本的数据,崩溃后看到了T版本的数据,这样看到未来数据的行为显然是不被允许的。
如何定义真正的提交
真正的提交就是原子性位点提交。
还是最基本的道理,真正提交的标志就是数据版本从T升级为T+1。这个位点就是原子性位点。在这个点之前,其他事务看到的数据版本都是T,说真正的提交是不恰当的;在这个点之后事务无法被回滚。这足以说明这就是事务真正的提交点。
其他操作
我们最后关注1/3号操作:
1 事务提交
本节我们回到事务提交函数中,看到这几个操作在函数调用栈中的位置。
2 事务回滚
最后对全文做一个总结,本文主要围绕着“如何实现事务原子性”这个话题展开,分别从数据库的崩溃恢复特性和事务可见性来说明了PolarDB数据库实现原子性的底层原理。在介绍预写日志+buffer IO原理的过程中还谈到了shared buffer、WAL日志、clog、ProcArray、这些对原子性来说重要的数据结构。在事务这个整体下数据库的各个模块巧妙的搭接起来,充分利用磁盘、缓存、IO这些计算机资源组成了一套完整的数据库系统。
联想到计算机科学其他的模型,如ISO网络模型中传输层TCP协议在一个不可靠的信道上提供可靠的通信服务。数据库事务实现了类似的思想,即在一个不可靠的操作系统(随时可能崩溃)和磁盘存储(无法大量数据的原子写)上可靠的存储数据。这一简单而重要的思想可谓是数据库系统的基石,它如此重要以至于整个数据库中最核心的数据结构大多其有关。或许随着数据库的发展未来技术更迭出更先进的数据库架构体系,但是我们不能忘记是原子性、持久性仍旧应当是数据库设计的核心。
到这里事务原子性的重点就结束了,最后针对本文提到的观点留下几个问题供大家思考。
作者 | 佑熙
本文为阿里云原创内容,未经允许不得转载。