MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

ACID 的持久性:redo log 与 undo log 深度解析

2024-09-086.6k 阅读

1. 数据库事务与 ACID 特性

在深入探讨 redo log 和 undo log 之前,我们先来回顾一下数据库事务以及 ACID 特性。数据库事务是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。ACID 特性是确保数据库事务可靠执行的一组属性,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

  • 原子性:事务中的所有操作要么全部成功执行,要么全部失败回滚,就好像事务是一个不可分割的整体。例如在银行转账操作中,从账户 A 扣除金额和向账户 B 添加金额这两个操作必须同时成功或者同时失败,否则就会出现数据不一致的情况。
  • 一致性:事务执行前后,数据库的完整性约束没有被破坏。例如在转账操作中,转账前后账户 A 和账户 B 的总金额应该保持不变。
  • 隔离性:多个并发事务之间相互隔离,一个事务的执行不会被其他事务干扰。每个事务都感觉像是在独自使用整个数据库。
  • 持久性:一旦事务提交,它对数据库所做的修改就会永久保存下来,即使系统发生崩溃、断电等故障,这些修改也不会丢失。这正是 redo log 和 undo log 发挥关键作用的地方。

2. 持久性与故障恢复

持久性是通过数据库的故障恢复机制来实现的。当系统发生故障时,数据库需要能够恢复到故障前的状态,确保已提交事务的修改不丢失,未提交事务的修改不残留。故障恢复主要依赖于两种日志:redo log(重做日志)和 undo log(回滚日志)。

2.1 故障类型

在讨论日志机制之前,我们先了解一下可能导致数据库故障的几种类型:

  • 事务故障:事务在执行过程中由于各种原因(如逻辑错误、资源不足等)未能成功完成,需要回滚。例如在转账操作中,如果账户 A 的余额不足,那么整个转账事务就需要回滚。
  • 系统故障:整个系统发生崩溃,如操作系统崩溃、硬件故障等。系统重启后,需要恢复到故障前的状态。
  • 介质故障:存储数据的介质(如硬盘)发生故障,导致数据丢失。这种故障相对较少,但影响严重。

2.2 基于日志的恢复原理

为了实现故障恢复,数据库系统记录了两种重要的日志信息:redo log 和 undo log。redo log 用于在系统故障后重做已提交事务的修改,确保已提交事务的持久性;undo log 用于在事务故障时回滚未提交事务的修改,保证事务的原子性。

3. Redo Log(重做日志)

3.1 Redo Log 的作用

redo log 是一种记录数据库物理修改的日志。它记录了数据库在执行事务过程中对数据页所做的修改。当系统发生故障后重启时,数据库可以通过重做 redo log 中记录的操作,将数据库恢复到故障前已提交事务的状态,从而保证持久性。

例如,假设我们有一个简单的事务,将表 usersid 为 1 的用户的余额增加 100。在执行这个事务过程中,数据库会在 redo log 中记录对 users 表数据页的修改操作(具体修改了哪些字节等物理信息)。如果系统在事务提交后但数据还未完全持久化到磁盘时发生故障,重启后数据库可以通过重做 redo log 中的记录,再次执行增加余额的操作,确保该事务的修改不会丢失。

3.2 Redo Log 的结构与格式

redo log 通常由一系列的日志记录组成,每个日志记录包含以下关键信息:

  • 事务标识(Transaction ID):用于标识产生该日志记录的事务。
  • 操作类型:例如插入、更新、删除等操作。
  • 数据页地址:记录操作所影响的数据页在磁盘中的地址。
  • 修改前的值(可选):某些操作(如更新)可能需要记录修改前的值,以便在必要时进行回滚(虽然主要用于 undo log,但在某些恢复场景下 redo log 也可能记录部分相关信息)。
  • 修改后的值:记录操作对数据页所做的实际修改。

例如,一个简单的更新操作的 redo log 记录可能如下:

Transaction ID: 123
Operation Type: UPDATE
Data Page Address: 0x1000
Old Value: 500 (balance before update)
New Value: 600 (balance after update)

