1502 字
4 分钟
检查点(Checkpointing)
一、为什么需要检查点
你的系统用了预写日志(WAL)来保证持久性,每次操作先写日志再执行,崩溃后重放日志恢复状态。这很可靠,直到数据库跑了一年:WAL 文件累积了几十个 GB。服务器重启后,重放全部日志需要 20 分钟,期间服务不可用。
更糟的是,WAL 会无限增长。没有机制告诉系统「这些日志已经没用了」,旧日志只能一直留着。磁盘空间和恢复时间都在持续膨胀。
检查点要解决的就是这个问题:定期对当前状态做一次快照,快照完成之前的日志就可以安全删除。崩溃恢复时,加载最近的检查点,只重放之后的日志。检查点越频繁,恢复越快,但每次检查点都有 I/O 开销。这是一个经典的权衡。
二、现实类比
游戏存档。玩一会儿,按「保存」,如果死了就从最近的存档点重来,而不是从头开始。存档越频繁,丢失的进度越少,但每次存档都需要时间。没有存档的话,每次死亡都要从第一关重新打,和没有检查点时重放全部日志一个道理。
三、核心思想
检查点在已知时间点捕获当前系统状态的一致性快照。崩溃后,恢复加载最后的检查点,只重放之后记录的操作。没有检查点,基于 WAL 的系统必须在每次重启时重放整个历史,恢复时间会无限增长。检查点将恢复时间限制在最后一个检查点以来的时间间隔内。
flowchart LR
subgraph WAL
OP1[op1] --> OP2[op2] --> OP3[op3] --> OP4[op4] --> OP5[op5] --> OP6[op6] --> OP7[op7] --> OP8[op8]
end
CP1["检查点 1"] -.-> OP3
CP2["检查点 2"] -.-> OP6
style CP1 fill:#f9f,stroke:#333
style CP2 fill:#f9f,stroke:#333
没有检查点:恢复需重放 op1-op8(8 次操作)。有检查点:恢复只需加载检查点 2,重放 op7-op8(2 次操作)。检查点之前的日志可以安全截断。
| 属性 | 值 |
|---|---|
| 恢复时间 | 与上次检查点以来的操作数成正比 |
| 检查点代价 | O(state_size) 序列化当前状态 |
| WAL 截断 | 可安全丢弃检查点之前的日志条目 |
| 一致性 | 检查点必须捕获一致性快照 |
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| 预写日志(WAL) | 检查点截断 WAL | WAL 保证持久性,检查点限制恢复时间 |
| 写时复制(Copy-on-Write) | COW 在不停止写入的情况下实现一致快照 | COW 是快照技术,检查点是恢复策略 |
| 逻辑时钟(Logical Clock) | 检查点与逻辑时钟位置关联 | 逻辑时钟保证一致性顺序,检查点提供恢复起点 |
| Merkle 树 | Merkle 树验证检查点完整性 | Merkle 树是校验工具,检查点是恢复机制 |
检查点与 WAL 是互补关系:WAL 解决「数据不丢」的问题,检查点解决「恢复不慢」的问题。没有 WAL,检查点之后的操作会丢失;没有检查点,WAL 会无限增长导致恢复时间失控。
五、多语言实现
package checkpoint
// LogEntry WAL 日志条目type LogEntry struct { ID int Operation string Key string Value any}
// stateSnapshot 检查点快照type stateSnapshot struct { state map[string]any walPosition int // 快照对应的 WAL 位置}
// CheckpointableStore 带检查点的可恢复存储type CheckpointableStore struct { state map[string]any wal []LogEntry nextID int checkpoint *stateSnapshot}
func NewStore() *CheckpointableStore { return &CheckpointableStore{ state: make(map[string]any), nextID: 1, }}
// Apply 执行操作并记录到 WALfunc (s *CheckpointableStore) Apply(operation, key string, value any) { entry := LogEntry{ID: s.nextID, Operation: operation, Key: key, Value: value} s.nextID++ s.wal = append(s.wal, entry) s.executeOp(entry)}
// Get 读取当前状态func (s *CheckpointableStore) Get(key string) (any, bool) { v, ok := s.state[key] return v, ok}
// TakeCheckpoint 创建检查点:快照当前状态和 WAL 位置func (s *CheckpointableStore) TakeCheckpoint() { snap := make(map[string]any, len(s.state)) for k, v := range s.state { snap[k] = v } s.checkpoint = &stateSnapshot{state: snap, walPosition: len(s.wal)}}
// SimulateCrash 模拟崩溃:清空内存状态func (s *CheckpointableStore) SimulateCrash() { s.state = make(map[string]any)}
// Recover 从检查点 + WAL 恢复,返回重放的条目数func (s *CheckpointableStore) Recover() int { if s.checkpoint != nil { s.state = make(map[string]any, len(s.checkpoint.state)) for k, v := range s.checkpoint.state { s.state[k] = v } replayed := 0 for i := s.checkpoint.walPosition; i < len(s.wal); i++ { s.executeOp(s.wal[i]) replayed++ } return replayed } // 无检查点:重放整个 WAL s.state = make(map[string]any) for _, entry := range s.wal { s.executeOp(entry) } return len(s.wal)}
func (s *CheckpointableStore) executeOp(entry LogEntry) { switch entry.Operation { case "SET": s.state[entry.Key] = entry.Value case "DELETE": delete(s.state, entry.Key) }}// WAL 日志条目interface LogEntry { id: number; operation: string; data: { key: string; value: unknown };}
// 带检查点的可恢复存储class CheckpointableStore { private state = new Map<string, unknown>(); private wal: LogEntry[] = []; private nextId = 1; private checkpoint: { state: Map<string, unknown>; walPosition: number } | null = null;
// 执行操作并记录到 WAL apply(operation: string, key: string, value: unknown): void { const entry: LogEntry = { id: this.nextId++, operation, data: { key, value } }; this.wal.push(entry); this.executeOp(entry); }
get(key: string): unknown { return this.state.get(key); }
// 创建检查点 takeCheckpoint(): void { this.checkpoint = { state: new Map(this.state), // 浅拷贝当前状态 walPosition: this.wal.length, }; }
// 模拟崩溃 simulateCrash(): void { this.state = new Map(); }
// 从检查点 + WAL 恢复 recover(): number { if (this.checkpoint) { this.state = new Map(this.checkpoint.state); let replayed = 0; for (let i = this.checkpoint.walPosition; i < this.wal.length; i++) { this.executeOp(this.wal[i]!); replayed++; } return replayed; } // 无检查点:重放整个 WAL this.state = new Map(); for (const entry of this.wal) this.executeOp(entry); return this.wal.length; }
private executeOp(entry: LogEntry): void { const { key, value } = entry.data; if (entry.operation === "SET") this.state.set(key, value); else if (entry.operation === "DELETE") this.state.delete(key); }}六、生产验证
- PostgreSQL — checkpointer.c#L206 中
CheckpointerMain是检查点后台进程。循环等待检查点请求或checkpoint_timeout(默认 5 分钟),调用CreateCheckPoint将所有脏缓冲区刷写到磁盘,写入检查点 WAL 记录,更新pg_control。崩溃恢复时从最后的检查点开始重放 WAL。 - Redis — rdb.c#L1880 中
rdbSaveRio将整个 Redis 数据集序列化到 RDB 文件。Redis fork 一个子进程写入快照而不阻塞主线程。RDB 文件本身是状态检查点,但 Redis 启动恢复不会「加载 RDB 再重放其后 AOF」:loadDataFromDisk对 AOF 与 RDB 走互斥分支,AOF 开启时只从 AOF 加载(AOF 文件内部可含一段 RDB preamble 作为 base,其后的增量命令才重放),AOF 关闭时才加载dump.rdb。 - Apache Flink — 基于 Chandy-Lamport 算法的分布式快照实现精确一次的流处理保证。
七、小结
何时使用:
- 数据库崩溃恢复,限制 WAL 重放时间(PostgreSQL、MySQL)
- 内存缓存持久化,重启后恢复状态(Redis RDB)
- 流处理,保存处理位置实现精确一次保证(Flink、Kafka)
- 长时间运行的计算,保存进度在故障后恢复(ML 训练)
何时不用:
- 无状态服务,没有需要检查点的状态
- 非常小的状态,WAL 重放时间不到 1 秒时,检查点增加复杂性但收益很小
- 快速变化的状态,整个状态在检查点之间都变了,快照和重放 WAL 一样昂贵
- 分布式状态,跨节点协调一致性检查点需要分布式快照协议(Chandy-Lamport)
八、参考资料
- PostgreSQL 检查点机制 - 数据库检查点后台进程实现
- Redis RDB 持久化 - fork + COW 的非阻塞快照
- Apache Flink 分布式快照 - Chandy-Lamport 算法的工程实现
- etcd 快照机制 - Raft 日志压缩与定期快照
- SQLite WAL 检查点 - 轻量级数据库的 WAL 检查点协议
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
预写日志(Write-Ahead Log)
程序设计 在修改数据前先把意图写进日志,崩溃后可以重放恢复
2
Event Sourcing 事件溯源(Event Sourcing)
程序设计 状态 = 所有事件的 fold,审计追踪和可回放系统的核心模式
3
流式输出 / SSE(Streaming / Server-Sent Events)
程序设计 实时增量输出,降低首字延迟,从 ChatGPT 打字效果到实时推荐的通用工程模式
4
指数退避重试(Retry with Backoff)
程序设计 失败后等待越来越久再重试,避免雪崩
5
脏标记(Dirty Flag)
程序设计 只标记脏了而不立即重算,延迟昂贵的重计算直到值真正被需要时再执行






