learn-tech/专栏/周志明的架构课/11_本地事务如何实现原子性和持久性?.md
2024-10-16 06:37:41 +08:00

16 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        11 _ 本地事务如何实现原子性和持久性?
                        你好,我是周志明。

在接下来的五节课里,我们将会一起讨论软件开发中另一个常见的话题:事务处理。

事务处理几乎是每一个信息系统中都会涉及到的问题它存在的意义就是保证系统中的数据是正确的不同数据间不会产生矛盾也就是保证数据状态的一致性Consistency

关于一致性,我这里先做个说明。“一致性”在数据科学中有严肃定义,并且有多种细分类型的概念。这里我们重点关注的是数据库状态的一致性,它跟课程后面第三个模块“分布式的基石”当中,即将要讨论的分布式共识算法时所说的一致性,是不一样的,具体的差别我们会在第三个模块中探讨。

说回数据库状态的一致性,理论上,要达成这个目标需要三方面的共同努力:

原子性Atomic在同一项业务处理过程中事务保证了多个对数据的修改要么同时成功要么一起被撤销。 隔离性Isolation在不同的业务处理过程中事务保证了各自业务正在读、写的数据互相独立不会彼此影响。 持久性Durability事务应当保证所有被成功提交的数据修改都能够正确地被持久化不丢失数据。

以上就是事务的“ACID”的概念提法。我自己对这种已经形成习惯的“ACID”的提法是不太认同的因为这四种特性并不正交A、I、D是手段C是目的完全是为了拼凑个单词缩写才弄到一块去误导的弊端已经超过了易于传播的好处。所以明确了这一点也就明确了我们今天的讨论就是要聚焦在事务处理的A、I、D上。

那接下来,我们先来看看事务处理的场景。

事务场景

事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。

当一个服务只操作一个数据源的时候通过A、I、D来获得一致性是相对容易的但当一个服务涉及到多个不同的数据源甚至多个不同服务同时涉及到多个不同的数据源时这件事情就变得很困难有时需要付出很大、甚至是不切实际的代价因此业界探索过许多其他方案在确保可操作的前提下获得尽可能高的一致性保障。由此事务处理才从一个具体操作上的“编程问题”上升成一个需要仔细权衡的“架构问题”。

人们在探索这些事务方案的过程中,产生了许多新的思路和概念,有一些概念看上去并不那么直观,因此,在接下来的这几节课中,我会带着你,一起探索同一个事例在不同的事务方案中的不同处理,以此来贯穿、理顺这些概念。

场景事例

我先来给你介绍下具体的事例。

Fenixs Bookstore是一个在线书店。一份商品成功售出需要确保以下三件事情被正确地处理

用户的账号扣减相应的商品款项; 商品仓库中扣减库存,将商品标识为待配送状态; 商家的账号增加相应的商品款项。

接下来,我将逐一介绍在“单个服务使用单个数据源”“单个服务使用多个数据源”“多个服务使用单个数据源”以及“多个服务使用多个数据源”的不同场景下,我们可以采用哪些手段来保证以上场景实例的正确性。

今天这一讲,我们先来看“单个服务使用单个数据源”,也就是本地事务场景。

本地事务

本地事务Local Transactions其实应该翻译成“局部事务”才好与第13讲中要讲解的“全局事务”对应起来。不过现在“本地事务”的译法似乎已经成为主流我们就不去纠结名称了。

本地事务是指仅操作特定单一事务资源的、不需要“全局事务管理器”进行协调的事务。如果这个定义你现在不能理解的话,不妨暂且先放下,等学完“全局事务”这个小章节后再回过头来想想。

本地事务是最基础的一种事务处理方案通常只适用于单个服务使用单个数据源的场景它是直接依赖于数据源通常是数据库系统本身的事务能力来工作的。在程序代码层面我们最多只能对事务接口做一层标准化的包装如JDBC接口并不能深入参与到事务的运作过程当中。

事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式全部都要依赖底层数据库的支持这一点与后面的14、15两讲中要介绍的XA、TCC、SAGA等主要靠应用程序代码来实现的事务有着十分明显的区别到时你可以跟今天所讲的内容相互对照下

