清朝末年,京城最红火的永昌钱庄有条不成文的铁规矩:柜上每收一笔银子,账房先生必须先掏出随身流水簿记上一笔,等墨迹晾干,才允许誊进总账本。新来的伙计嫌麻烦,老账房捋着胡子说:“要是铺子夜里走了水,总账本烧了,只要流水簿还在,账就能一笔笔找回来。”后来钱庄开遍五城,成了连锁字号——问题也随之而来:各分号各记各的账,京城总号说收了这笔,保定分号却一无所知,年底一结账,满城账房吵成一锅粥。最后东家拍板:设一位总账房统一拍板,各分号抄录回执,过半确认才算板上钉钉。这套“先记流水、再誊账本、总号拍板、分号对齐”的家规,正是分布式存储里 WAL 预写日志的完整写照。本文就用这本金字招牌的流水簿,带你读懂预写日志如何撑起分布式系统的一致性。
学懂 WAL,你就掌握了分布式存储引擎的“账本核心”——这是数据库内核、分布式存储与高并发后端工程师的必备底层功底,面试与实战都绕不开它。
目录
可视化演示
展示WAL预写日志写入四阶段流程、Raft多节点日志复制同步及崩溃恢复回放机制,揭示预写日志保证分布式存储数据一致性的核心原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
WAL写入流程 日志写入:修改前先生成redo log写入WAL缓冲区 事务提交:fsync将日志刷盘保证持久性 检查点:定期将脏页刷盘并截断旧日志 Write → fsync(WAL) → Apply → Checkpoint 先写日志再改数据保证崩溃可恢复
WAL基本概念:账房先生的铁规矩
永昌钱庄的老账房有个传了三代的规矩:无论多忙,先记流水簿,再誊总账本。伙计觉得多此一举,老账房说:“总账本可能被烧、被抢、被虫蛀,但流水簿是每天最早落笔的记录,只要它在,任何一笔账都能重新誊出来。”这个朴素的直觉,就是预写日志(Write-Ahead Logging,WAL)的起源——先写日志,再动数据。
数据库里的 WAL 遵循同一顺序:收到写操作,先把操作以追加形式写入日志文件,日志确认落盘后,才修改真正的数据页。为什么这么绕?因为数据页是随机读写的,而日志是顺序追加的。磁盘顺序写让机械臂像推土机一样平推过去,随机写却要反复寻道摆动,两者性能可以差几十上百倍。更重要的是安全:日志落盘了,数据哪怕只改了一半就崩溃,重启时重放日志就能补齐;日志没落盘,说明操作从未被承诺,直接当作没发生——这就是事务原子性的边界。
老账房那句“先记后誊”扛过了三任东家,WAL 这条铁律也扛过了无数真实的数据库崩溃事故。理解了这个顺序,你就拿到了理解一切存储系统(MySQL 的 redo log、RocksDB 的 WAL、Kafka 的 partition 日志)的万能钥匙。
- Op(写操作)客户端发起的修改请求,例如“把余额改成 200”
- WAL 落盘操作先以追加形式写入日志,并且 fsync 强制刷入磁盘
- Data 应用日志确认安全后,才把改动应用到真实数据区
- ACK 返回数据更新完成,客户端此时才收到成功确认
单机WAL原理:流水簿怎么记才不丢
老账房记流水有四道工序:蘸墨、落笔、晾干、誊账。他反复叮嘱徒弟:“晾干这道工序最不能省。墨迹没干就誊账,一阵穿堂风糊了字,月底对账就成了一笔糊涂账。”
单机 WAL 的写入流程同样分四步:第一步,把日志追加到内存缓冲区;第二步,调用 fsync 把缓冲强制刷入磁盘——这正是“晾干墨迹”;第三步,把改动应用到数据页;第四步,向客户端返回成功。若在第二步之前崩溃,日志还在操作系统页缓存里,断电即失;只有 fsync 完成,日志才真正“干透”。
fsync 是昂贵的操作,一次可能耗费数毫秒,而内存写入只要微秒级。为了摊薄成本,数据库普遍采用组提交(group commit):攒一批日志,一次性刷盘,让昂贵的磁盘操作被 N 条日志平分。
墨没晾干就誊账,等于日志没落盘就返回成功——停电瞬间,客户的钱就“蒸发”了。单机 WAL 的全部秘密,就是把“晾干”这道工序做成不可省略的硬性门槛。
- t_append(追加耗时)把日志写入内存缓冲的时间,微秒级,几乎可以忽略
- t_fsync(刷盘耗时)一次 fsync 把缓冲拍进磁盘的时间,毫秒级,是主要开销
- N_group(组大小)一次 fsync 覆盖的日志条数,组提交把它从 1 放大到几十上百
一致性挑战:分号多了对不上账
钱庄开成连锁后,麻烦接踵而至。保定分号柜上收了银子,记在自己的流水簿上,可京城总号不知道;等年底对账,两本账怎么也对不齐。更棘手的是:保定分号的账房先生突然辞工跑了,他那本流水簿再也没人看得懂。
分布式系统把数据复制到多台机器(称为副本),本质就是开连锁分号。单机 WAL 只保证“本号”的账不丢,但多个副本之间必须就日志的内容和顺序达成一致,否则每个分号各记各的,系统整体就是一本烂账。分布式场景下有三大敌人:节点故障——账房先生跑了;网络分区——传话的伙计被堵在半路,两边互相联系不上;脑裂——两个分号都以为自己是总号,各自拍板,账目分叉。
CAP 定理给出了残酷的答案:网络分区(P)是分布式系统的必然前提,一旦发生,一致性(C,所有节点读到相同数据)和可用性(A,每个请求都有响应)只能二选一。于是人们发明了共识协议(Raft、Paxos),用“投票 + 日志复制”的机制,让所有节点对账本形成唯一共识。
老东家拍板:“宁可关店,也不能记错账。”所以强一致系统(如 TiKV、etcd)在分区时宁可拒绝写入也不返回脏数据——这正是 Raft 这类共识协议存在的意义。
- Partition(分区)节点之间消息不可达,天然存在,无法根除
- Consistency(一致性)所有节点在同一时刻读到相同的最新数据
- Availability(可用性)每个请求都能在有限时间内得到非错误响应
- 二选一分区前提下,一致性 XOR 可用性,只能满足其一
Raft日志复制:总账房拍板,分号抄录
为了根治分号对不上账,东家立了新家规:设一位总账房(Leader),全城所有账目由他一人拍板;各分号(Follower)收到指令后抄录一份,并回执“收到”。任何分号不得私自记账。
这就是 Raft 协议的工作方式。集群先通过选举选出一个 Leader(相当于总账房选举,任期内只有一个当家人);客户端的所有写请求只发给 Leader;Leader 把新日志通过 AppendEntries RPC 广播给所有 Follower;Follower 按收到的顺序追加到自己的日志并回执。为了防止“两个总号”的脑裂,Raft 引入任期(term)机制——Leader 失联后,Follower 将任期号加一再竞选,老的 Leader 回来发现任期落后,自动退位。
Raft 最精妙的是日志匹配性质:如果两条日志条目的序号(index)和任期(term)都相同,那么它们的日志内容必定相同,而且在此之前的所有日志也完全一致。这保证所有副本的日志前缀相同,为后续提交和恢复打下根基。
新总账房上任第一件事,就是翻前任留下的流水簿和各分号对账,把不一致的旧账逐条校准。Raft 选举结束后的日志对齐也是这样:Leader 找到双方共同的最新日志位置,强制落后的 Follower 补齐或回退。
- index(日志序号)每条日志在序列中的位置,从 0 开始严格递增
- term(领导人任期)每个任期的编号全局递增,日志带着写入时的任期戳
- 匹配结论序号与任期都相同的两条日志,内容必然一致,且所有更早日志也一致
提交与持久化:什么时候算板上钉钉
分号回执“收到”还不算数。老东家定下新规矩:总号加至少过半分号都回执“已誊录完毕、墨已晾干”,这笔账才盖上“已结”的朱砂印。只回来一个分号的回执?这笔账就悬着,谁也不敢认。
Raft 的提交规则如出一辙:Leader 确认某条日志被多数派(quorum)节点持久化后,才更新提交位(commitIndex),并向客户端返回成功。为什么要“过半”而不是“全部”?全部确认在故障时会锁死系统——任何一个分号失联,整笔账就永远无法盖章;而过半只需要大多数活着。更深层的数学原因是:任意两个多数派之间必有交集(至少共享一个节点),这个节点一定持有最新日志,新 Leader 当选后必然不缺账。
{% math %}
N_{\text{quorum}} = \left\lfloor \frac{N_{\text{节点}}}{2} \right\rfloor + 1
{% endmath %}
1. 总数 N:集群中参与投票的节点总数
2. 半数取整:先计算总数一半的整数部分
3. 加一得多数:半数加一构成多数派,任意两个多数派必有交集
fsync 的时机同样关键:Follower 收到日志必须先刷盘再回执,绝不能只写入页缓存就回复“已收到”,否则账房嘴上说誊好了,实际墨迹还在砚台里。过半回执 = 盖章生效。三个分号只回来一个,账就悬着——宁可让客户等,也不能假盖章。
日志截断与快照:流水账太厚怎么办
流水簿日复一日地记,十年后库房里堆满了发黄的账册。老账房想了个办法:每月月底结账一次,把当月已结清的旧账装订成一册“月结册”,册子里只有每笔账的最终结果,之后的新流水另起新簿继续记。
分布式系统的日志同样会无限膨胀:每一条写操作都追加到日志,磁盘迟早被撑爆,崩溃重放也会越来越慢。Raft 的解决方案就是快照(snapshot):把某个时刻的完整数据状态固化成一份文件,该时刻之前的日志就可以安全删除。日志压缩的本质不是“删账”,而是“把已结清的旧账换算成一张总表”。
对于落后的节点(比如新加入的分号),Leader 可以直接把快照发过去安装,再补齐快照之后的少量日志,免去从盘古开天开始重放。注意一个原则:日志始终是追加写模式(Append-only),永远不会修改或回填旧条目,只做“截断 + 快照”的压缩动作。
流水簿烧旧册,新簿接着记——快照不是删账,而是把历史折算成一张总表。只要月结册和后续流水都在,账就永远完整,这也让跨节点的日志同步在工程上变得可行。
- 快照点 k已结清的日志位置,k 之前的日志被状态快照代替
- 增量日志快照点之后的新日志继续追加,原样保留
- 压缩后集合日志总量被压缩为“快照 + 增量”两部分
故障恢复一致性:重新开张怎么找回账
最坏的事情还是发生了:保定分号半夜失火,账房、账本、流水簿烧了个精光。重新开张那天,新任账房带着从京城总号拿来的月结册以及总号的流水副本,从失火前那个时点开始一笔笔重演,硬是把整本账盘了回来,分毫不差。
分布式节点的恢复正是这个流程:崩溃重启后,先加载最新快照,再重放快照点之后的 WAL 日志,逐步追平提交位。单机场景下靠本机 WAL 重放;分布式场景下,Leader 会通过 AppendEntries 检查 Follower 的日志进度,发现落后就逐条补发,落后太多则直接安装快照。每一步都带着校验(checksum、任期号、序号),防止“脏账”混入。
为什么最终能收敛到一致?因为无论哪个节点,只要遵循“同一个快照 + 同一串日志”,按同样的顺序应用,必然得到同样的状态。这也是分布式系统一致性的终极保证:不是靠运气,而是靠确定性的重放。
老账房临终前留下一句话:“账烧了没关系,流水簿和月结册在,账就永远在。”WAL 和快照,就是分布式系统那两本永远烧不掉的册子。
- 快照基线先加载离崩溃点最近的快照,作为恢复起点
- 逐条重放从 k+1 到 n 的每条日志按原顺序应用到状态
- 收敛终态快照加重放得到最终一致状态,且可被校验
AI 应用场景
场景1:TiKV / CockroachDB 的 Raft + WAL
阶段:数据写入与持久化阶段
解决的问题:传统主从异步复制在主库崩溃时会丢失最近写入,主备数据不一致。TiKV 把数据划分为多个 Region,每个 Region 是一个独立的 Raft 组:写请求先进入 Raft 日志(分布式 WAL),多数派节点落盘后才返回成功;单机层面再由 RocksDB 的 WAL 保证本机崩溃恢复。AI 平台的海量特征数据和模型元数据写多读少,这套双层 WAL 机制确保“一条不丢、一字不重”。
具体案例:某推荐系统特征平台每天写入千万级特征更新到 TiKV。一次施工挖断光缆导致机房分区,Raft 自动完成 Leader 切换,写入零丢失、秒级恢复。换成传统主从复制,主库故障瞬间的写入会全部蒸发,模型训练数据出现空洞。
场景2:Kafka 分区日志与 ISR 副本
阶段:数据接入与流式处理阶段
解决的问题:AI 训练管道需要可靠且有序的数据流。Kafka 的每个 partition 本质上就是一个 WAL:消息只能追加写,消费者按 offset 顺序读。副本机制通过 ISR(同步副本集合)实现类似“过半确认”的持久化保证,只有 ISR 中的副本才参与 Leader 选举,保证已提交消息不丢失。
具体案例:实时风控系统把用户行为事件写入 Kafka,下游 Flink 消费并训练异常检测模型。某次 broker 断电宕机,ISR 切换新 Leader 后,消费者从最后提交的 offset 继续消费,几千条在途事件一条未丢,模型训练数据保持完整。
场景3:MySQL 半同步复制与 redo log 容灾
阶段:数据持久化与容灾切换阶段
解决的问题:单机崩溃恢复靠 InnoDB 的 redo log(经典 WAL);主备一致性靠半同步复制——主库提交事务时,必须等至少一个备库确认收到 binlog 才返回客户端,从根源上杜绝“主库宕机丢事务”。AI 业务背后的订单、用户等核心数据,靠这套机制扛住机房级故障。
具体案例:电商订单库使用 MySQL 半同步复制 + 组提交。一次机房意外断电,主库通过 redo log 重放完整恢复,备库无数据空洞,DBA 半小时内完成容灾切换,订单数据分毫不差。
Python 演示
# -*- coding: utf-8 -*-"""WAL 预写日志 + Raft 式日志复制的最小演示"""import os class WAL: """单机预写日志:先追加日志,再让上层应用改数据""" def __init__(self, path="wal.log"): self.path = path self.entries = [] if os.path.exists(self.path): os.remove(self.path) def append(self, entry): """追加一条日志并立刻 fsync,保证落盘""" with open(self.path, "a") as f: f.write(entry + "\n") f.flush() os.fsync(f.fileno()) self.entries.append(entry) def replay(self): """崩溃后重放日志,恢复出状态字典""" state = {} if not os.path.exists(self.path): return state with open(self.path) as f: for line in f: line = line.strip() if not line: continue parts = line.split() if len(parts) == 3 and parts[0] == "SET": state[parts[1]] = int(parts[2]) return state class KVStore: """带 WAL 的 KV 存储:先写日志,再改内存数据""" def __init__(self): self.data = {} self.wal = WAL() def set(self, key, value): self.wal.append("SET %s %d" % (key, value)) self.data[key] = value def recover(self): self.data = self.wal.replay() return self.data class Replica: """Raft 复制中的一个节点,保存日志副本""" def __init__(self, name): self.name = name self.entries = [] self.commit_index = -1 def append_entries(self, entry): self.entries.append(entry) def raft_replicate(cluster, entry): """Leader 把日志广播给所有节点,过半确认才算提交""" leader = cluster[0] leader.entries.append(entry) acks = 1 for node in cluster[1:]: node.append_entries(entry) acks += 1 if acks > len(cluster) // 2: for node in cluster: node.commit_index = len(node.entries) - 1 return True return False def demo(): store = KVStore() store.set("balance", 100) store.set("balance", 200) print("模拟崩溃后重放:", store.recover()) cluster = [Replica("n0"), Replica("n1"), Replica("n2")] ok = raft_replicate(cluster, "SET balance=300") print("过半提交成功:", ok) for node in cluster: print(node.name, node.entries, "commit=", node.commit_index) if __name__ == "__main__": demo()
os.fsync(f.fileno())把日志从页缓存强制刷入磁盘,模拟“墨迹晾干”这一不可省略的工序self.wal.append("SET %s %d" % (key, value))先写日志,这是 WAL 的第一原则self.data[key] = value日志落盘成功后才修改内存数据,顺序绝不可颠倒store.recover()模拟崩溃后重放日志,从磁盘流水恢复出全部状态acks > len(cluster) // 2统计超过半数节点确认,才判定日志提交成功node.commit_index = len(node.entries) - 1提交后同步推进所有节点的提交位置
常见误区
误区我理解的 WAL 就是数据写一份到日志、再写一份到数据区,等于双倍写入放大,磁盘很快就不够用了,这设计也太蠢了吧?
正解WAL 记录的是“操作描述”(比如 SET balance 200),一条日志可能只有几十字节,而数据页通常是 4KB 甚至更大。更重要的是,WAL 是顺序追加写,数据区是随机写——顺序写比随机写快一到两个数量级。用极小的顺序写开销,换取崩溃恢复能力,这笔买卖非常划算。双写(double-write)是另一个完全不同的机制,别混为一谈。
为何常见:初学者看到“先写日志再写数据”就直觉以为是“写两遍数据”,忽略了日志是紧凑的顺序记录,而非完整的数据拷贝。
误区我调用了 `write()` 把日志写进文件,操作系统说写成功了,这不就已经落盘了吗?为什么还要 fsync?
正解write() 只是把数据拷贝到操作系统页缓存(page cache),真正的磁盘写入由内核稍后异步完成。此时断电,页缓存里的日志全部蒸发。fsync() 才会强制把页缓存拍进磁盘并等待完成确认——只有 fsync 返回成功,日志才算真正“晾干墨迹”。
为何常见:日常编程中我们很少直接关心内核缓冲与磁盘的边界,write 成功给人的错觉太强,导致忽略了 fsync 这道工序。
误区既然每台机器都有 WAL,那分布式系统不就是各写各的 WAL 吗?多副本之间又不需要协调。
正解单机 WAL 只保证“本机不丢”,而分布式系统要求“多副本一致”。各写各的 WAL 会导致副本间日志分叉,必须靠 Raft/Paxos 这类共识协议把日志复制到多数派并排序,让所有节点对日志内容与顺序达成一致。单机 WAL 解决持久性,分布式复制解决一致性,两者是叠加关系:先用共识协议对齐日志,再在每台机器上用 WAL 保证落盘。
为何常见:理解了单机 WAL 后容易产生“局部正确 = 全局正确”的直觉,忽视了网络故障带来的副本分叉问题。
误区Leader 是总账房啊,它说了算。它收到客户端的写请求,直接更新自己的数据并返回成功,这有什么问题?
正解Leader 单独拍板恰恰是危险的:如果它只写了自己的日志就返回成功,随后它崩溃了,这条日志只有它一个节点有,新 Leader 不会承认这笔账,客户端就“被丢数据”了。Raft 要求日志必须复制到多数派并持久化后才算提交,Leader 只能返回已提交日志的结果。多数派的交集性质保证:只要过半数节点持有该日志,任何新 Leader 都能找到它。
为何常见:人们习惯把 Leader 类比为“老板”,以为老板一句话就生效,忽略了系统中“多数派确认”才是真正的法律效力。
误区都有快照了,把整个状态存下来多省事,日志又占空间又慢,直接删掉不就行了?
正解快照是“某个时刻的状态压缩图”,它只回答“那一刻数据是什么”,不记录“之后发生了什么”。快照点之后的新写入仍然需要 WAL 来保证。恢复的正确姿势是:先装快照,再重放快照之后的日志。而且快照生成本身也需要依赖日志的一致性边界,两者是配合关系而非替代关系。
为何常见:看到“日志压缩”就以为是彻底消灭日志,没意识到压缩只是把旧日志折算成快照,新日志依然在持续追加。
面试题
Q1. WAL 预写日志的核心写入顺序是什么?
- A.先写日志,后改数据← 正确答案
- B.先改数据,后写日志
- C.同时写日志和数据
- D.只写数据不写日志
答案:A。WAL 的全称是 Write-Ahead Logging,“提前写日志”就是先落日志再改数据。这和老账房“先记流水簿再誊总账本”同理:日志是数据的保险单,顺序颠倒就失去了崩溃恢复的意义。B 是“后写日志”,C 混淆了先后关系,D 完全没有日志,都属于错误选项。
Q2. 单机数据库崩溃后,靠什么机制重建数据?
- A.从磁盘数据文件直接恢复
- B.从远程备份拷贝数据
- C.重放 WAL 日志重建状态← 正确答案
- D.依赖操作系统自动修复
答案:C。崩溃时数据文件可能只写了一半,无法直接使用,所以靠重放 WAL 日志来重建——日志记录了所有已落盘的操作,按顺序重演即可恢复最终状态。A 忽略了数据页可能半写,B 是另一个容灾手段,D 则完全不存在这种能力。
Q3. 为什么 WAL 用顺序追加写性能更高?
- A.顺序追加写避开随机寻道← 正确答案
- B.顺序写完全不需要落盘
- C.顺序写能省略数据校验
- D.顺序写一定占用空间更小
答案:A。磁盘顺序写让磁头连续移动,而随机写需要反复寻道摆动,两者性能差一到两个数量级,这正是 WAL 选择追加写的根本原因。B 错在日志仍然需要 fsync 落盘,C 与校验无关,D 与空间占用无关。
Q4. 关于 fsync 的作用,下列说法正确的是?
- A.把缓冲日志强制刷入磁盘← 正确答案
- B.把磁盘日志加载到内存
- C.对日志进行压缩编码
- D.把日志转发给客户端
答案:A。write() 只是把日志写入操作系统页缓存,断电即失;fsync() 才强制把页缓存拍进磁盘并确认完成,相当于老账房“晾干墨迹”的工序。B 是读操作,C 是压缩,D 是网络传输,都与 fsync 无关。
Q5. Raft 中 Leader 何时才把一条日志判定为已提交?
- A.Leader 本地落盘即可提交
- B.过半节点持久化后提交← 正确答案
- C.全部节点收到后提交
- D.写入页缓存即可提交
答案:B。Raft 要求日志被多数派节点持久化后,Leader 才更新提交位并返回 ACK。过半而非全部,是为了在部分节点故障时系统仍可用;且任意两个多数派必有交集,保证新 Leader 不缺账。A 只有一票,D 连落盘都不算,C 要求太高会导致可用性下降。
Q6. Raft 日志匹配性质保证了什么?
- A.相同任期与序号日志内容一致← 正确答案
- B.所有日志内容必须完全相同
- C.Leader 日志必须短于节点
- D.日志一旦写入就不能截断
答案:A。日志匹配性质:若两条日志的 index 和 term 都相同,则内容必定相同,且之前所有日志也一致。它保证了副本日志前缀对齐,是提交与恢复安全性的根基。B 过于绝对,不同节点日志长度可以不同,C 描述错误,D 快照机制允许截断旧日志。
Q7. 快照机制主要解决什么问题?
- A.压缩历史日志降低重放成本← 正确答案
- B.让日志不需要再落盘保存
- C.加快单条日志的写入速度
- D.替代一致性协议完成选举
答案:A。日志无限增长会吃光磁盘、拖慢重放,快照把某个时刻的完整状态固化,使其之前的日志可以删除,相当于把旧账装订成月结册。B 错在快照后的新日志仍需 WAL,C 无关,D 选举靠任期与投票,与快照无关。
Q8. 下列哪些措施能增强日志的持久性?
- A.fsync 把日志强制落盘← 正确答案
- B.组提交把多次刷盘合并← 正确答案
- C.只依赖内存缓存不落盘
- D.日志多副本复制并确认← 正确答案
答案:ABD。A 是单机持久化的关键;B 组提交通过批量刷盘让持久化变得更高效,从而鼓励更频繁落盘;D 是分布式层面的持久性——多个副本都确认收到,单点故障也不丢。C 恰好相反,只存内存缓存断电即失,是典型的反面做法。
Q9. 分布式一致性面临哪些典型故障挑战?
- A.节点宕机失联账本丢失← 正确答案
- B.网络分区节点彼此隔离← 正确答案
- C.脑裂出现多个 Leader← 正确答案
- D.日志文件自动被压缩
答案:ABC。节点故障、网络分区、脑裂是分布式系统三大经典挑战,分别对应账房跑路、传话被堵、两个总号并立。D 是误解——日志压缩是快照机制的正常行为,不会自动发生,也不属于故障挑战。
Q10. 关于 WAL 与快照的关系,下列说法正确的有?
- A.快照压缩日志降低重放成本← 正确答案
- B.WAL 采用追加写不可修改← 正确答案
- C.快照可以完全替代 WAL
- D.恢复先装快照再重放日志← 正确答案
答案:ABD。A 正确,快照让旧日志可以被压缩删除;B 正确,WAL 是 Append-only,日志一经写入永不修改;D 正确,恢复流程是“快照基线 + 日志重放”。C 错误,快照点之后的新写入仍依赖 WAL,两者互补而非替代,就像月结册不能取代每天的新流水簿。