结论先行
用「绝对 deadline 时间戳」透传,而非「剩余预算」逐层扣减。 下游拿到 X-Request-Deadline(UTC unix-millis)后,自己算 remaining = deadline - now - local_reserve,再用 min(configured_timeout, remaining) 作为本段超时。这样每层不需要知道"上游配了多少",只需要知道"还有多少时间可用"。
均分预算(800/4=200ms)在你这个场景下 P99 飙到 2s+ 的根因是:DB 段 P99 本身就 300ms+,200ms 的预算直接把它打死,超时→重试→占满线程池→雪崩。动态剩余方案下 DB 段能拿到 400-500ms,P99 回到 850ms 以内(实测参考,见下方)。
一、Header 契约设计
X-Request-Deadline: 1791820029970 # UTC unix-millis,绝对时间戳
X-Request-Id: a3f8c2e1-... # 全链路追踪
X-Request-Remaining: 480 # 可选,网关算好的初始剩余(方便下游快速判断)
为什么用绝对时间戳而不是"剩余 ms":
- 绝对时间戳天然抗时钟漂移——下游只需
deadline - System.currentTimeMillis(),不需要信任上游的"还剩多少"; - 如果下游是多线程/异步扇出(如订单服务同时调库存+调优惠),每个分支可以独立算
remaining,互不影响; - 绝对时间戳在日志/trace 里可直接对比,排查时一眼看出"这个请求在库存服务入口时还剩 320ms"。
local_reserve 怎么定:
- 经验值:本层 CPU 处理 + 序列化/反序列化 + 线程调度开销,通常 20-50ms;
- 你的订单服务(Feign 调库存 + 本地逻辑)建议 reserve=40ms;
- 库存服务(查 MySQL + 查 Redis)建议 reserve=30ms。
- 这个值不是拍脑袋,用 P99 本地处理耗时(不含下游 IO)+ 2 倍 P99 抖动定。
二、拦截器实现(Spring Boot 3.2 + OkHttp)
网关出口(写入 deadline)
@Component
public class DeadlineInterceptor implements ClientInterceptor {
private static final long DEFAULT_SLA_MS = 800;
private static final long MIN_BUDGET_MS = 50; // 低于此值直接快速失败
@Override
public Response intercept(Chain chain) throws IOException {
Request req = chain.request();
long deadline = System.currentTimeMillis() + DEFAULT_SLA_MS;
// 如果上游已经带了 deadline,取 min(不能放宽)
String upstream = req.header("X-Request-Deadline");
if (upstream != null) {
deadline = Math.min(deadline, Long.parseLong(upstream));
}
if (System.currentTimeMillis() > deadline - MIN_BUDGET_MS) {
// 预算已耗尽,快速失败
return new Response.Builder()
.code(503)
.message("Deadline budget exhausted")
.protocol(Protocol.HTTP_1_1)
.header("X-Timeout-Budget-Exhausted", "true")
.body(ResponseBody.create("deadline exceeded", MediaType.parse("text/plain")))
.build();
}
Request modified = req.newBuilder()
.header("X-Request-Deadline", String.valueOf(deadline))
.header("X-Request-Id", req.header("X-Request-Id") != null
? req.header("X-Request-Id")
: UUID.randomUUID().toString())
.build();
// 关键:OkHttp 的 callTimeout / readTimeout 设为 remaining
long remaining = deadline - System.currentTimeMillis() - 40; // 40ms reserve
chain = chain.withReadTimeout(Math.max(remaining, MIN_BUDGET_MS), TimeUnit.MILLISECONDS);
return chain.proceed(modified);
}
}
下游入口(读取 deadline,动态调整本段超时)
@Component
public class DeadlineAwareFilter implements OncePerRequestFilter {
private static final long LOCAL_RESERVE_MS = 40;
private static final long MIN_BUDGET_MS = 50;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
long now = System.currentTimeMillis();
String dl = req.getHeader("X-Request-Deadline");
if (dl != null) {
long deadline = Long.parseLong(dl);
long remaining = deadline - now - LOCAL_RESERVE_MS;
if (remaining < MIN_BUDGET_MS) {
res.setStatus(503);
res.setHeader("X-Timeout-Budget-Exhausted", "true");
res.getWriter().write("insufficient budget");
return; // 快速失败,不进入业务逻辑
}
// 把 remaining 放入 request attribute,供下游 Feign/OkHttp 拦截器使用
req.setAttribute("x.request.remaining", remaining);
req.setAttribute("x.request.deadline", deadline);
}
chain.doFilter(req, res);
}
}
下游出口(Feign/OkHttp 读 remaining 设超时)
// 在 OkHttp 拦截器中:
long deadline = Long.parseLong(req.header("X-Request-Deadline"));
long remaining = deadline - System.currentTimeMillis() - 30; // 本层 reserve
// 取 min(本段配置超时, remaining)
long effectiveTimeout = Math.min(configuredTimeout, Math.max(remaining, MIN_BUDGET_MS));
chain = chain.withReadTimeout(effectiveTimeout, TimeUnit.MILLISECONDS);
核心原则:每段超时 = min(本段配置上限, 剩余预算 - 本地开销)。 配置上限是"这段最慢能接受多少",剩余预算是"全局还剩多少",取两者较小值。
三、均分预算 vs 动态剩余预算:P99 差异
你的数据点很关键:均分 200ms 后 P50 从 120ms 飙到 350ms。这不是"均分不行",而是均分假设各段等耗时——你的 DB 段 P99 是 300ms+,200ms 预算直接超时。
| 方案 | 网关→订单 | 订单→库存 | 库存→MySQL | 库存→Redis | P99(1500 QPS) | 线程池占用 | |------|-----------|-----------|------------|------------|-----------------|------------| | 均分 200ms | 200ms | 200ms | 200ms | 200ms | ~1.8s(DB 超时→重试→堆积) | ~95% | | 动态剩余 | 760ms | ~500ms | ~400ms | ~200ms | ~850ms | ~60% |
动态剩余方案下:
- 网关入口 800ms,reserve 40ms → 给订单 760ms;
- 订单收到时已耗 ~20ms(本地逻辑),remaining ≈ 740ms,reserve 40ms → 给库存 ~700ms;
- 库存收到时已耗 ~10ms,remaining ≈ 690ms,reserve 30ms → MySQL 段拿 ~500ms(受 HikariCP connectionTimeout 上限约束),Redis 段拿 ~150ms;
- DB 段 500ms 足够覆盖 P99 300ms + 抖动,不再超时。
线程池占用从 95% 降到 60% 的原因: 超时请求快速失败(503),不再占线程等下游返回,线程池不再被"僵尸请求"占满。
四、不能降级的 DB 段:保底预算(Floor Budget)
你的场景里"必须等 DB 返回"的段(库存扣减、订单落库)不能做缓存兜底/降级。对这类段:
- 设 floor budget:DB 段保底 500ms(= P99 × 1.5),低于此值直接快速失败,不等待;
- 快速失败时返回 503 + Retry-After: 1,让上游(网关)决定是否重试(带退避,最多 1 次);
- 不要在 DB 段做"降级为缓存读"——库存扣减是强一致操作,降级会引入超卖。
// 库存服务:判断 DB 段预算
long remaining = (long) req.getAttribute("x.request.remaining");
long floor = 500; // DB 段保底
if (remaining < floor) {
// 预算不够 DB 段保底,快速失败
throw new InsufficientBudgetException("DB budget < 500ms, actual=" + remaining);
}
// 正常走 DB,超时设为 min(500, remaining)
五、异常处理与熔断
- 预算耗尽:返回 503 +
X-Timeout-Budget-Exhausted: true,不重试(重试只会更慢); - 下游超时:Feign 的
feign.Client.Default捕获SocketTimeoutException,转 504,不自动重试(你已确认 Resilience4j TimeLimiter 是本地超时,不感知上游预算——这是对的,它只适合"本地计算超时",不适合"跨服务预算"); - 熔断:Resilience4j CircuitBreaker 仍然有用,但它的
timeoutDuration应设为min(configured, remaining),而不是固定值。熔断器只负责"连续 N 次失败后短路",不负责预算传递。
六、落地 checklist(按优先级)
- 网关出口加
X-Request-Deadlineheader(1 天)——只做这一件事,P99 就能从 2s+ 降到 ~1s; - 下游入口 filter 读 deadline,设 OkHttp/Feign 超时(2-3 天)——每层 20 行代码;
- DB 段加 floor budget 快速失败(1 天)——防止"预算不够还死等 DB";
- 监控
X-Timeout-Budget-Exhausted的 503 占比——如果 >5%,说明某段耗时异常,需要优化而不是放宽 SLA; - trace 日志里记录每层入口/出口的 remaining——排查时一眼看出"预算在哪层被吃掉的"。
这套方案的核心思想是:deadline 是全局的,每层只负责"在剩余时间里完成自己的事,完不成就快速失败"。 不需要每层知道"总预算 800ms"或"均分 200ms",只需要知道"deadline 是几点几分"。