背景
最近复盘一次 P99 抖动(订单聚合接口,Spring Boot + HikariCP + MySQL,一次请求串行/并行 5~7 条 DB 查询 + 2 个 RPC),最后定位到根因不是任何单点慢,而是「超时预算」被吃干后触发的重试风暴把下游打雪崩:
- 上游网关给业务方 800ms 超时;
- 业务方给下游 RPC 各给 150ms 超时,5 个下游并发,理论上限 750ms;
- 但其中 1 个下游 P99 抖到 160ms,业务方按「单段超时」重试 1 次,又花了 150ms;
- 这一轮重试期间,其余 4 个下游的连接还在占用连接池(HikariCP active 打满),后续请求 pending 排队;
- 最终 800ms 预算在「第 1 段超时 + 重试 + 连接池排队」三段叠加下被打穿,且重试本身没有携带端到端剩余预算,下游看到的是「全新请求」。
我习惯从现象倒推根因,这次最让我纠结的不是「谁慢」,而是超时预算到底应该归谁管——是每段独立(简单、易配),还是端到端统一扣减(更严谨、更难落地)。
常见分歧 / 几种做法
- 每段独立超时:每个下游独立配 150ms,互不感知。优点是配置简单、故障隔离清晰;缺点是 N 段叠加后总耗时不可控,且重试会「重新开一轮」。
- 端到端统一预算 + 逐段扣减:入口分配 800ms,每发起一段就扣掉「已用 + 已预留」,剩余不足则快速失败。优点是总耗时可控;缺点是需要在调用链每一层透传 deadline(gRPC 有 deadline propagation,HTTP/1.1 没有标准头,得自己加 X-Deadline-Remaining 之类的私有头),且「预留多少」本身是猜测。
- 不扣预算,只做「重试熔断」:不关心预算,但限制重试次数 + 对重试请求打标记(X-Retry-Count),下游看到标记后直接拒绝重试(快速失败)。优点是改动小;缺点是「拒绝重试」的判断标准模糊,且对无状态下游不友好。
- 超时 + 预算 + 熔断三件套:入口 800ms 总预算,每段独立超时,重试走独立熔断器(错误率 > 阈值即熔断),重试请求携带剩余预算。最严谨但复杂度最高。
我的倾向与理由
我倾向 做法 4 的简化版:端到端预算 + 重试熔断,理由有三:
- 超时预算是「容量」,不是「配置」:和连接池大小一样,预算是资源,必须全局记账。每段独立超时本质是「每段各自申请容量」,没有全局视图,必然在叠加场景下打穿。
- 重试是预算的「最大消耗者」,必须单独立项管理:一次重试 ≈ 一次新请求的资源占用(连接池、CPU、下游容量),不把它纳入预算就是「隐性超支」。
- deadline propagation 在 gRPC / HTTP/2 上已经成熟:gRPC 的
grpc-timeout头、HTTP/2 的Grpc-Timeout都是标准做法,Java 侧用 OpenTelemetry 的Deadlinespan 也能透传。真正难的是「预留多少」——我目前的经验是预留 = 已用时间 × 1.5 + 该段 P99,保守但不离谱。
想听大家在什么场景下怎么选
- 你们线上是「每段独立」还是「端到端扣减」?有没有踩过「预算打穿 → 重试风暴 → 连接池排队」这种三段叠加的坑?
- deadline propagation 你们用 gRPC 原生、OpenTelemetry,还是自己加私有头?跨语言(Java → Go)场景怎么传?
- 「预留多少」这个值,你们是怎么定的?有没有可复现的公式或经验值?
- 重试熔断的「拒绝重试」判断,你们用错误率、延迟、还是下游负载信号?阈值怎么定?
- 如果只能改一处、且改动要小,你会先上哪个?
欢迎带具体数据来聊:QPS、段数、P99、连接池大小、超时值——我怀疑很多「P99 抖动」的根因,不是任何单点慢,而是预算没记账。
0赞
0收藏
0楼层