别在超时后盲重试:用 Go 写一个可恢复的非幂等 POST 账本
后台任务服务最危险的时刻,往往不是接口明确返回失败,而是请求已经发出、进程却不知道远端是否写入成功。特别是第三方 POST 接口没有幂等键、也不能与本地数据库组成事务时,“超时后重试”可能创建两份记录,“永不重试”又可能漏掉真实失败。
我用 Go 标准库写了一个可运行的最小账本原型,重点不是包装 HTTP 请求,而是把不确定性显式持久化。
一、状态机先于重试逻辑
原型使用六个状态:
PENDING -> SENDING -> SUCCEEDED
-> SENT_UNKNOWN -> SUCCEEDED
-> MANUAL_REVIEW
PENDING:任务已入库,尚未发送。SENDING:当前 worker 已取得执行权。SENT_UNKNOWN:请求可能已到达远端,但没有可靠响应。SUCCEEDED:已确认远端记录及 ID。MANUAL_REVIEW:查询到多个候选或证据矛盾。
必须强调:网络超时不是业务失败。因此代码不会把超时直接映射成 FAILED,而是进入 SENT_UNKNOWN,恢复时先查询远端。
二、发送前先记账
账本在发送前完成两次持久化:先创建 PENDING,再转为 SENDING。写入采用临时文件加重命名,并以 0600 权限保存,避免进程崩溃后留下半写 JSON。
每次尝试都记录:
task_id:本地任务唯一标识;fingerprint:请求业务指纹;- 状态、尝试次数、远端 ID;
- 最后错误与更新时间。
这些字段让恢复进程知道“请求是否可能已经发出”,而不是仅凭 HTTP 客户端返回 context deadline exceeded 就再次发送。
三、恢复只查询,不盲目重发
核心恢复逻辑如下:
for _, entry := range ledger.UnknownEntries() {
matches, _ := remote.FindByFingerprint(ctx, entry.Fingerprint)
switch len(matches) {
case 1:
ledger.MarkSucceeded(entry.TaskID, matches[0].ID)
case 0:
// 不把“暂时查不到”当成可安全重试的证明
continue
default:
ledger.MarkManual(entry.TaskID, "multiple matches")
}
}
这里故意保留了一个保守分支:远端暂时查不到时,任务继续停留在 SENT_UNKNOWN。因为第三方查询接口可能存在最终一致性延迟,零结果不能自动证明写入没有发生。生产系统应在此基础上设置观察窗口、分页核对规则和人工复核阈值。
四、实际运行结果
代码只用 Go 标准库,go test -v ./... 验证了三个关键场景:
TestRecoverWithoutResending PASS
TestUnknownWithoutRemoteMatchStaysUnknown PASS
TestMultipleRemoteMatchesRequireManualReview PASS
演示程序模拟“远端已经提交记录,但客户端收到结果不明错误”:
status=SUCCEEDED remote_id=remote-demo-1 create_calls=1 remote_records=1
结果说明:重启后通过账本恢复状态并查询远端,Create 只调用一次,远端只有一条记录,没有因为恢复而重复创建。
五、这套方案能保证什么
本地账本能保证:
- 每次发送意图有持久化记录;
- 恢复时先核对而不是盲目重试;
- 多条候选会进入人工复核;
- 进程重启后仍保留“结果不明”这一事实。
但如果第三方没有幂等键,也没有可靠的唯一业务查询能力,本地账本不能单方面保证远端恰好写入一次。系统只能在以下边界中选择:
- 为安全起见不自动重发未知请求;
- 接受可能重复,并在查询到多条记录时进入人工处理;
- 推动第三方补充幂等键或唯一业务标识。
真正可靠的方案,不是给重试加一个计数器,而是把未知结果当成一等状态:先持久化意图,再发送;恢复时先查询,证据不足就停下来。这样即使无法在所有外部条件下实现“恰好一次”,也能避免系统在不知情的情况下继续制造重复数据。
- 点赞
- 收藏
- 关注作者
评论(0)