我举个具体的例子假设你的代码调用了JDBC中的Transaction::rollback()方法方法的成功执行并不代表事务就已经被成功回滚如果数据表采用引擎的是MyISAM那rollback()方法便是一项没有意义的空操作。因此我们要想深入地讨论本地事务便不得不越过应用代码的层次去了解一些数据库本身的事务实现原理弄明白传统数据库管理系统是如何实现ACID的。

ARIES理论

如今研究事务的实现原理必定会追溯到ARIES理论Algorithms for Recovery and Isolation Exploiting Semantics基于语义的恢复与隔离算法。起这拗口的名字应该多少也有些拼凑“ARIES”这个单词的目的跟ACID一样的恶趣味

虽然我们不能说所有的数据库都实现了ARIES理论但现代的主流关系型数据库Oracle、Microsoft SQLServer、MySQL-InnoDB、IBM DB2、PostgreSQL等等在事务实现上都深受该理论的影响。

上世纪90年代IBM Almaden研究院总结了研发原型数据库系统“IBM System R”的经验发表了ARIES理论中最主要的三篇论文这里先给你介绍两篇。《ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging》着重解决了事务的ACID三个属性中原子性A和持久性D在算法层面上应当如何实现而另一篇《ARIES/KVL: A Key-Value Locking Method for Concurrency Control of Multiaction Transactions Operating on B-Tree Indexes》则是现代数据库隔离性I奠基式的文章。

我们先从原子性和持久性说起。至于隔离性,在下一节课中我们再接着展开介绍。

实现原子性和持久性

原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

显而易见数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性只存储在内存中的数据一旦遇到程序忽然崩溃、数据库崩溃、操作系统崩溃机器突然断电宕机后面我们都统称为崩溃Crash等情况就会丢失。实现原子性和持久性所面临的困难是“写入磁盘”这个操作不会是原子的不仅有“写入”与“未写入”还客观地存在着“正在写”的中间状态。

按照上面我们列出的示例场景从Fenixs Bookstore购买一本书需要修改三个数据在用户账户中减去货款、在商家账户中增加货款、在商品仓库中标记一本书为配送状态由于写入存在中间状态可能发生以下情形

未提交事务:程序还没修改完三个数据,数据库已经将其中一个或两个数据的变动写入了磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。 已提交事务:程序已经修改完三个数据,数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

这种数据恢复操作被称为崩溃恢复Crash Recovery也有称作Failure Recovery或Transaction Recovery。为了能够顺利地完成崩溃恢复在磁盘中写数据就不能像程序修改内存中变量值那样直接改变某表某行某列的某个值必须将修改数据这个操作所需的全部信息比如修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值等等以日志的形式日志特指仅进行顺序追加的文件写入方式这是最高效的写入方式先记录到磁盘中。

只有在日志记录全部都安全落盘见到代表事务成功提交的“Commit Record”后数据库才会根据日志上的信息对真正的数据进行修改修改完成后在日志中加入一条“End Record”表示事务已完成持久化这种事务实现方法被称为“Commit Logging”。

额外知识Shadow Paging-

通过日志实现事务的原子性和持久性是当今的主流方案但并非唯一的选择。除日志外还有另外一种称为“Shadow Paging”有中文资料翻译为“影子分页”的事务实现机制常用的轻量级数据库SQLite Version 3采用的就是Shadow Paging。-

Shadow Paging的大体思路是对数据的变动会写到硬盘的数据中但并不是直接就地修改原先的数据而是先将数据复制一份副本保留原数据修改副本数据。在事务过程中被修改的数据会同时存在两份一份修改前的数据一份是修改后的数据这也是“影子”Shadow这个名字的由来。-

当事务成功提交所有数据的修改都成功持久化之后最后一步要修改数据的引用指针将引用从原数据改为新复制出来修改后的副本最后的“修改指针”这个操作将被认为是原子操作所以Shadow Paging也可以保证原子性和持久性。-

Shadow Paging相对简单但涉及到隔离性与锁时Shadow Paging实现的事务并发能力相对有限因此在高性能的数据库中应用不多。

Commit Logging保障数据持久性、原子性的原理并不难想明白。

首先日志一旦成功写入Commit Record那整个事务就是成功的即使修改数据时崩溃了重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可这保证了持久性。

其次如果日志没有写入成功就发生崩溃系统重启后会看到一部分没有Commit Record的日志那将这部分日志标记为回滚状态即可整个事务就像完全没有发生过一样这保证了原子性。

