0
点赞
收藏
分享

微信扫一扫

分布式数据库设计——分布式事务与数据恢复

GG_lyf 2022-01-23 阅读 100
数据库

摘要

主要是介绍分布式数据库的设计上在分布式事务与数据恢复处理方法。事务管理是数据库中存储引擎的一个相当独立并且重要的组件,它可以保证对数据库的一系列操作看起来就像只有一步操作一样。这大大简化了面向数据库的应用的开发,特别是在高并发场景下,其意义更为重要。

一、事务ACID原则

A:原子性

原子性保证了事务内的所有操作是不可分割的,也就是它们要么全部成功,要么全部失败,不存在部分成功的情况。成功的标志是在事务的最后会有提交(Commit)操作,它成功后会被认为整个事务成功。而失败会分成两种情况,一种是执行回滚(Rollback)操作,另一种就是数据库进程崩溃退出。

原子性是数据库提供给使用者的保证,是为了模拟现实原子操作,如上文提到的转账。在现实生活中,一些看似不可分割的操作转换为计算机操作却并不是单一操作。而原子性就是对现实生活中原子操作的保证。

C:一致性

一致性其实是受用户与数据库共同控制的,而不只是数据库提供的一个服务。它首先是一个业务层面的约束,比如开篇中的例子,甲向乙转 100 元。业务应用首先要保证在甲账户扣款 100 元,而且在乙账户增加 100 元,这个操作所带来的一致性与数据库是无关的。而数据库是通过 AID 来保证这两个正确的动作可以得到最终正确的结果。

这里的一致性与模块一中的分布式一致性有本质区别,想了解详细对比的同学,请移步到“05 | 一致性与 CAP 模型:为什么需要分布式一致性”,这里就不进行赘述了。

I:隔离性

事务的一个伟大之处是能处理并发操作,也就是不同的事务在运行的时候可以互相不干扰,就像没有别的事务发生一样。做并发编程的同学会对此深有体会,处理并发操作需要的精力与经验与处理串行操作完全不在一个等级上。而隔离性恰好能将实际上并发的操作,转化为从使用者角度看却是串行的,从而大大降低使用难度。

当然在实际案例中,以上描述的强并发控制性能会偏低。一般数据库会定义多种的隔离级别来提供不同等级的并发处理能力,也就是一个事务在较低隔离级别下很可能被其他事务看见。详细内容我会在“隔离级别”部分进行说明。

D:持久性

持久性比较简单,就是事务一旦被提交,那么它对数据库的修改就可以保留下来。这里要注意这个“保存下来”不仅仅意味着别的事务能查询到,更重要的是在数据库面临系统故障、进程崩溃等问题时,提交的数据在数据库恢复后,依然可以完整地读取出来。

二、事务管理器

事务主要由事务管理器来控制,它负责协调、调度和跟踪事务状态和每个执行步骤。当然这与分布式事务两阶段提交(2PC)中的事务管理器是不同的,

2.1 页缓存

关于事务管理器,首先要提到的就是页缓存(Page Cache)或者缓冲池(Buffer Pool),它是磁盘和存储引擎其他组件的一个中间层。数据首先被写入到缓存里,而后同步到数据磁盘上。它一般对于其他组件,特别是对于写入来说是透明的,写入组件以为是将数据写入磁盘,实际上是写入了缓存中。这个时候如果系统出现故障,数据就会有丢失的风险。

缓存首先解决了内存与磁盘之间的速度差,同时可以在不改变算法的情况下优化数据库的性能。但是,内存毕竟有限,不可能将磁盘中的所有数据进行缓存。这时候就需要进行刷盘来释放缓存,刷盘操作一般是异步周期性执行的,这样做的好处是不会阻塞正常的写入和读取。

刷盘时需要注意,脏页(被修改的页缓存)如果被其他对象引用,那么刷盘后不能马上释放空间,需要等到它没有引用的时候再从缓存中释放。刷盘操作同时需要与提交日志检查点进行配合,从而保证 D,也就是持久性。

当缓存到达一定阈值后,就不得不将有些旧的值从缓存中移除。这个时候就需要缓存淘汰算法来帮忙释放空间。这里有 FIFO、LRU、表盘(Clock)和 LFU 等算法,

