一、为什么需要检查点
你的系统用了预写日志(WAL)来保证持久性——每次操作先写日志再执行,崩溃后重放日志恢复状态。这很可靠,直到数据库跑了一年:WAL 文件累积了几十个 GB。服务器重启后,重放全部日志需要 20 分钟,期间服务不可用。
更糟的是,WAL 会无限增长。没有机制告诉系统「这些日志已经没用了」,旧日志只能一直留着。磁盘空间和恢复时间都在持续膨胀。
检查点要解决的就是这个问题:定期对当前状态做一次快照,快照完成之前的日志就可以安全删除。崩溃恢复时,加载最近的检查点,只重放之后的日志。检查点越频繁,恢复越快——但每次检查点都有 I/O 开销。这是一个经典的权衡。
二、现实类比
游戏存档。玩一会儿,按「保存」,如果死了就从最近的存档点重来,而不是从头开始。存档越频繁,丢失的进度越少——但每次存档都需要时间。没有存档的话,每次死亡都要从第一关重新打,和没有检查点时重放全部日志一个道理。
三、核心思想
检查点在已知时间点捕获当前系统状态的一致性快照。崩溃后,恢复加载最后的检查点,只重放之后记录的操作。没有检查点,基于 WAL 的系统必须在每次重启时重放整个历史——恢复时间会无限增长。检查点将恢复时间限制在最后一个检查点以来的时间间隔内。
没有检查点:恢复需重放 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 会无限增长导致恢复时间失控。
五、多语言实现
5.1 Go 实现
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) }}5.2 TypeScript 实现
// 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); }}一个重要的安全原则:只有在检查点完全写入并确认持久化(fsync)之后,才能截断检查点之前的 WAL。如果检查点写入中途崩溃,你又提前删了 WAL,就既丢了不完整的检查点,又丢了恢复所需的日志。安全顺序是:写临时文件 → fsync → 原子重命名 → 才截断 WAL。
六、生产验证
- PostgreSQL — checkpointer.c#L218-L360 中
CheckpointerMain是检查点后台进程。循环等待检查点请求或checkpoint_timeout(默认 5 分钟),调用CreateCheckPoint将所有脏缓冲区刷写到磁盘,写入检查点 WAL 记录,更新pg_control。崩溃恢复时从最后的检查点开始重放 WAL。 - Redis — rdb.c#L1414-L1529 中
rdbSaveRio将整个 Redis 数据集序列化到 RDB 文件。Redis fork 一个子进程写入快照而不阻塞主线程。RDB 文件就是完整的检查点,结合 AOF 只需重放最后一次 RDB 之后的 AOF 条目。 - 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 检查点协议
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






