3.3 超时与重试:幂等与重试风暴

超时与重试是调用链上最容易被乱用的一对:超时给短了正常请求被杀,给长了线程池被拖死;重试加多了直接放大成风暴。1.1 验收表里的 R3「超时统一在调用侧截止」就靠这一篇落地。本篇的预算制思路与 go-micro-cps-guide 的调用链章节同源,差异在于我们把预算写进了 govkit 的启动校验,倒挂直接拒绝启动。

场景:超时给错的两种姿势

姿势 现象 后果
超时 30s(依赖服务端自保) 下游慢时调用方干等 线程池拖满,入口 502
超时 100ms(拍脑袋给短) 正常请求 P99 是 180ms 误杀 25% 正常调用
重试 3 次无退避 下游抖动时流量放大 4 倍 把抖动态直接打成挂

三种姿势我们都试过。正确解法:超时按「调用链预算」倒推,重试只在幂等接口上加退避,且总量受预算约束。

超时给短那次值得细说:给库存读接口拍了 100ms 的超时,没看压测数据。实测 P99 是 180ms、P999 到 420ms,误杀 25% 正常调用后,误杀本身又触发重试,库存流量放大 1.3 倍,雪上加霜。教训是超时值必须从 5.1 的压测 P999 倒推,加 30% 余量,不拍脑袋。

还有一个反面案例:有人把「服务端设 8s 超时兜底」当成了保护,结果调用方 30s 内都在等那个兜底,线程池拖满。原则只有一条:兜底必须在调用侧,服务端超时只是防悬挂事务的辅助,不是调用方的等待预算。

方案:调用链预算制

整条入口链路的预算 800ms(R3 口径),逐段分配:

段 预算 说明
网关 → 订单 800ms 入口截止,全链路总预算
订单 → 库存 300ms 库存查询走缓存,P99 实测 40ms
订单 → 支付 400ms 第三方网关波动大,留余量
订单 → 用户 100ms 纯缓存读

下游段的预算必须小于上游剩余预算,govkit 在 dial 时自动继承 ctx 剩余时间,不需要每个调用点手填。预算的余量分配原则:波动大的段(支付)留 P999 加 30%,波动小的段(用户缓存读)留 P99 加 10%,总预算 800ms 的分解与 k8s-pipeline-guide 的链路预算一节互相对过口径。

// 调用侧统一截止,ctx 带预算
ctx, cancel := context.WithTimeout(parent, 300*time.Millisecond)
defer cancel()
resp, err := invClient.Deduct(ctx, req)
// 超时后 ctx 取消,in-flight 立刻释放,不等服务端

超时触发后的行为要固定:调用侧收到 ctx deadline 后立刻返回 DEADLINE_EXCEEDED,该次调用计入 3.1 的熔断错误率,in-flight 连接不主动断(服务端可继续完成写操作,幂等键保证不重复),下一次调用从新实例或预算内重试。

重试规则表:

条件 是否重试 退避
超时加幂等键 重试 1 次 退避 100ms 起,翻倍
UNAVAILABLE 加幂等键 重试 2 次 100/300ms 加随机抖动
业务 err_code 非 0 不重试 直接走 3.2 兜底
非幂等接口 永不重试 走幂等键改造

重试总耗时必须小于本段剩余预算,预算内重试不完就停,宁兜底不超预算。重试次数本身打点 retry=n cause=deadline,4.4 检索时按 cause 聚合能看到重试的真实诱因分布——我们统计过,超时重试里 70% 最终是下游 200 回来的(慢但成),30% 是真失败,说明 100ms 的退避基本够下游消化慢请求。

幂等键:重试的地基

重试放大的前提是幂等。我们的幂等设计:调用方生成 order_id(雪花 id 加请求序号)传下游,服务端在 Redis 里查重 idem:{key} TTL 24h。写接口的幂等状态机是「首次写入 pending、成功置 done、重复请求返回首次结果」,防 pending 期间被重入。

幂等场景 去重键 TTL 说明
下单 order_id 24h 首次 pending,成功后 done
支付 pay_no 7d 与对账周期对齐
加分 user_id+batch 1d 批量幂等
库存扣减 order_id 24h 与下单共用键