3.3 Redo Log 的写入策略

为了保证性能和持久性,redo log 的写入采用了一些特定的策略:

  • 循环写:redo log 通常采用循环写的方式,即日志空间是固定大小的,当写满后会覆盖旧的日志记录。这样可以避免日志文件无限增长。
  • 刷盘策略:redo log 并非每次有新记录就立即写入磁盘,而是根据一定的策略进行刷盘。常见的刷盘策略有:
    • Write-Ahead Logging(WAL):在将数据真正持久化到数据文件之前,先将对应的 redo log 记录写入磁盘。这确保了即使在数据持久化过程中发生故障,也可以通过重做日志恢复数据。
    • Checkpoint:定期将内存中的脏数据(已修改但未持久化到磁盘的数据)刷盘,并在 redo log 中记录一个检查点(Checkpoint)。在故障恢复时,只需要从检查点开始重做日志,而不需要从头开始,从而提高恢复效率。

3.4 Redo Log 示例代码(模拟)

下面我们通过一段简单的 Python 代码来模拟 redo log 的记录和恢复过程。假设我们有一个简单的数据库表 accounts,包含 idbalance 字段。

import logging

# 配置日志记录器,模拟redo log
logging.basicConfig(filename='redo_log.log', level=logging.INFO,
                    format='%(asctime)s - %(message)s')


class Account:
    def __init__(self, id, balance):
        self.id = id
        self.balance = balance


class Database:
    def __init__(self):
        self.accounts = {1: Account(1, 1000)}

    def update_balance(self, account_id, amount):
        account = self.accounts[account_id]
        old_balance = account.balance
        account.balance += amount
        new_balance = account.balance
        # 记录redo log
        logging.info(f'Transaction - UPDATE account {account_id} balance from {old_balance} to {new_balance}')


# 模拟系统故障
def simulate_system_failure():
    raise SystemExit


# 模拟系统重启后的恢复
def recover_from_failure():
    with open('redo_log.log', 'r') as f:
        lines = f.readlines()
        for line in lines:
            parts = line.strip().split(' - ')
            if parts[1].startswith('UPDATE'):
                parts = parts[1].split(' ')
                account_id = int(parts[2])
                old_balance = int(parts[4])
                new_balance = int(parts[6])
                db.accounts[account_id].balance = new_balance


db = Database()
try:
    db.update_balance(1, 500)
    simulate_system_failure()
except SystemExit:
    print("System failed, attempting recovery...")
    recover_from_failure()
    print(f"Account 1 balance after recovery: {db.accounts[1].balance}")

在这段代码中,update_balance 方法在更新账户余额时记录了 redo log。如果模拟系统故障发生,recover_from_failure 方法会读取 redo log 并恢复账户余额。

4. Undo Log(回滚日志)

4.1 Undo Log 的作用

undo log 主要用于事务的回滚,确保事务的原子性。当事务执行过程中出现故障需要回滚时,数据库可以根据 undo log 中记录的信息,将数据库恢复到事务开始前的状态。

继续以银行转账为例,假设在转账过程中,从账户 A 扣除金额后,在向账户 B 添加金额之前系统出现故障。此时,数据库可以利用 undo log 中记录的账户 A 扣除金额前的余额信息,将账户 A 的余额恢复到初始状态,从而保证整个转账事务要么全部成功,要么全部失败。

4.2 Undo Log 的结构与格式

undo log 同样由一系列的日志记录组成,每个日志记录包含以下关键信息:

  • 事务标识(Transaction ID):用于标识产生该日志记录的事务。
  • 操作类型:与 redo log 类似,但主要用于回滚操作,如插入操作对应的回滚操作是删除,更新操作对应的回滚操作是将数据恢复到修改前的值。
  • 数据页地址:记录操作所影响的数据页在磁盘中的地址。
  • 修改前的值:这是 undo log 的关键信息,用于在回滚时将数据恢复到原来的状态。

例如,一个更新操作的 undo log 记录可能如下:

