结论先行:超时不是「让请求成功的开关」,而是「让失败尽快返回的开关」;重试不是救场,而是容量放大器。这两句话是 2025-11-28 那次事故教给我的,代价是 82000 次重试、2300 笔重复订单、8100 条用户工单。下面按「现象 → 根因 → 做法 → 坑 → 清单」复盘一次真实事故。
一、现象:入口 6000 QPS 平稳,下游 14000 QPS 被打爆
先摆规模与数字。我们一个电商订单链路,入口网关 6000 QPS,下游 6 台 RS(Spring Boot 3.2.5 / JDK 17.0.10 / Nginx 1.24.0 / MySQL 8.0.35),K8s 1.28。
2025-11-28 14:32:11 → 14:39:47(约 7 分钟):
- RS 侧
gc.log记录 G1 Full GC 一次 1420ms(pause full 1420.7ms,concurrent-mark-abort后退化); - 入口网关的 QPS 曲线全程平稳在 6000 ± 5%,没有任何尖刺;
- 下游 MySQL 的
performance_schema.events_statements_history显示峰值 14000 QPS,平均放大系数约 1.3×; - 订单表
order_no唯一索引冲突日志 2300 条,客诉工单 8100 条; - 客户端侧(OkHttp 4.12)
CallTimeoutException计数 82000 次,全部集中在 7 分钟窗口内。
诡异的是:入口 QPS 没涨、应用 CPU 没涨、慢日志也没涨——按「常规三板斧」(看 CPU、看 GC、看慢 SQL)根本定位不到。最后是靠把 5 层日志的 traceId 全串起来,才发现下游的 14000 QPS 里有 82000 次是重试流量,占入口 6000 QPS × 420s = 2520000 次正常请求之外。
二、根因:超时预算按 P99 配,P99 和 P99.9 之间那 0.1% 被重试吃掉了
把 5 层的超时配置拉出来对表,问题一眼就出来了:
| 层 | 超时 | 配的依据 |
|---|---|---|
| 客户端 OkHttp | 1000ms | 拍脑袋 |
| 网关 Nginx | 800ms | 拍脑袋 |
| RS 业务层 | 500ms | 拍脑袋 |
| RS → DB(HikariCP connectionTimeout + socketTimeout) | 200ms | 「P99 是 180ms,留点余量」 |
三处致命错配:
1)DB 超时 200ms < 实测 P99.9(320ms)。 平时就有 0.1% 的请求(约 6 QPS)会超 200ms 触发 RS 层重试。这是常态,不是事故时才有——平时我们没注意到,是因为 6 QPS 的重试流量淹没在 6000 QPS 里根本看不见。
2)链路超时之和 1000 + 800 + 500 + 200 = 2500ms > 客户端 1000ms。 这是最反直觉的:上游超时比下游还短,意味着下游还在正常处理时,上游已经判定失败并发起重试。重试流量在 RS 侧堆积,RS 侧处理不过来又触发更上层重试——正反馈。
3)重试策略没带幂等键。 订单创建接口(写)被重试 2300 次,因为 order_no 由客户端生成、服务端用唯一索引兜底才没全部重复落库——唯一索引是这次事故的最后一道保险丝,不是幂等设计。
为什么 GC 那 1.4s 会引爆? 正常时 0.1% 的超时重试是「背景噪音」;GC 1.4s 期间,RS 侧所有 in-flight 请求全部超过 500ms 超时,0.1% 瞬间变成 100%,叠加「链路超时之和 > 客户端预算」的正反馈,7 分钟内 82000 次重试把下游打到 14000 QPS。
三、做法:超时预算从 P99.9 倒推,链路超时之和 ≤ 最外层预算
核心公式:每层超时 = 下游 P99.9 × 1.5(50% 余量),且链路超时之和 ≤ 客户端超时。
按这个公式重排(基于 P99.9 = 320ms 的实测):
客户端 OkHttp: 3000ms (P99.9 × 1.5 × 跳数余量)
网关 Nginx: 1600ms (proxy_read_timeout)
RS 业务层: 1000ms (Future.get 超时)
RS → DB: 480ms (320 × 1.5)
校验:1600 + 1000 + 480 = 3080ms ≈ 客户端 3000ms ✓(留 80ms 余量给序列化与调度)。
具体配置(可直接抄):
Nginx:
upstream order_rs { server 10.20.4.11:8080; server 10.20.4.12:8080; }
location /order {
proxy_pass http://order_rs;
proxy_connect_timeout 200ms; # 建连要短
proxy_read_timeout 1600ms; # 按 P99.9 × 1.5
proxy_send_timeout 1600ms;
# 关键:关掉默认重试,避免 Nginx 把 POST 重放到下一台 RS
proxy_next_upstream off;
}
OkHttp(客户端):
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(200, TimeUnit.MILLISECONDS)
.readTimeout(3000, TimeUnit.MILLISECONDS) // 最外层预算
.writeTimeout(1000, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(false) // 关键:关掉自动重试
.build();
HikariCP(RS → DB):
spring:
datasource:
hikari:
connection-timeout: 480 # 320 × 1.5
validation-timeout: 200
socket-timeout: 480 # 单位 ms,走 Hikari 原生属性
重试策略表(写进 API 契约,上线前上下游对齐):
| 层 | 重试次数 | 退避 | 触发条件 | |---|---|---|---| | 客户端 | 0 | — | 不重试(避免把弱网抖动放大) | | 网关 | 1 | 500ms + 全抖动 | 仅 503 / 连接失败,不含超时 | | RS 业务层 | 0 | — | 不重试,交给网关 | | RS → DB | 0 | — | 不重试,靠 DB 自身重做 |
全链路只允许一处重试(网关),且只对可重试错误重试(503、连接失败),超时不重试——因为超时可能是下游正在处理,重试等于双写。
四、踩过的坑(反直觉之处)
-
「超时配得越紧,失败率反而越高」。P99 和 P99.9 之间是真实的延迟分布(GC、锁竞争、网络抖动),不是噪声。把超时卡在 P99 附近,等于把 0.1% 的尾部流量全部转成重试——平时是 6 QPS 背景噪音,事故时被重试策略放大 1000 倍。超时的真正作用是「让失败尽快返回」,不是「让请求都成功」。真正要成功,靠的是扩容和降级,不是重试。
-
「Nginx 默认会重试 POST」。
proxy_next_upstream默认值是error timeout,意味着 RS 超时或 5xx 时 Nginx 会把同一个 POST 请求重放到下一台 RS。我们这次 2300 笔重复订单里,有约 1400 笔是 Nginx 重放造成的,不是客户端重试。proxy_next_upstream off;一行配置就能避免,但大多数团队的 Nginx 配置从没改过这个默认值。 -
「看入口 QPS 完全正常,以为下游没问题」。重试流量是下游入口的尖刺,不是上游入口的尖刺。这次事故里,入口 6000 QPS 平稳 7 分钟,下游 14000 QPS 尖刺 7 分钟——只看入口监控的人,会得出「下游扛住了」的错误结论。监控必须每层独立看 5xx 与 P99.9,不能只看入口。
-
「唯一索引不是幂等」。这次
order_no唯一索引挡住了 2300 笔重复落库,但客户端收到的是「插入失败」错误,不是「订单已创建」的成功响应——用户体验是「点了两次才成功」。真正的幂等是服务端用Idempotency-Key去重并返回首次结果,不是靠唯一索引兜底。
五、结论与可复用清单
一句话总结:超时预算按 P99 配,等于把 0.1% 的尾部流量常态化转成重试;链路超时之和超过客户端预算,等于给重试正反馈开了绿灯。GC 那 1.4s 只是引信,火药是超时与重试的错配。
可复用清单(建议贴到团队 wiki):
- [ ] 每层超时 = 下游 P99.9 × 1.5(不是 P99)
- [ ] 链路超时之和 ≤ 最外层客户端超时(留 10% 余量)
- [ ] 全链路只允许一处重试(建议放网关),写接口重试必须带
Idempotency-Key - [ ] 超时不重试(503 / 连接失败才重试),避免双写
- [ ] Nginx
proxy_next_upstream off;(至少对 POST) - [ ] OkHttp
retryOnConnectionFailure(false)(重试交给网关统一管) - [ ] Grafana 每层独立看 5xx 比例 与 P99.9 / P99 比值(比值 > 2 说明尾部在恶化)
- [ ] 压测时主动注入 1.5s 延迟(
tc netem或 ChaosBlade),验证重试放大效应 - [ ] 用一条命令快速核对超时预算:
把三组数字加起来,和客户端超时比一下——超过就是雷。grep -E 'proxy_(read|connect|send)_timeout' /etc/nginx/conf.d/order.conf grep -E 'connectionTimeout|socketTimeout' application.yml grep -E 'readTimeout|connectTimeout' src/main/java/**/HttpClientConfig.java
最后一句:超时不是 bug,是「失败预算」;重试不是救场,是「容量放大器」。把这两件事当容量规划来做,而不是当配置项来调,事故就会少一半。