WAL:数据库如何保证崩溃后还能恢复

10 阅读11分钟

上次我们解决了一个问题:

数据库如何把磁盘上的 Page 放进内存,并管理有限的 Buffer Pool。

但这留下了一个更加关键的问题:

如果我们修改了内存中的 Page,却还没有来得及写回磁盘,此时数据库突然崩溃,会发生什么?

WAL 就是数据库解决这个问题的核心机制之一。


一、先从一次最简单的 UPDATE 开始

假设磁盘上存在:

Page 100

balance = 100

执行:

UPDATE account
SET balance = 80
WHERE id = 1;

数据库首先很可能把 Page 100 读入 Buffer Pool:

Disk
 │
 │ Page 100
 ▼
Buffer Pool
 │
 │ balance = 100
 ▼
执行 UPDATE
 │
 ▼
balance = 80

此时:

Memory:
balance = 80

Disk:
balance = 100

内存中的 Page 已经是 Dirty Page。

如果这时候机器突然断电:

Memory → 消失
Disk   → 仍然是 100

那么刚才的更新就丢失了。


二、最直接的解决方案是什么?

最容易想到的方法:

UPDATE
  ↓
修改 Page
  ↓
立即写磁盘
  ↓
UPDATE 完成

也就是:

Data Page
    ↓
Disk

这样确实可以保证数据不会因为内存丢失而消失。

但问题是:磁盘 I/O 太慢。

如果一个事务修改了:

Page 10
Page 20
Page 30
Page 40

每次都立即写磁盘:

UPDATE
 ↓
Write Page 10

UPDATE
 ↓
Write Page 20

UPDATE
 ↓
Write Page 30
...

数据库性能会受到严重影响。

于是数据库采用了一个非常重要的思想:不要求 Data Page 立即落盘。

而是:先把“发生了什么变化”记录下来。这就是 WAL。


三、WAL 到底是什么?

WAL:Write-Ahead Logging (预写日志)

核心思想只有一句话:

在数据页真正写入磁盘之前,描述这次修改的日志必须先持久化。

也就是:

修改 Data Page
       │
       ▼
   产生 WAL
       │
       ▼
 WAL 持久化
       │
       ▼
Data Page
以后再写

注意:

WAL 并不是“数据页的另一个副本”这么简单。

它是一条记录数据库状态变化的信息流。


四、为什么叫 Write-Ahead?

因为存在一个严格的先后关系。

假设:

LSN = 100

某个 Page 发生修改:

Page 10
balance: 100 → 80

产生:

WAL Record
LSN = 100

数据库必须保证:

WAL LSN 100
      ↓
先持久化
      ↓
Page 10 的修改
      ↓
以后才允许持久化

不能反过来:

Page 10
  ↓
先写磁盘

WAL
  ↓
还没写

否则:

Page 已经包含新数据
WAL 却不存在

一旦数据库崩溃,恢复系统就无法知道 Page 为什么变成了现在的状态。

所以 WAL 的 “Ahead” 是整个机制最重要的约束。


五、WAL 到底记录什么?

这是学习 WAL 时非常容易产生误解的地方。

很多人第一反应:

“是不是把整个 Page 再写一遍?”

通常不是。

例如:

Page 100

balance = 100

执行:

balance = 80

日志可以描述:

Page 100
某个位置
发生某种修改

具体记录形式取决于数据库。

有的日志偏向记录:

Redo Information

有的系统同时存在:

Redo
+
Undo

所以 WAL 是一种机制,而不是一种固定的数据格式。


六、WAL 最重要的用途:Crash Recovery

现在模拟数据库崩溃。

数据库执行:

UPDATE
 ↓
修改 Buffer Pool
 ↓
生成 WAL
 ↓
WAL 已经持久化
 ↓
Data Page 尚未写回
 ↓
Crash

此时磁盘状态:

Data Page:
旧数据

WAL:
已经记录修改