Transaction ID: 123
Operation Type: UPDATE (ROLLBACK)
Data Page Address: 0x1000
Old Value: 500 (balance before update)

4.3 Undo Log 的写入策略

undo log 与 redo log 的写入策略有所不同。undo log 是在事务执行过程中不断记录的,并且在事务提交前一直保留。因为只要事务没有提交,就有可能需要回滚。一旦事务提交,undo log 中与该事务相关的记录就可以在适当的时候被清理(例如在检查点之后,确定该事务不会再需要回滚时)。

4.4 Undo Log 示例代码(模拟)

下面我们通过代码模拟 undo log 的记录和回滚过程。同样以 accounts 表为例:

import logging

# 配置日志记录器,模拟undo log
logging.basicConfig(filename='undo_log.log', level=logging.INFO,
                    format='%(asctime)s - %(message)s')


class Account:
    def __init__(self, id, balance):
        self.id = id
        self.balance = balance


class Database:
    def __init__(self):
        self.accounts = {1: Account(1, 1000)}

    def update_balance(self, account_id, amount):
        account = self.accounts[account_id]
        old_balance = account.balance
        # 记录undo log
        logging.info(f'Transaction - UPDATE account {account_id} old balance {old_balance}')
        account.balance += amount

    def rollback(self, account_id):
        with open('undo_log.log', 'r') as f:
            lines = f.readlines()
            for line in lines[::-1]:
                parts = line.strip().split(' - ')
                if parts[1].startswith('UPDATE') and parts[2].endswith(str(account_id)):
                    parts = parts[2].split(' ')
                    old_balance = int(parts[3])
                    self.accounts[account_id].balance = old_balance
                    break


db = Database()
try:
    db.update_balance(1, 500)
    # 模拟事务故障
    raise Exception
except Exception:
    print("Transaction failed, rolling back...")
    db.rollback(1)
    print(f"Account 1 balance after rollback: {db.accounts[1].balance}")

在这段代码中,update_balance 方法在更新账户余额前记录了 undo log。如果模拟事务故障发生,rollback 方法会读取 undo log 并将账户余额回滚到初始状态。

5. Redo Log 与 Undo Log 的协同工作

虽然 redo log 和 undo log 分别负责不同的功能(redo log 保证持久性,undo log 保证原子性),但在实际的数据库系统中,它们是协同工作的。

在事务执行过程中,数据库会同时记录 redo log 和 undo log。redo log 记录的是事务对数据页的物理修改,以便在系统故障后进行恢复;undo log 记录的是事务修改前的数据状态,以便在事务故障时进行回滚。

当系统发生故障后重启时,数据库首先会根据 redo log 重做已提交事务的修改,确保已提交事务的持久性。然后,数据库会检查未提交事务的状态,并根据 undo log 回滚这些未提交事务的修改,保证事务的原子性。

例如,假设有两个并发事务 T1 和 T2。T1 进行转账操作,从账户 A 向账户 B 转账;T2 对账户 C 进行余额更新操作。在执行过程中,系统发生故障。重启后,数据库会先根据 redo log 重做 T1 和 T2 已提交部分的操作(如果有提交的话),然后根据 undo log 回滚 T1 和 T2 未提交部分的操作,从而将数据库恢复到故障前的正确状态。

6. 深入理解 Redo Log 和 Undo Log 的性能影响

6.1 Redo Log 对性能的影响

  • 写入性能:由于 redo log 采用循环写和 Write - Ahead Logging 策略,频繁的日志写入可能会成为性能瓶颈。特别是在高并发事务环境下,大量的日志写入可能导致磁盘 I/O 压力增大。为了缓解这个问题,数据库系统通常会采用批量写入、异步刷盘等技术。例如,MySQL 会将多个 redo log 记录先缓存到内存中的 log buffer 中,然后批量写入磁盘,减少磁盘 I/O 次数。
  • 恢复性能:redo log 的恢复性能与日志记录的大小、数量以及检查点的设置有关。合理设置检查点可以减少恢复时需要重做的日志量,提高恢复效率。如果检查点设置过于频繁,虽然恢复时重做的日志量会减少,但频繁的刷盘操作会影响正常的事务处理性能;如果检查点设置过于稀疏,恢复时需要重做大量的日志,可能导致恢复时间过长。

