Hotdry.

Article

从零实现 Durable Execution:手写 Checkpoint、幂等重放与故障恢复机制

深入解析 Hatchet 的 durable execution 核心原语,从 checkpoint 设计、幂等重放到故障恢复,提供可落地的手写实现参数与工程 checklist。

2026-05-28systems

传统任务队列(如 Celery、BullMQ)在 Worker 崩溃时往往面临两难:要么接受任务丢失,要么依赖业务层实现幂等 —— 后者在涉及外部 API 调用、邮件发送或状态变更时几乎不可能做到完全无副作用。Durable execution 的核心价值正在于此:通过持久化执行状态,让任务具备「断点续传」能力,而非简单重试。

Hatchet 作为新兴的工作流编排引擎,其设计哲学值得拆解。本文不讨论如何调用 Hatchet SDK,而是聚焦其背后的 durable execution 原语 ——checkpoint、replay、idempotency—— 并给出从零手写这些机制的技术路径。

核心原语:Checkpoint 与 Event Log

Durable execution 的基石是确定性假设:任务在两次 checkpoint 之间的代码路径必须完全一致。Hatchet 规定,durable task 只能执行两类操作 ——等待(sleep、event、child task 完成)或派生(spawn child task)。每次等待完成或子任务派生,系统便写入一条 checkpoint 到 durable event log。

这一设计的精妙之处在于执行与持久化的解耦。当任务进入等待状态,Hatchet 可以将其从 Worker 上驱逐(evict),释放 slot 给其他任务;待条件满足后,系统从 event log 恢复状态、重放(replay)至最后一个 checkpoint,任务仿佛从未中断。

手写实现时,checkpoint 结构应包含:

  • checkpoint_id:单调递增的序列号,用于重放排序
  • timestamp:物理时间戳,仅用于观测,不参与确定性判断
  • input_hash:触发此次 checkpoint 的输入摘要
  • output_payload:checkpoint 产出结果,作为后续代码的决策依据
  • child_task_refs:派生的子任务 ID 列表,用于重建依赖关系

Event log 推荐采用追加写模式存储于 PostgreSQL 或专用日志系统(如 Kafka),避免原地更新带来的并发复杂度。

幂等重放:Exactly-Once 的工程实现

Checkpoint 解决的是「从哪里恢复」,幂等解决的是「恢复后会不会重复执行副作用」。Hatchet 的 exactly-once 语义依赖两层机制:

第一层是框架层重放。已完成的 checkpoint 在重放时直接读取 output_payload,跳过实际执行。这意味着只要 checkpoint 写入成功,即使后续代码崩溃,也不会重复执行已持久化的逻辑。

第二层是子任务幂等键。每个子任务派生时分配全局唯一的 idempotency key(通常由 workflow_id + step_index 构成)。下游系统收到请求时,先查询该 key 是否已处理,若已存在则直接返回缓存结果。

手写实现时的关键参数:

  • Idempotency key 生成策略{workflow_id}:{step_index}:{input_hash},确保同一逻辑步骤在重放时生成相同 key
  • Key 存储 TTL:建议设置为任务超时时间的 2-3 倍,既防内存膨胀,又留足重放窗口
  • 幂等表结构(idempotency_key, status, output, created_at, expires_at),status 区分 PENDING/COMPLETED/FAILED
  • 去重边界:框架层保证「同一 checkpoint 不重复执行」,业务层保证「同一副作用不重复触发」,两者缺一不可

故障恢复:Eviction 与 State Reconstruction

Worker 资源有限,durable execution 必须支持有状态驱逐。Hatchet 的实现逻辑是:

  1. 任务调用 wait_for_event()sleep() 时,Worker 将当前执行上下文序列化,连同 checkpoint 一并持久化
  2. 任务从 Worker 移除,slot 释放
  3. 事件到达或超时触发后,调度器重新分配 Worker,从 event log 重建执行上下文
  4. 代码从 checkpoint 处继续执行,仿佛从未中断

手写实现时需注意的陷阱:

非确定性代码。若两次 checkpoint 之间读取了外部数据库、随机数或 wall-clock time,重放时可能产生不同分支。解决方案是将所有决策依赖项纳入 checkpoint:代码应从 context.checkpoint_outputs 读取历史结果,而非重新查询外部状态。

副作用外泄。发送邮件、扣减库存等操作应封装为子任务,由框架统一调度并持久化其结果。父任务的 durable code 只负责编排决策,不直接触发副作用。

Checkpoint 顺序变更。生产环境一旦建立 checkpoint 序列,不可随意增删或调整顺序,否则历史 workflow 的重放将产生状态分歧。如需变更,应通过版本号隔离新旧 workflow 定义。

手写 Durable Execution:最小可行实现

若要在现有系统上叠加 durable execution 能力,可按以下步骤推进:

阶段一:Event Log 基础设施

  • 选定存储:PostgreSQL(推荐,支持 ACID 事务)或 Kafka(高吞吐场景)
  • 表结构:event_log(id, workflow_id, checkpoint_seq, type, payload, created_at)
  • 索引:(workflow_id, checkpoint_seq) 唯一约束,确保顺序一致性

阶段二:Checkpoint 机制

  • 在业务代码中显式插入 checkpoint() 调用点
  • Checkpoint 函数内部:序列化当前上下文,写入 event log,返回 checkpoint ID
  • 异常处理:写入失败立即终止任务,依赖数据库事务保证原子性

阶段三:Replay 逻辑

  • 任务启动时查询 SELECT * FROM event_log WHERE workflow_id = ? ORDER BY checkpoint_seq
  • 遍历 event log,跳过 type = 'COMPLETED' 的记录,直接恢复 output
  • 遇到最后一个 incomplete checkpoint,从该点恢复执行

阶段四:幂等子任务

  • 子任务调用前生成 idempotency key
  • 先查询幂等表,若已存在 COMPLETED 记录,直接返回缓存结果
  • 执行子任务,成功后写入幂等表,失败进入重试队列

阶段五:Worker 驱逐

  • 任务进入等待状态时,标记为 EVICTABLE
  • 调度器定期扫描,将 EVICTABLE 任务从 Worker 移除,保留内存中的 event log 索引
  • 等待条件触发后,重新调度并执行 replay 流程

可落地 Checklist

在 production 引入 durable execution 前,建议逐项确认:

  • 所有 durable code 路径已审计,无非确定性操作(随机数、时间函数、外部查询)
  • 副作用操作已下沉至子任务,父任务仅做编排
  • Checkpoint 粒度合理:既不过细(IO 开销大),也不过粗(重放代价高)
  • Idempotency key 生成策略已文档化,确保跨重放一致性
  • 幂等表已配置 TTL 和定期清理任务
  • Event log 存储已评估容量规划,避免单点瓶颈
  • Worker 驱逐策略已测试:模拟 Worker 崩溃,验证 replay 正确性
  • 监控告警已配置:checkpoint 写入延迟、event log 堆积、重放耗时
  • Workflow 版本管理策略已确定:checkpoint 序列变更需走版本隔离

结语

Durable execution 不是简单的「自动重试」,而是通过 checkpoint 与 event log 将执行状态外化,使任务具备跨越进程生命周期的连续性。Hatchet 的设计展示了这一模式在现代工作流编排中的工程价值:以 Postgres 为持久层,以确定性代码为约束,以幂等子任务为边界,构建出既可靠又可扩展的异步执行系统。

理解这些原语后,即使不采用 Hatchet,也能在现有架构中逐步叠加 durable execution 能力 —— 从手写 checkpoint 开始,到完整的 replay 与故障恢复,每一步都有明确的工程落点。


资料来源

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com