Administrator
发布于 2020-05-22 / 2259 阅读
23

接口超时时间该怎么设置?超时传递与降级

压测雪崩复盘:每一层的超时都设成了 30 秒

5 月初做 618 前的压测。我们给网关发了 3000 QPS 的流量,持续 90 秒,结果整个交易链路崩了:网关大量 504,订单服务 CPU 98%,成功率从 99.9% 掉到 34%,压测停止后还花了 4 分钟才恢复。

排查时发现一个很荒谬的事:从网关到数据库,每一层的超时都是 30 秒

Nginx                proxy_read_timeout     30s
Gateway              Ribbon ReadTimeout     30000
order-service        Ribbon ReadTimeout     30000
inventory-service    Ribbon ReadTimeout     30000
Druid                maxWait                30000(等连接)
MyBatis              statement timeout      默认(不超时)

这个配置下,一个慢查询能把整条链路的线程占住 30 秒。3000 QPS 进来,网关 200 个线程 2 秒就全占满了,之后所有请求排队。而排队的时间也算在 30 秒里,所以用户看到的是"转圈 30 秒然后失败"。

超时要预算,不是每层随便填

正确的思路是给用户一个总的等待上限,然后往回倒推每一层能分到多少。这叫超时预算(timeout budget)。

我们的下单接口,产品定的体验目标是"3 秒内必须给结果,成功或者明确的失败"。往下拆:

层级调用超时说明
Nginx→ Gateway3.5 s比目标多 500 ms 余量
Gateway→ order-service3 s留 500 ms 给网关自己的过滤器
order-service→ MySQL800 ms三条 SQL,加起来预算 800
order-service→ inventory-service1 s允许 1 次重试,总预算 1.5 s
order-service→ promo-service700 ms不重试
inventory-service→ MySQL300 ms单行扣减,300 ms 足够

倒推的逻辑:inventory 自己要 300 ms 查库,加上 100 ms 处理,留 600 ms 网络和处理余量,所以 order 调它设 1 秒。order 自己的 SQL 预算 800 ms,加上调库存 1.5 s(含重试)和调促销 700 ms(这两个可以并行),总共约 2.3 秒,加上 700 ms 余量,网关设 3 秒。

三条硬规则:

  • 下游超时必须小于上游。上游已经超时放弃了,下游还在跑,纯属浪费资源。
  • 越靠近用户,超时越大;越靠近数据库,超时越小。
  • 每一层都要留余量给自己的处理时间。别把上游的 3 秒整个传给下游。

更靠谱的做法:传截止时间而不是传超时值

按上面的表格配完之后,还是有问题:如果 order-service 自己先花了 1.2 秒做前置校验,剩下给库存的时间只剩 1.8 秒,可配置上写的是 1 秒——配得对,但实际预算被吃掉了。

更准确的做法是传截止时间(deadline):入口处记一个"这个请求必须在什么时候完成",每一层在发起下游调用前,先算一下还剩多少时间。

public class DeadlineContext {

    private static final ThreadLocal<Long> DEADLINE = new ThreadLocal<>();

    public static void start(long timeoutMs) {
        DEADLINE.set(System.currentTimeMillis() + timeoutMs);
    }

    /** 剩余预算,毫秒 */
    public static long remaining() {
        Long d = DEADLINE.get();
        return d == null ? Long.MAX_VALUE : d - System.currentTimeMillis();
    }

    public static void clear() {
        DEADLINE.remove();
    }
}

在 Feign 的 RequestInterceptor 里用剩余时间覆盖配置的超时:

@Component
public class DeadlineInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        long remaining = DeadlineContext.remaining();
        if (remaining != Long.MAX_VALUE) {
            // 留 50 ms 给网络回程,别把最后一点预算全用光
            int timeout = (int) Math.max(100, remaining - 50);
            template.options(new Request.Options(timeout, timeout));
        }
    }
}

关键点:Request.Options 是在 RequestTemplate 上设的,优先级高于 ribbon.ReadTimeout。这样每次下游调用都只花"剩余的时间"。

如果剩余时间已经不足 100 ms,就直接不发起调用,走降级:

if (DeadlineContext.remaining() < 100) {
    log.warn("预算耗尽, 跳过库存调用");
    return Result.degraded();        // 兜底
}

这个改动上线后,压测时的表现好了很多——慢请求会被快速放弃,线程不再被长时间占住。

ThreadLocal 方案在异步场景(线程池、MQ)里会丢。跨线程要手动传递:

long deadline = DEADLINE.get();
executor.submit(() -> {
    DEADLINE.set(deadline);
    try { ... } finally { DEADLINE.remove(); }
});

这个正是 gRPC 里 Context.withDeadline() 解决的问题,HTTP 生态里没有对应标准,只能自己做。

重试会把流量放大,这个必须算清楚

