mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1453 字
4 分钟
检查点(Checkpointing)
2026-06-13

一、为什么需要检查点#

你的系统用了预写日志(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)检查点截断 WALWAL 保证持久性,检查点限制恢复时间
写时复制(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 执行操作并记录到 WAL
func (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。

六、生产验证#

  • PostgreSQLcheckpointer.c#L218-L360CheckpointerMain 是检查点后台进程。循环等待检查点请求或 checkpoint_timeout(默认 5 分钟),调用 CreateCheckPoint 将所有脏缓冲区刷写到磁盘,写入检查点 WAL 记录,更新 pg_control。崩溃恢复时从最后的检查点开始重放 WAL。
  • Redisrdb.c#L1414-L1529rdbSaveRio 将整个 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)

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

检查点(Checkpointing)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-checkpointing/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时