分布式任务幂等键:重试安全要从协议开始设计
分布式任务幂等键:重试安全要从协议开始设计
一、重试不是免费可靠性
分布式任务系统里,网络超时、节点故障、队列重投、客户端重试都很常见。重试可以提升成功率,但如果任务不是幂等的,就可能重复扣款、重复发券、重复写文件、重复触发模型训练。
重试安全不能靠“应该不会重复”。协议层必须提供幂等键。
二、幂等键要进入请求模型
flowchart TD A[客户端请求] --> B[携带幂等键] B --> C[服务端查重] C --> D{是否已执行} D -->|否| E[执行任务] D -->|是| F[返回历史结果]幂等键应由业务语义决定,而不是服务端随机生成。比如同一次订单支付、同一次导出任务、同一次模型训练提交,都应该有稳定 key。
{ "idempotency_key": "tenantA:export:20260704:report42", "payload_hash": "sha256:abc123", "request": {} }同时保存 payload hash,可以防止同一个 key 被不同参数复用。
三、状态机要覆盖中间态
enum TaskState { Pending, Running, Succeeded, FailedRetryable, FailedFinal, }幂等表不能只记录成功结果。任务执行中、可重试失败、最终失败都要有状态。客户端重试时,服务端根据状态返回已有结果、提示稍后查询,或允许重新调度。
如果任务已经 Running,再次收到同 key 请求,不应启动第二个任务。可以返回任务 ID,让客户端订阅原任务进度。
任务状态机的幂等处理还需覆盖"部分完成"的边界。以模型训练任务为例:任务标记为 Succeeded,但实际只有前 3 个 epoch 持久化、第 4 个 epoch 的 checkpoint 写入失败后任务仍被标记为成功。此时重试应从中断点恢复而非从头开始。这就要求幂等记录不仅保存最终状态枚举值,还要保存"进度 cursor"——可以是 epoch 数、已处理行数、已写入分片序号等。在 Rust 中,cursor 可用serde_json::Value或泛型枚举存储,状态机在不同阶段更新 cursor,重试时根据 cursor 决定跳过还是继续。另一个边缘场景是幂等键碰撞:若客户端错误复用之前的 key 但 payload 已变,payload_hash 校验会发现不匹配,此时应返回"幂等键冲突"错误而非静默返回历史结果,避免旧结果被当成新任务的正确输出。
四、幂等记录要和业务写入原子化
最难的是原子性:业务结果写成功了,但幂等记录没写;或者幂等记录写了,业务没执行。两者不一致,重试就会出错。
idempotency_storage: store_payload_hash: true store_result_reference: true use_transaction_when_possible: true ttl_days: 7如果业务数据库支持事务,幂等记录和业务写入应放在同一事务里。如果跨系统,就需要 outbox、事务消息或补偿机制。不能简单地先写缓存再执行任务。
幂等记录还要设置合理 TTL。永久保存成本高,太快过期又会让迟到重试重新执行。TTL 应根据客户端重试窗口、队列最大延迟和业务风险确定。
最后,幂等键要进入日志和 Trace。排查重复执行时,第一时间应该能按 key 找到所有请求、任务状态和返回结果。没有可观测性,幂等问题会非常难查。
幂等键还要防止冲突和滥用。客户端传来的 key 不能无限长,不能跨租户复用,也不能跳过 payload hash 校验。服务端应把租户、业务类型和 key 一起作为唯一约束,避免不同业务碰撞。
create unique index uk_idempotency on idempotency_record(tenant_id, task_type, idempotency_key);清理历史记录时,也要保留最终结果的可追溯性。可以把完整记录过期删除,但把任务 ID、最终状态和摘要保留更久。这样既控制存储成本,又不至于让售后或审计完全查不到。
对于高风险任务,幂等键生成规则应该由服务端 SDK 提供,减少业务方手写 key 的机会。手写规则越多,越容易出现同一次操作生成多个 key,幂等保护就失效了。
五、总结
分布式任务幂等键要进入协议、状态机、存储事务和可观测链路。
重试安全不是客户端多发几次就能得到的可靠性。幂等从请求模型开始设计,系统才敢自动重试。