6.2 Undo Log 对性能的影响

  • 记录开销:在事务执行过程中不断记录 undo log 会带来一定的性能开销。每个修改操作都需要记录相应的 undo 信息,这会增加内存和磁盘 I/O 的负担。为了减少这种开销,数据库系统通常会对 undo log 的记录进行优化,例如只记录必要的信息,并且采用更紧凑的存储格式。
  • 清理开销:事务提交后,undo log 需要在适当的时候进行清理。清理操作也会占用一定的系统资源。如果清理不及时,undo log 可能会占用过多的空间,影响系统性能。

7. 分布式系统中的 Redo Log 和 Undo Log

在分布式数据库系统中,redo log 和 undo log 的管理变得更加复杂。由于数据分布在多个节点上,事务可能涉及多个节点的操作,因此需要协调各个节点的日志记录和恢复过程。

7.1 分布式 Redo Log

  • 多节点同步:在分布式系统中,为了保证数据的一致性和持久性,redo log 需要在多个节点之间同步。常见的方法是采用复制协议,如 Paxos、Raft 等。这些协议可以确保所有副本节点上的 redo log 顺序一致,从而在故障恢复时能够正确地重做事务。
  • 故障恢复:当某个节点发生故障时,其他节点可以通过同步的 redo log 帮助故障节点恢复数据。例如,在一个基于 Raft 协议的分布式数据库中,Leader 节点会将 redo log 同步到 Follower 节点。当 Leader 节点故障时,Follower 节点可以通过 Raft 协议选举出新的 Leader,并利用同步的 redo log 继续处理事务和恢复故障节点的数据。

7.2 分布式 Undo Log

  • 分布式事务回滚:在分布式事务中,如果某个子事务失败,需要回滚整个事务。这就需要协调各个节点上的 undo log 进行回滚操作。一种常见的方法是采用两阶段提交(2PC)或三阶段提交(3PC)协议,在事务提交前,先协调各个节点准备提交(记录 undo log),如果所有节点准备成功,则提交事务;如果有节点准备失败,则回滚所有节点的操作,利用 undo log 将数据恢复到事务开始前的状态。
  • 一致性维护:分布式 undo log 还需要保证各个节点之间数据的一致性。在回滚过程中,需要确保所有节点上的数据都能正确地恢复到事务开始前的状态,避免出现部分节点回滚成功,部分节点回滚失败的情况。

8. 总结 Redo Log 和 Undo Log 的最佳实践

  • 合理配置日志参数:根据系统的负载和性能要求,合理配置 redo log 和 undo log 的相关参数,如 log buffer 大小、刷盘频率、检查点间隔等。例如,在高并发事务环境下,可以适当增大 log buffer 大小,减少磁盘 I/O 次数;根据系统可接受的恢复时间,合理设置检查点间隔。
  • 定期清理日志:定期清理不再需要的 undo log 和 redo log 记录,释放磁盘空间,提高系统性能。例如,在事务提交后,及时清理相关的 undo log 记录;对于已经不再需要用于恢复的 redo log 记录(例如在多次检查点之后),可以进行清理或覆盖。
  • 监控与优化:通过监控工具实时监测 redo log 和 undo log 的写入、刷盘等操作,及时发现性能瓶颈并进行优化。例如,通过监控磁盘 I/O 利用率、日志写入速率等指标,调整日志相关参数,提高系统的整体性能。

在数据库系统中,redo log 和 undo log 是保证事务 ACID 特性,特别是持久性和原子性的关键机制。深入理解它们的原理、结构、写入策略以及在分布式系统中的应用,对于开发高性能、高可靠的数据库应用至关重要。无论是单机数据库还是分布式数据库,合理管理和利用这两种日志,都能有效提升系统的稳定性和性能。