第 14 章 日志与崩溃恢复:让时间可以倒流
这一章,我们讲”承认失败是常态”这一哲学的最纯粹体现:日志(Log / WAL)。
操作系统的设计者们从一开始就假设:进程会崩溃、磁盘会写坏、掉电随时发生。他们不是在”如果出错怎么办”的前提下工作,而是在”出错是必然,不出错才是意外”的前提下工作。
日志,就是这种哲学的技术结晶。理解了它,你就理解了数据库的 WAL、文件系统的 journaling、分布式系统的复制——它们本质是同一个东西。
引子:为什么”写成功了”,不代表”安全了”?
先问一个反直觉的问题:
write(fd, buf, 4096); // 返回 4096,看起来"写成功了"
// 问:这 4096 字节,现在安全了吗?(掉电也不丢?)答案是:不一定。
因为(第 3 章讲过)write 只是把数据放进了 page cache(内存),还没落盘。真正落盘要等内核的 writeback,可能 30 秒后才发生。
那怎么保证”写成功了 = 安全了”?答案是 fsync。
但 fsync 慢(毫秒级)。那怎么在”快”和”安全”之间平衡?
答案是:日志(WAL)。
日志的核心思想,用一句话说:
先把这个”要做什么”记在一个”顺序追加、快速落盘”的日志里,然后才慢慢做真正的事。
这样,即使中途崩溃,恢复时只要”重放日志”,就能让系统回到”崩溃前的状态”。
日志不是防止崩溃,而是让崩溃后的恢复变得快、且确定。
本章路线图
第一幕:日志的本质
Q1 日志(WAL)要解决什么问题?为什么"先写日志"能防崩溃?
Q2 日志为什么必须是"顺序追加"的?
第二幕:日志的机制
Q3 redo 和 undo 日志有什么区别?
Q4 一次完整的"写 + 崩溃 + 恢复"流程是怎么样的?
Q5 fsync 在日志里扮演什么角色?(回顾深化)
第三幕:日志的普适性
Q6 为什么说"数据库 WAL = 文件系统日志 = 同一种东西"?
Q7 幂等性为什么是"重试"和"恢复"的前提?
第四幕:工程实战
Q8 2PC 是 WAL 的放大?分布式事务和单机日志什么关系?
Q9 在你的业务里,"日志思想"怎么落地?
第一幕:日志的本质
Q1 日志(WAL)要解决什么问题?为什么”先写日志”能防崩溃?
问题:一个”非原子”的操作,中途崩溃了,怎么恢复?
例:文件系统要更新一个文件的元数据(inode 的块指针)
这个更新涉及多个步骤:
1. 分配一个新数据块
2. 更新 inode 指向新块
3. 更新空闲块位图(标记块已用)
如果崩溃发生在"第 2 步之后、第 3 步之前":
→ inode 指向了新块,但位图还标记它是"空闲"
→ 下次分配时,这个块又被分给别人 → 数据损坏
传统解法:崩溃后全盘扫描修复(fsck)。
问题:
1. 慢(全盘扫描,大磁盘要几十分钟到几小时)
2. 不确定(可能修复不完整)
日志解法:先写日志,再改数据。
核心思想:把"要做什么"先记在日志里,然后才做。
流程:
1. 写日志:"我要把 inode 5 的块指针从 A 改成 B"
2. 写日志完成,fsync 日志(保证日志落盘)
3. 改实际数据:inode 5 的块指针 = B
4. 标记日志"已完成"
崩溃恢复:
- 如果日志里有"未完成"的记录 → 重放(redo):把块指针改成 B
- 如果日志标记"已完成" → 忽略
为什么这样能防崩溃?
关键:日志是"顺序追加"的,且"先于数据落盘"。
崩溃时,最多是:
- 日志写了一半(崩溃在写日志时)→ 这条日志不完整,忽略它
- 日志写完,但数据没写完(崩溃在改数据时)→ 重放日志,补上数据
无论崩溃在哪一步,恢复时"重放日志"就能让系统达到一致状态。
日志的本质:用一个”顺序、快速、可靠”的操作(写日志),来保护一个”随机、慢、易错”的操作(改数据)。
这是第 12 章”摊销”思想的又一种体现:把”随机改数据”的不确定性,换成”顺序写日志”的确定性。
Q2 日志为什么必须是”顺序追加”的?
日志的”顺序追加”性质,是它能快、能可靠的关键。
原因一:顺序写比随机写快得多。
磁盘的物理特性(第 10 章讲过):
顺序写:~100MB/s(机械盘)
随机写:~1MB/s(机械盘,要寻道)
日志是"顺序追加"(只往末尾写),
→ 没有寻道,速度快 100 倍
→ 这是日志能"快速落盘"的物理基础
原因二:顺序追加天然是”原子的”(可判断)。
日志追加 = 往文件末尾写。
崩溃时,日志要么:
- 写完了(完整的记录)
- 没写完(不完整的记录,尾部是"半条")
恢复时,能判断"哪条日志是完整的":
- 每条日志有"长度"或"校验和"
- 不完整的(尾部半条)→ 丢弃
- 完整的 → 重放
原因三:顺序追加让”落盘”变得高效。
随机写:每次写都要 fsync(否则不知道哪些落盘了)
顺序追加:可以"攒一批"一起 fsync(组提交,第 12 章)
顺序追加 + 组提交 = 高吞吐 + 可靠
一个对比:为什么不用”原地更新 + 日志”?
原地更新(in-place update):直接改数据,崩溃时数据可能"半新半旧"
日志(WAL):先写日志,再改数据
- 崩溃时,数据可能"半新半旧",但日志里有"应该是什么样"
- 重放日志,把数据修正到"一致状态"
“顺序追加”是日志的物理基础,它让日志”快”(无寻道)且”可靠”(可判断完整性)。
这也是为什么几乎所有的”日志型系统”(WAL、binlog、Kafka、事件溯源)都采用”追加写”: 追加写是磁盘上唯一”又快又可靠”的写模式。
第二幕:日志的机制
Q3 redo 和 undo 日志有什么区别?
日志有两种基本类型:redo(重做)和 undo(撤销),它们的区别是”记录了新值还是旧值”。
redo 日志(记录”新值”,用于”重做”):
记录:操作要"改成什么"(新值)
例:把 inode 5 的块指针从 A 改成 B
redo 日志记录:inode 5,块指针 = B(新值)
恢复时:如果日志有这条"未完成"的记录,
就"重做":把 inode 5 的块指针改成 B
适用:数据可能"没写完",用 redo 补上
undo 日志(记录”旧值”,用于”撤销”):
记录:操作"原来是什么"(旧值)
例:把 inode 5 的块指针从 A 改成 B
undo 日志记录:inode 5,块指针 = A(旧值)
恢复时:如果这条操作"没完成"(事务未提交),
就"撤销":把 inode 5 的块指针改回 A
适用:事务没提交,要"回滚"到事务前
redo 和 undo 的对比:
| redo 日志 | undo 日志 | |
|---|---|---|
| 记录内容 | 新值 | 旧值 |
| 用途 | 重做(补上没写完的) | 撤销(回滚没提交的) |
| 场景 | 崩溃恢复(补数据) | 事务回滚(撤销) |
| 时机 | 恢复时”前进” | 恢复时”后退” |
很多系统两者都用(redo + undo):
数据库(如 InnoDB):
- redo log:保证"已提交的事务"不丢(崩溃后重做)
- undo log:保证"未提交的事务"能回滚(崩溃后撤销)
两者配合,实现"事务的原子性和持久性":
- 已提交的 → redo 保证不丢
- 未提交的 → undo 保证能回滚
redo/undo 的区别,本质是”恢复的方向”:
- redo:往前(重做没做完的)
- undo:往后(撤销不该做的)
这个”前进/后退”的思想,在工程里很常见:
- 版本控制:git revert(undo)vs git cherry-pick(redo)
- 部署:回滚(undo)vs 重放(redo)
- 数据库:事务回滚(undo)vs 崩溃恢复(redo)
Q4 一次完整的”写 + 崩溃 + 恢复”流程是怎么样的?
以数据库的一个事务为例,走一遍完整的流程。
场景:事务要”把用户 A 的余额从 100 改成 80”(扣款 20)。
正常流程(不崩溃):
1. 写 undo 日志:记录"用户 A 余额 = 100"(旧值,用于回滚)
2. 写 redo 日志:记录"用户 A 余额 = 80"(新值,用于重做)
3. 修改内存里的数据:用户 A 余额 = 80(在 buffer pool 里)
4. 提交事务:写一条"commit"日志
5. fsync redo 日志(保证日志落盘)
6. 返回"事务成功"
(内存里的脏页,稍后由后台刷盘,此时数据已"安全"因为 redo 日志已落盘)
崩溃场景一:崩溃在”提交前”(步骤 1-3 之间)。
崩溃时,redo 日志里没有"commit"记录。
恢复流程:
1. 扫描 redo 日志,发现这个事务"没有 commit 记录"
2. 用 undo 日志回滚:把"用户 A 余额"改回 100
3. 事务相当于"没发生"
结果:用户 A 余额还是 100(事务被撤销了)
崩溃场景二:崩溃在”提交后”(步骤 5 之后)。
崩溃时,redo 日志里有"commit"记录,且已 fsync 落盘。
恢复流程:
1. 扫描 redo 日志,发现这个事务"有 commit 记录"
2. 重放 redo:把"用户 A 余额"改成 80(如果还没落盘)
3. 事务的结果被"重做"了
结果:用户 A 余额是 80(事务生效了,没丢)
关键:commit 记录是”分水岭”。
commit 之前崩溃 → undo 回滚(事务没发生)
commit 之后崩溃 → redo 重做(事务生效了)
"commit"这条日志,就是"事务到底成没成功"的标志。
这个流程展示了日志的完整能力:
- redo:保证”已提交”的不丢(持久性)
- undo:保证”未提交”的能回滚(原子性)
- commit:划分”提交前/提交后”的分水岭
这就是数据库事务 ACID 里 A(原子性)和 D(持久性)的实现基础。
Q5 fsync 在日志里扮演什么角色?(回顾深化)
第 3 章讲过 fsync,这里从”日志”的角度看它的关键作用。
fsync 是”日志生效”的开关。
写日志(write)→ 日志在 page cache(内存)
fsync 日志 → 日志真正落盘
关键:日志必须"先于数据"落盘,才能保护数据。
所以日志的写入流程,必须包含 fsync:
写日志 → fsync 日志 → 改数据
如果日志没 fsync(只 write 到 page cache),
崩溃时日志和数据都可能丢,日志就"白写了"。
fsync 的成本(为什么是性能瓶颈):
fsync 要等数据真正落到物理介质(第 3 章):
机械盘:~10ms
SSD:~1ms
NVMe:~0.1ms
如果每个事务都 fsync:
1000 TPS = 1000 次 fsync = 10 秒(机械盘)→ 只能 100 TPS
所以有了”组提交”(第 12 章):
多个事务的日志,攒一批,一起 fsync。
100 个事务 = 1 次 fsync
→ 吞吐提升 100 倍(但单事务延迟增加)
fsync 的语义陷阱(第 3 章讲过,这里强调):
1. fsync 保证"数据落盘",但不保证"到了持久介质"
(磁盘自己的 write cache 可能还没刷)
→ 要彻底可靠,需要"写穿"或带电容保护的阵列卡
2. fsync 文件,不 fsync 目录(第 3 章)
→ 新建文件,光 fsync 文件不够,还要 fsync 父目录
3. fsync 的性能和硬件强相关
→ 机械盘上 fsync 是灾难,NVMe 上好很多
fsync 在日志里的角色:它是”日志可靠”的保证。
没有 fsync,日志就是”内存里的一堆数据”,崩溃就丢,起不到保护作用。 有了 fsync,日志才是”落盘的、可靠的、可重放的”。
而 fsync 的代价(毫秒级),是”可靠性”的价格。这就是”性能 vs 可靠性”的权衡,日志系统里最核心的矛盾。
第三幕:日志的普适性
Q6 为什么说”数据库 WAL = 文件系统日志 = 同一种东西”?
这一问是”知识缝合”,帮你看到”日志”的统一本质。
三者对比:
| 文件系统日志(journaling) | 数据库 WAL | 消息队列(Kafka) | |
|---|---|---|---|
| 记录什么 | 元数据变更 | 数据变更(redo/undo) | 消息(事件) |
| 为什么 | 崩溃后修复元数据 | 崩溃后恢复数据 | 解耦 + 重放 |
| 追加写 | 是 | 是 | 是 |
| 恢复方式 | 重放日志 | 重放 redo | 消费者重放 |
它们的共同结构:
1. 都有一个"顺序追加的日志"(journal / WAL / partition)
2. 都是"先写日志,再做事"
3. 崩溃后都能"重放日志"恢复
4. 都依赖"追加写"的物理优势(快 + 可靠)
本质:
日志是一个通用的”可靠性模式”:
面对”易错、非原子、随机”的操作,用”顺序追加、快速、可靠”的日志来记录”意图”, 崩溃后用日志”重放”或”回滚”来恢复一致。
无论这个操作是”改文件系统元数据”、“改数据库记录”、还是”处理业务消息”, 日志模式都适用。
一个更广的视角(事件溯源 Event Sourcing):
事件溯源(Event Sourcing)是日志思想的极致应用:
不存"当前状态",只存"一系列事件"(追加日志)。
当前状态 = 重放所有事件的结果。
好处:
- 完整审计(所有历史操作都在)
- 可回放(重建任意时间点的状态)
- 天然支持"时间旅行"
这是"日志"从"可靠性机制"上升为"架构模式"的例子。
日志的普适性,源于一个朴素的事实:
“记录发生了什么”比”保证不发生错误”更可靠。
你无法保证系统不出错(崩溃、掉电、bug 都会发生), 但你可以”记录发生了什么”,出错后”重放记录”来恢复。
这就是”承认失败是常态”这一哲学的技术体现:不幻想”不出错”,而是设计”出错后能恢复”。
Q7 幂等性为什么是”重试”和”恢复”的前提?
日志的恢复,本质是”重放操作”。而”重放”有一个致命的问题:如果操作被重放多次,结果还对吗?
问题:重放不幂等 = 重复执行 = 错误结果。
例:日志里有一条"用户 A 余额 -20"(扣款)
崩溃恢复,这条日志被重放:
第 1 次重放:余额 100 → 80
第 2 次重放(如果日志被重复扫描):余额 80 → 60
→ 错了!扣了两次 20
所以”重放”要求操作是”幂等”的:
幂等(idempotent):执行一次和执行多次,结果一样。
幂等的操作:
- "把余额设为 80"(设值,重放多次还是 80)
- "标记订单为已支付"(标记,重放多次还是"已支付")
不幂等的操作:
- "余额 -20"(减法,重放多次扣多次)
- "计数 +1"(加法,重放多次加多次)
日志怎么保证幂等?
方法一:记录"结果"而不是"操作"(redo 记录新值)
日志记"余额 = 80",重放就是"设为 80",幂等
方法二:加"唯一标识"(事务 ID / 消息 ID)
重放前检查"这个事务重放过吗",
重放过就跳过
方法三:给操作加"版本号"
只有"版本匹配"才执行,防止重复执行
幂等性在分布式系统里的重要性:
分布式系统的"重试"无处不在:
- 网络超时,重试
- 消息消费失败,重试
- 崩溃恢复,重放
如果操作不幂等,重试就会导致:
- 重复扣款(支付接口重试)
- 重复发货(订单接口重试)
- 重复计数(统计接口重试)
所以"幂等"是分布式系统的基本要求。
怎么设计幂等接口?
1. 用"唯一 ID"(幂等键):
- 客户端生成一个唯一的请求 ID
- 服务端记录"处理过的请求 ID"
- 重复请求(相同 ID)→ 直接返回上次结果,不重复执行
2. 用"状态机":
- 操作 = 状态转移(pending → paid)
- 重复执行"pending → paid",第二次发现已经是 paid,跳过
3. 用"数据库唯一约束":
- 插入时用唯一键(订单号),重复插入失败 → 幂等
幂等性,是”日志/重试/恢复”这套思想的”地基”。
日志让你能”重放”,重试让你能”再试”,但它们都假设”重放/重试是安全的”(幂等)。
没有幂等,重放就是”重复扣款”,重试就是”重复下单”。
所以设计任何”可能被重试”的操作,第一个问题就是:它幂等吗?
第四幕:工程实战
Q8 2PC 是 WAL 的放大?分布式事务和单机日志什么关系?
这一问把”单机日志”和”分布式事务”连起来,是理解分布式一致性的桥梁。
2PC(两阶段提交)回顾:
分布式事务(多个节点要一起提交):
协调者(coordinator)+ 多个参与者(participant)
两阶段:
阶段 1(准备 prepare):
协调者问所有参与者:"你能提交吗?"
每个参与者:把事务写进自己的日志(WAL),回复"可以"
阶段 2(提交 commit):
如果所有参与者都"可以",协调者说"提交"
每个参与者:正式提交,回复"完成"
2PC 和 WAL 的关系:
WAL(单机):事务写日志 → commit 落盘 → 崩溃后重放
2PC(分布式):每个参与者写自己的 WAL(prepare)→ 协调者统一 commit
本质:
2PC 是 WAL 的"分布式版本"。
每个参与者的 WAL,保证了"单机的持久性";
协调者的两阶段,保证了"跨节点的原子性"。
为什么 2PC 需要”两阶段”?
如果只有"一阶段"(协调者直接说"提交",大家各自提交):
问题:某个参与者提交失败了,其他参与者已经提交了
→ 无法回滚(已经提交的收不回来)
→ 不一致
两阶段解决:
先"准备"(大家把日志写好,但还没提交,可以回滚)
再"提交"(大家都准备好了,才一起提交)
→ 提交前可以回滚,提交后一定成功(日志已落盘)
2PC 的问题(分布式事务的代价):
1. 阻塞:阶段 1 后,如果协调者挂了,参与者"卡在"准备状态
(不知道该提交还是回滚)→ 锁一直持有 → 阻塞
2. 性能差:两轮通信(prepare + commit)+ 多次 fsync
→ 慢
3. 可用性差:一个参与者挂了,整个事务卡住
→ 分布式事务牺牲了可用性
2PC 和 WAL 的共同点:
都是”先记录意图(日志),再真正执行”,用”记录”保证”可恢复”。
- WAL:单机的”先写日志再提交”
- 2PC:分布式的”先 prepare(写日志)再 commit”
而”2PC 的阻塞问题”,本质是”分布式下,‘日志’和’提交’分开了,中间状态(prepare 后、commit 前)无法自决”。
这也是为什么现代分布式系统(Raft/Paxos)倾向于用”共识”而不是”2PC”——共识用”多数派”避免了”协调者挂掉就阻塞”的问题(第 18 章会讲)。
Q9 在你的业务里,“日志思想”怎么落地?
这一章的落地总结,把”日志”从”数据库/文件系统的概念”变成”业务开发的工具”。
落地一:重要的写操作,先落”日志”再处理。
// 场景:处理一个"扣款"请求,要求"不能丢"
// 1. 先写"意图日志"(比如写一条 Kafka 消息或数据库记录)
intent := Intent{
ID: uuid.New(), // 唯一 ID(幂等键)
Type: "deduct",
UserID: 123,
Amount: 20,
Status: "pending", // 初始状态
}
db.InsertIntent(intent) // 先落库(持久化意图)
// 2. 再执行扣款
if err := deduct(123, 20); err != nil {
// 扣款失败,标记 intent 为 failed
db.UpdateIntentStatus(intent.ID, "failed")
return err
}
// 3. 扣款成功,标记 intent 为 done
db.UpdateIntentStatus(intent.ID, "done")为什么这样设计?
1. "意图"先落库 → 崩溃后,能知道"我本来要做什么"
2. 唯一 ID → 幂等(重试不会重复扣款)
3. 状态机(pending → done/failed)→ 崩溃恢复时能判断"做到哪了"
落地二:用”状态机 + 唯一 ID”实现幂等。
// 幂等的扣款接口
func Deduct(requestID string, userID, amount int) error {
// 1. 检查这个 requestID 处理过吗(幂等键)
if status := getStatus(requestID); status != "" {
return nil // 处理过,直接返回(幂等)
}
// 2. 标记处理中(用唯一约束防并发重复)
if err := insertRequest(requestID, "processing"); err != nil {
return nil // 并发重复请求,另一个在处理,忽略
}
// 3. 执行扣款
if err := doDeduct(userID, amount); err != nil {
updateRequest(requestID, "failed")
return err
}
// 4. 标记完成
updateRequest(requestID, "done")
return nil
}落地三:重要的状态变更,用”追加日志”而非”原地更新”。
普通做法(原地更新):
UPDATE orders SET status = 'paid' WHERE id = 123
→ 状态被覆盖了,历史丢了
日志做法(追加):
INSERT INTO order_events (order_id, event) VALUES (123, 'paid')
→ 事件被追加,完整历史保留
→ 当前状态 = 重放事件的结果
→ 崩溃恢复、审计、回放,都容易
落地四:关键路径的”落盘”意识。
问自己:这个写操作,需要"持久化"到什么程度?
1. 写内存就行(丢了无所谓)→ 普通缓存、临时数据
2. 要落盘(丢了是事故)→ 关键数据,必须 fsync / 落库
3. 要落盘 + 幂等(可能重试)→ 意图日志 + 唯一 ID
不同的可靠性要求,对应不同的设计。
“日志思想”在业务里的核心,就三句话:
- 先记录意图,再执行(崩溃了知道要做什么)
- 用唯一 ID 保证幂等(重试安全)
- 用状态机记录进度(恢复时知道做到哪了)
这三句话,是”承认失败是常态”这一哲学,在业务代码里的落地。
思考题
思考题 1
面试官问:“什么是 WAL(Write-Ahead Logging)?为什么它能让数据库崩溃后不丢数据?“请从”先写日志、顺序追加、redo/undo、commit 分水岭”四个角度回答。
参考答案
核心答案:WAL 是”先写日志、再改数据”的机制。日志是顺序追加的,崩溃后重放日志(redo 已提交的、undo 未提交的)来恢复一致,保证”已提交的事务不丢”。
第一点:先写日志(Write-Ahead)。
关键原则:任何数据修改,必须先写日志,日志落盘后,才修改数据。
为什么"先"?
如果先改数据,再写日志:
崩溃在"改了数据、没写日志"之间 → 数据改了,但没有记录 → 无法判断
崩溃在"写了日志、没改数据"之间 → 日志有记录,重放即可 → 可恢复
所以"先写日志"保证:崩溃时,要么"没做"(无日志),要么"知道要做什么"(有日志)。
第二点:顺序追加。
日志是"追加写"(往末尾写),利用顺序写的物理优势:
- 快(无寻道)
- 可靠(可判断完整性,尾部半条日志可丢弃)
- 可批量(组提交,一次 fsync 多条日志)
第三点:redo/undo。
redo 日志:记录"新值",崩溃后"重做"已提交的事务(补上没写完的数据)
undo 日志:记录"旧值",崩溃后"撤销"未提交的事务(回滚到事务前)
两者配合:
已提交的 → redo 重做(不丢)
未提交的 → undo 撤销(回滚)
第四点:commit 分水岭。
"commit"日志是分水岭:
崩溃在 commit 之前 → 事务未提交 → undo 回滚
崩溃在 commit 之后 → 事务已提交 → redo 重做
"commit 日志落盘" = "事务持久化"的标志。
完整的崩溃恢复流程:
1. 扫描日志
2. 对"有 commit 但数据可能没写完"的事务 → redo(重做)
3. 对"没有 commit"的事务 → undo(回滚)
4. 结果:所有已提交事务生效,所有未提交事务回滚 → 一致
追问:为什么 fsync 对 WAL 很重要?
因为日志必须”先于数据”落盘。如果日志只 write 到 page cache 没 fsync,崩溃时日志和数据都可能丢,日志就白写了。所以 WAL 的提交,必须包含 fsync 日志。
追问:WAL 的性能代价是什么?
fsync 慢(毫秒级)。每个事务都 fsync 的话,吞吐很低。解法是”组提交”(多个事务一批 fsync)。
这道题考察什么:
- 知不知道”先写日志”的顺序(Write-Ahead)
- 知不知道”顺序追加”的物理优势
- 知不知道 redo/undo 的区别
- 知不知道 commit 是”分水岭”
思考题 2
你的支付服务,收到一个”扣款”请求。这个请求可能因为网络问题被”重试”(客户端超时重发)。你怎么设计,保证”扣款”不会因为重试而”重复执行”?
参考答案
核心答案:用”幂等键 + 状态机”保证扣款幂等,重试不会重复扣款。
方案:幂等键(request_id / idempotency key)。
// 客户端每次扣款请求,带一个唯一 ID
type DeductRequest struct {
RequestID string // 幂等键(客户端生成,UUID)
UserID int64
Amount int64
}
func Deduct(ctx context.Context, req DeductRequest) error {
// 1. 用唯一约束插入请求记录(防止并发重复)
// 如果插入失败(已存在),说明这个 request 处理过
err := db.Insert(&DeductRecord{
RequestID: req.RequestID,
Status: "processing",
UserID: req.UserID,
Amount: req.Amount,
})
if err != nil {
if isDuplicateKey(err) {
// 已经处理过,查状态返回
record := db.GetByRequestID(req.RequestID)
if record.Status == "done" {
return nil // 已经成功,幂等返回
}
// 处理中或失败,返回相应状态
return errors.New("request already processed")
}
return err
}
// 2. 执行扣款
if err := doDeduct(ctx, req.UserID, req.Amount); err != nil {
db.UpdateStatus(req.RequestID, "failed")
return err
}
// 3. 标记完成
db.UpdateStatus(req.RequestID, "done")
return nil
}关键点:
1. 幂等键(RequestID):
- 客户端生成唯一 ID,重试时用同一个 ID
- 服务端用唯一约束(数据库唯一索引)保证"一个 ID 只处理一次"
2. 状态机(processing → done/failed):
- 崩溃恢复时,能判断"这个请求做到哪了"
- processing 卡住的(崩溃了)→ 需要人工处理或超时恢复
3. 唯一约束是"并发安全"的关键:
- 两个相同 RequestID 的请求并发进来
- 数据库唯一索引保证只有一个能插入成功
- 另一个插入失败 → 返回"已处理"
更严谨的方案:扣款 + 记录在同一个事务里。
func Deduct(ctx context.Context, req DeductRequest) error {
// 在事务里:扣款 + 记录状态,保证原子性
return db.Transaction(func(tx *Tx) error {
// 1. 检查幂等(查 + 插,用唯一约束)
if exists := tx.CheckRequestID(req.RequestID); exists {
return nil // 已处理
}
// 2. 扣款(带条件更新,防超扣)
result := tx.Exec("UPDATE balance SET amount = amount - ? WHERE user_id = ? AND amount >= ?",
req.Amount, req.UserID, req.Amount)
if result.RowsAffected == 0 {
return errors.New("insufficient balance")
}
// 3. 记录幂等键
tx.Insert(&DeductRecord{RequestID: req.RequestID, Status: "done"})
return nil
})
}为什么用”条件更新”(amount >= ?)而不是”读-改-写”?
"读-改-写"有竞态:
读余额 100 → 判断够 → 扣 20
两个请求并发,都读到 100,都扣 20 → 扣了 40(超扣)
"条件更新"(原子):
UPDATE ... WHERE amount >= 20
数据库原子地"检查 + 扣减",不会超扣
其他保证幂等的手段(根据场景选择):
1. 幂等键 + 唯一约束(本方案):通用,适合"扣款、下单"等
2. 状态机 + 版本号:适合"订单状态流转"
3. 数据库乐观锁(version 字段):适合"更新记录"
4. 分布式锁(第 17 章):适合"跨系统的幂等"
一个重要的补充:客户端的幂等键怎么生成?
1. 客户端生成 UUID,重试时复用
2. 或者用"业务自然键"(订单号、用户 ID + 操作类型)
3. 关键:重试时必须"复用同一个 ID",否则幂等失效
(这也是为什么支付接口几乎都要求客户端传"幂等键")
这道题考察什么:
- 知不知道”幂等键”这个核心概念
- 知不知道用”唯一约束”保证并发安全
- 知不知道”条件更新”避免超扣(而不是读-改-写)
- 知不知道”状态机”处理崩溃恢复
思考题 3
“日志(WAL)和备份(snapshot)有什么区别?什么时候用日志,什么时候用备份?“请说明,并解释它们如何配合。
参考答案
核心答案:日志记录”增量变更”(发生了什么),备份记录”完整快照”(某个时刻的完整状态)。日志用于”恢复到最近”,备份用于”恢复到某个时间点”。两者配合:备份 + 重放日志 = 恢复到任意时间点。
日志(WAL):
记录:增量变更(每个操作/事务)
特点:
- 追加写,快
- 完整历史(所有操作都在)
- 但"重放全部日志"很慢(如果日志很长)
用途:崩溃恢复(重放到最近的 commit)
备份(snapshot):
记录:某个时刻的完整状态(全量快照)
特点:
- 一次性的完整拷贝
- 恢复快(直接加载快照)
- 但"快照之间有间隔"(快照之后的变化丢了)
用途:恢复到某个时间点(快照时刻)
两者的配合(这是关键):
备份 + 日志 = 完整的恢复能力:
1. 定期做快照(比如每天凌晨 3 点)
2. 快照之后的所有变更,记在日志里
3. 恢复时:加载最近的快照 + 重放快照之后的日志
= 恢复到"崩溃前"(或任意时间点)
例:
快照:昨天凌晨 3 点的完整状态
日志:3 点之后的所有操作
恢复:加载 3 点快照 + 重放日志到 14:30
= 恢复到今天 14:30 的状态
对比表:
| 日志(WAL) | 备份(snapshot) | |
|---|---|---|
| 内容 | 增量变更 | 完整状态 |
| 写入 | 追加(快) | 全量(慢) |
| 恢复速度 | 重放(慢,如果日志长) | 加载(快) |
| 恢复精度 | 精确到最近操作 | 精确到快照时刻 |
| 存储 | 持续增长(要清理) | 一次性(但大) |
工程里的对应:
数据库(InnoDB):
redo log(日志)+ 定期 checkpoint(快照)
checkpoint 之前的日志可以丢弃(数据已落盘)
Redis:
RDB(快照,全量)+ AOF(日志,增量)
RDB 恢复快但可能丢最近数据,AOF 恢复慢但精确
两者结合:RDB + AOF 重放
Git:
commit(快照/日志的混合)+ 每次提交是"完整快照"(但存储用增量)
Kafka:
partition(追加日志)+ 定期 compaction(快照化,合并旧消息)
什么时候用日志,什么时候用备份?
只用日志:
- 数据变更频繁,需要"恢复到最近"
- 但日志会无限增长(要配合快照清理)
只用备份:
- 数据变更不频繁
- 能接受"快照间隔内的数据丢失"
两者配合(最佳):
- 定期快照(恢复的基础)+ 日志(快照后的增量)
- 这是几乎所有可靠存储系统的标准做法
这道题考察什么:
- 知不知道日志(增量)和备份(全量)的本质区别
- 知不知道”备份 + 重放日志”的恢复机制
- 能不能举出”两者配合”的实例(Redis RDB+AOF、数据库 checkpoint)
思考题 4(开放题)
“日志让时间可以倒流。“这句话你怎么理解?结合”事件溯源(Event Sourcing)“和”可回放性(replayability)“,说说”日志思想”如何从”可靠性机制”上升为”架构模式”。
参考答案
开放题,考察你对”日志”思想的理解深度和抽象能力。
“日志让时间可以倒流”的含义:
传统系统:只存"当前状态",历史被覆盖(改了就没了)
→ 你无法知道"昨天这个数据是什么样",时间"不可倒流"
日志系统:记录"所有变更",历史完整保留
→ 你可以"重放日志",重建任意时间点的状态
→ 时间"可以倒流"(回到任意历史时刻)
事件溯源(Event Sourcing)的核心:
传统做法(状态存储):
存:user.balance = 80
问题:怎么变成 80 的?(从 100 扣了 20?还是别的?)历史丢了
事件溯源(事件存储):
存:[充值 +100, 扣款 -20]
当前状态 = 重放所有事件 = 80
好处:
1. 完整历史(所有操作都在)
2. 可重建任意时间点(重放到某个事件)
3. 可审计(谁、什么时候、做了什么)
4. 可回放(bug 修复后,重放事件得到正确状态)
日志如何从”可靠性机制”上升为”架构模式”:
第一层(可靠性机制):
日志 = WAL,用于崩溃恢复
(数据库、文件系统的内部实现)
第二层(数据模型):
日志 = 事件流,用于解耦和通信
(Kafka 的消息、binlog 的订阅)
第三层(架构模式):
日志 = 事实源(source of truth),整个系统的"真相"
(事件溯源、CQRS、审计系统)
第三层的变化是本质性的:
日志不再只是"恢复的辅助",而是"系统的核心"——
系统的状态,是"日志的重放结果"。
“可回放性”的价值(这是关键):
可回放(replayable)意味着:
1. 可恢复:崩溃后重放日志,恢复状态
2. 可重建:重建任意时间点的状态(时间旅行)
3. 可修正:bug 修复后,重放事件(用修正后的逻辑)得到正确状态
4. 可审计:完整的历史,可追溯
5. 可测试:用历史事件重放,测试新逻辑
6. 可扩展:重放事件到新系统,迁移数据
一个完整的例子(电商订单):
传统:orders 表存当前状态
id=123, status='paid'
事件溯源:order_events 表存事件流
[创建订单, 加入商品, 支付, 发货, 签收]
价值:
- 审计:能回答"这个订单什么时候支付的"
- 恢复:重放事件,重建订单状态
- 修正:发现"发货"逻辑有 bug,重放事件修正
- 分析:统计"从创建到支付的转化时间"
- 迁移:把事件重放到新系统
事件溯源的代价(要客观):
1. 复杂度高:要管理事件流、重放逻辑、快照
2. 查询难:要"当前状态",得重放(或配合 CQRS 用快照/物化视图)
3. 事件演进:事件格式变了,旧事件要迁移
4. 存储大:完整历史比当前状态大得多
什么时候用事件溯源?
适合:
- 强审计需求(金融、合规)
- 需要"时间旅行"(分析历史、重建状态)
- 事件驱动的领域(订单、交易)
- 需要"可回放"(bug 修复、数据修正)
不适合:
- 简单的 CRUD
- 不需要历史(临时数据)
- 团队不熟悉(复杂度高)
这道题的深层价值:
它让你看到,“日志”不只是”技术”,更是一种”世界观”:
“真相”不是”当前状态”,而是”发生了什么”。
当前状态是”可推导的”(重放事件),而历史是”不可还原的”(状态覆盖了就没了)。
所以”日志思想”的本质,是用”记录过程”来替代”存储结果”,从而获得”可回放、可审计、可修正”的能力。
这和第 12 章”摊销”、第 9 章”缓存”一样,都是”换个角度看问题”——不存”答案”,存”推导过程”,答案随时可以重算。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 14 章 · 日志与崩溃恢复:让时间可以倒流 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【日志的本质】 │
│ 先写日志(顺序追加、快速落盘),再改数据 │
│ 不是防崩溃,是让崩溃后的恢复"快且确定" │
│ 用"顺序写日志"的确定性,换"随机改数据"的不确定性 │
│ │
│ 【redo vs undo】 │
│ redo 记录新值(重做,补上没写完的) │
│ undo 记录旧值(撤销,回滚没提交的) │
│ commit 是分水岭:提交前回滚,提交后重做 │
│ │
│ 【fsync 的角色】 │
│ 日志必须"先于数据"落盘(fsync 是"日志生效"的开关) │
│ 代价:毫秒级(性能 vs 可靠性的核心矛盾) │
│ 解法:组提交(批量 fsync) │
│ │
│ 【日志的普适性】 │
│ 文件系统 journaling = 数据库 WAL = 消息队列(Kafka) │
│ 共同:顺序追加 + 先写日志 + 重放恢复 │
│ 极致形态:事件溯源(Event Sourcing) │
│ │
│ 【幂等 = 地基】 │
│ 重放/重试的前提是幂等(否则重复扣款、重复下单) │
│ 手段:唯一 ID + 状态机 + 条件更新 + 唯一约束 │
│ │
│ 【分布式放大】 │
│ 2PC = WAL 的分布式版本(每个参与者写日志 + 协调者统一提交) │
│ 代价:阻塞、性能差、可用性差 │
│ │
│ 【一句话】 │
│ 不幻想"不出错",而是"记录发生了什么",出错后重放来恢复; │
│ 日志让时间可以倒流,幂等让重放安全。 │
│ │
│ 【下一章预告】 │
│ 日志处理"崩溃",但还有"资源耗尽"—— │
│ 内存不够了、CPU 超了、邻居捣乱, │
│ 下一章讲隔离与配额:邻居是魔鬼。 │
└──────────────────────────────────────────────────────────────┘