传统任务队列(如 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 的实现逻辑是:
- 任务调用
wait_for_event()或sleep()时,Worker 将当前执行上下文序列化,连同 checkpoint 一并持久化 - 任务从 Worker 移除,slot 释放
- 事件到达或超时触发后,调度器重新分配 Worker,从 event log 重建执行上下文
- 代码从 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 与故障恢复,每一步都有明确的工程落点。
资料来源
- Hatchet Documentation: Durable Execution, Durable Tasks
- GitHub: hatchet-dev/hatchet
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。