重新启动数据库:

Database Start
      ↓
读取 WAL
      ↓
发现之前发生过修改
      ↓
重新执行必要的修改
      ↓
恢复 Data Page

于是:

旧 Page
  +
WAL
  ↓
新 Page

这就是:Redo


七、Redo:重新执行已经发生过的修改

可以把 Redo 理解成:

如果某些修改已经被日志确认,但 Data Page 没有完全落盘,那么恢复时重新应用这些修改。

例如:

WAL:

Page 100
balance 100 → 80

Crash 后:

Disk Page:
balance = 100

Recovery:

读取 WAL
    ↓
发现 Page 100 的修改
    ↓
重新应用
    ↓
balance = 80

这就是 Redo。


八、为什么 WAL 可以让 Data Page 延迟写?

现在可以理解数据库为什么敢于:

Memory
 ↓
修改 Page
 ↓
不立即写 Page

因为:

修改信息
 ↓
WAL
 ↓
持久化

已经先保存了。

因此 Data Page 可以晚一点写。

例如:

时间 →

T1       T2       T3       T4
│        │        │        │
UPDATE   WAL      ...      Page Flush
│        │                 │
└────────┴─────────────────┘

这就是 WAL 带来的一个非常重要的性能优势:

把随机的数据页写入,转换成更加连续、顺序化的日志写入。


九、为什么日志通常非常适合顺序写?

假设修改了:

Page 10
Page 800
Page 30
Page 5000

如果直接写 Data Page:

10
800
30
5000

这些 Page 在磁盘上的位置可能完全不同。

而 WAL 可以:

Log 1
Log 2
Log 3
Log 4

连续追加。

从抽象角度:

Data Page

Page 10   ─────┐
Page 800  ─────┤
Page 30   ─────┤
Page 5000 ─────┘
               ↓
             随机写


WAL

Log 1
Log 2
Log 3
Log 4
   ↓
连续追加

因此 WAL 非常适合承担高频修改的持久化压力。


十、WAL 并不意味着 Data Page 不需要写

这一点必须特别注意。

WAL 只是:

保证日志先于 Data Page 持久化。

Data Page 最终还是必须写回磁盘。

否则:

WAL
WAL
WAL
WAL
WAL
...

会无限增长。

所以数据库还需要:

WAL
 ↓
Data Page Flush
 ↓
Checkpoint
 ↓
旧 WAL 可以逐步回收

这就引出了另一个非常重要的概念:Checkpoint


十一、Checkpoint 是什么?

假设数据库运行了很长时间:

WAL 1
WAL 2
WAL 3
...
WAL 1000000

如果数据库突然崩溃:

恢复时难道从:

WAL 1

一直执行到:

WAL 1000000

理论上可以。

但是 Recovery 时间会越来越长。

因此数据库会周期性地进行:Checkpoint

简单理解:

把某个时间点之前需要持久化的数据库状态推进到稳定位置。

于是:

WAL
│
├── Old WAL
│
├── Old WAL
│
├── Checkpoint
│
└── New WAL

Checkpoint 之后:

恢复通常不需要重新处理无限久以前的日志。


十二、Checkpoint 不是简单的“把所有数据写一遍”

这是一个容易产生的误解。

Checkpoint 的具体实现与数据库有关。

例如:

Flush Dirty Pages
+
记录 Recovery Position
+
维护相关元数据

最终目标是建立一个:

可靠的恢复起点。

所以不要简单理解成:

Checkpoint = 全库刷盘

现代数据库的实际 Checkpoint 机制通常更加复杂。


十三、LSN:WAL 中非常重要的概念

LSN:Log Sequence Number。

可以把它简单理解成:

日志中的一个全局位置。

例如:

LSN 100
LSN 101
LSN 102
LSN 103

某个 Page 可能记录:

Page 100
page_lsn = 103

表示:

