上次我们解决了一个问题:
数据库如何把磁盘上的 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 为什么存在。