为什么超时、重试和幂等要一起设计?
简化版
超时只说明调用方没按时收到结果,不代表服务端没有执行;重试可能提高瞬时故障下的成功率,也可能重复副作用并放大过载。因此只有在操作可幂等、错误可重试、剩余时间充足且有重试预算时才应重试,并为未知结果提供查询能力。
详细版
这三者是一条完整故障语义:超时把结果变成“未知”,重试决定是否再次尝试,幂等保证多次尝试只产生一次业务效果。缺少任一环都会出问题:无超时会长期占资源;无重试会放弃可恢复故障;无幂等会重复扣款;无预算的多层重试会制造流量风暴。
超时应基于端到端 deadline 向下分配,而非每层固定相同值。重试只适合连接失败、限流或部分 5xx 等暂时性错误,不应重试参数错误和明确业务拒绝;采用指数退避、抖动、次数上限,并把全链路额外请求控制在预算内。非只读操作应携带稳定业务幂等键,服务端原子保存状态与结果。
若首次执行可能成功但响应丢失,调用方应使用同一幂等键重试或查询,而不是创建新请求。服务端长任务还应响应取消信号,避免调用方已放弃后仍浪费资源,但取消也不能被当作事务回滚证明。
完整版教学
一、超时产生的是未知状态
请求经过客户端、网关、服务和数据库,任一段延迟都可能让调用方超时。服务端可能尚未收到,也可能已提交事务但响应在返回途中丢失。调用方只能确定“截止时间前没有观察到成功响应”。
支付请求超时后若直接显示失败并生成新支付单,旧请求稍后成功就可能重复支付。正确交互通常显示处理中,并按业务订单号查询或用同一幂等键重试。
记忆钩子:超时不是失败判决,而是观测不完整;先保护重复执行,再决定是否重试。
二、超时应服从端到端 deadline
每层都配置 1 秒超时,会让三层串行调用最坏持续约 3 秒,违反入口 1.5 秒目标。入口应携带绝对 deadline 或剩余预算,下游从剩余时间中预留返回和清理时间,再设置更短的子调用超时。
入口预算 1000ms
网关处理 50ms,传给 A 剩余约 950ms
A 本地预留 100ms,调用 B 最多 850ms
B 访问数据库预留返回 100ms,查询最多 750ms
超时太短会误杀正常长尾并增加重试,太长会占用线程和连接。应参考延迟分位数、业务 deadline 和资源上限校准,而非复制默认值。
三、哪些错误值得重试
重试适合短暂且下一次可能成功的故障,如连接建立失败、服务返回明确限流并给出等待时间、leader 切换。参数非法、余额不足、权限拒绝等确定性结果重复再多也不会改变。写操作若不幂等,即使错误暂时也不能盲重试。
| 结果 | 通常是否重试 | 处理方式 |
|---|---|---|
| 连接前失败 | 可 | 退避后换健康节点 |
| 429/过载 | 谨慎 | 尊重 Retry-After 和预算 |
| 503/leader 切换 | 可 | 少量、带抖动重试 |
| 参数校验失败 | 否 | 修正请求 |
| 明确业务拒绝 | 否 | 返回业务结果 |
| 超时且写结果未知 | 先查询/同键重试 | 必须有幂等保护 |
是否重试还要看剩余 deadline;只剩 20ms 时发一个预计 100ms 的请求没有意义。
四、重试怎样放大流量
若原始流量 1000 QPS,20% 请求失败,每个最多额外重试 2 次,粗略可能新增 1000 × 20% × 2 = 400 QPS。多个层级各自重试时,最坏尝试次数相乘:客户端 3 次、网关 3 次、服务 3 次,底层可能面对 27 次尝试。
应只在最了解操作语义的一层重试,或明确各层预算。指数退避加随机抖动能避免所有客户端同步醒来:
delay(n) = min(2s, 100ms × 2^n) + random(0, 100ms)
额外重试流量预算 <= 正常流量的 5%
断路器和并发上限负责在下游持续故障时停止尝试,避免重试抵消背压。
五、幂等如何把多次尝试变成一次效果
调用方为一次业务意图生成稳定键,例如 merchantId + orderNo,所有网络尝试复用它。服务端通过唯一约束原子抢占,将请求参数摘要、处理状态和结果一起保存;重复请求参数相同则返回原结果,不同则报冲突。
业务写和幂等结果应尽量在同一本地事务中提交。若先扣款后写幂等记录,进程恰好崩溃会让重试再次扣款。跨外部系统时,需要用对方业务号查询、状态机接管或 outbox,不能靠内存锁。
INSERT INTO payment_request(idem_key, request_hash, status)
VALUES (:key, :hash, 'PROCESSING'); -- idem_key UNIQUE
-- 同一事务完成账务写入并把状态更新为 SUCCEEDED
六、取消与资源释放
调用方 deadline 到期后应尽可能向下游传播取消,避免已经没人等待的查询继续占数据库连接。但取消通常是尽力而为:请求可能在取消到达前已经提交,数据库事务也未必能安全中断。因此取消不能替代幂等和结果查询。
服务端应区分“客户端不再等待”和“业务动作必须撤销”。前者释放计算资源,后者需要业务补偿或事务语义。长任务可按阶段检查取消标记,并在安全点停止。
监控孤儿请求,即入口已结束但下游仍运行的工作量,可以发现 deadline 未传播的问题。
七、如何测试这套组合
测试重点是提交边界:请求到达前断网、事务提交前崩溃、提交后响应丢失、客户端超时后取消、重试同时到达。每种情况下都验证最终业务效果不超过一次,并且调用方能查到明确状态。
指标包括超时率、各原因重试数、重试成功率、重试放大系数、幂等命中、未知结果停留时间和过期后仍执行的请求。重试成功率低且放大系数高,说明它在制造负担而非提高可用性。
timeout_rate: 0.8%
retry_amplification: 1.04
retry_success_rate: 38%
idempotency_hits: 1260
unknown_result_over_1m: 0
orphan_work_seconds: 12
八、常见误区与追问
- 误区:超时就代表请求失败。 服务端可能已经提交,只是响应未及时返回。
- 误区:多重重试能显著提高成功率。 多层重试次数相乘,过载时会加速雪崩。
- 误区:POST 不能幂等。 HTTP 方法不决定业务效果,POST 可以通过业务幂等键做到幂等。
- 误区:调用取消等于服务端回滚。 取消可能迟到或不被底层支持,已提交副作用不会自动撤销。
- 追问:重试放在哪一层最好? 放在最理解业务语义、能判断幂等且掌握剩余 deadline 的层,避免层层重试。
- 追问:幂等记录 TTL 怎么定? 至少覆盖最大重试、消息保留和人工重放窗口,并考虑业务结果本身的唯一约束。
- 追问:如何处理长期 PROCESSING? 查询真实业务结果或外部系统,再由状态机接管;未经确认不能直接删除重做。
九、加强记忆
用“超时未知、重试有价、幂等兜底、预算贯穿”串起这道题。deadline 限制整条链路的时间和资源,错误分类决定是否值得再试,退避与预算阻止流量放大,稳定幂等键保证多次尝试只有一次业务效果。对于无法确认的写请求,查询最终状态比创建新请求更安全。