1 从一个经典问题说起
几乎每一位后端开发者都听过 ACID 四大特性,也都在面试中背过 "持久性(Durability)" 的定义。但如果你追问一句:当一条 UPDATE 语句执行完毕后,数据究竟是怎么被 "持久" 到磁盘上的?很多人会下意识地回答 "写到数据文件里"。
这个答案不算错,但它忽略了一个关键的工程问题 ------ 如果数据库在写入数据文件的过程中崩溃了,怎么办?
传统的关系型数据库(如 PostgreSQL、MySQL 的 InnoDB 引擎)以及许多现代存储系统(如 SQLite、RocksDB),并不是直接把修改写回原始数据文件的。它们采用了一种更为精巧的策略:先把修改操作记录到一份独立的日志中,再异步地把日志中的变更应用到数据文件。这份日志,就是 Write-Ahead Log,即 "预写式日志",通常简称为 WAL。
本文将从原理、流程和工程实现三个层面,带你真正理解 WAL 的设计哲学。
2 WAL 的核心原理
2.1 什么是 WAL
WAL 的本质是一份**仅追加写入(append-only)**的顺序日志文件。每当数据库需要对数据做出修改时,它不会立刻去修改磁盘上的数据页,而是先将这次修改的 "描述" 以日志记录(log record)的形式追加到 WAL 文件中。只有当 WAL 记录被安全地刷写到磁盘后,数据库才认为这次修改是 "持久化" 的。
这里有一个容易混淆的概念需要澄清:WAL 记录的是物理修改(某个数据页的某个偏移位置被改成了什么值),而不是 SQL 语句本身。也就是说,UPDATE users SET name='Alice' WHERE id=1 这条语句在 WAL 中不会以 SQL 文本的形式出现,而是被翻译成类似 "第 42 号数据页、偏移 128 字节处,写入 5 字节的 Alice" 这样的底层描述。
2.2 WAL 的三条铁律
WAL 协议的正确性依赖于三条必须严格遵守的规则,在学术文献中它们被称为 WAL 协议的基本原则:
- **先写日志,再写数据(Write-Ahead):**任何对数据页的修改,必须先以 WAL 记录的形式持久化到磁盘,然后才能将脏页(dirty page)写回数据文件。这是 "预写" 二字的由来。
- **日志记录必须按顺序写入:**WAL 中的记录严格按照事务提交的时间顺序排列,恢复时也必须按此顺序重放(redo)。
- **Checkpoint 之前的日志可以回收:**当所有脏页都已经被刷写到数据文件后,对应的 WAL 日志段就可以被安全地删除或回收。
这三条规则共同保证了一个核心性质:即使在任意时刻发生崩溃,只要 WAL 文件完整,数据库就能通过重放 WAL 恢复到崩溃前的一致状态。
3 WAL 的工作流程
理解了原理之后,我们来看 WAL 在实际运行中是如何工作的。整个流程可以分为三个阶段:写入、Checkpoint 和崩溃恢复。
3.1 写入阶段
当一条写操作到达数据库引擎时,典型的处理流程如下:
- 在内存中找到或加载目标数据页(通常通过 Buffer Pool 管理)。
- 在内存中修改该数据页,使其成为脏页。
- 生成一条 WAL 日志记录,描述这次修改。
- 将 WAL 记录追加到 WAL 文件,并执行 fsync(或等效的系统调用)确保日志落盘。
- 向客户端返回 "提交成功"。
注意,在第 5 步返回成功时,脏页仍然在内存中,并没有被写回数据文件。这就是 WAL 最大的性能优势所在 ------ 它将随机写 (修改散布在数据文件各处的数据页)转化为了顺序写(追加到 WAL 文件末尾),极大地降低了磁盘 I/O 的开销。
3.2 Checkpoint 机制
如果 WAL 文件无限增长,磁盘空间终将耗尽,而且崩溃后需要重放的日志量也会越来越大。Checkpoint(检查点)机制就是用来解决这个问题的。
Checkpoint 的工作方式是:数据库后台线程会周期性地将 Buffer Pool 中的脏页批量刷写到数据文件中,并在 WAL 文件中记录一个 Checkpoint 标记。这个标记的含义是 ------ 在此标记之前的所有修改,都已经反映到数据文件中了。因此,下次崩溃恢复时,只需要从这个 Checkpoint 开始重放后续的 WAL 记录即可。
Checkpoint 的触发通常有两种策略:
- **时间驱动:**每隔固定时间(如 5 分钟)执行一次。
- **日志量驱动:**当 WAL 文件累积到一定大小(如 1 GB)时触发。
PostgreSQL 就同时使用了这两种策略,并通过 checkpoint_timeout 和 max_wal_size 两个参数分别控制。
3.3 崩溃恢复
当数据库因意外崩溃而重启时,恢复流程异常简洁:
- 找到 WAL 文件中最后一个完整的 Checkpoint。
- 从该 Checkpoint 的位置开始,顺序扫描后续的所有 WAL 记录。
- 对每一条记录执行 redo 操作 ------ 将记录中描述的修改重新应用到对应的数据页上。
- 对于已提交的事务,redo 确保其修改不会丢失;对于未完成的事务,通过 undo 或回滚日志将其影响撤销。
这个过程的正确性完全依赖于前面提到的三条铁律。只要 WAL 记录是完整的、有序的,恢复后的数据库状态就一定是正确的。
4 动手实现一个简易 WAL
理论讲完了,我们用一个 Python 示例来实现一个极简的 WAL 引擎。这个示例省略了并发控制、Buffer Pool 等复杂组件,仅聚焦于 WAL 的核心逻辑:追加写入、顺序重放和崩溃恢复。
python
import struct
import os
WAL_FILE = "demo.wal"
DATA_FILE = "demo.data"
# ========== 数据文件:一个简单的键值存储 ==========
def load_data() -> dict:
"""从数据文件加载键值对,格式为每行 key=value"""
data = {}
if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r") as f:
for line in f:
line = line.strip()
if "=" in line:
k, v = line.split("=", 1)
data[k] = v
return data
def flush_data(data: dict):
"""将全部键值对写回数据文件(模拟 Checkpoint)"""
with open(DATA_FILE, "w") as f:
for k, v in data.items():
f.write(f"{k}={v}\n")
# ========== WAL 文件:追加式日志 ==========
def append_wal(key: str, value: str):
"""
向 WAL 追加一条记录。
记录格式:[2字节长度][key=value]
写入后立即 fsync,确保持久性。
"""
record = f"{key}={value}".encode("utf-8")
with open(WAL_FILE, "ab") as f:
f.write(struct.pack("<H", len(record))) # 小端序,2 字节长度前缀
f.write(record)
f.flush()
os.fsync(f.fileno()) # 关键:确保日志真正落盘
def replay_wal() -> list:
"""
从 WAL 文件中顺序读取所有日志记录。
返回一个 (key, value) 元组的列表,按写入顺序排列。
"""
records = []
if not os.path.exists(WAL_FILE):
return records
with open(WAL_FILE, "rb") as f:
while True:
length_bytes = f.read(2)
if len(length_bytes) < 2:
break # 文件结束或写入不完整(崩溃导致)
length = struct.unpack("<H", length_bytes)[0]
record = f.read(length)
if len(record) < length:
break # 不完整的记录,说明崩溃发生在这条记录写入过程中
text = record.decode("utf-8")
k, v = text.split("=", 1)
records.append((k, v))
return records
# ========== 核心操作 ==========
def put(key: str, value: str):
"""写入一个键值对:先写 WAL,再返回成功"""
append_wal(key, value)
print(f"[WAL] 已记录: {key}={value}")
def checkpoint():
"""
执行 Checkpoint:
1. 重放 WAL 中的所有记录,应用到内存数据
2. 将内存数据刷写到数据文件
3. 清空 WAL 文件
"""
data = load_data()
for k, v in replay_wal():
data[k] = v # redo:将 WAL 记录应用到数据
flush_data(data)
# 清空 WAL 文件(生产环境中通常是删除旧的 WAL 段文件)
with open(WAL_FILE, "wb"):
pass
print("[Checkpoint] 数据已持久化,WAL 已清空")
def recover():
"""
崩溃恢复:
1. 从数据文件加载基线数据
2. 重放 WAL 中的所有记录
3. 得到崩溃前的一致状态
"""
data = load_data()
wal_records = replay_wal()
if not wal_records:
print("[Recover] WAL 为空,无需恢复")
return data
for k, v in wal_records:
data[k] = v
print(f"[Recover] 已重放 {len(wal_records)} 条 WAL 记录")
return data
# ========== 演示 ==========
if __name__ == "__main__":
# 清理旧文件
for f in [WAL_FILE, DATA_FILE]:
if os.path.exists(f):
os.remove(f)
# 写入 3 条数据(只写 WAL,不碰数据文件)
put("user:1", "Alice")
put("user:2", "Bob")
put("user:3", "Charlie")
# 模拟崩溃恢复:此时数据文件还不存在
print("\n--- 模拟崩溃后重启 ---")
recovered = recover()
print(f"恢复后的数据: {recovered}")
# 执行 Checkpoint,将数据持久化到数据文件
print("\n--- 执行 Checkpoint ---")
checkpoint()
# 再次写入新数据
put("user:4", "Diana")
# 再次模拟恢复
print("\n--- 再次模拟崩溃后重启 ---")
recovered = recover()
print(f"恢复后的数据: {recovered}")
这段代码有几个值得关注的细节:
- **长度前缀保证记录边界:**每条 WAL 记录以一个 2 字节的长度前缀开头。在恢复时,如果读到的数据长度不足,就说明这条记录在写入过程中发生了崩溃,应被丢弃。这正是 "部分写入(partial write)" 问题的典型处理方式。
- **fsync 是持久性的真正保障:**仅仅调用 write 并不能保证数据到达磁盘 ------ 操作系统会将写入暂存在页缓存(page cache)中。只有 fsync 才能强制将缓存刷写到物理介质上。在生产级数据库中,fsync 的调用频率和代价往往是性能调优的核心焦点。
- **Checkpoint 的本质是 "合并":**它将 WAL 中的增量修改合并到数据文件中,从而允许 WAL 文件被清空。你可以把 Checkpoint 理解为 Git 中的 squash ------ 把一系列增量提交合并为一个基线快照。
5 WAL 的工程取舍
5.1 顺序写 vs 随机写
WAL 最核心的性能收益来自于 I/O 模式的转变。机械硬盘(HDD)的随机写吞吐量通常只有每秒几百次 IOPS,而顺序写可以达到每秒数千甚至上万次。即便是 SSD,顺序写的延迟和写放大(write amplification)也显著优于随机写。
WAL 利用了这一硬件特性:所有写操作都转化为对 WAL 文件的顺序追加,而脏页的刷写则被推迟到 Checkpoint 时批量进行。这种 "先记账、后对账" 的模式,是数据库能够在高并发写入场景下保持良好性能的关键。
5.2 WAL 带来的代价
当然,WAL 并非没有代价:
- **写放大:**每次修改既要写 WAL,最终又要写数据文件,相当于数据被写了两次。在极端情况下(如频繁修改同一个数据页),写放大可能达到数倍。
- **恢复时间:**如果两次 Checkpoint 之间的间隔过长,崩溃后需要重放的 WAL 记录量就越大,恢复时间也就越长。这也是为什么 PostgreSQL 引入了 "持续归档" 和 "流复制" 来缓解这一问题。
- **存储开销:**WAL 文件本身需要占用额外的磁盘空间。对于写密集型应用,WAL 的累积量可能非常可观。
不同的数据库在这些取舍上做出了不同的选择。例如,RocksDB 使用的 LSM-Tree 结构天然就是一种 "只追加" 的写入模式,其 memtable 日志本身就承担了 WAL 的角色;而 MySQL 的 MyISAM 引擎则完全放弃了 WAL,以牺牲崩溃安全性为代价换取了更简单的实现和略高的写入性能。
6 总结
WAL 是数据库持久性保障的基石。它的设计思想可以浓缩为一句话:用顺序写的日志来捕获所有修改,用 Checkpoint 来同步日志与数据文件,用日志重放来应对崩溃恢复。
理解了 WAL,你不仅能更好地进行数据库参数调优(比如调整 synchronous_commit 或 wal_buffers 的大小),还能在设计自己的存储系统时借鉴这一经典模式。毕竟,在分布式系统中广泛使用的 Raft 共识算法,其核心日志机制在本质上也是一种 WAL。
技术的世界里没有银弹,WAL 也不例外。但正是这些经过数十年工程实践检验的设计模式,构成了我们日常依赖的每一个可靠系统的隐形骨架。下次当你在代码中轻描淡写地写下 db.commit() 时,不妨想一想,在这三个字符背后,有一整套精密的日志机制在默默守护着你的数据。