这个 Page 至少已经应用到日志位置 103 对应的修改。

于是 Recovery 时可以判断:

WAL LSN = 103
Page LSN = 103

那么:

这个修改已经在 Page 上,不需要再次应用。

如果:

WAL LSN = 103
Page LSN = 100

那么:

Page 可能还没有应用到这个修改。

因此需要 Redo。


十四、Page LSN 是一个非常漂亮的设计

把它简化成:

WAL
│
├── LSN 100
├── LSN 101
├── LSN 102
└── LSN 103

Page
└── page_lsn = 101

Recovery:

100 → 已处理
101 → 已处理
102 → 未处理
103 → 未处理

于是可以从正确的位置继续恢复。

这就是:

日志序列号与 Page 状态之间的关联。


十五、WAL 与 Buffer Pool 终于连接起来了

现在回忆之前说过的:

Disk
 ↓
Buffer Pool
 ↓
Page

今天加入 WAL:

                  Database
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
     Buffer Pool               WAL
          │                     │
          ↓                     ↓
      Dirty Page            Log Records
          │                     │
          └──────────┬──────────┘
                     ↓
                   Disk

一次修改可能经历:

SQL
 ↓
找到 Page
 ↓
Buffer Pool
 ↓
修改 Page
 ↓
产生 WAL
 ↓
WAL 持久化
 ↓
事务提交
 ↓
Page 之后再 Flush

这就是现代数据库存储引擎非常核心的一条路径。


十六、WAL 与事务提交

假设:

UPDATE account
SET balance = 80
WHERE id = 1;

最后:

COMMIT;

数据库必须保证:

一旦告诉用户“Commit 成功”,即使马上发生崩溃,也不能把这个事务当成完全没有发生。

因此通常需要确保相关日志已经达到足够的持久化状态。

可以抽象成:

UPDATE
  ↓
修改内存 Page
  ↓
产生 WAL
  ↓
WAL Flush
  ↓
COMMIT 成功

注意:

Commit 不一定意味着所有 Data Page 都已经写入磁盘。

WAL 的持久化

WAL 可以频繁 flush,但不等于每条 WAL 都单独 fsync;数据库通常通过 WAL Buffer、批量 flush、group commit、后台写入等机制,把大量日志合并处理。

这正是 WAL 的核心价值。


十七、这也是“持久性”的基础

事务 ACID:

Atomicity (原子性)
Consistency (一致性)
Isolation (隔离性)
Durability (持久性)

其中 Durability

就是:

事务提交之后,即使系统发生崩溃,结果也应该能够恢复。

WAL 是实现 Durability 的重要基础。

但不要认为:

Durability = WAL

完整的事务持久性还涉及:

  • WAL
  • Flush
  • Checkpoint
  • Recovery
  • Storage Hardware
  • fsync / fdatasync 等持久化语义
  • 事务提交协议

它们共同构成完整的可靠性体系。


十八、Redo 和 Undo 不要混淆

这是学习数据库时非常重要的知识。

Redo:

已经发生的修改,崩溃后怎么重新应用?

例如:

100 → 80

Redo:

重新做到 80

Undo:

某些修改不能保留,怎么恢复到修改之前?

例如:

100 → 80

Undo:

恢复到 100

所以:

Redo → 向前
Undo → 向后

但不同数据库具体如何实现 Redo、Undo,差异非常大。


十九、InnoDB 怎么使用 WAL?

InnoDB 有非常典型的:

Redo Log

大致可以理解为:

SQL
 ↓
Buffer Pool
 ↓
修改 Page
 ↓
Redo Log
 ↓
Log Buffer
 ↓
Redo Log File

然后:

Buffer Pool
 ↓
Dirty Page
 ↓
以后 Flush

因此 Redo Log 和 Data Page 是两条不同的持久化路径。


二十、PostgreSQL 的 WAL

PostgreSQL 直接把这种机制称为:

WAL — Write-Ahead Logging