最后存在部分数据我们希望它们一直在缓存中,且不受淘汰算法的影响,这时候我们可以把它们“锁”(Pinned)在缓存里。比如 B 树的高节点,它们一般数据量不大,且每次查询都需要访问。还有一些经常访问的元数据也会长期保存在缓存中。

2.2 日志管理器

其次是日志管理器,它保存了一组数据的历史操作记录。缓存内的数据没有刷入磁盘前,系统就崩溃了,通过回放日志,缓存中的数据可以恢复出来。另外,在回滚场景,这些日志可以将修改前的数据恢复出来。

2.3 锁管理器

最后要介绍的就是非常重要的锁管理器,它保证了事务访问共享资源时不会打破这些资源的完整性约束。同时,它也可以保证事务可以串行执行。关于锁的内容我会在后面详细说明。

三、数据库如何恢复事务

数据库系统是由一系列硬件和软件组成的复杂生态系统,其中每个组件都有产生各种稳定性问题的可能,且将它们组合为数据库系统后,这种可能被进一步放大了。而数据库的设计者必须为这种潜在的稳定性问题给出自己的解决方案,并使数据库作出某种“承诺”。

提交日志,即 CommitLog 或 WAL(Write-Ahead Log)就是应对此种问题的有效手段。这种日志记录了数据库的所有操作,并使用追加(Append)模式记录在磁盘的日志文件中。

上文中我们知道数据库的写操作首先是写入了缓存,而后再刷入磁盘中。但是在刷盘之前,其实这些数据已经以日志的形式保存在了磁盘的提交日志里面。当数据没有刷入磁盘而仅仅驻留在缓存时,这些日志可以保证数据的持久性。也就是,一旦数据库遭遇故障,可以从日志中恢复出来数据。

3.1 提交日志的特性

首先,提交日志非常类似于上一讲介绍的 LSM 树的磁盘文件特性,都是顺序写入且不可变。其益处也是相同的,顺序写保障了写入的高性能,不可变保证了读取可以安全地从前到后读取里面的数据。

提交日志一般都会被分配一个序列号作为唯一键,这个序号不是一个自增数字,就是一个时间戳。此外,每条日志都非常小,有些数据库会将它们进行缓存而后批量写入磁盘。这就导致,默写情况下日志不能完全恢复数据库,这是对于性能的考虑,大部分数据库会给出不同的参数来描述日志缓存刷盘的行为,用户可在性能与恢复数据完整性上作出平衡。

而事务在提交的时候,一定要保证其日志已经写入提交日志中。也就是事务内容完全写入日志是事务完成的一个非常重要的标志。

日志在理论上可以无限增长,但实际上没有意义。因为一旦数据从缓存中被刷入磁盘,该操作之前的日志就没有意义了,此时日志就可以被截断(Trim),从而释放空间。而这个被截断的点,我们一般称为检查点。检查点之前的页缓存中的脏页需要被完全刷入磁盘中

日志在实现的时候,一般是由一组文件组成。日志在文件中顺序循环写入,如果一个文件中的数据都是检查点之前的旧数据,那么新日志就可以覆盖它们,从而避免新建文件的问题。同时,将不同文件放入不同磁盘,以提高日志系统的可用性。

3.2 物理日志 Redo Log 与逻辑日志 Undo Log

事务对数据的修改其实是一种状态的改变,比如将 3 改为 5。这里我们将 3 称为前镜像(before-image),而 5 称为后镜像(after-image)。我们可以得到如下公式:

  1. 前镜像+redo log=后镜像
  2. 后镜像+undo log=前镜像

redo log 存储了页面和数据变化的所有历史记录,我们称它为物理日志。而 undo log 需要一个原始状态,同时包含相对这个状态的操作,所以又被称为逻辑日志。我们使用 redo 和 undo 就可以将数据向前或向后进行转换,这其实就是事务操作算法的基础。

3.3 Steal 与 Force 策略

redo 和 undo 有两种写入策略:steal 和 force。

steal 策略是说允许将事务中未提交的缓存数据写入数据库,而 no-steal 则是不能。可以看到如果是 steal 模式,说明数据从后镜像转变为前镜像了,这就需要 undo log 配合,将被覆盖的数据写入 undo log,以备事务回滚的时候恢复数据,从而可以恢复到前镜像状态。

