主题
全链路超时与自适应限流
一句话:全链路超时用一个随请求流动的绝对截止时间(deadline)取代每跳各自设定的超时,换来尾延迟可控;自适应限流用按 RT、异常、负载自动收放的阈值取代写死的 QPS,换来免人工调参。代价是链路上每一跳、每一次线程切换都必须显式传递并尊重这份约定。
适用版本:Spring Boot 3.2.x / Spring Cloud 2023.0.x / Sentinel 1.8.x。与 04-远程调用与负载均衡 的分工是:04 讲单次调用的超时、重试、连接池;06-熔断降级与限流 讲 Sentinel 的滑动窗口与规则模型;本文只讲跨服务的超时预算,以及阈值随流量自动调整的限流。文中 x-deadline-ms、DeadlineContext 等均为示例命名。
为什么需要
A 调 B 设 3s,B 调 C 也设 3s。C 卡住时,A 在第 3s 断开,B 却还在等 C,要到第 3s 之后才超时;这期间 B 的线程、连接、数据库事务都占着。如果 A 还配了 2 次重试,一次用户请求会变成 3 次 A→B→C 的调用,下游越慢,请求越多。
固定阈值也一样:阈值写 100 QPS,扩容后机器能扛 300,白限制;缩容后只能扛 60,又保护不住。流量结构、实例规格、依赖延迟都在变,靠人盯监控改配置,改慢了出事,改错了误杀。
核心机制
每跳独立超时:时钟各算各的
每跳的 timeout 只约束「本跳愿意等多久」,不约束「整个请求还剩多久」。调用链一深,最坏耗时是各跳超时之和乘以重试次数,而不是入口设定的那个数。上游先放弃后,下游仍在执行,产生「做了但没人要」的无效负载。
deadline:把剩余时间传下去
入口只生成一次绝对截止时间:deadline = now + 总预算。每一跳在发起调用前计算 remaining = deadline - now - 安全余量,用它作为本跳的读超时;剩余不足以完成一次有意义的调用时,直接失败,不再发起注定超时的请求。
图里传递的是同一个绝对 deadline,不是逐跳递减的「剩余值」;每跳的剩余时间由各自的当前时间算出。这样即使中间某跳排队久了,后面的跳也会自动收紧超时。安全余量用于覆盖序列化、网络抖动和时钟偏差,经验值 100–200ms。
传递链路
- 入口过滤器解析或生成 header,写入请求上下文;Servlet 服务用 ThreadLocal,网关是 WebFlux,ThreadLocal 不可靠,要用 exchange attribute 或 Reactor Context。
- Feign 的
RequestInterceptor、RestTemplate 的ClientHttpRequestInterceptor负责透传;同时要把本跳 socket 读超时设成remaining,只传 header 不改超时,慢下游仍会占住线程。 - 线程池/异步:
TaskDecorator复制上下文;@Async、CompletableFuture不会自动继承 ThreadLocal。 - MQ:deadline 放进消息 header,消费前先判断;消息在队列里滞留超过 deadline 时应丢弃或转补偿,而不是继续处理。
- 跨语言:传绝对时间戳(epoch ms),约定时钟同步(NTP)和最大偏差;只传相对剩余时间则每跳都要重新减,容易漏。
重试预算
重试次数必须和总预算共用,不能每跳各自重试。只对幂等调用重试,带幂等键;退避用指数加 jitter,避免同一时刻的重试洪峰。下游已经过载时,重试是放大器:每次重试都在给已经排满的队列加请求。剩余预算不够下一次尝试时,不重试。
分层设置
网关总预算 > 服务总预算 > 单依赖超时。连接超时、首字节超时、读超时分开设:连接超时管握手,首字节管下游开始响应,读超时管响应体传输。不同下游给不同值,全局一刀切要么误杀快接口,要么放过慢接口。
自适应限流:阈值跟着指标走
固定阈值假设容量不变,自适应限流承认容量是波动的。触发信号通常有:平均 RT、异常比例、异常数、系统负载/CPU。每个统计窗口内,指标连续多次采样超阈值才触发(例如连续 5 次),避免偶发抖动误判;请求量低于最小样本数时不触发,否则冷启动阶段一两个慢请求就会把阈值打到 0。
触发后按步长下调阈值(百分比步长 3%–10%,或固定数值;经验值,按业务调),指标恢复后缓慢上调;设置上下限和最小调整周期,防止阈值抖动。规则要能动态下发、留版本、可回滚,否则改错一次只能重启。
热点参数限流按参数值(用户、商品、租户)单独计数,适合「整体没超,但单个大客户打满」的场景。代价是每个参数值都要维护统计器,基数不可控时内存会涨;它和普通资源规则叠加时,先过资源级规则,再按参数值细分,别把两者阈值当成相加关系。
网关限流按路由、租户、API 维度做,拒绝时返回 429 和 Retry-After,让客户端知道等多久;只返回 500 会触发无条件重试。单机限流实现简单,但实例数变化时总阈值会漂移;需要全局精确值时用集群限流,代价是 Token Server 的可用性和额外一跳。
限流之后返回什么要提前定:快速失败、排队、还是转熔断。排队会占线程,熔断要配合下游恢复;不要把限流、熔断、业务异常都映射成同一个 500。
实战场景
一、上游 3 秒就走,下游干了 7 秒还没人认领
现象:搜索推荐链路入口超时设 3s,用户侧表现为“偶发加载失败”。但服务端更反常:推荐服务 CPU 不高,数据库连接池长期打满,slow_query_log 里出现大量“执行完成但客户端已断开”的语句。trace 显示一条 SQL 在第 7 秒才结束,而用户在第 3 秒已经拿到错误页。
排查:确认是否存在“做了但没人要”的孤儿工作。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 各跳超时值 | 汇总表:入口 / 网关 / A / B / C / DB 的 connect、首字节、read 超时 | 每跳都是 3s,最坏链路是各跳之和而不是 3s |
| deadline 是否存在 | curl -i 看下游请求头里有无 x-deadline-ms | 没有 → 只传了各自配置,没有共享预算 |
| 本跳是否尊重余量 | 下游 socket read 超时是否等于 remaining | 只传 header 不改超时 → 慢下游照样占住线程 |
| 孤儿工作量 | “客户端已断开但仍执行完成”的请求计数(按连接关闭事件统计) | 占比高即说明无效负载真实存在 |
| 重试叠加 | 同一 traceId 的下游调用次数 | 入口重试 2 次会把孤儿工作乘 3 |
| 排队位置 | 线程栈停在 DB 等待还是本地计算 | 等待型占用是连接池耗尽的主因 |
根因结构:B 不知道 A 只愿意等 3 秒,于是继续等 C;A 已经放弃,B 占着的连接和事务没人通知它释放。
对症做法:
java
// 1. 每跳用剩余预算作为本跳读超时,而不只是传 header
public class DeadlineFeignInterceptor implements RequestInterceptor {
private static final long MARGIN_MS = 200;
public void apply(RequestTemplate t) {
Long deadline = DeadlineContext.get();
if (deadline == null) return;
long remaining = deadline - System.currentTimeMillis() - MARGIN_MS;
if (remaining <= 0) throw new DeadlineExhaustedException("no budget left");
t.header("x-deadline-ms", String.valueOf(deadline));
t.request().setTimeout((int) remaining); // 关键:本跳超时随剩余收紧
}
}
// 2. 预算耗尽要映射成确定语义,而不是 500
@RestControllerAdvice
class DeadlineAdvice {
@ExceptionHandler(DeadlineExhaustedException.class)
ResponseEntity<ApiError> onExhausted(DeadlineExhaustedException e) {
return ResponseEntity.status(504) // 504 而不是 500
.body(ApiError.of("DEADLINE_EXHAUSTED", e.getMessage()));
}
}
// 3. 长任务内部也要检查:批处理每页判断一次剩余预算,主动放弃
while (hasMore() && DeadlineContext.remaining() > NEXT_PAGE_COST_MS) { fetchNextPage(); }text
4. 分层预算写成表,发布前用脚本校验不等式:
网关端到端 3000ms > 服务总预算 2500ms > 单依赖超时(B 读 800ms = P99 600ms + 余量 200ms)
连接超时 300ms;重试次数计入总预算,剩余不足以完成下一次尝试时不重试跨语言只传绝对 epoch 毫秒并约定 NTP 同步;时钟偏差超过安全余量时 deadline 会失真,这点要在接入文档里写明。
复盘要点:判据是“孤儿工作比例”——客户端断开后仍完成的请求占比应持续下降(阈值例如 2%,经验值,需校准)。这条链路还需要与熔断配合:deadline 让本跳不再等,熔断让本跳不再调;两者解决的不是同一个问题,不能互相替代。
二、自适应限流把冷启动的自己限死了
现象:新上的自适应规则在凌晨发布后开始拒绝请求,blockQps 占总请求 40%;白天正常、夜间误杀严重。手动把阈值改回固定值后立刻恢复。
排查:自适应规则的错误几乎都发生在“样本不足”和“步长叠加”两处。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 最小样本数 | 触发时刻的 passQps 与规则里的 minRequestAmount | 夜间 QPS 低于最小样本 → 一两个慢请求就能把均值推高 |
| 连续采样 | 触发需要连续几次超阈值 | 1 次即触发 → 偶发抖动被当成趋势 |
| 下调步长叠加 | 阈值随时间的曲线 | 每窗口降 10%,连续 6 窗口降到下限附近 |
| 上下限 | 阈值是否被压到容量 30% 以下 | 下限设置错误导致“越降越死”的正反馈 |
| 调整周期 | 上调速度与下调速度是否对称 | 上调太快会抖动,太慢会长时间误杀 |
| 信号来源 | avgRt 是否被少数长任务污染 | 长任务与短请求共用资源 → 均值失真 |
本例命中的是最小样本与步长叠加:夜间 QPS 约 5,慢请求阈值按白天容量算;两笔 2s 的导出任务把 avgRt 拉高,规则连续下调到下限,普通请求随之被拒。
对症做法:
json
// 规则里把“不触发”的条件写足,而不是只设阈值
{
"resource": "order-query",
"signal": "avgRt",
"triggerThresholdMs": 800,
"minRequestAmount": 20, // 样本不足不触发,避免冷启动误判
"consecutiveSamples": 5, // 连续 5 个窗口超标才动作
"stepDownRatio": 0.05, // 每次只降 5%
"stepUpRatio": 0.02, // 恢复时更缓慢上调
"lowerBound": 60, // 不低于压测容量的 30%
"upperBound": 320, // 不高于压测容量的 80%
"minAdjustIntervalMs": 5000
}java
// 长任务与在线请求分资源:统计口径不同,就不能共用一条自适应规则
@SentinelResource("report.export") // 单独资源,用线程数限流而非 RT 自适应
public String export(ExportReq req) { ... }观察口径要同时看 passQps、blockQps、avgRt 三条曲线,只看阈值本身会误判;规则进配置中心并版本化,灰度到少量实例,保留上一版可回滚。
复盘要点:自适应限流是保护手段,不是容量规划。判据是“持续触顶次数”:同一规则连续触顶超过一个业务周期(例如 30 分钟)就说明容量不足,要走扩容或降级,而不是继续下调阈值。误杀要有独立告警:blockQps / 总请求 > 5% 且 avgRt 正常时立刻通知,因为这几乎一定是规则问题。
三、限流返回 500,客户端把保护变成重试风暴
现象:大促开始 5 分钟,营销接口按规则限流(预期拒绝 20%),实际却出现整体吞吐腰斩、延迟从 80ms 涨到 2s,并且每 800ms 出现一次尖刺。客户端 SDK 的日志里满是“收到 500,立即重试”。
排查:限流生效之后的响应形态决定了客户端行为。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 拒绝时的状态码 | 被限流请求的响应码 | 500 → 客户端视为可重试错误 |
是否带 Retry-After | 响应头 | 缺失 → 客户端不知道该等多久 |
| 重试退避 | 客户端重试间隔分布 | 固定 800ms 且无 jitter → 周期性洪峰与尖刺 |
| 语义是否可区分 | 业务异常、限流、熔断是否共用同一码 | 无法区分 → 兜底策略全错 |
| 服务端排队 | 排队等待策略是否占住线程 | controlBehavior=排队 时线程被拖长 |
限流的目标是保护容量,但把拒绝表达成 500 就把保护变成了放大器。
对症做法:
java
// 1. 拒绝给 429 + Retry-After + 幂等提示,客户端据此退避
@ExceptionHandler(BlockException.class)
ResponseEntity<ApiError> onBlock(BlockException e) {
return ResponseEntity.status(429)
.header("Retry-After", String.valueOf(retryAfterSeconds(e))) // 建议 1–3s,带 jitter
.body(ApiError.of("RATE_LIMITED", "请求过多,请稍后重试"));
}yaml
# 2. 错误语义分工写进契约:限流 429、熔断降级 503、参数 400、业务失败 200 + 错误码
# 503 里带降级说明,客户端不再把它当“未发生的操作”java
// 3. 客户端策略:只对幂等读重试,指数退避 + ±20% jitter,且尊重 429 的 Retry-After
Retry backoff = Retry.backoff(2, Duration.ofMillis(200))
.maxBackoff(Duration.ofSeconds(2))
.jitter(0.2)
.filter(t -> t instanceof IOException); // 业务错误不重试排队类流控(controlBehavior 为匀速排队)会占住线程,只适合短时削峰,且队列等待时间要设上限;容量型保护优先用快速失败加明确状态码。
复盘要点:限流的验收要包含“被拒请求的客户端行为”:演练时观察拒绝后的重试率,接近 0 才是正确语义。判据可固化成契约测试——限流响应必须是 429 且带 Retry-After,出现 500 即失败;这与 熔断降级与限流 里的降级结果设计是同一条纪律。
最小上手
下面是一段最小实现,示例命名,省略错误处理与参数校验:
java
public final class DeadlineContext {
private static final ThreadLocal<Long> DEADLINE = new ThreadLocal<>();
public static void set(long deadlineMs) { DEADLINE.set(deadlineMs); }
public static Long get() { return DEADLINE.get(); }
public static void clear() { DEADLINE.remove(); }
}
public class DeadlineFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest http = (HttpServletRequest) req;
String raw = http.getHeader("x-deadline-ms");
long deadline = raw == null
? System.currentTimeMillis() + 3000 // 无头时用默认预算
: Long.parseLong(raw);
DeadlineContext.set(deadline);
try { chain.doFilter(req, res); } finally { DeadlineContext.clear(); }
}
}
public class DeadlineFeignInterceptor implements RequestInterceptor {
private static final long SAFETY_MARGIN_MS = 200;
public void apply(RequestTemplate template) {
Long deadline = DeadlineContext.get();
if (deadline == null) return;
long remaining = deadline - System.currentTimeMillis() - SAFETY_MARGIN_MS;
if (remaining <= 0) throw new IllegalStateException("deadline exhausted");
template.header("x-deadline-ms", String.valueOf(deadline));
}
}拦截器只解决「传」;本跳读超时还要设成 remaining(Feign 可通过自定义 Client 包装 Request.Options,具体 API 以所用版本为准)。验证:
bash
# 选一个会发起下游调用的接口。预算已耗尽:应不发起调用,立即失败
# (示例未做错误映射,异常会变成 500;生产应映射为 504 或约定错误码)
curl -i -H 'x-deadline-ms: 1' http://127.0.0.1:8080/api/order/1
# 预算充足:正常返回 200,下游日志里能看到同一个 deadline
curl -i -H "x-deadline-ms: $(( $(date +%s) * 1000 + 3000 ))" \
http://127.0.0.1:8080/api/order/1自适应规则的字段通常包括资源名、触发指标、阈值、调整步长、窗口和上下限。以 Sentinel 1.8.x 的动态数据源为例,规则 JSON 由配置中心下发,应用侧监听后调用 FlowRuleManager.loadRules(...);自适应规则若为自研扩展,注册方式以具体实现为准。
生产动作
- 超时值来自 P99,不拍脑袋。每跳读超时 = 下游 P99 + 安全余量;连接超时 200–500ms;首字节超时按下游首包 P99。没有 P99 就先压测或从 trace 里取。
| 层 | 示例预算 | 依据 |
|---|---|---|
| 网关端到端 | 3000ms | 面向用户的 SLO |
| 服务总预算 | 2500ms | 网关预算减去网关与网络开销 |
| 下游 B 读超时 | 800ms | B 的 P99 600ms + 200ms 余量 |
| 连接超时 | 300ms | 局域网握手远小于此 |
- 重试开关按接口开:只给幂等 GET 和带幂等键的写开;次数 1–2 次,退避基数 100ms,jitter ±20%;扣款、创建订单、长事务内调用禁止重试。
- 自适应规则的上下限与步长:下限不低于压测容量的 30%,上限不超过容量的 80%(经验值);步长 3%–10%,窗口 1–2s;观察
passQps、blockQps、avgRt,不要只看阈值本身。 - 规则变更可回滚:规则进配置中心并版本化,灰度到少量实例,保留上一版;变更记录里写清触发指标和预期。
- 排障路径:
- 网关 504、下游日志显示调用仍在继续 → 查每一跳的
x-deadline-ms是否还在 → 判断是 header 丢传还是本跳没设读超时。 - P99 升高但本机 CPU 不高 → 查下游 RT 和连接池等待 → 判断是下游慢还是本服务排队。
- 发布后
blockQps突增 → 查规则版本、最小样本数、阈值下限 → 判断是规则误杀还是容量真的下降。 - 客户端重试风暴 → 查 429 是否带
Retry-After、重试是否无退避 → 判断是限流返回不完整还是客户端策略问题。
- 网关 504、下游日志显示调用仍在继续 → 查每一跳的
边界
- deadline 依赖调用方时钟:跨语言、跨团队要先统一协议和时钟同步方式;时钟偏差大于安全余量时,截止时间会失真。
- 自适应限流是保护,不是容量规划:持续触顶说明容量不足,该扩容或降级;只把阈值调高只是把故障推迟。
- 热点参数有内存成本:参数基数不可控(如原始 URL、随机 ID)时不要用,或先做参数归一化。
- 流式/长连接不适合固定总超时:用空闲超时判断「连接是否还活着」,用 deadline 约束「整个请求最晚何时结束」,两者分开。
- 重试不能修复非幂等写:重试只适合可安全重复的调用;下游过载时,重试越多故障越久。
讲清楚
全链路超时解决的是「每跳各设 3s,上游 3s 就走了,下游还在等」的问题:入口生成一个绝对 deadline,每跳用剩余预算减安全余量作为本跳超时,预算不够就快速失败,重试也必须从这份预算里扣。自适应限流解决的是「阈值写死,扩容白限、缩容保护不住」的问题:用 RT、异常比例、异常数或系统负载做信号,连续超阈值才下调,恢复后缓慢上调,并设上下限和调整周期防抖动。两者共同的代价是约定必须贯穿全链路:header 要传、线程池和 MQ 要复制上下文、规则要能回滚。deadline 依赖时钟同步,自适应限流只是保护手段,持续触顶要扩容而不是继续调阈值。