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 上最后一道保命闸——限流,防的是流量本身。
延伸阅读
- 超时预算制的更多实践,见 go-micro-cps-guide。
- 重试事件进日志检索,见 rust-logpipe-guide。
- 链路预算与压测的衔接,见 k8s-pipeline-guide。