force 策略是说事务提交的时候,需要将所有操作进行刷盘,而 no-force 则不需要。可以看到如果是 no-force,数据在磁盘上还是前镜像状态。这就需要 redo log 来配合,以备在系统出现故障后,从 redo log 里面恢复缓存中的数据,从而能转变为后镜像状态。

从上可知,当代数据库存储引擎大部分都有 undo log 和 redo log,那么它们就是 steal/no-force 策略的数据库

3.4 ARIES 数据恢复算法

这个算法全称为 Algorithm for Recovery and Isolation Exploiting Semantics。

该算法同时使用 undo log 和 redo log 来完成数据库故障崩溃后的恢复工作,其处理流程分为如下三个步骤。

  1. 首先数据库重新启动后,进入分析模式。检查崩溃时数据库的脏页情况,用来识别需要从 redo 的什么位置开始恢复数据。同时搜集 undo 的信息去回滚未完成的事务。
  2. 进入执行 redo 的阶段。该过程通过 redo log 的回放,将在页缓存中但是没有持久化到磁盘的数据恢复出来。这里注意,除了恢复了已提交的数据,一部分未提交的数据也恢复出来了
  3. 进入执行 undo 的阶段。这个阶段会回滚所有在上一阶段被恢复的未提交事务。为了防止该阶段执行时数据库再次崩溃,存储引擎会记录下已执行的 undo 操作,防止它们重复被执行。

ARIES 算法虽然被提出多年,但其概念和执行过程依然在现代存储引擎中扮演着重要作用。

以上我们讲解了数据库如何恢复数据,保持一致性状态。它对应着 AID(C 如前文所示,是一种约束,一般不认为是数据库提供的功能)中的 AD。同时我们也要知道以提交日志为代表的数据库恢复技术,在没有事务概念的数据库中也扮演着重要的作用,因为页缓存是无处不在的,解决主存掉电丢失数据的问题,是提交日志的主要功能。

四、隔离级别

数据库最强的隔离级别是序列化,它保证从事务的角度看自己是独占了所有资源的。但序列化性能较差,因此我们引入了多种隔离界别来提高性能。在本讲的最后我会介绍分布式数据库中常用的并发控制手段,它们是实现隔离级别的有效方案,其中以多版本方式实现快照隔离最为常见。

序列化的概念与事务调度(Schedule)密切相关。一个调度包含该事务的全部操作。我们可以用 CPU 调度理论来类比,当一个事务被调度后,它可以访问数据库系统的全部资源,同时会假设没有其他事务去影响数据库的状态。这就类似于一个进程被 CPU 调度,从而独占该 CPU 资源(这里的 CPU 指的是时分系统)。但是实际设计调度时,会允许调度事务内部的操作被重新排序,使它们可以并行执行。这些都是优化操作,但只要不违反 ACID 的原则和结果的正确性就可以了。

那什么是序列化呢?如果一个调度被说成是序列化的,指的是它与其他调度之间的关系:在该调度执行时没有其他被调度的事务并行执行。也就是说,调度是一个接着一个顺序执行的,前一个调度成功完成后,另一个调度再执行。这种方法的一个好处是执行结果比较好预测。但是,我们发现这种做法有明显的缺陷:性能太低。在实现时,一个序列化调度可能会并行执行多个事务操作,但是会保证这样与一个个顺序执行调度有相同的结果。

以上就是序列化的概念,它揭示了序列化也会存在并发执行的情况。这一点很重要,在隔离理论中,一个隔离概念只是描述了一种行为,而在实现层面可以有多种选择,只要保证这个行为的结果符合必要条件就没有问题了。

序列化是最强的事务隔离级别,它是非常完美的隔离状态,可以让并行运行的事务感知不到对方的存在,从而安心地进行自己的操作。但在实现数据库事务时,序列化存在实现难度大、性能差等问题。故数据库理论家提出了隔离级别的概念,用来进行不同程度的妥协。在详解隔离级别之前,来看看我们到底可以“妥协”什么。

这些“妥协”被称为读写异常(Anomalies)。读异常是大家比较熟悉的,有“脏读”“不可重读”和“幻读”。写异常不太为大家所知,分别是“丢失更新”“脏写”和“写偏序”。读异常和写异常是分别站在使用者和数据本身这两个角度去观察隔离性的,我们将成对介绍它们。传统上隔离级别是从读异常角度描述的,但是最近几年,一些论文也从写异常角度出发,希望你能明白两种表述方式之间是有联系的。下表就是经典隔离级别与读异常的关系。