Commit Logging实现事务简单清晰也有一些数据库就是采用Commit Logging机制来实现事务的较具代表性的是阿里的OceanBase。但是Commit Logging存在一个巨大的缺陷所有对数据的真实修改都必须发生在事务提交、日志写入了Commit Record之后即使事务提交前磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大占用大量的内存缓冲无论何种理由都决不允许在事务提交之前就开始修改磁盘上的数据这一点对提升数据库的性能是很不利的。

为了解决这个缺陷前面提到的ARIES理论终于可以登场了。ARIES提出了“Write-Ahead Logging”的日志改进方案其名字里所谓的“提前写入”Write-Ahead就是允许在事务提交之前提前写入变动数据的意思。

Write-Ahead Logging先将何时写入变动数据按照事务提交时点为界分为了FORCE和STEAL两类

FORCE当事务提交后要求变动数据必须同时完成写入则称为FORCE如果不强制变动数据必须同时完成写入则称为NO-FORCE。现实中绝大多数数据库采用的都是NO-FORCE策略只要有了日志变动数据随时可以持久化从优化磁盘I/O性能考虑没有必要强制数据写入立即进行。 STEAL在事务提交前允许变动数据提前写入则称为STEAL不允许则称为NO-STEAL。从优化磁盘I/O性能考虑允许数据提前写入有利于利用空闲I/O资源也有利于节省数据库缓存区的内存。

Commit Logging允许NO-FORCE但不允许STEAL。因为假如事务提交前就有部分变动数据写入磁盘那一旦事务要回滚或者发生了崩溃这些提前写入的变动数据就都成了错误。

Write-Ahead Logging允许NO-FORCE也允许STEAL它给出的解决办法是增加了另一种称为Undo Log的日志。当变动数据写入磁盘前必须先记录Undo Log写明修改哪个位置的数据、从什么值改成什么值以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除。

Undo Log现在一般被翻译为“回滚日志”此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为Redo Log一般翻译为“重做日志”。

由于Undo Log的加入Write-Ahead Logging在崩溃恢复时会以此经历以下三个阶段

分析阶段Analysis该阶段从最后一次检查点Checkpoint可理解为在这个点之前所有应该持久化的变动都已安全落盘开始扫描日志找出所有没有End Record的事务组成待恢复的事务集合一般包括Transaction Table和Dirty Page Table。 重做阶段Redo该阶段依据分析阶段中产生的待恢复的事务集合来重演历史Repeat History找出所有包含Commit Record的日志将它们写入磁盘写入完成后增加一条End Record然后移除出待恢复事务集合。 回滚阶段Undo该阶段处理经过分析、重做阶段后剩余的恢复事务集合此时剩下的都是需要回滚的事务被称为Loser根据Undo Log中的信息回滚这些事务。

重做阶段和回滚阶段的操作都应该设计为幂等的。而为了追求高性能以上三个阶段都无可避免地会涉及到非常繁琐的概念和细节如Redo Log、Undo Log的具体数据结构等这里我们就不展开讲了如果想要继续学习前面讲到的那两篇论文就是学习的最佳途径。

Write-Ahead Logging是ARIES理论的一部分整套ARIES拥有严谨、高性能等很多的优点但这些也是以复杂性为代价的。

数据库按照“是否允许FORCE和STEAL”可以产生四种组合从优化磁盘I/O的角度看NO-FORCE加STEAL组合的性能无疑是最高的从算法实现与日志的角度看NO-FORCE加STEAL组合的复杂度无疑是最高的。

这四种组合与Undo Log、Redo Log之间的具体关系如下图所示

小结

今天这节课我们学习了经典ARIES理论下实现本地事务中原子性与持久性的方法。通过写入日志来保证原子性和持久性是业界的主流做法这个做法最困难的一点就是如何处理日志“写入中”的中间状态才能既保证严谨也能够高效。

ARIES理论提出了Write-Ahead Logging式的日志写入方法通过分析、重做、回滚三个阶段实现了STEAL、NO-FORCE从而实现了既高效又严谨的日志记录与故障恢复。

一课一思

为什么Logging会成为实现原子性、持久性的主流做法Shadow Paging等其他方法的不足之处是什么

欢迎在留言区分享你的见解。如果你身边的朋友也对实现本地事务中原子性与持久性的方法感兴趣欢迎你把今天的内容分享给TA我们一起交流探讨。

好,感谢你的阅读,我们下一讲再见。