2023 年我们做电商主链路压测,商品 → 购物车 → 下单 → 支付五个微服务串行调用,P99 偶发 8 秒+,根本无法接受。复盘后发现是经典的"超时叠加"问题:每个服务自己设 3 秒超时,5 个串起来理论上 15 秒,出现重试时雪崩。投入两周做全链路超时治理:超时预算、传递、对齐重试,P99 从 8 秒降到 800ms,稳定可预期。本文复盘超时治理的工程方法,覆盖 gRPC、HTTP、消息队列三类场景。
事故现场
压测:商品中心 → 购物车 → 库存 → 价格 → 风控 → 订单
配置:每个服务调用下游 timeout = 3000ms,重试 2 次
正常路径:每跳 50ms,总 P50 = 250ms
异常路径(下游慢):
- 商品 → 购物车 3s 超时 + 重试 3s + 再重试 3s = 9s
- 用户已经看到错误页了,后端还在重试
- 关键问题:超时叠加,无意义重试
实际监控:
P50:300ms
P95:1200ms
P99:8500ms ← 这个不可接受
P99.9:30000ms ← 还有 30s 的请求
业务影响:
- 用户加车成功率 95%(5% 超时失败)
- 下单转化率比预期低 8%
- 客服反馈"加车不响应"投诉
超时传递原理
没有传递(典型错误):
User → A(3s) → B(3s) → C(3s) → D(3s) → E(3s)
时间 0 用户请求到 A
时间 0 A 调 B
时间 3s A 给 B 的超时到了,A 返回超时
但 B 还在调 C,C 还在调 D... 没人通知它们停
→ 浪费资源,雪崩
有超时传递(正确):
User(5s 总预算) → A(剩 4.9s) → B(剩 4.7s) → C(剩 4.5s) → D(剩 4.3s) → E(剩 4.1s)
每跳消耗一点(网络 + 自己处理),剩余预算往下传
到 E 还有 4.1s,够用
任何一环超时,整条链路立即停,不浪费
gRPC 实现:context.WithTimeout
// gRPC 天然支持超时传递(基于 context.Deadline)
// Go 服务端:接收上游 deadline,自动传给下游
func (s *OrderServer) Create(ctx context.Context, req *pb.CreateRequest) (*pb.Order, error) {
// ctx 已经带了上游 deadline
deadline, ok := ctx.Deadline()
if ok {
remaining := time.Until(deadline)
if remaining < 50*time.Millisecond {
// 剩余预算不够,直接降级
return nil, status.Error(codes.DeadlineExceeded, "no budget")
}
}
// 串行调用下游(自动传递 ctx)
product, err := s.productClient.Get(ctx, &pb.GetRequest{Id: req.ProductId})
if err != nil {
return nil, err
}
// 并行调用(为剩余 deadline 留点 buffer)
g, gctx := errgroup.WithContext(ctx)
var inv *pb.Inventory
g.Go(func() error {
var err error
inv, err = s.inventoryClient.Check(gctx, ...)
return err
})
var price *pb.Price
g.Go(func() error {
var err error
price, err = s.priceClient.Calculate(gctx, ...)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return s.createOrder(ctx, ...)
}
// 网关入口设总预算
func gatewayHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
resp, err := grpcClient.Create(ctx, req)
// ...
}
HTTP / Spring Cloud:OkHttp + Sleuth 传递
// Spring 应用通过 HTTP header 传递剩余 deadline
// 头部:X-Deadline(ms 时间戳)
@Component
public class DeadlineInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest req, byte[] body, ClientHttpRequestExecution exec) throws IOException {
Long deadline = DeadlineContext.getDeadlineMillis();
if (deadline != null) {
long remaining = deadline - System.currentTimeMillis();
if (remaining < 50) {
throw new DeadlineExceededException("no budget");
}
req.getHeaders().set("X-Deadline", String.valueOf(deadline));
req.getHeaders().set("X-Deadline-Remaining-Ms", String.valueOf(remaining));
// 把剩余预算设到 RestTemplate 的超时(每跳留 50ms buffer)
((HttpURLConnection) req).setReadTimeout((int)(remaining - 50));
}
return exec.execute(req, body);
}
}
// 服务端 Filter:从 header 提 deadline 放到 ThreadLocal
@Component
public class DeadlineFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) {
String deadlineHeader = req.getHeader("X-Deadline");
if (deadlineHeader != null) {
DeadlineContext.setDeadlineMillis(Long.parseLong(deadlineHeader));
}
try {
chain.doFilter(req, resp);
} finally {
DeadlineContext.clear();
}
}
}
public class DeadlineContext {
private static final ThreadLocal DEADLINE = new ThreadLocal<>();
public static void setDeadlineMillis(long millis) {
DEADLINE.set(millis);
}
public static Long getDeadlineMillis() {
return DEADLINE.get();
}
public static long remainingMillis() {
Long d = DEADLINE.get();
return d == null ? Long.MAX_VALUE : d - System.currentTimeMillis();
}
public static void clear() {
DEADLINE.remove();
}
}
// 网关设置总预算
@RestController
public class GatewayController {
@PostMapping("/api/order/create")
public Order create(@RequestBody CreateRequest req) {
DeadlineContext.setDeadlineMillis(System.currentTimeMillis() + 5000); // 5s 总预算
try {
return orderService.create(req);
} finally {
DeadlineContext.clear();
}
}
}
重试策略:重试 + 超时配合
// 关键:重试不能突破总 deadline
func callWithRetry(ctx context.Context, fn func(ctx context.Context) error) error {
backoff := []time.Duration{0, 100*time.Millisecond, 300*time.Millisecond}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
// 检查剩余 deadline
deadline, ok := ctx.Deadline()
if ok && time.Until(deadline) < 100*time.Millisecond {
// 剩余预算不够,不重试
return lastErr
}
if backoff[attempt] > 0 {
select {
case <-time.After(backoff[attempt]):
case <-ctx.Done():
return ctx.Err()
}
}
// 给本次调用 30% 预算
var callCtx context.Context
var cancel context.CancelFunc
if ok {
remaining := time.Until(deadline)
timeout := remaining / time.Duration(3-attempt) // 剩余次数均分
callCtx, cancel = context.WithTimeout(ctx, timeout)
} else {
callCtx, cancel = context.WithCancel(ctx)
}
err := fn(callCtx)
cancel()
if err == nil {
return nil
}
lastErr = err
// 只重试可重试错误
if !isRetryable(err) {
return err
}
}
return lastErr
}
func isRetryable(err error) bool {
st, ok := status.FromError(err)
if !ok {
return false
}
switch st.Code() {
case codes.Unavailable, codes.DeadlineExceeded:
return true // 网络问题,可重试(幂等接口)
case codes.NotFound, codes.InvalidArgument, codes.PermissionDenied:
return false // 业务错误,重试无意义
}
return false
}
超时预算分配
链路:Gateway → A → B → C
总预算:5000ms
分配原则:
1. 网络 RTT 留 20%(5 跳 × 20ms = 100ms)
2. 每个服务自己处理 + 调下游
3. 越下游预算越紧
实际配置:
Gateway(总 5000ms):
→ A 调用超时 = 4900ms
A(收到 4900ms):
自己处理 200ms
→ B 调用超时 = 4500ms (减去自己 200ms + RTT 200ms)
B(收到 4500ms):
自己处理 100ms
→ C 调用超时 = 4200ms
C(收到 4200ms):
自己处理 50ms,返回
各服务还可以并行调多个下游
B 并行调 D + E + F,每个分一份预算(不是均分,按 P99 估)
不能重试的关键场景:
- 链路接近 deadline(剩 < 200ms)
- 非幂等操作(支付、转账)
- 业务错误码(NotFound、PermissionDenied)
熔断 + 降级配合
// Resilience4j 配置:超时 + 熔断 + 降级三件套
@Component
public class ProductClient {
@CircuitBreaker(name = "product", fallbackMethod = "getFallback")
@TimeLimiter(name = "product") // 必须先有 timeout,熔断才能统计
@Retry(name = "product")
public CompletableFuture get(Long id) {
long remainingMs = DeadlineContext.remainingMillis();
if (remainingMs < 100) {
return CompletableFuture.failedFuture(new DeadlineExceededException());
}
return CompletableFuture.supplyAsync(() -> restTemplate.getForObject(
"http://product/api/products/" + id, Product.class));
}
public CompletableFuture getFallback(Long id, Throwable ex) {
// 降级:返回缓存或默认值
Product cached = localCache.get(id);
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return CompletableFuture.completedFuture(Product.placeholder(id));
}
}
// application.yml
resilience4j:
timelimiter:
instances:
product:
timeoutDuration: 800ms
cancelRunningFuture: true
circuitbreaker:
instances:
product:
slidingWindowSize: 50
failureRateThreshold: 50
waitDurationInOpenState: 10s
minimumNumberOfCalls: 20
# 关键:超时也算失败
recordExceptions:
- java.util.concurrent.TimeoutException
retry:
instances:
product:
maxAttempts: 2
waitDuration: 100ms
retryExceptions:
- java.net.SocketTimeoutException
# 超时不重试(避免叠加)
ignoreExceptions:
- java.util.concurrent.TimeoutException
监控:链路超时追踪
# OpenTelemetry 追踪
# 每个 Span 记录:
# - start_time
# - duration
# - timeout_remaining_at_start
# - status (DEADLINE_EXCEEDED 还是其他)
# Prometheus 告警
- alert: HighDeadlineExceededRate
expr: |
sum(rate(grpc_server_handled_total{grpc_code="DeadlineExceeded"}[5m]))
/
sum(rate(grpc_server_handled_total[5m]))
> 0.01
for: 5m
annotations:
summary: "DeadlineExceeded 占比 > 1%"
- alert: DeadlineBudgetExhausted
expr: histogram_quantile(0.99, deadline_remaining_at_call_start_seconds) < 0.2
for: 5m
annotations:
summary: "P99 调用开始时剩余预算 < 200ms,链路压紧"
- alert: RetryStorm
expr: rate(http_client_retry_total[1m]) > 1000
annotations:
summary: "重试风暴,可能下游故障"
# Grafana 看板必备指标:
# - P50/P95/P99 端到端延迟
# - 每跳延迟分解
# - DeadlineExceeded 占比
# - 各服务剩余预算分布
# - 重试次数分布
优化效果
指标 优化前 优化后
=======================================================
P50 延迟 300ms 280ms
P95 延迟 1200ms 650ms
P99 延迟 8500ms 800ms ← 关键
P99.9 延迟 30000ms 1500ms ← 关键
DeadlineExceeded 率 0%(默默重试) 0.05%(明确返回)
下游 QPS(异常时) 3x(重试) 1x(无叠加)
用户加车成功率 95% 99.5%
下单转化率 基线 +6%
业务影响:
- 终端用户体验稳定,无 5-30s 黑屏
- 故障期间不再雪崩,资源消耗稳定
- 监控可观测,问题定位 10min → 1min
- 重试预算可控,不会无限拖累上游
避坑清单
- 所有 RPC 必设 timeout,默认无穷大是定时炸弹
- 用 context.WithTimeout / X-Deadline 传递剩余预算
- 越下游预算越紧(留出处理时间 + 网络 RTT)
- 重试不能突破总 deadline,剩余预算不够就 fail-fast
- 超时错误不重试(避免雪崩),业务错误不重试
- 熔断 + 降级配合超时,三件套不能拆
- Retry on 4xx 永远是错的(InvalidArgument 重试也是 InvalidArgument)
- 异步任务(消息队列)单独超时,不传递入口 deadline
- 压测必须模拟下游慢(故障注入)
- OpenTelemetry 追踪每跳剩余预算,监控告警
总结
超时治理是分布式系统的隐藏地雷,大多数团队配了 timeout 就觉得万事大吉,实际上"超时叠加 + 无脑重试"是 P99 失控的最常见原因。这次治理最大的认知改变:超时不是单点配置,是链路预算,要从入口往下游传递,每跳消耗一点。最被低估的是 gRPC 的 context.WithTimeout 机制,你设了入口超时,gRPC 自动把 deadline 通过 HTTP/2 header 传给下游,不需要手写 — 这也是 gRPC 比 REST 优秀的地方之一(REST 要自己 X-Deadline header)。最容易踩的是"重试 + 超时"组合,3 次重试 × 3s 超时 = 9s,叠加上游就是 27s,雪崩在所难免;正确做法是重试也吃总预算,剩 200ms 就别重试了。最后,2024 年还有项目敢不设 RPC timeout 的,迟早被一次下游慢拖到全站雪崩,这是血的教训。
—— 别看了 · 2026