从中可以看到序列化是不允许任何读写异常存在的。

可重读允许幻读的产生。幻读是事务里面读取一组数据后,再次读取这组数据会发现它们可能已经被修改了。幻读对应的写异常是写偏序。写偏序从写入角度发现,事务内读取一批数据进行修改,由于幻读的存在,造成最终修改的结果从整体上看违背了数据一致性约束。

读到已提交在可重读基础上放弃了不可重读。与幻读类似,但不可重读针对的是一条数据。也就是只读取一条数据,而后在同一个事务内,再读取它数据就变化了。

刚接触这个概念的同学可能会感觉匪夷所思,两者只相差一个数据量,就出现了两个隔离级别。这背后的原因是保证一条数据的难度要远远低于多条,也就是划分这两个级别,主要的考虑是背后的原理问题。而这个原理又牵扯出了性能与代价的问题。因此就像我在本专栏中反复阐述的一样,一些理论概念有其背后深刻的思考,你需要理解背后原理才能明白其中的奥义。不过不用担心,后面我会详细阐述它们之间实现的差别。

而不可重读对应的是丢失更新,与写偏序类似,丢失更新是多个事务操作一条数据造成的。

最低的隔离级别就是读到未提交,它允许脏读的产生。脏读比较简单,它描述了事务可以读到其他事务为提交的数据,我们可以理解为完全没有隔离性。而脏读本身也会造成写异常:脏写。脏写就是由于读到未提交的数据而造成的写异常。

以上,我们详细阐述了经典的隔离级别。但是这套理论是非常古早的,较新的 MVCC 多版本技术所带来的快照隔离又为传统隔离级别增添一个灵活选型。它可以被理解为可重读隔离级别,也就是不允许不可重读。但是在可重读隔离下,是可以保证读取不到数据被修改的。但快照隔离的行为是:一旦读到曾经读过的数据被修改,将立即终止当前事务,也就是进行回滚操作。在多并发事务下,也就是只有一个会成功。你可以细细品味两者的差异。

快照隔离可以解决丢失更新的问题,因为针对同一条数据可以做快照检测,从而发现数据被修改,但是不能防止写偏序的问题。

快照隔离是现代分布式数据库存储引擎最常使用的隔离级别,而解决写偏序问题,也是存储引擎在该隔离级别下需要解决的问题。SSI(Serializable Snaphost Isoltion)正是解决这个问题的方案,

五、并发控制

目前存储引擎引入多种并发模型来实现上面提到的隔离级别,不同的模式对实现不同的级别是有偏好的,虽然理论上每种控制模型都可以实现所有级别。下面我就从乐观与悲观、多版本、基于锁的控制三个方面进行介绍

5.1 乐观与悲观

乐观与悲观的概念类似于并发编程中的乐观锁与悲观锁。但是这里你要注意,实现它们并不一定要借助锁管理。

乐观控制使用的场景是并行事务不太多的情况,也就是只需要很少的时间来解决冲突。那么在这种情况下,就可以使用一些冲突解决手段来实现隔离级别。最常用的方案是进行提交前冲突检查。

冲突检查有多种实现模式,比如最常用的多版本模式。而另一种古老的模式需要检查并行事务直接操作的数据,也就是观察它们操作的数据是否有重合。由于其性能非常差,已经很少出现在现代存储引擎中了。这里需要你注意的是,乐观控制不一定就是多版本这一种实现,还有其他更多的选择。

同样的,悲观控制也不仅仅只有锁这一种方案。一种可能的无锁实现是首先设置两个全局时间戳,最大读取时间与最大写入时间。如果一个读取操作发生的时间小于最大写入时间,那么该操作所在的事务被认为应该终止,因为读到的很可能是旧数据。而一个写操作如果小于最大读取时间,也被认为是异常操作,因为刚刚已经有读取操作发生了,当前事务就不能去修改数据了。而这两个值是随着写入和读取操作而更新的。这个悲观控制被称为 Thomas Write Rule,对此有兴趣的话你可以自行搜索学习。