重试是最容易失控的一件事。看这个链路:

网关 → A → B → C
每层都配:重试 2 次(也就是最多 3 次请求)
C 慢了,A 重试 3 次,B 每次又被重试 3 次,C 每次又被重试 3 次
C 实际收到的请求数 = 3 × 3 × 3 = 27 倍

这不是理论数字。我们压测时看到 inventory-service 收到的 QPS 是入口的 9 倍(我们当时只有两层重试),C 服务直接被打挂。

三个约束:

一、只在最外层重试

链路上的重试应该只发生在一处。我们的做法是:网关层不做重试(交给用户手动重试),服务间的 Feign 只在最底层调用方开重试,中间层一律不重试。

更常见的写法是只允许对幂等接口重试:

ribbon:
  OkToRetryOnAllOperations: false    # 只重试 GET
  MaxAutoRetries: 0                  # 同一实例不重试
  MaxAutoRetriesNextServer: 1        # 换一个实例重试 1 次

这样最多 2 倍,可控。

二、重试次数要计入超时预算

前面表格里"允许 1 次重试,总预算 1.5 秒",意思是单次超时设 700 ms,两次加起来 1.4 秒,仍在预算内。很多人配了 3 次重试、每次 3 秒,实际最坏耗时是 9 秒,而上游设的超时是 3 秒——重试根本没机会跑完,白配。

三、重试要有熔断配合

单纯的重试在下游真的挂掉时会变成"重试风暴"。必须配合熔断:连续失败到一定比例就停止调用一段时间,别再重试了。我们用的是 Sentinel 的熔断规则(异常比例 40%,熔断 10 秒)。

降级:兜底数据从哪来

超时之后不能只返回一个错误。我们的降级策略按数据的重要程度分四档:

场景降级方式例子
锦上添花的信息空兜底,返回空或默认值商品详情页的"猜你喜欢"推荐位,拿不到就不显示
有近期缓存的数据缓存兜底,返回过期数据并标记库存数量降级为 10 分钟前的缓存值,标注"可能不准"
有历史规律的数据静态兜底,写死的保守值优惠券不可用时的默认折扣额度
影响金额的数据不放兜底,直接失败支付金额算不出来,宁可报错也不能给错价

代码上就是一个 fallback 工厂:

@Component
public class InventoryFallbackFactory implements FallbackFactory<InventoryClient> {

    @Autowired
    private StringRedisTemplate redis;

    @Override
    public InventoryClient create(Throwable cause) {
        return new InventoryClient() {

            @Override
            public Result<Integer> getAvailable(Long skuId) {
                // 缓存兜底:Redis 里存了一份 10 分钟前的快照
                String key = "stock:snapshot:" + skuId;
                String val = redis.opsForValue().get(key);
                if (val != null) {
                    return Result.of(Integer.parseInt(val), true);  // true = 降级数据
                }
                // 实在没有,返回保守值 0,宁可让用户下不了单
                return Result.of(0, true);
            }

            @Override
            public Result<Boolean> deduct(DeductRequest req) {
                // 扣减操作不降级,直接失败
                return Result.fail("库存服务不可用");
            }
        };
    }
}

注意 deduct 这个方法没有降级。写操作不能随便兜底,扣减失败就是失败,不能假装成功(否则会超卖)。读操作可以返回不准的数据,写操作不行——这是我一直强调的原则。

降级数据的标记要传到前端。我们给返回值加了 degraded 字段,前端看到就显示"数据可能不是最新的"。宁可让用户知道不准,也不要静默给出错数据。

改完之后的数据

同样的 3000 QPS、90 秒压测,改完配置再跑:

指标改之前改之后
成功率34%97.2%
网关 P9930 s(超时)1.4 s
inventory 收到的 QPS入口的 9 倍入口的 1.3 倍
订单服务 CPU98%61%
压测停止后恢复时间4 分钟12 秒
降级返回占比02.8%

成功率没有 100%,剩下 2.8% 是降级返回(用户看到的是旧库存数,能下单)。这比全部超时好得多。

配置落地的两个提醒

一、把超时配置集中管理。我们原来散在各个服务的 application.yml 里,改一次要发好几个包。现在统一放 Nacos 的 common-timeout.yaml,并且写了份文档说明每个值是怎么算出来的。谁要调,先更新文档。

二、加监控。光配了没用,得知道实际发生了多少次超时和降级:

// 在 fallback 里埋点
Metrics.counter("feign.timeout", "service", "inventory").increment();
Metrics.counter("feign.degrade", "service", "inventory", "type", "snapshot").increment();

我们配了告警:单个服务的超时率 5 分钟内超过 3% 就发钉钉。这条告警在 5 月中旬真的救了一次——促销服务慢了,告警比用户投诉早了 20 分钟。

写在后面

现在回头看,《接口超时时间该怎么设置?超时传递与降级》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考