数据库中的原子性保证了事务的多个操作要么全部生效,要么全部不生效,不会存在中间状态;持久性则保证了事务一旦生效,就不会因为任何原因导致其修改的内容被撤销或丢失。
数据必须成功写入磁盘、磁带等持久化存储器之后才拥有持久性,只存储在内存中的数据,一旦遇到程序崩溃,或者数据库、操作系统崩溃,甚至机器突然断电宕机,就会丢失。
问题的根源:写入并非原子操作
实现原子性和持久性最大的困难在于,“写入磁盘”这个操作本身并不是原子的:它不仅有“已写入”与“未写入”两种状态,客观上还存在“正在写”的中间状态。由于这种中间状态与崩溃都无法消除,所以不做额外保障措施的话,仅仅把内存中的数据写入磁盘,并不能保证原子性与持久性。
举个例子,当我们购买一本书时需要修改三处数据:在用户账户中扣减货款、在商家账户中增加货款、在商品仓库中将这本书标记为配送状态。由于写入存在中间状态,可能出现以下情形:
- 未提交事务,写入后崩溃: 程序还没修改完三处数据,但数据库已经把其中一两处的变动写入了磁盘。此时若发生崩溃,重启后数据库必须能够识别出崩溃前存在一次不完整的购物操作,把已经修改过的数据从磁盘中恢复成未修改前的样子,以保证原子性。
- 已提交事务,写入前崩溃: 程序已经修改完三处数据,但数据库还没把全部变动写入磁盘。此时若发生崩溃,重启后数据库必须能够识别出崩溃前存在一次完整的购物操作,把还没来得及写入磁盘的那部分数据重新写入,以保证持久性。
由于写入的中间状态与崩溃都无法避免,要保证原子性和持久性,就只能在崩溃发生后采取恢复的补救措施,这种数据恢复操作被称为“崩溃恢复”(Crash Recovery,也有资料称作 Failure Recovery 或 Transaction Recovery)。
Commit Logging:先写日志,再改数据
为了顺利完成崩溃恢复,向磁盘写入数据就不能像程序修改内存变量那样,直接改变某表某行某列的值,而是必须把修改数据这一操作所需的全部信息——包括修改了什么数据、数据物理上位于哪个内存页和磁盘块、从什么值改成什么值等——以日志的形式(即仅进行顺序追加的文件写入)先记录到磁盘中。只有当日志记录全部安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会依据日志信息对真正的数据进行修改;修改完成后,再在日志中追加一条“结束记录”(End Record),表示该事务已完成持久化。这种事务实现方法被称为“提交日志”(Commit Logging)。
Commit Logging 保障数据持久性、原子性的原理是:
- 日志一旦成功写入 Commit Record,整个事务就宣告成功。即便真正修改数据时发生崩溃,重启后也可以根据已经落盘的日志信息恢复现场、继续完成数据修改,这保证了持久性。
- 如果日志没有成功写入 Commit Record 就发生了崩溃,那么整个事务就是失败的。系统重启后会看到一部分没有 Commit Record 的日志,将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。
Commit Logging 的缺陷在于:所有对数据的真实修改都必须发生在事务提交(即日志写入 Commit Record)之后。在此之前,无论磁盘 I/O 多么空闲,无论某个事务修改的数据量有多庞大、占用了多少内存缓冲区,都绝不允许提前把数据改到磁盘上——这是 Commit Logging 成立的前提,却也对数据库性能十分不利。
Write-Ahead Logging:FORCE 与 STEAL
为了解决这个问题,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 中的信息,把已经提前写入磁盘的数据改写回去,以达到回滚这些 Loser 事务的目的。
重做阶段和回滚阶段的操作都应设计为幂等的。为了追求高 I/O 性能,以上三个阶段不可避免地涉及非常繁琐的概念和细节(如 Undo Log、Redo Log 的具体数据结构等)。Write-Ahead Logging 是 ARIES 理论的一部分,整套 ARIES 拥有严谨、高性能等诸多优点,但这些都是以高度复杂为代价换来的。
参考自《凤凰架构:构建可靠的大型分布式系统》周志明