5.2 多版本

多版本并发控制(MVCC,Multiversion concurrency control)是一种实现乐观控制的经典模式。它将每行数据设置一个版本号,且使用一个单调递增的版本号生成器来产生这些版本号,从而保证每条记录的版本号是唯一的。同时给每个事物分为一个 ID 或时间戳,从而保证读取操作可以读到事务提交之前的旧值。

MVCC 需要区分提交版本与未提交版本。最近一次提交的版本被认为是当前版本,从而可以被所有事务读取出来。而根据隔离级别的不同,读取操作能或者不能读取到未提交的版本。

使用 MVCC 最经典的用法是实现快照隔离。事务开始的时候,记录当前时间,而后该事务内所有的读取操作只能读到当前提交版本小于事务开始时间的数据,而未提交的数据和提交版本大于事务开始时间点的数据是不能读取出来的。如果事务读取的数据已经被其他事务修改,那么该数据应该在上一讲提到的 undo log 中,当前事务还是能够读取到这份数据的。故 undo log 的数据不能在事务提交的时候就清除掉,因为很可能有另外的事务正在读取它。

而当事务提交的时候,数据其实已经写入完成。只需要将版本状态从未提交版本改为提交版本即可。所以 MVCC 中的提交操作是非常快的,这点会对分布式事务有很多启示。

而上文提到的 SSI 模式可以在 MVCC 的基础上引入冲突解决机制,从而解决写偏序问题。当提交发生的时候,事务会检测其修改和读取的数据在提交之前是否已经被其他已提交事务修改了,如果是,则会终止当前事务,并进行回滚。同时这个冲突检测时机会有两个:一个是在事务内进行读取操作时就进行检测,称为前向检测(forward)。而相对的,在提交时进行检测被称为后向检测(backward)。你会明显感觉到,前者会快速失败,但是性能较低;而后者对异常的反应较慢,但速度会有优势。

5.3 基于锁的控制

基于锁的控制是典型的悲观控制。它会使用显示的锁来控制共享资源,而不是通过调度手段来实现。锁控制可以很容易实现“序列化操作”,但是它同时存在锁竞争和难扩展等问题。

一个比较知名的锁技术是两阶段锁(2PL),它将锁操作总结为两个阶段。

  1. 锁膨胀阶段。在该过程中,事务逐步获得所有它需要的锁,同时不释放任何锁。这期间事务可以对加锁的数据进行操作。
  2. 锁收缩阶段。该过程中,在上一过程中获得的锁全部被释放。这个事务是逐步的,这期间事务依然可以对还持有锁的数据进行操作。

以上过程简单明了,它是针对一次性加锁提出来的,一次性加锁的缺点是没有并发度,性能低;而两阶段锁可以保证一定的并发度,但其缺点是会有死锁的产生。

死锁是两个事务互相持有对方的锁,从而造成它们都无法继续运行。解决死锁需要引入超时机制,但超时机制又有明显的性能缺憾。此时,人们会引入死锁检测机制来尽早发现死锁。一般实现手段是将所有事务的锁依赖构建成一棵依赖图,而后使用图算法来发现其中的环形死锁结构,从而快速判断死锁的产生。

而与锁相对的一个概念就是“闩”(latch,读“shuān”)。一般资料说闩是轻量的,锁是重量的,这其实体现在两个方面。

一是说它们处理的对象。闩一般用在粒度很小的数据中,比如数据块、索引树的节点等。而锁一般作用在大颗粒操作,如锁定多行数据、事务和修改存储结构等。

二是它们本身的实现不同。闩一般使用 CAS 执行,是基于比较而后设置的无锁指令级别的操作。如果原始值发生变化就重新进行以上操作,这个过程叫自旋(spin)。而锁是使用独立的资源,且有锁管理器来控制。可想而知,调度锁也是一个比较耗时且复杂的过程。

这里就要解释上文中隔离级别“序列化”和“可重读”之间实现的差异了。“序列化”由于要保证一组数据重复读取的一致性,就需要引入重量级的锁,其代价是很高的;而“可重读”只需要保证一行数据重复读取是一致的,它可以使用轻量级的闩来实现。故隔离级别将它们分成两种是非常合理的,因为从原理看,它们是完全不同的。

博文参考

举报

相关推荐

0 条评论