结论先行
超时和重试不是「加个配置就完事」的运维项,而是容量规划的一部分。我见过三次线上事故,根因都不是 bug,而是超时和重试配错了:
- 上游 200ms 超时,下游 P99 是 180ms —— 平时没事,下游一次 GC 抖到 300ms,上游全超时,雪崩;
- 客户端 3 次重试 + 退避 1s,下游恰好慢,重试流量把下游彻底打挂;
- 写接口被重试,订单重复扣款。
下面把这三类坑拆开讲,每段都给具体数字和做法。
一、超时:先定 SLO,再倒推每一跳
错误做法:每层都拍脑袋写 5s / 10s / 30s,结果整条链路加起来 60s,上游调用方早就放弃了。
正确顺序:
- 先定对外 SLO(比如 P99 < 800ms);
- 减去本层自身处理(业务逻辑 + 序列化 + 框架开销),我一般按 P99 估 50–100ms;
- 剩下的是给下游的预算,再按跳数拆;
- 每一跳的超时 < 上游剩余预算,留 10–20% 余量。
一个 3 跳链路的例子:对外 P99 800ms → 本层 100ms → 给下游 700ms;下游 A P99 400ms、下游 B P99 250ms,A 的超时设 350ms,B 设 220ms。这样任何一跳挂掉,上游都能在预算内拿到失败信号,去做降级。
踩坑:超时只配在 HTTP 客户端上,没配在 socket 层。连接池里那些「半死连接」第一次握手能成功,但读数据时卡住,最终卡满整个请求线程池。做法:connect timeout(短,200–500ms)和 read timeout(按业务预算)分开配,且都要配。
二、重试:默认「不重试」,重试要带条件
默认原则:写接口不重试,读接口可重试。原因很简单——重试对读是幂等的(多查一次),对写是灾难(多写一次)。
必须带条件的重试:
- 只对可重试错误重试:连接失败、超时、5xx(且是 503);对 4xx、业务错误、空结果不要重试;
- 指数退避 + 抖动:
delay = base * 2^n + random(0, jitter),base 建议 200–500ms,jitter 建议 100–300ms。不加抖动的重试会在同一时刻集体打到下游,等于把「一次慢」放大成「一次洪峰」; - 重试次数 ≤ 2(不含首次):3 次以上基本是「用重试掩盖容量问题」,应该去扩容或降级;
- 重试要消耗 token:用限流器或信号量把重试流量纳入限流,防止重试流把正常流挤掉。
踩坑:客户端 3 次重试 × 中间网关 2 次重试 × 下游再 1 次,实际放大 12 倍。排查时只看入口 QPS 完全看不出来,必须看下游入口 QPS。做法:全链路只允许一处做重试,其他层要么透传、要么不重试,且要在 API 文档里写清楚。
三、幂等:重试的「保险丝」
只要允许重试写接口,就必须做幂等。三种常见方案,按成本从低到高:
- 业务唯一键 + 唯一索引:最便宜,适合订单、转账这类天然有唯一键的场景。重复写入时靠 DB 报错,返回「已存在」;
- 幂等键 + Redis SETNX:客户端生成 UUID 或业务 ID,服务端先 SETNX
idem:{key} EX 3600,成功才执行,失败直接返回上次结果。适合通用接口,TTL 按业务窗口设(订单 24h、支付 7 天); - 状态机:订单从 CREATED → PAID → SHIPPED,重复的 PAID 请求直接幂等返回。最稳,但需要业务模型支持。
踩坑:用 if (exists) return cached; else { insert; } 这种「先查后写」做幂等——并发下两个请求同时查都返回 false,两个都写。必须用 DB 唯一索引或 Redis SETNX 的原子性来兜底。
四、一个可以直接抄的默认配置
Java / Spring 栈,假设对外 P99 800ms:
connect timeout: 300 ms
read timeout (1 跳): 350 ms
read timeout (2 跳): 250 ms
重试: 写=0 次, 读=1 次
退避: base=300ms, jitter=200ms, 指数
限流: 全局令牌桶 1.2× 峰值, 重试共享令牌
幂等: 写接口一律带 idem key, Redis SETNX TTL=业务窗口
最后一条经验:超时和重试的数字,必须和下游一起对齐,单独调一边没有意义。我每次上线前都会把这条链路的超时 / 重试 / 退避表贴在群里,让上下游各认一遍——比任何监控都管用,因为监控只能告诉你「挂了」,这张表能告诉你「为什么会挂」。