微服务 P99 8s 雪崩复盘:全链路超时预算 + 传递 + 重试治理

电商主链路 P99 8s 雪崩复盘:每个服务 3s 超时 + 2 次重试,5 跳叠加成 30s 黑洞。两周治理:gRPC context.WithTimeout 传递 + Spring X-Deadline header + 剩余预算分配 + 重试不突破总 deadline + 熔断降级配合。P99 8s→800ms,DeadlineExceeded < 0.1%。

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
- 重试预算可控,不会无限拖累上游

避坑清单

  1. 所有 RPC 必设 timeout,默认无穷大是定时炸弹
  2. 用 context.WithTimeout / X-Deadline 传递剩余预算
  3. 越下游预算越紧(留出处理时间 + 网络 RTT)
  4. 重试不能突破总 deadline,剩余预算不够就 fail-fast
  5. 超时错误不重试(避免雪崩),业务错误不重试
  6. 熔断 + 降级配合超时,三件套不能拆
  7. Retry on 4xx 永远是错的(InvalidArgument 重试也是 InvalidArgument)
  8. 异步任务(消息队列)单独超时,不传递入口 deadline
  9. 压测必须模拟下游慢(故障注入)
  10. 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

TypeScript monorepo 编译 12min 优化到 90s:Project Refs + SWC + Turbo

2026-5-19 13:09:24

技术教程

Webpack 构建 9min 优化到 1.5min:SWC + 缓存 + 分包全实战

2026-5-19 13:13:37

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索