它的存储系统中:

Heap Page
Index Page
...

发生修改时:

Page Change
 ↓
WAL Record

WAL 被用于:

  • Crash Recovery
  • Replication
  • Point-in-Time Recovery
  • 备份恢复等

所以在 PostgreSQL 中:

WAL 不只是“崩溃恢复日志”。

它后来还成为:

整个数据库复制与恢复体系的重要基础设施。


二十一、SQLite 也有 WAL

SQLite 同样支持:

Write-Ahead Logging

但由于 SQLite 的架构更加轻量:

Application
    ↓
SQLite
    ↓
Database File

它的 WAL 设计与 PostgreSQL、InnoDB 并不相同。

这里值得建立一个非常重要的认识:

“都叫 WAL”并不意味着内部实现相同。

数据库学习不能只看名词。

真正需要研究的是:

WAL Record 怎么组织?
WAL 写到哪里?
什么时候 Flush?
Checkpoint 怎么做?
Recovery 怎么做?

这些才是实现。


二十二、为什么 WAL 会成为数据库的核心组件?

因为它解决了一个非常矛盾的问题:

我们希望:

Data Page
不要频繁写磁盘

但是同时又希望:

事务提交
必须可靠

WAL 在中间建立了一个缓冲:

频繁修改
   ↓
Memory Page

快速记录变化
   ↓
WAL

之后再慢慢整理
   ↓
Data Page

所以可以把 WAL 理解成:

把“事务提交的持久性”和“数据页最终落盘”解耦。

这是今天最核心的设计思想。


二十三、从 C++ 的角度看

如果我们实现一个极简数据库,可以抽象成:

struct Page {
    PageId id;
    uint64_t lsn;
    bool dirty;
};

日志:

struct LogRecord {
    uint64_t lsn;
    PageId page_id;

    // 简化表示
    uint32_t offset;
    std::vector<std::byte> data;
};

一次修改:

modify(page) {
    LogRecord log = makeLogRecord(page);

    appendLog(log);

    flushLog();

    applyModification(page);

    page.dirty = true;
}

然后:

flushPage(page) {
    assert(page.lsn <= durable_lsn);

    writePage(page);
}

这里最关键的约束就是:

Page LSN
    ≤
Durable WAL LSN

这就是 Write-Ahead 的核心思想。


二十四、把内容串成完整流程

现在模拟一次:

UPDATE User
SET age = 21
WHERE id = 100;

完整路径可以理解为:

             SQL
              │
              ▼
        找到目标 Page
              │
              ▼
         Buffer Pool
              │
              ▼
         修改 Page
              │
              ├──────────────┐
              │              │
              ▼              ▼
         Dirty Page       WAL Record
                              │
                              ▼
                         WAL 持久化
                              │
                              ▼
                         COMMIT 成功
                              │
                              │
                    Data Page 后续 Flush
                              │
                              ▼
                            Disk

如果此时 Crash

那么:

Disk Data Page
      +
Persistent WAL
      ↓
   Recovery
      ↓
    Redo
      ↓
恢复一致状态

这就是现代数据库持久化机制的基本骨架。


二十五、需要理解的六个概念

WAL: 在 Data Page 持久化之前,先持久化描述修改的日志。

Dirty Page: Buffer Pool 中已经被修改、但磁盘版本还没有同步的 Page。

Redo: 崩溃恢复时重新应用已经记录的修改。

Undo: 将不应该保留的修改恢复掉。

LSN: 用来标识 WAL 中日志位置,并帮助判断 Page 已经应用到哪个日志位置。

Checkpoint: 建立一个更靠近当前状态的恢复起点,并帮助控制 WAL 的长期增长。


最重要的一句话

WAL 并不是为了让数据库“多保存一份数据”,而是为了让“事务的持久性”和“数据页的落盘”能够解耦。

理解这句话,才算真正理解 WAL 为什么存在。