压测雪崩复盘:每一层的超时都设成了 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 | → Gateway | 3.5 s | 比目标多 500 ms 余量 |
| Gateway | → order-service | 3 s | 留 500 ms 给网关自己的过滤器 |
| order-service | → MySQL | 800 ms | 三条 SQL,加起来预算 800 |
| order-service | → inventory-service | 1 s | 允许 1 次重试,总预算 1.5 s |
| order-service | → promo-service | 700 ms | 不重试 |
| inventory-service | → MySQL | 300 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% |
| 网关 P99 | 30 s(超时) | 1.4 s |
| inventory 收到的 QPS | 入口的 9 倍 | 入口的 1.3 倍 |
| 订单服务 CPU | 98% | 61% |
| 压测停止后恢复时间 | 4 分钟 | 12 秒 |
| 降级返回占比 | 0 | 2.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 分钟。
写在后面
现在回头看,《接口超时时间该怎么设置?超时传递与降级》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。