幂等键覆盖率是硬指标:网关进来的写请求 100% 带幂等键才允许重试,没带的一律拒绝重试并打点。当前 17 个服务里 12 个写接口有幂等键,剩 5 个在对账链路里走批处理去重,不算缺口。

一个细节:幂等键的 TTL 要对齐下游的恢复时间。24h 的依据是下游故障最长恢复实测 18h(一次数据库主从切换),TTL 短于恢复时间就会出现「故障期间首次写入 pending 未 done,恢复后同键请求被放行重入」。TTL 宁长勿短。

踩坑:三起重试事故

第一起,重试风暴。库存接口 400ms 时,订单侧重试 2 次加退避 0,流量放大 3 倍,库存错误率 61% 顶穿熔断线,熔断反而更晚开。修法是退避起步 100ms 加随机抖动,放大系数压到 1.4 倍,抖动打散同时刻的重试尖峰。重试风暴的放大系数进 4.3 看板长期盯,超过 2 倍自动告警。

第二起,非幂等重试。早期对「加分」接口直接重试,一次网络超时后加分重复执行,客诉 2 起。复盘后把重试白名单收紧到幂等接口,非幂等接口要重试必须先补幂等键。白名单是硬编码在 govkit 的,接口加进白名单要在 PR 里附幂等测试,测试要覆盖「同键重复提交 100 次结果唯一」。

第三起,预算倒挂。某调用链网关预算 800ms,下游段配了 500 加 400 共 900ms,倒挂 100ms 导致入口总超时永远先于下游截止,下游的超时保护形同虚设。govkit 加了一条启动校验:下游预算之和必须小于上游剩余,倒挂直接拒绝启动。

还有一类隐蔽事故:超时后服务端继续执行,产生「客户端已放弃、服务端还在写」的悬挂事务。写接口必须支持取消感知:循环里每步检查 ctx.Done(),超时取消后服务端停止后续写操作、把 pending 态留在幂等键里,等客户端重试时补完。悬挂事务的排查靠 CLS 检索 ctx_cancelled but pending 的日志组合,4.4 篇给过具体语句。

预算与重试的联动纪律

预算制不是定了就完,有三条联动纪律防退化:

纪律 检查点 频率
预算值随压测更新 P999 变化超 20% 触发重算 每次 5.1 压测后
重试总耗时不超段预算 启动校验加运行打点 持续
预算倒挂拒绝启动 govkit 启动期断言 每次发布

还有一个容易漏的联动:熔断窗口(3.1)要大于重试退避总时长。窗口 5s、退避总时长约 400ms,比例 12 倍,足够熔断器在重试期内观察到错误率爬坡;若窗口配成 1s,重试还没打完熔断就开了,探测与重试互相干扰。这条联动写进 3.1 的参数速查表脚注。

预算数字本身也要版本化:每次 5.1 压测产出一份预算基线存 COS(gov/bench/{date}/budget.json),发版单里引用基线号。基线漂移(比如新硬件 P999 降了 30%)要主动重算预算,预算不随硬件更新就是白留了余量。

验收:R3 的实测回放

R3 验收的回放脚本:断下游网络 30s,观察调用侧行为。

指标 验收线 实测
入口 502 率 < 5% 2.1%(兜底接住)
线程池使用率 < 80% 74%
超时取消释放 1s 内 实测 0.4s
悬挂事务数 0 0

回放脚本每季度重跑一次,预算或 govkit 有大改动时加跑。指标进 4.3 看板做长期监控,某项漂移超验收线 20% 自动告警,防 R3 的纪律随迭代悄悄失效。

小结

三条纪律:超时按链路预算倒推(800/300/400/100)、重试只在幂等接口加退避(100ms 起翻倍)、预算倒挂启动即拒。幂等键是重试的地基,覆盖率进 checklist,TTL 对齐下游最长恢复时间。预算、重试、熔断窗口三者的比例关系写进脚注,预算基线随压测版本化存 COS,防参数之间互相打架。下一篇 3.4 上最后一道保命闸——限流,防的是流量本身。

延伸阅读