限流、熔断与降级:过载保护的三道刹车 原创
本文是 SRE 实战系列第 25 篇,第三循环的第一篇。
前二十四篇把"人怎么把系统管住"写完了:定目标、被叫醒、安全地改、证明它扛得住、算清余量、组织一次事故、把数据捞回来。这一篇写的是另一件事——系统在被压垮的那几秒里,自己有没有刹车。
选择这个题目的原因不复杂:前面的篇目已经在反复调用它。第 8 篇的处置表把"切降级开关"写成 10 秒止血动作;第 10 篇的止血命令里写着"开启熔断(如果服务有熔断配置)";第 19 篇的止血清单问"有没有更小的动作(限流、降载、关开关)";第 20 篇演练撞出的现场是"熔断器触发了,但降级打到了两年前下线的老接口";第 21 篇要求"限流与降级方案必须在峰值场景下演练过";第 22 篇把"要不要限流"列为 IC 必须拍板的决定。六篇都在用它,没有一篇写过它。
全文约 1.4 万字。从一次 36 分钟的故障开头:机器全在线、CPU 只有 61%、没有一行报错,下单接口的 p99 却从 180ms 涨到了 6 秒。查到最后发现,应用一直在正常工作,只是工作得越来越慢。
然后回答六个问题:为什么过载不是"请求太多"而是"等待太长"、超时预算该怎么在一条链路上分配、重试为什么能把流量放大 27 倍、固定 QPS 限流在真实流量下错在哪、熔断的半开探测和冷启动为什么会骗人、开关为什么只增不减。
这一篇的立场:过载保护不是给系统加几道阀门,是给系统写一份"撑不住的时候先放弃什么"的优先级表。没有这份表的系统,在过载时不是保护自己,而是随机放弃用户。
一、36 分钟:机器都正常,但什么都慢了
一个电商平台的订单域,周三晚 20:10 开始出问题。
告警是延迟类的:order-api p99 从 180ms 涨到 6.2s,错误率 0.02% → 1.3%。这个量级按第 13 篇的分级是个 P2,值班同学按第 22 篇的定级树定了 SEV-2。
他先做的是最标准的四步验证:指标、Pod、事件、日志。
- 指标:CPU 61%,内存 58%,GC 正常,所有资源都没满
- Pod:40 个副本全部 Ready,没有任何重启、没有 OOMKilled、没有探针失败
- 事件:最近 30 分钟没有发布、没有配置变更、没有节点漂移
- 日志:没有 ERROR,只有一种 Warning,出现了 4000 多次——
upstream request timeout after 2000ms
20:20,他把日志里那个 warning 截了图发到群里,同时补了一句"下游 payment-gateway 超时,但网关那个服务的指标都很正常"。
这句话是当时最关键的信息,也是当时最容易被忽略的信息。两个服务的指标都正常,只有一个服务的调用方在报超时——这类现象有一个统一的解释框架,但那天群里没人想到。
20:31,链路追踪数据被调出来了。下面是 20:15 那一分钟的调用链 p99 分解:
| 段 | 目标 | 端点 | 耗时 |
|---|---|---|---|
| 前端 → 网关 | 800ms | $LB | 210ms |
| 网关 → order-api | 1000ms | /api/order/create | 8ms |
| order-api 自身 | — | 业务逻辑 | 40ms |
| order-api → payment-gateway | 2000ms | /charge | 2000ms |
| order-api → inventory | 800ms | /deduct | 22ms |
| order-api → 订单库 | 300ms | pg/order | 15ms |
| 合计 | 6105ms |
七个 span,六个在几十毫秒的量级,合计本该是 210 + 8 + 40 + 20 + 22 + 15 ≈ 315ms,实际是 6105ms。差的那 5.8 秒全在 payment-gateway 的一个 2000ms 上,而且这个 2000ms 不是一次,是三次——重试的痕迹被 span 的 parent-child 关系藏起来了,因为重试没有新建 trace。
20:38 查到根因:支付网关那边有个渠道(某家银行)在 20:00 之后开始变慢,平时 80ms 的调用变成 1.9s,没超时,只是慢。这个渠道占全部流量的 4%。
一个 4% 流量的渠道变慢,是怎么把整条下单链路拖到 6 秒的?三个动作叠在一起,每个单独看都是"合理的工程实践":
- 超时设成 2000ms。 这是支付网关的调用方配置的超时,理由是"支付渠道偶尔慢,超时设短了会造成不必要的失败"。这个 2000ms 单独看没问题,但它占了订单接口 p99 预算(200ms)的 10 倍。
- 失败重试 3 次。 这是 SDK 的默认配置,指数退避 200ms / 400ms / 800ms。重试本身也没问题——支付这种接口最怕的是"没成功也没失败",重试是必要的补偿手段。
- 重试不区分错误类型。 超时重试、5xx 重试、连接失败重试,全走同一条路。慢渠道的调用超时 → 重试 → 还是那个慢渠道 → 再超时,最长路径 2000×3 + 退避 1.4s = 7.4 秒。
于是 order-api 的线程池开始堆积。40 副本 × 200 线程 = 8000 个线程,每个线程被一个慢调用钉住 6 秒,吞吐量从 4.2k QPS 掉到 700 QPS,Tomcat 队列排满,后面的请求在队列里等到超时,前端拿到 503 开始重试……
20:44,运维同学做了两件事,问题在 20:46 结束:
第一件是关掉的:把 payment-gateway 的调用超时从 2000ms 改成 800ms。 第二件是打开的:把那个慢渠道的熔断开关打开,直接返回"该渠道暂不可用,请更换支付方式"。
36 分钟,从 20:10 告警到 20:46 恢复。
事后复盘会上,有人问了一个很不好回答的问题:这 36 分钟里,系统做了哪一件事是在保护自己?
答案是没有。它做了三件事:接受请求、等待、重试。三件事都在放大自己承受的压力。系统里甚至装了熔断器(SDK 自带,默认阈值),它一次都没触发——因为熔断器的阈值是"错误率 50%",而那条链路的错误率只有 1.3%。链路没有被错误打垮,它是被等待拖垮的。
二、过载不是"请求太多",是"等待太长"
上一节的现象能落到一个很简单的式子上。任何一个能处理并发的系统,都可以用利特尔法则描述(第 21 篇也用过它来描述容量):
并发数 L = 到达率 λ × 停留时间 Wλ:每秒进来多少请求W:一个请求在系统里停留多久(含排队)L:同一时刻系统里有多少请求正在被处理或在排队
过载的定义不是 λ 很大,而是 L 超过了系统能同时处理的线程数/连接数。而 L 是 λ 和 W 的乘积——W 变长和 λ 变大,效果一模一样。
那天的数字:
- 平时:λ = 4.2k QPS,W = 0.2s,L = 840
- 故障时:λ = 4.2k QPS(流量没涨),W = 6s,L = 25,200
流量一个都没多,并发翻了 30 倍。8000 个线程面对 25,200 个并发请求,注定有 17,000 个请求在等。等不到的请求超时、前端重试、重试产生新的 λ。
2.1 一个反直觉的结论:过载时,拒绝请求比处理请求更有价值
这是过载保护最反直觉、也最需要被讲清的一点。
系统在过载时有一个它自己不知道的状态:它处理每个请求的成本在上升。 因为队列变长、缓存失效、上下文切换变多、锁竞争变多,同样一个请求,空闲时要 10ms,过载时要 100ms。
用一个简化模型看。假设一个服务有 100 个线程,每个请求的"纯处理时间"是 10ms,忽略排队:
空闲时:吞吐 = 100 线程 / 0.01s = 10,000 QPS但如果排队长度是 n,请求的完成时间会包含排队,而排队会让 CPU 花在调度和上下文切换上的时间增加,实际处理时间上升。用 k 表示"过载导致处理时间膨胀的倍数":
过载时:吞吐 = 100 / (0.01 × k)
k = 2 → 5,000 QPS
k = 4 → 2,500 QPS也就是说,进来 10,000 QPS 的系统,在过载状态下只能完成 2,500 个,剩下 7,500 个全部超时失败,而且它们每一个都消耗了资源。 那些被消耗掉的资源,本来可以服务于 2,500 个能成功的请求。
更糟的是,这些注定失败的请求不是零成本的:它们读完了缓存、查了数据库、占了一个线程 6 秒,然后失败。它们把成本付给了整个系统,收益是零。
有效吞吐 = 吞吐 × 成功比例
过载且不拒绝:2,500 × 25% = 625 有效 QPS
过载且快速拒绝 70%:3,000 × 100% = 3,000 有效 QPS数字是构造出来的,但方向是对的,而且现实里比这更悬殊。这就是为什么"过载保护"这个说法容易误导人:它保护的从来不是系统,是那些本来能成功的用户。 放弃一部分请求,是为了让剩下的请求真的被完成。
第 8 篇的 Runbook 里有一步叫"扩容买时间",第 22 篇说过"扩容是买时间,不是解决问题"。过载保护里也是同一个道理:刹车不是让车停下,是让车不撞墙。
2.2 刹车要装在四个位置
把一辆车的刹车拆开看,过载保护的机制也正好是四层,从外到内:
| 位置 | 机制 | 保护对象 | 失效后果 |
|---|---|---|---|
| 入口 | 限流(Rate Limiting) | 整个系统 | 请求涌进来,全线排队 |
| 出口 | 舱壁(Bulkhead)+ 超时 | 单个依赖 / 单类调用 | 一条慢依赖拖垮全部线程 |
| 链路 | 熔断(Circuit Breaker) | 下游 + 自己 | 持续给一个坏掉的东西发请求 |
| 功能 | 降级(Degradation) | 用户能感知的部分 | 全部功能一起失败,或者全部功能一起拖慢 |
四个位置对应四种不同的判断依据:入口看速率,出口看占用,链路看健康度,功能看价值优先级。
这一篇按这个顺序讲,每一层都给可落地的配置和容易踩的错。但在此之前,必须先讲那个把上面那个案例打穿的东西——超时预算。它是所有四层的地基,也是那天最直接的技术原因。
三、超时预算是所有刹车的地基
上一节的案例里,2000ms 这个数字单独看完全合理。它错的地方不是太大,是没人算过它和上游的关系。
3.1 一条链路的超时之和,不能超过入口的 SLO
一句话规则:
一条调用链上,所有下游超时之和(含重试和退避),必须小于入口处的超时;而入口超时必须满足 SLO 的延迟要求。
写成一个不等式:
Σ(timeout_i × max_retries_i + backoff_total_i) + 自身处理时间 < upstream_timeout ≤ SLO_p99那天这条链路的账是这样的:
payment-gateway: 2000ms × 3 次 + 退避 1400ms = 7400ms
inventory: 800ms × 2 次 + 退避 200ms = 1800ms
订单库: 300ms × 1 次 = 300ms
自身处理 = 40ms
合计 = 9540ms
入口(网关 → order-api)的超时 = 1000ms
SLO 上 p99 = 200ms下游一个环节的超时上限(7400ms)是入口超时(1000ms)的 7.4 倍,是 SLO(200ms)的 37 倍。 这份配置从写下的那天起就不可能满足 SLO,只是平时渠道不慢,没人发现。
这解释了那天最诡异的现象:为什么应用"一切正常"。因为在应用看来,它做的事全都是对的——它等 2000ms(小于自己配置的超时 2000ms),它重试(符合 SDK 默认策略),它最终可能成功了(第 3 次重试成功)。没有一次决策是错的,但每一个决策都让自己的 6 秒无法避免。
3.2 超时预算怎么倒着算
正确的做法是从入口往回算,而不是每个调用各凭经验。给一个可以直接用的推算规则。
假设入口对下游的超时预算是 T(由 SLO 决定),链路上一共有 n 个串行依赖,那么:
每个依赖的超时上限 ≈ T × (该依赖的 p99 基线 / 链路 p99 总基线)
再留 10~20% 的余量给自身处理和抖动用那天的数据算一遍,假设入口预算取 SLO 的 3 倍(600ms,留给抖动):
链路 p99 总基线 = 315ms
payment-gateway 基线 80ms → 预算 ≈ 600 × 80/315 × 0.9 ≈ 137ms
inventory 基线 22ms → 预算 ≈ 600 × 22/315 × 0.9 ≈ 38ms
订单库基线 15ms → 预算 ≈ 600 × 15/315 × 0.9 ≈ 26ms算出来的数字会让很多人第一反应是"太小了,这样会误判"。这个反应本身说明了一件事:团队对"支付网关慢到 137ms"这件事的接受度,和它对"SLO 200ms"的承诺,是两个互相矛盾的东西。 超时预算的作用就是把这个矛盾摆到桌面上。
实际落地时不会一刀切到 137ms,而是取一个折中(比如 300ms),但折中的过程必须显式算一次,而不是各自拍脑袋。这里有个工程上的务实做法:把预算表写进配置仓库,用 CI 检查——
# timeout-budget-check.py(挂在 CI 的配置校验阶段)
# 目标:任何一次超时配置的修改,都必须重新算一遍总账
BUDGET = { # 入口 p99 预算,单位 ms
"order-api": 600, # SLO 200ms × 3(留抖动余量)
}
CHAINS = {
"order-api": [
# (依赖名, timeout_ms, max_retries, backoff_ms 总退避, 基线p99)
("payment-gateway", 300, 2, 300, 80),
("inventory", 80, 1, 0, 22),
("order-db", 30, 0, 0, 15),
],
}
def check(service):
budget = BUDGET[service]
total = 0
for name, t, retries, backoff, baseline in CHAINS[service]:
worst = t * (retries + 1) + backoff
total += worst
if t > budget * 0.6:
print(f"[WARN] {name} 单次超时 {t}ms 已占入口预算 {budget}ms 的 {t/budget:.0%}")
self_time = 50
if total + self_time > budget:
raise SystemExit(
f"[FAIL] {service} 最坏路径 {total + self_time}ms 超过入口预算 {budget}ms,"
f"请调小超时或重试次数"
)
print(f"[OK] {service} 最坏路径 {total + self_time}ms / 预算 {budget}ms")
check("order-api")这个脚本的价值不在于它算得多准,而在于它把"最坏路径"这个数字变成了配置的一部分。当有人把超时从 300ms 改成 2000ms 时,CI 会红,改动者必须回答"为什么",而不是悄悄合进去。
3.3 超时通常"向下传递"这条规则,是很多错误的来源
有一个在分布式系统里流传很广的做法:下游超时应该小于上游超时,逐层递减(也常被叫做"超时传递"或 deadline propagation)。
这条规则本身是对的,但它正确的前提是"各层都有适配",而很多实现只做了一半。常见的两种错:
错误一:只在第一层递减,后面全是常量。 网关传 1000ms 下来,order-api 传 2000ms 给支付——递减了一次,然后数字就不管了。上面的案例就是这个形状。
错误二:deadline 传下去了,但重试没算进去。 gRPC 和 HTTP 都有 deadline / X-Request-Deadline 之类的机制,把剩余时间传下去,下游据此决定"还来不来得及"。但绝大多数重试逻辑是在调用方实现的,重试时会重新发起一个新的请求,deadline 也重新算——于是三次重试各自以为自己有 300ms,加起来 900ms,而调用方本身只剩 200ms。
后一种情况有个很实用的检查办法:在 trace 里让重试产生新的 span,并带上 retry.attempt 标签。 这样重试就不会被 parent-child 关系藏起来,一条 trace 里能看到三个耗时为 300ms ± 2ms(说明每次都打满超时)的 span,问题一眼就能看出。
# OpenTelemetry Collector:给 span 补 retry.attempt(从 SDK 打点即可)
# 关键不是配置,是纪律:凡是重试,必须新建 span 并打 attempt 标签
processors:
attributes/retry:
actions:
- key: retry.attempt
from_attribute: http.request.resend_count # 部分 SDK 会填这个
action: insert3.4 超时之后要不要重试,取决于错误的种类
超时的处理是过载保护的分水岭。一个超时发生后,有三种可能:
| 错误形态 | 含义 | 该不该重试 |
|---|---|---|
连接失败(ECONNREFUSED、DNS 失败) | 对端不在,或者被拒绝 | 可以重试,但要换实例 |
| 请求超时(已发出,无响应) | 不知道对面做没做 | 只有幂等操作可以;且必须限制次数 |
| 明确失败(4xx、业务错误码) | 对面明确失败 | 不重试(重试也不会变好) |
第二条是重试的真正风险所在。支付这种接口,超时意味着"可能已经扣款",重试可能造成重复扣款;而反过来不重试,可能造成"用户付了钱但订单没生成"。这两种后果都不轻,所以这里的正确做法不是"重试或不重试",而是先让操作幂等(幂等键 + 去重表),再谈重试。第 19 篇第五节讲回滚不是撤销键时说过同一件事:写操作的可重复性,是比报错信息更重要的属性。
一个可以直接抄的幂等键约定:
idempotency_key = {business_type}:{business_id}:{attempt_scope}
例如:charge:order_20261110_8823:v1
↑ 业务类型 ↑ 业务主键 ↑ 同一业务动作的版本(重试时不变)关键是重试时必须复用同一个 key,而不是生成新的。很多团队的重试 SDK 在重试时会重新生成 request id,导致服务端的去重表完全不起作用——去重表建了,但没人在用它。
四、重试放大:一次重试风暴的流量是 27 倍
上一节讲的是"一次重试"。这一节讲"重试在链路上叠加",它是过载事故里杀伤力最大的一环,也是计算上最容易算错的一环。
4.1 三层重试 = 27 倍
一个三层调用:client → service-a → service-b → service-c,每一层都配了"失败重试 2 次"(即最多 3 次尝试)。
当 service-c 慢或挂的时候:
c 收到的请求量 = 1(原始)
b 收到 = 3 (b 对 c 重试 3 次)
a 收到 = 9 (a 对 b 重试,每次 b 又对 c 重试 3 次)
client 发出 = 27 (client 对 a 重试,每次 a 又向下放大 9 倍)放大倍数 = ∏(1 + retries_i)3 层各重试 2 次 → 3³ = 27。这不是理论值,是很多重试风暴的现场量级。而实际情况通常更糟,因为:
- 重试是同时发生的。 第一波失败后,所有调用方在同一秒重试,形成尖峰。所以重试必须加抖动(jitter),否则退避只是把尖峰往后推。
- 重试打在已经过载的下游上。 下游本来就慢,重试让它更慢,然后触发更多重试。这是一个正反馈。
- 重试发生在最长的那条路径上。 慢的调用最容易超时,也最容易被重试,于是重试流量集中在最慢的依赖上。
4.2 三条纪律
纪律一:链路上只允许一层重试。
这是最有效也最不受欢迎的一条。常见做法是"业务层重试 + 网关层也重试 + SDK 自带重试",三层每层都觉得"我只是加了一点点可靠性"。落地做法很简单:在所有中间层的调用上,把重试次数显式设为 0 或 1,只保留最外层(或者专门的重试代理)具备重试能力。
# 反例:每一层都在"贴心地"重试
service-a: {retries: 2}
service-b: {retries: 2}
service-c: {retries: 2}
# 正例:重试集中在一层,其余显式关闭
client: {retries: 2, backoff: exponential, jitter: full}
service-a: {retries: 0}
service-b: {retries: 0}用第 3.2 节那个脚本再加一条检查,把"链路重试层数"变成一个能被 CI 校验的数字,比写给团队看十遍文档有用。
纪律二:只重试幂等操作,且只重试特定错误。
非幂等写操作(创建订单、扣款、发券)在没有幂等键的情况下不该重试。这一条在多数团队没有争议,落不了地的地方在于"哪些错误该重试":
| 该重试 | 不该重试 |
|---|---|
| 连接超时、连接被重置 | 业务错误码(余额不足、参数错) |
| 5xx 中的 502 / 503 / 504 | 4xx(除 429)、500 |
明确的重试建议头(Retry-After、429) | 认证失败、权限不足 |
纪律三:重试预算要跟着错误预算走。
一个可以被量化的做法:给重试流量设一个上限,超过就整体熔断重试。
重试流量上限 = 正常流量的 20%
监控表达式:
sum(rate(http_requests_total{retry="true"}[1m]))
/ sum(rate(http_requests_total[1m])) > 0.2
→ 触发 RetryStormDetected 告警,并自动把重试开关关掉(回落到直接失败)这条规则在一个真实团队里拦住过一次事故:某次下游抖动,重试流量从 2% 涨到 34%,告警在 40 秒内触发,重试被自动关闭,下游在 3 分钟内恢复。如果没有这个开关,重试会把下游压死,恢复时间会按十分钟算。
重试放大也是 27 倍公式的另一面:它的危险不只在于总量,还在于它把"下游慢"变成了"下游挂"。一次 30% 性能下降,经过三层重试就可能变成 8 倍的负载冲击。第 20 篇那场演练里注入的"下游超时",最终撞出来的就是这个形状。
五、限流:固定 QPS 为什么在真实流量下必然错
超时和重试管的是"已经进来的请求"。限流管的是"让多少请求进来",也是过载保护的第一道也是唯一一道入口刹车。
5.1 静态阈值的三个无解问题
先看一段绝大多数团队的限流配置:
# 网关限流,常见形态
rate_limit:
endpoint: /api/order/create
qps: 5000 # 这个数字通常是"扩容之后压测过的最大值"
burst: 10000
action: reject # 直接返回 429这份配置有三个问题,每一个都不需要跑起来就能验证:
问题一:阈值是单机能力和副本数的乘积,而副本数会变。 5000 这个数字是按 40 副本算的。HPA 缩到 20 副本时(夜间常态),真实承载上限变成 2500,但限流仍然放 5000 进来。限流器不知道自己的系统缩了一半。
问题二:真实容量会随着状态变化。 缓存全热时一个实例能扛 300 QPS;缓存被清空过一次之后只能扛 80。同样的请求,同样的机器,不同的容量。限流器读的是一个常量,而容量是一个随缓存命中率、数据分布、下游健康度变化的变量。第 21 篇算的"水位线"是区间,而限流器要的是边界判定。
问题三:固定阈值在流量曲线面前是滞后的。 峰值来的时候,固定阈值要么太松(冲垮),要么太紧(提前拒绝正常用户)。这两件事没有中间地带,因为阈值本身是个常数,而负载是曲线。
再补一个更隐蔽的问题:分布式限流的一致性。 40 个副本各限 125 QPS,看起来总数是 5000。但流量不均匀,某些实例拿到了 300 QPS(被拒绝一部分),另一些只有 20 QPS(大量空闲)。总量没到阈值,但局部已经在拒绝。这是一个古老的问题("热点实例"),解决它通常靠更均匀的负载均衡,而不是更精细的限流。
5.2 一条更实用的路:自适应并发限制
与其"限每秒多少个请求",不如"限每秒完成多少个请求"——或者更准确地说,限同时在处理的请求数。这是并发限制(concurrency limiting)和速率限制(rate limiting)的区别,也是过载保护里最容易被忽略的一次视角切换。
为什么要限并发?回到第二节那个式子 L = λ × W:
- 少部分请求变慢(W 上升)时,
L会自己涨上去,而你并没有放更多请求进来 - 限速率(λ)对这种情况完全无效——λ 根本没变
- 限并发(L)才直接作用于真正的瓶颈
Netflix 的 concurrency-limits 和 Java 生态的 Resilience4j 都用了这个思路,其中"梯度下降"(gradient)和 AIMD(加性增、乘性减)是两种常见的自适应算法。AIMD 的规则可以只用三行描述:
稳态:每过一个采样窗口(比如 1 秒),允许并发 +1
异常:一旦检测到排队(延迟超过基线 × 2),允许并发 × 0.5
约束:允许并发 ∈ [下限, 上限],上限由压测给出这三行的效果是:系统自动在"刚好不排队"的点上振荡。它的判断依据不是外部给的数字,而是自己测出来的延迟——也就是系统自己感受到了压力。这正是第 21 篇的水位线想做的事,只是用了更快的反馈回路。
一段可以直接跑的 Go 实现(约 60 行),基于排队时间做 AIMD:
package overload
import (
"math"
"sync"
"time"
)
// AdaptiveLimiter 基于 Little's Law 估算当前允许并发:
// L = λ × W
// 目标:把 W(排队+处理时间)控制在 targetLatency 附近
type AdaptiveLimiter struct {
mu sync.Mutex
limit float64 // 允许并发
minLimit float64
maxLimit float64
targetLatency time.Duration // 目标排队时间,取压测基线的 2 倍
count int64 // 当前在途请求
windowStart time.Time
completed int64
sumLatency time.Duration
}
func NewAdaptiveLimiter(maxLimit float64, target time.Duration) *AdaptiveLimiter {
return &AdaptiveLimiter{
limit: maxLimit * 0.3, // 从较低水位起步,向上探测
minLimit: maxLimit * 0.05,
maxLimit: maxLimit,
targetLatency: target,
windowStart: time.Now(),
}
}
// Acquire 尝试获取一个并发名额。返回 false 表示应快速拒绝。
func (l *AdaptiveLimiter) Acquire() bool {
l.mu.Lock()
defer l.mu.Unlock()
if float64(l.count) >= l.limit {
return false
}
l.count++
return true
}
// Release 归还名额,并记录本次请求的停留时间。
func (l *AdaptiveLimiter) Release(latency time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
l.count--
l.completed++
l.sumLatency += latency
// 每 1 秒调整一次
if time.Since(l.windowStart) < time.Second {
return
}
avg := float64(l.sumLatency.Microseconds()) / float64(l.completed)
target := float64(l.targetLatency.Microseconds())
switch {
case avg > target: // 排队严重:乘性减
l.limit = math.Max(l.minLimit, l.limit*0.5)
case avg < target*0.7: // 余量充足:加性增
l.limit = math.Min(l.maxLimit, l.limit+1)
}
// 介于 0.7*target 与 target 之间:保持在当前点
l.windowStart = time.Now()
l.completed = 0
l.sumLatency = 0
}
func (l *AdaptiveLimiter) Limit() float64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.limit
}这段代码有四个刻意的设计,值得逐条说:
| 设计 | 原因 |
|---|---|
从 maxLimit × 0.3 起步 | 冷启动时如果从最大值开始,第一个高峰会直接打穿系统。从小到大探测更安全 |
| 加性增、乘性减(AIMD) | 温和上涨、激进下跌。这个不对称是刻意的:多放一点流量的收益是线性的,放多了的代价是雪崩 |
| 用实测延迟而非外部数字作为反馈 | 阈值会过期,延迟不会。系统自己知道自己在排队 |
保留 minLimit | 防止在持续高延迟下把限量压到零,形成"自我 ddos"(全拒绝也会让上游不停重试) |
5.3 自适应不是万能的,它有三个前提
必须说清边界,否则落地时会被当成银弹:
前提一:判断"延迟"的指标必须是排队时间,不是端到端时间。 端到端延迟里混着下游的耗时,而下游变慢不一定是你的问题(可能该熔断,不该限流)。理想的做法是在应用内埋一个"从入队到开始处理"的时间,用它作为反馈信号。做不到的话,退而用本机 CPU 的 run queue 长度或 GC 停顿占比。
前提二:自适应限流会给上游带来抖动。 它天然是一段"振荡",压力越大越明显。所以它不适合放在对下游有严格 SLA 的接口上(比如对外 API 承诺 99.9% 成功率),更适合放在内部服务之间——上游本来就有重试和降级能力,能容忍短暂拒绝。
前提三:它和全局限流不冲突,两者要并存。 自适应限流管的是"这个实例自己能吃多少",全局限流管的是"这个业务整体允许多少"(比如保护一个第三方配额,或防止爬虫)。前者面向容量,后者面向策略。混为一谈会导致两个都做不好。
5.4 拒绝也要有礼仪
限流一旦生效,拒绝的方式会直接影响上游的行为:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 0
Content-Type: application/json
{"code":"RATE_LIMITED","message":"当前请求过多,请稍后重试","retry_after_ms":1000}三个要点:
- 429 而不是 503。 429 表示"你太快了",503 表示"我坏了"。上游的熔断器通常只统计 5xx,如果把限流也用 503 返回,会造成"限流触发熔断"的连锁反应——第 20 篇那个"熔断成功、降级打到下线接口"的例子,部分是这么来的。
- 带
Retry-After。 没有这个头,客户端只能自己猜退避时间,多半会立刻重试。 - 拒绝也是要消耗资源的。 如果拒绝路径自己有数据库查询、日志写入,那限流就等于没限。拒绝路径必须是最短路径:不查库、不打日志到磁盘、直接返回。这一点在压测里很容易被验证——把限流打开,看 QPS 掉到阈值时 CPU 是否真的下来了。
六、熔断:断路器的可靠性有一半在"半开"上
限流保护的是"进来的太多",熔断保护的是"出去的太慢"。两者的判断依据完全不同:限流看自己,熔断看别人。
6.1 三种状态和它们的真实时间尺度
教科书里的熔断器有三个状态,但真正决定它可靠性的不是三个状态本身,而是每个状态的停留时间:
失败率超阈值
CLOSED ─────────────→ OPEN
↑ │
│ 探测成功 │ 等待冷却期(wait_duration)
│ ↓
└──── HALF_OPEN ←─────┘
│
│ 探测失败
└──────→ OPEN| 状态 | 作用 | 关键参数 | 常见错误值 |
|---|---|---|---|
| CLOSED | 正常放行,统计失败率 | 统计窗口 | 用固定 1 分钟窗口,突发流量时判断滞后 |
| OPEN | 快速失败,不再打下游 | wait_duration | 设 30 秒以上,下游 3 秒就恢复了,白拒 27 秒 |
| HALF_OPEN | 放少量请求探测 | permitted_calls | 设成 1,探测失败率 100%,永远开不回来 |
三行都指向同一个错误:参数按"感觉"设,而不是按下游的恢复时间设。
一个可用的推算方式:
wait_duration≈ 下游恢复 P90 时间的 0.5~1 倍。不知道下游恢复时间,就去查过去 3 个月它每次故障的恢复时长分布。 没有数据就从 5 秒起,比 30 秒强太多permitted_calls至少 5,且要按失败率 < 阈值来判断是否回到 CLOSED(而不是"全部成功")。1 个探测点等于用一次掷硬币决定全部流量- 统计窗口不要用固定时间,用滑动窗口 + 最小请求数。这个坑在第 19 篇灰度的
failureLimit里出现过,熔断里一模一样:
# Resilience4j 配置:注意 minimumNumberOfCalls
resilience4j.circuitbreaker:
instances:
paymentGateway:
slidingWindowType: COUNT_BASED
slidingWindowSize: 50 # 最近 50 次调用
minimumNumberOfCalls: 20 # 少于 20 次不评估!否则夜间低流量会误开
failureRateThreshold: 50
slowCallDurationThreshold: 800ms
slowCallRateThreshold: 60 # 慢调用也算失败——这一行最重要
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: trueslowCallRateThreshold 是这份配置里最关键的一行,也是多数默认配置里没有的一行。过载事故里,下游最常见的形态不是报错,是变慢。 只按失败率熔断的熔断器,面对一个"不报错但慢 20 倍"的下游,永远不会触发——第一节那个案例里,那条链路的熔断器一次都没动作,原因就在这里。
6.2 半开状态:真正的风险在这里
HALF_OPEN 是熔断器唯一"试错"的机会,也是最容易变成事故放大器的地方。三种典型失败:
失败一:半开期间把全部探测流量打到一个还没恢复的下游。 半开的正确语义是"极小流量探测",但有些实现把 permitted_calls 理解成"状态切换门槛",实际放行了整批请求。探测必须是恒定小流量,与总流量无关。
失败二:多个实例同时半开。 40 个副本,每个都有一个本地的熔断器,同一个时刻全进入 HALF_OPEN,每个放 5 个请求,下游瞬间收到 200 个探测请求——而这 200 个请求都在"下游很脆弱"的时刻。
对付第二种,业界有两个方向:
- 单实例熔断 + 随机抖动:
waitDurationInOpenState加入 ±20% 的随机量,让各实例错开 - 集中式熔断(如 Envoy 的 outlier detection,或独立的熔断服务):由数据面统一决策,代价是增加一跳
多数团队的规模下,第一个方案足够,而且几乎零成本:
# 用 jitter 错开各实例的半开时刻
waitDurationInOpenState: 5s
# 实现上:实际等待 = 5s × (0.8 + rand()*0.4)失败三:冷启动的放大。 服务刚启动或刚扩容,本地缓存全空,每次请求都要穿透到下游。这时熔断器处于 CLOSED,一切"正常",但下游会收到比平时多几倍的请求。第 16 篇讲过 HPA 扩容与冷启动的关系,这里补上它的另一半:扩容出来的副本,应该有慢启动(slow start),否则自动扩容这个动作本身就是一次过载。
6.3 熔断之后干什么:这才是"降级"
熔断器打开的那一刻,调用方必须回答一个问题:失败之后返回什么?
这个问题看起来无关紧要,实际上决定了熔断是"保护"还是"制造故障"。四种答案,按质量排序:
| 返回什么 | 用户的体验 | 适用场景 |
|---|---|---|
| 缓存/兜底数据(可能是旧的) | 几乎无感 | 商品详情、库存展示、推荐 |
| 明确的业务失败 + 可操作提示 | 知道发生了什么,能绕行 | 支付渠道不可用,提示换渠道 |
| 通用的"稍后重试" | 知道失败,不知道为什么 | 大多数非核心链路 |
| 抛异常 / 5xx / 空指针 | 一脸懵,可能重复提交 | ——(这是没做降级) |
第 20 篇那次演练撞出的"降级打到两年前已下线的老接口",属于第五种:看起来有降级,实际上是坏的。而它坏的原因很朴素——那段代码三年没执行过。
用一句话概括这一节:熔断器只负责"停止打下游",降级才负责"用户看到什么"。很多团队装了熔断器,却没写降级逻辑,等于装了个开关没接灯。
6.4 一个完整的组合配置
把超时、重试、熔断、舱壁放在一起,才是真实可用的形态。下面是 Resilience4j 的完整装饰顺序,顺序本身很关键:
// 正确的装饰顺序(从外到内):
// 舱壁 → 限流 → 熔断 → 重试 → 超时 → 实际调用
Supplier<ChargeResult> decorated =
Bulkhead.decorateSupplier(bulkhead, // 1. 先隔离:超了就立刻拒,不进后面
RateLimiter.decorateSupplier(rateLimiter, // 2. 再限速
CircuitBreaker.decorateSupplier(cb, // 3. 看下游健康度
Retry.decorateSupplier(retry, // 4. 重试(注意:重试在内,意味着每次重试都受熔断管辖)
TimeLimiter.decorateSupplier(timeLimiter, // 5. 每次尝试的超时
() -> paymentClient.charge(req))))));
// 错误的顺序(很常见):
// 重试在最外层 → 熔断感受不到重试的流量,每次重试都是新的"正常调用"四个顺序上的判断依据:
- 舱壁在最外:隔离的意义是在入口就拦下,让过载不进入后续管道。放里面就失去意义。
- 熔断在重试之外:这样重试流量也受熔断约束。放反了,熔断打开后重试还在打下游(熔断在里,重试在外,每次重试都触发一次熔断判断,都可能"恢复")。
- 超时在最内:超时必须约束单次尝试,而不是整个重试链。否则一次 800ms 的超时会变成"三次重试共 800ms",每次都必然超时。
TimeLimiter必须真正能中断请求。 很多 Java 实现里的超时只是"不再等",而不是"取消请求"——后者需要配合下游的context cancellation才能释放资源。这是一个非常常见的假超时。
七、舱壁:按什么维度分池
舱壁(Bulkhead)这个词来自船体:船被撞破一个舱,其他舱还能浮着。在系统里,它的意思也一样朴素:不同类别的请求不要共用同一个资源池。
7.1 该按什么分池
分池的维度选错了,等于没分。四种常见维度的比较:
| 维度 | 例子 | 什么时候有效 | 代价 |
|---|---|---|---|
| 按依赖 | 支付调用池 / 库存调用池 / DB 池 | 最有效:一条慢依赖不会吃掉全部线程 | 每个池要预留资源,总量会变大 |
| 按租户 | 大客户池 / 普通客户池 | 有一个租户的流量是别人 100 倍时 | 需要识别租户,配额管理复杂 |
| 按接口类型 | 读接口池 / 写接口池 | 读写资源特性差异大(如缓存 vs 事务) | 池多了以后调参成本上升 |
| 按优先级 | 核心链路池 / 非核心池 | 有明确的业务优先级排序 | 需要有人拍板"谁降级"——这是第 22 篇 IC 的活 |
最少要做的分池,是按依赖分。 第一节那个案例,如果 order-api 对 payment-gateway、inventory、order-db 各有一个独立线程池(或信号量),支付变慢最多吃掉支付池的 30 个线程,另外两个依赖和主链路都还能工作,故障范围会从"下单全挂"缩到"部分支付方式不可用"。
# Spring Cloud CircuitBreaker / Resilience4j 的舱壁配置
resilience4j.bulkhead.instances:
paymentGateway:
maxConcurrentCalls: 30 # 这条依赖最多占 30 个并发
maxWaitDuration: 0 # 拿不到名额立刻拒绝(不排队!)
inventory:
maxConcurrentCalls: 60
maxWaitDuration: 0
orderDb:
maxConcurrentCalls: 80
maxWaitDuration: 0配置里有两个细节值得说:
maxWaitDuration: 0。 这是舱壁的关键。如果设置成 200ms,拿不到名额的请求会排队等 200ms,那它就不再是"快速拒绝",而变成了"引入新的排队"。舱壁的价值全在"立刻拒绝"上。
maxConcurrentCalls 之和要远小于线程池大小。 上例是 30 + 60 + 80 = 170 < 200 的总线程数,看起来刚好。这个"刚好"是危险的:一旦有第三种依赖(比如日志上报、短信)没被约束,它就能吃掉剩下的 30 个线程,把整个池挤爆。经验值是所有舱壁之和 ≤ 总并发能力的 70%,剩下的 30% 是给未分类流量和自身处理留的余量。
7.2 信号量还是线程池
两种实现方式,差别在"能不能被中断":
| 信号量舱壁 | 线程池舱壁 | |
|---|---|---|
| 机制 | 计数,超了直接拒 | 独立线程池,超了排队或被拒 |
| 超时 | 无法中断正在执行的调用 | 可以(Future.cancel(true)) |
| 开销 | 极小 | 每池一组线程,上下文切换有成本 |
| 适用 | IO 已是非阻塞(Netty / WebClient / Go) | 阻塞式调用(传统 Tomcat + JDBC) |
阻塞式调用要用线程池舱壁,因为在信号量模式下,一个已经卡住的调用无法被回收,超时配置形同虚设。非阻塞栈用信号量就够,因为"卡住"的代价只是内存里挂着一个回调,不是占着一个线程。
7.3 一个容易忽略的资源:连接池
应用层的舱壁只管"多少个请求同时在跑",管不住"多少个连接同时被占用"。数据库连接池、HTTP 客户端连接池,是另一层需要单独配的边界。
第 21 篇那个案例(扩容反而更糟)就是连接池层面的问题:40 副本 × 30 连接 = 1200 个连接打到同一个 DB,超过了 max_connections。第 23 篇给过 conn-budget.sh 那套连接预算的不等式,这里补上它的过载侧视角:
连接池要紧配,不能松配。
pool_size × replica_count ≤ db_max_connections × 0.8
且 pool_size 应接近 该实例的并发工作线程数 × 平均持有时间比
设大了的后果:过载时连接全部被占住、等锁、DB 侧排队,恢复更慢
设小了的后果:本地快速拒绝(这其实是好的!),有 429 可观测连接池宁可小一点。 这是一个和直觉相反的结论:池子大了,看起来"能扛更多",实际上过载时它变成了一个放大器——它把请求从应用层排队转移到数据库层排队,而数据库层排队是不可观测、不可控、恢复最慢的。
7.4 排队:最后一个控制点
在"接受"和"拒绝"之间,还有一个中间选项:有界队列。
无界队列是最坏的选择:它让所有请求都"被接受",然后全部超时。用户等 30 秒拿到 503,比 200ms 拿到 429 糟糕得多,而且服务器为这些注定失败的请求付了 30 秒的资源。
正确形态:小队列 + 明确的拒绝
队列长度 ≈ 线程数 × 0.5~1 倍
Tomcat: server.tomcat.accept-count(不是 max-connections)
Go: 用带容量的 channel,满了 select default 直接拒队列长度的判断依据不是"能排多少",而是"排多久"。如果单请求处理时间是 20ms,队列 100,那么排在第 100 位的请求要等 2 秒——早就超过了它上游的超时预算。队列长度应该用"目标延迟 / 单请求处理时间"来推,而不是拍一个整百的数字。
八、降级:先放弃什么,是一份要提前签字的优先级表
前面六节都是机制,这一节讲判断。
降级的本质是一个取舍:在容量不够的时候,放弃一部分功能,保住另一部分。这个取舍不能等到过载那一刻才做,因为过载时没有时间讨论,而且做决定的人往往不是最了解业务的人。
8.1 三级降级,各自的判断标准
| 级别 | 做法 | 用户感知 | 判断标准(什么时候可以降) |
|---|---|---|---|
| L1 读降级 | 返回缓存/旧数据、去掉个性化、隐藏非关键模块 | 轻微,多数不察觉 | 只要数据"旧但不假",可以随时降 |
| L2 写降级 | 排队/异步/延后处理、限制频率、只允许部分用户写 | 明显,但业务还在跑 | 写操作可补偿、失败可重放 |
| L3 功能降级 | 关闭整个功能模块(推荐位、评论、积分、部分渠道) | 明显,部分用户失去功能 | 有人明确说"这个功能可放弃",并接受了后果 |
三级的分界不是"影响大小",而是**"能不能反悔"**:
- L1 通常完全可反悔(缓存会自己热回来)
- L2 需要补偿逻辑(异步队列要保证最终一致)
- L3 意味着收入或体验的实质损失,必须有业务方签字
第 19 篇讲变更分级时用的是同一个维度(不可逆性),这里也是同一个道理。SRE 的很多判断都能收敛到这一个问题上:这个动作做出去之后,还能不能退回来。
8.2 那份"提前签字"的表
过载时的降级不是临场发挥,是一份提前排好序的清单。它至少包含四列:
# degradation-plan.yaml —— 每个核心服务一份,进配置仓库,进演练
service: order-api
owner: order-team
last_reviewed: 2026-11-01
triggers:
# 自动触发条件(满足任一即进入对应级别)
- level: L1
when: "slo_burn_rate_1h > 2" # 第 3 篇的燃烧率,1 小时窗口
auto: true
- level: L2
when: "slo_burn_rate_5m > 14.4" # 快烧,5 分钟窗口
auto: true
- level: L3
when: "manual" # 只能人工拍板,且必须 IC + 业务方
auto: false
actions:
- level: L1
item: 商品详情页推荐位改为静态兜底
cmd: "curl -X POST config-svc/flags/recommend_static -d '{\"on\":true}'"
expected_saving: "12% CPU"
reversible: true
verified_at: 2026-10-18 # 演练验证过的日期,没验证过不能写上来
- level: L1
item: 关闭订单列表的实时物流状态(改展示"运输中")
cmd: "curl -X POST config-svc/flags/logistics_realtime -d '{\"on\":false}'"
expected_saving: "依赖调用 -8%"
reversible: true
verified_at: 2026-10-18
- level: L2
item: 支付渠道降为仅保留主渠道,其余返回"暂不可用"
cmd: "payment-ctl channel disable --all-except wechat"
expected_saving: "支付池占用 -60%"
reversible: true
verified_at: 2026-11-06
- level: L3
item: 关闭新人专享价(影响券发放)
cmd: "业务后台手工操作"
expected_saving: "下单峰值 -15%"
reversible: false # 券一旦不发,补发成本高
approver: [ic, business_owner]
verified_at: null # 从未演练,标记为高风险三列最关键:
verified_at:没演练过的降级动作不算存在。第 20 篇那句"没演练过的降级开关,等于一行装饰性代码",在表里就是一个空字段expected_saving:写了才知道这个动作值不值。一个只能省 3% CPU 的降级,在峰值时刻不值得冒业务风险reversible:不可逆的降级必须有人签字,且优先级排最后
这张表还有个隐性作用:它是给 IC 用的,不是给 SRE 用的。 第 22 篇的决策权清单里有"限流 / 关闭入口",而 IC 通常不是最懂技术的人。给他一份按影响排序、带现成命令、标注可逆性的表,他能在 30 秒内做出决定;给他一段架构说明,他要开会。
8.3 降级的三个反模式
反模式一:降级只写不测。 前面说过,不重复。
反模式二:降级动作本身很贵。 有个团队把"降级"实现成"切换到一个备用服务",而那个备用服务的启动要 40 秒。降级的意义是把恢复时间从"分钟"压到"秒",一个需要 40 秒才生效的降级,本质上是一次新的发布。
反模式三:全降级等于全挂。 有过这样的现场:过载触发自动降级,降级把所有非核心功能一起关掉,结果"下单成功页"也被算作非核心功能关掉了,用户付了钱看不到结果,客服电话被打爆。降级清单必须有人从头到尾读一遍,问一句"全都关掉之后,用户还能完成最关键的那个动作吗"。
8.4 降级和限流的分工,别搞反
这两个经常被混为一谈,其实分工很清楚:
限流:我吃不下了 → 拒绝一部分请求(用户失败,但失败得快)
降级:这个功能不必要 → 少做一部分工作(用户可能感觉不到)
熔断:下游不行了 → 不打它,走备选(保护下游,也保护自己)一个简单的判断口诀:限流是"人太多",降级是"活太多",熔断是"对方不行了"。 现场的误用通常是这样的:流量冲进来,第一反应是"降级"(关功能),其实该做的是限流(少接人);下游慢了,第一反应是"限流"(拒绝请求),其实该做的是熔断(少打它)。搞反了会白丢用户,或者白扛压力。
九、开关账本:开关数量是一种负债
第 19 篇有一句结论:开关不是灰度。 用应用内配置开关来做灰度,会让新旧逻辑同时存在,形成永久分叉。这一节补上它的另一半:开关本身的成本。
9.1 开关只增不减,是通病
一个跑了三年的服务,配置文件里的 feature flag 通常在 150 到 400 个之间。其中大概:
- 30% 的开关永远为 true(功能早已全量,开关没删)
- 20% 的开关永远为 false(功能下线了,代码还在)
- 15% 的开关没人知道是干什么的(原作者离职了)
- 剩下的才是活的
这个分布不是某一家的特例,是几乎所有长期运行的服务的常态。它的代价是具体的:
- 认知成本:一次降级要在 200 个开关里找"关哪个"。事故时没人找得完
- 组合爆炸:每多一个开关,可能的状态翻倍,测试没法穷举。这直接导致"这段代码三年没执行过"(第 20 篇那个降级打到下线接口的现场)
- 技术债的复利:开关下的两套逻辑要同时维护,改一处要改两处,且常常只改了一处
- 安全性:开关是绕过评审的通道。一个能被
curl改掉的行为,等于没有代码评审
9.2 给每个开关写"出生证明"和"墓碑"
最小可行的做法不是禁止加开关(禁止不了,加开关往往是对的),而是要求每个开关带元数据:
flags:
- key: recommend_static
purpose: "推荐服务不可用时,详情页推荐位改为静态兜底"
owner: order-team
created: 2026-09-12
type: degradation # degradation | release | experiment | permission
expire_at: 2027-03-01 # 到这个日期必须处理,不能续期超过两次
linked_runbook: "runbooks/order-detail-degrade.md"
delete_after: "推荐服务SLA稳定3个月后删除"四个字段各自挡一类问题:
| 字段 | 挡什么 |
|---|---|
type | 把"降级开关"和"发布开关"分开。前者要长期存在,后者必须在发布完成后删掉(第 19 篇的灰度开关属于后者) |
expire_at | 让开关有寿命。到期不处理,CI 报警 |
linked_runbook | 保证每个降级开关都被写进 Runbook(第 18 篇的 Runbook 体系可以直接引用这份表) |
delete_after | 提前写下删除条件。"以后再说"的开关,永远是以后 |
有了元数据之后,一件很有价值的事就变成可自动化的了:用一个每日巡检脚本,把"该删的开关"和"该验证的开关"列出来。
#!/usr/bin/env bash
# flag-audit.sh —— 每日巡检,输出三类问题
set -euo pipefail
FLAGS_FILE="${1:-degradation-plan.yaml}"
echo "== 1. 已过期但仍在配置里的开关 =="
yq -r '.flags[] | select(.expire_at < now | todate) | "\(.key)\t已过期\towner=\(.owner)"' "$FLAGS_FILE"
echo
echo "== 2. 降级开关中从未演练过的(verified_at 为空)=="
yq -r '.flags[] | select(.type=="degradation" and (.verified_at == null)) | "\(.key)\t未演练\towner=\(.owner)"' "$FLAGS_FILE"
echo
echo "== 3. 发布类开关超过 30 天未清理 =="
yq -r '.flags[] | select(.type=="release") | .key as $k | .created as $c
| (now - ($c|fromdate)) / 86400 | floor | select(. > 30)
| "\($k)\t已存在 \(.)" + " 天"' "$FLAGS_FILE"把它接进每日通报(不是告警——开关不会半夜出事),每周在大群里出现一次就够了。这类"温和但持续"的机制,对开关治理的效果远好于一次性的清理运动。
9.3 事故中的开关:记录比使用更重要
第 22 篇提过一条容易忽略的纪律:事故中临时改的超时、临时摘的健康检查、临时放宽的阈值,如果不记录,会长期留在系统里,成为下一次事故的伏笔。
开关的风险是同一个形状,而且更隐蔽——因为开关本来就是"随时可以改回去"的,所以没人觉得需要记录。三个必须落地的动作:
- 所有开关变更走同一个入口,并且留痕(谁、什么时候、为什么、关联哪个事故单)。这不是审批,是记日志
- 事故结束清单里有一条"开关复位检查",把第 22 篇的事故记录 schema 加一个
flags_changed字段 - 每次改开关,必须同时写下"什么时候改回去"。事故中打开了降级,没人记得关,是"降级变成常态"的主要来源
十、一次真实过载的完整防守:把六节串起来
回到开头那次 36 分钟。如果这套东西都在,时间线会是什么样?
| 时刻 | 当时发生了什么 | 有机制时 | 差异 |
|---|---|---|---|
| 20:00 | 慢渠道开始变慢(1.9s) | 熔断器 slowCallRateThreshold: 60%,60 秒内慢调用占比超阈值 → OPEN(支付池) | 系统在 20:01 就开始自保 |
| 20:01 | 支付池熔断 → 走 L2 降级,该渠道返回"暂不可用,请更换支付方式" | 用户可见,但可操作 | 影响面从"全站下单失败"缩到"4% 渠道的用户换渠道" |
| 20:02 | 支付池 30 个名额被熔断占满(熔断后快速失败,占用极小) | 主链路不受影响 | 线程池 8000 个线程里只损失 30 个 |
| 20:10 | 告警:PaymentChannelSlow | 告警内容直接是"渠道 X 慢,已熔断" | 值班不需要再查 20 分钟 |
| 20:12 | 值班确认:错误率没涨、p99 正常 | — | 事故降级为 SEV-3,不需要 IC 进场 |
| 20:15 | 联系渠道方 | — | 真正的处置时间用于外联,不是排障 |
36 分钟变成 15 分钟,故障等级从 SEV-2 降为 SEV-3,用户影响从"下单全挂"变成"部分渠道不可用"。 差别不在人,在系统里多了四条约束:
超时预算(300ms,不是 2000ms)
重试层数(1 层,不是 3 层)
熔断判据(含 slowCallRate,不只失败率)
舱壁(支付池 30,不是共享 8000)四条都是可以被写下来、被 CI 检查、被演练验证的东西。
十一、五个度量:过载保护到底有没有用
写完机制,还得能证明它在起作用。五个数字,都能从现有监控里取:
| 指标 | 定义 | 目标 | 它挡的是什么 |
|---|---|---|---|
| 有效请求率 | 成功请求 / 进入请求 | 过载时 > 80% | 能反映"拒绝是为了保住有效请求"这件事 |
| 过载时长占比 | 处于过载状态(如自适应限流低于上限 70%)的时间 / 总时间 | < 1% | 长期在过载边缘意味着容量不足,不是保护得当 |
| 熔断误开率 | 熔断打开但下游实际正常的次数 / 熔断打开总次数 | < 10% | 参数设得太敏感,白白丢流量 |
| 降级命中正确率 | 降级动作生效且可用的次数 / 触发降级次数 | > 95% | 直指"降级打到下线接口"那类问题 |
| 重试放大系数 | 出站请求量 / 入站请求量 | < 1.3(正常) | 重试风暴的最早信号 |
第二个数字容易被误读:过载时长占比低,不代表保护做得好,可能只是容量充足。 所以它必须和第一个数字一起看。四个数字里最有价值的是第四个——它只能靠演练得到,而它恰好指向最致命的一类问题(以为有降级,其实降级是坏的)。
一条可以直接跑的采集脚本(Prometheus):
# 1. 有效请求率(过载期间)
sum(rate(http_requests_total{code!~"5..|429"}[1m])) by (service)
/ sum(rate(http_requests_total[1m])) by (service)
# 2. 自适应限流贴近上限的比例(需要应用暴露 limiter_limit 与 in_flight)
avg_over_time(overload_in_flight[5m]) / avg_over_time(overload_limit[5m])
# 3. 熔断误开:熔断打开后 30 秒内下游成功率仍 > 99%
sum(rate(circuitbreaker_calls_total{state="open"}[30s])) by (name)
and on(name) (sum(rate(upstream_requests_total{code=~"2.."}[30s])) by (name)
/ sum(rate(upstream_requests_total[30s])) by (name) > 0.99)
# 4. 重试放大
sum(rate(outbound_requests_total[1m])) by (service)
/ sum(rate(inbound_requests_total[1m])) by (service)第 4 条建议设一个 1.3 的告警线。正常系统的放大系数稳定在 1.0~1.2(重试、幂等校验、健康检查带来的正常放量);超过 1.3 说明有东西在放大流量;超过 2 就基本可以确认是重试风暴了。
十二、过载保护自检清单(20 项,可直接勾选)
按四道刹车分组。建议每季度过一遍,和 19 篇的发布清单、22 篇的事故准备清单一起放进季度节奏。
超时与重试
- 每个对外接口的入口超时 ≤ SLO p99 × 3,且写进了配置而不是代码常量
- 每条关键链路的"最坏路径"(超时 × 重试 + 退避)算过一遍,小于入口预算
- 链路上只有一层重试,其余层显式关闭
- 非幂等写操作要么有幂等键,要么禁止重试
- 重试有抖动(jitter),不是固定间隔
- 重试流量有上限告警(放大系数 > 1.3 触发)
限流与排队
- 核心接口有限流,且限流阈值不是"压测最大值",而是"当前副本数 × 单实例安全水位"
- 限流拒绝返回 429 +
Retry-After,不返回 5xx - 拒绝路径本身不查库、不写盘
- 队列有界,且队列长度按"目标延迟 / 单请求耗时"推算过
- 扩容后的新实例有慢启动,不是立刻全量接客
熔断与舱壁
- 熔断器配了慢调用判据(
slowCallRate),不只按错误率 minimumNumberOfCalls设了,夜间低流量不会误开waitDurationInOpenState来自下游历史恢复时间,带随机抖动- 半开探测是小流量,且各实例错开
- 关键依赖按依赖分舱壁,各舱壁之和 ≤ 总并发能力的 70%
- 连接池小于数据库连接上限的 80%,且"宁可小"
降级与开关
- 每个核心服务有一份
degradation-plan.yaml,含触发条件、动作、预期收益、可逆性 - 降级动作演练过(
verified_at不为空),且最近一次在 3 个月内 - 全部降级动作叠加后,核心业务仍能完成
- 每个开关有 owner、类型、过期时间;开关变更留痕;事故结束有复位检查
十三、六个真实踩过的坑
一、超时改了,重试没改。 把超时从 3s 降到 800ms,以为压住了延迟,结果重试从 1 次涨到 3 次(因为超时更容易触发重试),总最坏时间从 3s 变成 2.4s + 退避。第 22 篇那次"改超时后 QPS 涨 40%",就是这个形状。
二、熔断器的失败率统计把限流拒绝也算进去了。 限流返回 429,被下游的错误率统计当成失败,于是"限流触发熔断",两个机制互相打架。限流的拒绝必须从熔断统计里排除。
三、自适应限流的反馈信号用了端到端延迟。 下游变慢时,本地限流器以为是自己的问题,把并发压到很低。结果自己没垮,但流量全被拒了。反馈信号必须是"自己的排队时间",不是"用户的等待时间"。
四、降级开关的默认值写反了。 feature.enabled: false 表示"降级生效",结果某次配置中心推送了一个空配置,服务读到默认值,直接把核心功能当成"需要降级"关掉了。降级开关的默认值必须是"正常",任何读到空/异常都必须落在正常态。
五、舱壁配了,但共享的 HTTP 客户端连接池没配。 线程池隔离了 30 个名额给支付,但这 30 个线程共用同一个默认的 HTTP 连接池(200 连接,被三个依赖共用),支付把连接占满,库存和订单库调用开始拿不到连接。舱壁要一路隔离到连接池,否则只隔离了一半。
六、压力测试时把限流关掉,线上却忘了它一直在生效。 压测得出的"单机能扛 800 QPS"是在无限流下测的,而线上限流阈值是 300。容量模型和实际行为永远对不上。压测必须带着限流配置跑,而且要把"限流触发点"作为压测结论的一部分。
十四、从零开始:一个四阶段落地的顺序
不用一次全上,按"投入产出比"排:
阶段一(第 1~2 周):算清账,不改代码
- 把所有对外接口的入口超时、链路超时、重试次数、退避策略抄出来,算"最坏路径"
- 找出最坏路径 > 入口预算的链路,标红
- 先改配置(超时、重试次数),不改架构
产出:一张"链路超时预算表",一份超预算清单。这一步几乎零成本,常常能直接消掉一半的过载隐患。
阶段二(第 3~6 周):入口和出口各装一道
- 入口:核心接口上限流,先从固定阈值开始(配 429 + Retry-After),够用就先不追求自适应
- 出口:按依赖分舱壁 + 配
slowCallRate的熔断 - 连接池按
≤ DB 上限 × 80%重配一次
产出:第一次让系统具备"自己拒绝"的能力。这一步之后,多数"雪崩式"故障会被截断在依赖层面。
阶段三(第 2~3 月):写出降级表
- 每个核心服务一份
degradation-plan.yaml,含触发条件与动作 - 按第 20 篇的方法演练一次:注入下游超时,验证降级真的生效
- 把降级动作写进 Runbook(第 18 篇的模板直接能用)
产出:一份被验证过的优先级表,以及第一次"降级命中正确率"的数字。
阶段四(第 4~6 月):换成自适应,接进流程
- 把固定阈值限流换成自适应并发限制(先从非核心服务试)
- 五个度量接进看板,重试放大系数设告警
- 开关账本接进 CI 和每日巡检;开关增删进代码评审
产出:从"配了几个参数"变成"有一套会自我校正、能被度量的机制"。
四阶段的关键不是技术难度,是顺序:先算账(改配置就能见效),再装阀门(需要代码改动),再写优先级(需要业务方参与),最后才做自适应(工程复杂度最高)。反过来做——先上自适应限流,再发现超时预算从来没算过——就会一直在调参数,永远调不准。
十五、和前面几篇的关系
- 第 3、10 篇(SLO 与错误预算):过载保护是错误预算的下游执行机制。预算烧穿之前的最后一道刹车,就是本篇的四道闸门。第 10 篇那个"开启熔断(如果服务有熔断配置)"的"如果",本篇给了配置
- 第 8、22 篇(on-call 与 IC):本篇给的是"系统自己能做什么",那两篇给的是"人该做什么"。分工的边界很清楚:熔断、限流、慢启动是系统自动的,降级到 L3、关闭入口是人的决定。第 22 篇的决策权清单里,"限流"这一行的前置条件就是本篇的
degradation-plan.yaml - 第 19 篇(变更安全体系):灰度判别指标在过载时会失真——灰度期间流量小,判别基线不同。第 19 篇说有"更小的止血动作",本篇就是那份动作清单
- 第 20 篇(混沌工程):本篇每一道刹车都需要被注入验证。第 20 篇的"下游超时"注入撞出的正是熔断和降级,本篇给了判据和配置
- 第 21 篇(容量规划):第 21 篇算的是"还能撑多久",本篇解决"撑不住的那一刻怎么办"。自适应限流的
maxLimit来自第 21 篇的压测基线,水位线决定"到哪一档该限流" - 第 23 篇(数据库 SRE):连接池预算在那边的不等式里,本篇补上了过载侧的视角——池子宁可小
- 第 24 篇(AI 排障):过载事故最考验 AI 的边界。第一节那个案例里,AI 大概率会给出"扩容"的建议,因为 CPU 61% 而延迟高,看起来就是"资源不足"。它看得见线程池堆积,看不见重试放大——这正是第 24 篇说的"视野由工具清单决定"
写在最后
如果这篇只带走一句:
过载保护不是加阀门,是提前决定放弃什么。
没有这份决定的系统,在被压垮的那几秒里不是保护自己,而是随机抛弃用户——谁运气不好谁失败,哪条链路先撑不住哪条先崩。这个"随机"的代价通常比"有意的取舍"大得多。
如果只带走一个动作:
把一条核心链路的超时、重试次数、退避时间抄出来,乘一遍,和入口的超时比一下。
这个乘法 5 分钟就能做完,而它算出来的数字,有相当大的概率会超出你的预期。第一节那个案例里,2000ms 的超时乘 3 次重试再乘 3 层调用,是 27 倍——这个数字从来没被算过,因为没有人会在写一行 timeout: 2000 的时候想到它。
刹车不是为了让车停下。踩刹车是为了让车还能继续开。
本文是 SRE 实战系列第 25 篇,第三循环第一篇。第三循环换的题目是"过载":前二十四篇问的是"系统怎么变、怎么证明它扛得住、余量还有多少",这一篇问的是"系统在被压垮的那几秒里,自己有没有刹车"。下一篇(第 26 篇)转向另一件最危险的事:多活与容灾——切换才是最危险的那次变更。