11 容量、限流、降级、故障演练
一台机器到底能扛多少 QPS,为什么这个数会随下游延迟塌缩?为什么利用率刚过 70% 延迟就开始抖,90% 时一次小抖动就能把系统推过临界点?压测为什么经常「测得很漂亮、上线就崩」,开放压测和闭环压测差在哪?令牌桶、漏桶、滑动窗口和分布式限流各自保护谁?熔断、舱壁、降级应该在什么顺序上动作?自动扩缩容为什么救不了秒级尖峰?故障演练的假设怎么写、爆炸半径怎么控、game day 怎么收场?
能用 Little's law 把 QPS、并发、延迟换算成容量,并算出含故障域余量的副本数;能解释 1/(1-ρ) 曲线并判断系统处在膝点哪一侧;能设计开放/闭环压测并识别 coordinated omission;能实现令牌桶、熔断器和带白名单的 tc netem 注入脚本;能写出可执行、可回滚、有停止条件的故障演练方案;能说清压测回放为什么会制造真实订单,以及怎样在工具层堵住它。
一份压测报告写着「单实例 2000 QPS,p99 80 ms」。上线后流量刚到 900 QPS,接口开始大面积超时,CPU 却只有 35%。排查后发现:压测时下游是 mock,响应 1 ms;生产里每次请求要查两次数据库,p99 300 ms。线程都卡在等数据库,CPU 自然不高。压测测的不是这台机器,而是「这台机器加它依赖的那条路径」;只测前者,得到的数字在生产里没有意义。
上一章讲的是「看见」:trace、指标、日志、回放,让我们知道系统正在发生什么。这一章讲「撑住」:在流量超过能力之前,用容量规划预留空间,用限流挡住超额请求,用降级保住核心路径,用演练验证这些手段真的会动作。观测和治理是一对:没有观测的降级是盲降,没有降级的观测只是事后记录。 这两个话题经常被拆成两个团队的工作——SRE 做容量,开发做业务。但事故从不按组织边界发生。一次下游变慢会同时触发超时、重试、队列堆积、自动扩容、熔断抖动;如果容量模型、限流阈值、熔断参数、演练假设分别由四个人拍脑袋,它们在事故现场一定互相打架。本章的目标是把它们放进同一套数字里:用 Little’s law 统一 QPS、并发和延迟,用利用率曲线统一容量和排队,用同一条时间线统一重试、限流、熔断和扩容。
路线是这样:11.1 建立容量口径和 Little’s law,11.2 解释利用率曲线和变异性,11.3 讲压测方法(基线、梯度、开放/闭环),11.4 讲限流算法与分布式实现,11.5 讲降级、熔断状态机和舱壁,11.6 讲自动扩缩容的陷阱,11.7 讲故障演练的假设、爆炸半径和 game day。11.8 把前面所有机制放进一条重试风暴时间线;11.9 给出 tc、wrk、ss 的观测方法;11.10 复盘 2021-12-07 AWS us-east-1 故障;11.11 拆解压测回放打到生产造成的重复副作用;最后是清单、陷阱和出口检查。
11.1 容量:Little’s law 与利用率曲线
容量不是一个 QPS 数字,而是一个三元组:在什么延迟目标下、以什么错误率、能承载多少有效请求。同样一台机器,响应 1 KB 和 1 MB 的容量不同,命中缓存和回源的容量不同,下游 10 ms 和 300 ms 的容量更不同。把容量说成「单机 2000 QPS」,就像把一辆车的载重说成「能开 200 公里/小时」,却不说路况和油耗。 先看一个最小模型。一个有 个并发槽位的服务(线程、协程、连接都算),每个请求平均占用槽位 秒。稳定状态下,槽位要么在忙要么空闲,所以每秒能完成的请求数最多是:
这就是 Little’s law 的变形。Little’s law 的原式是
其中 是系统内的平均在途请求数, 是到达率, 是平均停留时间。它不假设泊松到达,也不假设服务时间分布,只要求系统稳定(长期看进入等于离开)。正因为它假设这么少,才适合做容量估算的第一把尺子。 代入数字。200 个工作线程,每个请求占用线程 80 ms:
下游变慢,每个请求占用线程的时间涨到 400 ms,线程数没变:
容量掉了 80%,而 CPU 可能只涨了一点——线程都在等下游,不在计算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。这也是为什么「加 CPU」经常救不了 IO 密集型服务:瓶颈是并发槽位和被拉长的 ,不是算力。
| 并发槽位 C | 平均停留 W | 吞吐上限 C/W | 系统状态 |
|---|---|---|---|
| 200 | 80 ms | 2500 req/s | 健康 |
| 200 | 200 ms | 1000 req/s | 下游开始变慢 |
| 200 | 400 ms | 500 req/s | 容量塌缩,队列增长 |
| 200 | 1 s | 200 req/s | 已过载,排队时间远超处理时间 |
| 200 | 4 s | 50 req/s | 连接/线程被长期占用,接近不可用 |
这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为 变大了, 就小了。第 2.6 节已经用过这个公式定并发上限;这里把它反过来,用目标吞吐和实测延迟算需要多少并发:
目标 1200 QPS、p99 250 ms,则 。用 p99 而不是 p50,是因为 p50 只覆盖一半请求;按 p50 算出来的并发,在高峰期一定排队。安全系数 1.2 是给突发和重试留的,不是给「拍脑袋」留的。
容量(capacity)是在给定工作负载、给定 SLO(延迟分位与错误率)下,一个系统长期可承载的有效请求速率。它由三个自变量共同决定:请求的资源成本(CPU、内存、连接、IO)、请求的停留时间 、以及可用的并发槽位 。
容量必须以 goodput 计量:只有满足 SLO 的成功请求才算容量。把 500、超时、重试都算进 QPS,会得到一个漂亮但无法交付的数字。
工作负载必须写清楚,否则容量数字不可比。至少固定六项:
| 维度 | 为什么影响容量 | 压测时必须固定 |
|---|---|---|
| 请求/响应体积 | 序列化、拷贝、带宽、GC | 用生产 p50 与 p99 两档 |
| 读/写比例 | 写有事务、锁、复制开销 | 按生产比例,不要只压读 |
| 缓存命中率 | 未命中会放大下游负载 | 冷缓存、热缓存各测一轮 |
| 下游延迟 | 直接进入 | mock 不够,要接真实依赖或等价延迟 |
| 依赖调用次数 | 每请求 1 次和 5 次差 5 倍 | 记录 fan-out |
| 并发模型 | 线程池、协程、连接池的上限不同 | 与生产同配置 |
11.1.1 Headroom:余量不是浪费
如果按「峰值刚好打满」规划容量,系统会在第一次抖动时过载。Headroom(余量)是刻意留出的空闲能力,用来吸收三件事:流量突发、故障切换、恢复期的冷缓存。
| 场景 | 目标利用率 | 依据 |
|---|---|---|
| 日常稳态 | ≤ 50% | 给日峰值、批处理、重试留空间 |
| 日峰值 | ≤ 70% | 越过膝点后延迟非线性上升(11.2) |
| 单故障域失效后 | ≤ 85% | 剩余节点要接住转移流量 |
| 故障恢复期 | ≤ 50% | 缓存冷、连接重建、队列积压 |
三可用区、每个区承载 1/3 流量。如果每区稳态 50%,丢一个区后剩余两区各承担 1.5 倍,变成 75%,仍在可接受区间;如果每区稳态 60%,丢一个区后变成 90%,一次小抖动就会越过临界点。余量不是浪费,它是故障时的缓冲资本;把稳态跑到 80% 的团队,等于把故障切换的预算提前花光了。
11.1.2 算一次副本数:加法比乘法重要
假设峰值 3600 QPS,单副本在 p99 200 ms 下能承载 500 QPS,目标 headroom 30%,三可用区、允许丢一个区。先算需要的总能力:
再除以单副本能力:
10 个副本分到 3 个区,无法均分;按 4/3/3 放,丢一个区后剩下 6 或 7 个,最多 3500 QPS,接不住 3600 的峰值。正确做法是按「每个区都能均分,且任意一个区失效后剩余能力仍 ≥ 峰值」来算:每区 4 个,共 12 个,丢一个区后剩 8 个,能力 4000 QPS,够了。副本数要按故障后的剩余能力倒推,而不是按正常时的总量正推。
11.1.3 容量单位:把不同资源换算成同一把尺子
一个服务有 CPU、内存、连接、带宽、数据库连接、下游配额六种资源,任何一种先耗尽,它就是瓶颈。容量规划要找出「当前瓶颈」和「故障后的新瓶颈」,而不是平均地给每种资源加一点。
| 资源 | 计量方式 | 常见瓶颈信号 | 扩容手段 |
|---|---|---|---|
| CPU | 核数 × 利用率 | 运行队列、CPU throttling | 加副本/升配 |
| 内存 | 工作集、堆外 | GC 频率、OOM、page cache 抖动 | 升配、减少对象 |
| 连接 | 文件描述符、连接池 | pool_wait、TIME_WAIT 堆积 | 调池、加副本 |
| 数据库连接 | 活跃连接数 | 锁等待、连接拒绝 | 代理、分库、限流 |
| IOPS/带宽 | 磁盘队列、网卡 | await、重传、带宽打满 | 分片、压缩、CDN |
| 下游配额 | QPS/并发配额 | 429、配额耗尽 | 申请配额、缓存、批量 |
这张表最重要的用法是「故障时的迁移」:CPU 平时是瓶颈,丢一个可用区后,数据库连接可能变成新瓶颈;因为剩余副本要接更多流量,每个副本打开的连接数上升,数据库先拒绝连接。容量评审要问「坏一个域之后,下一个瓶颈是谁」,而不是只问「现在够不够」。
11.2 排队论直觉:为什么 70% 就开始抖
11.1 用 算的是「稳态吞吐上限」,它假设请求来了立刻被处理。真实系统有队列:请求到达时如果没有空闲槽位,就要等。等待时间不是线性的,它随利用率上升急剧变大。理解这条曲线,才能理解为什么 70% 是常见的告警线,而不是 95%。 先定义利用率。对一个有 个服务台、每个服务台服务率 的系统:
是瓶颈资源的利用率,不是 CPU 利用率,也不是「平均负载」。如果数据库连接池只有 20 条,每请求占用连接 50 ms,那么连接池的服务率是 req/s;到达 360 req/s 时,连接池的 ,哪怕 CPU 只有 40%。
11.2.1 M/M/1:一条 1/(1-ρ) 的曲线
最简单的模型是单队列、单服务台、泊松到达、指数服务时间(M/M/1)。平均停留时间:
其中 是平均服务时间。也就是说,排队把服务时间放大了 倍。排队等待时间(不含服务)是:
| 利用率 ρ | 放大倍数 1/(1-ρ) | 服务 20 ms 时的 W | 其中排队 Wq |
|---|---|---|---|
| 0.50 | 2.0 | 40 ms | 20 ms |
| 0.70 | 3.3 | 67 ms | 47 ms |
| 0.80 | 5.0 | 100 ms | 80 ms |
| 0.90 | 10.0 | 200 ms | 180 ms |
| 0.95 | 20.0 | 400 ms | 380 ms |
| 0.99 | 100.0 | 2 s | 1.98 s |
这张表就是「70% 开始抖」的来源。70% 时,排队已经是服务时间的 2.3 倍;90% 时是 9 倍。从 70% 到 90%,利用率只涨了 29%,排队时间却涨了 3.8 倍。延迟对利用率的弹性不是 1,而是 。 更麻烦的是反向关系:延迟上升会让并发占用时间变长, 下降,等效服务能力下降, 进一步上升。这就是正反馈的种子,11.8 会把它接成完整的重试风暴。
11.2.2 M/M/c:加机器能缓解,但不能取消膝点
真实服务通常有多个服务台(线程、副本、连接)。M/M/c 的平均排队时间要用 Erlang C 公式算,但趋势和 M/M/1 一样: 时发散。下面是服务时间 1 个单位、 时的计算结果:
| 服务台数 c | Erlang C(要排队的概率) | 平均排队 Wq(服务时间的倍数) | 平均停留 W |
|---|---|---|---|
| 1 | 0.900 | 9.00 | 10.00 |
| 2 | 0.853 | 4.26 | 5.26 |
| 4 | 0.788 | 1.97 | 2.97 |
| 8 | 0.702 | 0.88 | 1.88 |
| 16 | 0.591 | 0.37 | 1.37 |
| 32 | 0.458 | 0.14 | 1.14 |
同样是 90% 利用率,单台要排队 9 个服务时间,32 台只要 0.14 个。池化(pooling)确实能显著降低排队,这也是为什么「把小实例合成大池子」常常有效。但注意: 固定为 0.9 时,c 从 1 加到 32,W 从 10 降到 1.14,仍然比服务时间多 14%;而且一旦 到 0.99,c=32 的 W 也会涨到约 4 个服务时间。加服务台把曲线压平,但没有取消膝点。
11.2.3 变异性:均值相同,p99 可以差十倍
M/M/1 假设到达和服务都是指数分布(变异系数 )。真实系统很少满足:流量有批处理、定时任务、重试脉冲;服务时间有 GC、锁等待、磁盘抖动、下游长尾。Kingman 公式给出了一个够用的近似:
其中 是到达间隔的变异系数(标准差/均值), 是服务时间的变异系数。 对应指数分布, 表示比指数更「尖峰/重尾」。
服务时间 20 ms、 时:
| 到达变异 | 服务变异 | Wq | 说明 |
|---|---|---|---|
| 1 | 1 | 180 ms | 教科书 M/M/1 |
| 1 | 4 | 450 ms | 服务端有 GC/锁等待 |
| 4 | 1 | 450 ms | 流量成批到达(定时任务、重试脉冲) |
| 4 | 4 | 720 ms | 批到达 + 长尾服务,最糟 |
这解释了三个常见现象:
- 同一份代码,白天 p99 比夜里高:白天到达是突发的( 大),夜里是均匀的。
- GC 一次,p99 抖一片:GC 让服务时间出现长尾( 大),即使平均服务时间没变。
- 重试会把 推高:重试不是均匀到达,而是一波一波同步到达;退避和抖动的作用之一就是降低 。
排队延迟不是「平均服务时间 × 请求数」,而是「服务时间 × 波动」。利用率决定放大器,变异性决定输入信号的幅度;两者相乘,才是你看到的 p99。
11.2.4 70% 不是定律,是经验起点
70% 来自「 时排队约 2.3 个服务时间」这一量级,不是普适定律。变异性低、池化程度高、有限流兜底的服务, 也可能很稳;有批处理、锁等待、下游长尾的服务, 就该警惕。正确的做法不是背 70%,而是在自己的系统上测出膝点:固定变异性,逐级加负载,画出「利用率—p99」曲线,把 p99 开始非线性上升的点当作告警线,把更低的点当作规划线。11.3 的梯度压测就是干这个的。
CPU 利用率是「CPU 忙的时间比例」, 是「瓶颈资源的利用率」。两者只有在 CPU 恰好是瓶颈时才接近。一个服务 CPU 60%、数据库连接池 100%, 是 1(连接池),系统已经在无界排队;此时看 CPU 会得出「还有 40% 余量」的错误结论。先找瓶颈,再算 。
11.3 压测:基线、梯度、开放/闭环
压测的目的不是「打出一个好看的 QPS」,而是回答四个问题:
- 在目标 SLO 下,单副本/单集群能承载多少 goodput?
- 瓶颈资源是什么,它在什么负载下先饱和?
- 过载时系统怎么坏:快速失败、排队、雪崩,还是抖动?
- 扩容/降级/限流是否按预期生效?
四个问题对应五类压测,而不是一次「打满」。
| 类型 | 目的 | 负载形状 | 时长 | 主要产出 |
|---|---|---|---|---|
| 基线 | 单请求成本、依赖 fan-out | 1–10 rps | 5–10 min | p50/p99、CPU/请求、调用链 |
| 梯度 | 找膝点与最大 goodput | 25/50/75/100/125% 峰值 | 每级 5–10 min | 利用率—延迟曲线、瓶颈 |
| 浸泡 | 内存泄漏、连接泄漏、GC 退化 | 70% 峰值恒定 | 2–24 h | 内存/句柄/延迟漂移 |
| 尖峰 | 突发吸收、自动扩容 | 30 s 内 3–5 倍 | 数分钟 | 队列、扩容时延、错误率 |
| 破坏性 | 过载行为、熔断/限流 | 逐步加到 150–200% | 直到 SLO 破 | 失败模式、恢复时间 |
11.3.1 基线:先把一次请求拆开
基线压测用极低负载(1–10 rps),目的是拿到「一次请求在无排队时的真实成本」,而不是测容量。要记录:
- 端到端 p50/p99,以及每个下游 span 的耗时;
- 每请求的 CPU 时间(不是 CPU 利用率)、内存分配、GC 次数;
- 下游调用次数与 fan-out;
- 请求/响应体积,缓存命中率。
基线的价值在于给后面的数字提供解释。梯度压测到 800 QPS 时 p99 从 50 ms 涨到 400 ms,如果基线显示下游 p99 只有 30 ms,那 350 ms 的增量就是排队;如果基线下游 p99 已经 200 ms,那多半是依赖先饱和。没有基线,梯度压测只能看到「变慢了」,说不出「慢在哪」。
11.3.2 梯度:一级一级加,直到膝点
梯度压测从低到高逐级加载,每级保持 5–10 分钟,等指标稳定后再进下一级。每级记录:
| 指标 | 用途 | 稳定判据 |
|---|---|---|
| goodput | 有效容量 | 连续 2 min 波动 < 5% |
| p50/p99/p999 | 延迟曲线 | p99 连续 2 min 无上升趋势 |
| 错误率(按码) | 过载信号 | 区分 429/503/500/超时 |
| 瓶颈资源利用率 | 找膝点 | CPU、连接池、队列、IO 各自算 |
| 队列等待 | 是否开始堆积 | 排队时间 > 服务时间即警戒 |
| 下游延迟/错误 | 是否先饱和 | 与基线对比 |
停止条件必须事先写死,不能现场「再看看」:
- p99 超过 SLO 的 1.5 倍,且持续 2 分钟;
- 错误率超过 1%,或 5xx 超过 0.1%;
- 队列等待超过服务时间的 50%;
- 下游开始出现 429/超时,且不是压测器自身造成;
- 压测器 CPU 超过 70%(结果已不可信)。
11.3.3 开放与闭环:最容易被忽略的测量错误
压测器有两种基本模型:
- 闭环(closed-loop):固定 个并发客户端,每个客户端发完一个请求、收到响应后再发下一个。
wrk -c 200就是闭环。 - 开放(open-loop):按固定速率发请求,不管上一个是否返回。真实用户、定时任务、上游服务调用更接近开放模型。
闭环压测有一个著名偏差,叫 coordinated omission(协调遗漏)。假设你用 100 个并发客户端压一个服务,正常情况下每个请求 10 ms,能打 10,000 rps。服务端卡了 1 秒:
- 闭环:100 个客户端都卡住,1 秒内只发出 100 个请求;压测器记录的 p99 是 1 秒,但「本应在这 1 秒内发出的 10,000 个请求」根本没有被记录。真实用户在那 1 秒里会排队,等待时间会累积到几秒甚至几十秒。
- 开放:压测器仍按 10,000 rps 发;1 秒内积压 10,000 个请求,延迟会如实反映排队。
闭环压测在系统变慢时自动降低压力,等于把最需要观测的过载区间跳过去了。这不是 wrk 的 bug,而是闭环模型的固有性质;wrk 适合测「给定并发下的吞吐」,不适合测「给定到达率下的延迟」。
一个最小修正:记录延迟时,用「请求计划发送时刻」而不是「实际发送时刻」作为起点。开放压测器通常是这样的:
import asyncio
import random
import time
async def send_once(i: int) -> float:
"""替换成真实请求;这里用随机睡眠模拟服务时间。"""
await asyncio.sleep(random.uniform(0.005, 0.05))
return time.monotonic()
async def open_loop(rate_per_s: float, duration_s: float):
start = time.monotonic()
total = int(rate_per_s * duration_s)
latencies = []
tasks = []
for i in range(total):
scheduled = start + i / rate_per_s
delay = scheduled - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
tasks.append(asyncio.create_task(measure(i, scheduled, latencies)))
if len(tasks) >= 2000:
await asyncio.gather(*tasks)
tasks.clear()
await asyncio.gather(*tasks)
latencies.sort()
return latencies
async def measure(i: int, scheduled: float, out: list) -> None:
done = await send_once(i)
out.append((done - scheduled) * 1000.0) # 关键:从计划时刻算起
lat = asyncio.run(open_loop(1000, 10))
print(f"n={len(lat)} p50={lat[len(lat)//2]:.1f}ms p99={lat[int(len(lat)*0.99)]:.1f}ms")
measure 里的 scheduled 是「这个请求本应发出的时刻」。如果服务端卡住,done - scheduled 会把排队时间算进去;闭环压测则会把这批请求「藏起来」。这个差别在系统健康时只有几毫秒,在过载时能差两个数量级。
11.3.4 压测器本身也会成为瓶颈
开放压测要维持恒定速率,压测器必须有足够的并发和精度:
目标 20,000 rps、p99 500 ms,需要约 12,000 个在途请求。如果单台压测机只能维持 2,000 个并发连接,就需要 6 台;否则压测器先排队,测出来的是压测器的上限。
三个检查点:
- 压测机 CPU 持续 > 70%:调度延迟会污染结果;
- 压测机网卡/带宽打满:请求发不出去,看起来像服务端慢;
- 压测机
ss -s的TIME_WAIT暴涨:端口耗尽,新建连接失败。
压测数字分「冷」「热」两种。预热 2–5 分钟(或直到 p99 连续 2 分钟稳定),丢弃预热期数据再计时;冷启动单独测一轮,记录「从 0 到满容量」的时间,它是 11.6 的关键输入。
11.4 限流:令牌桶、漏桶、滑动窗口、分布式限流
限流保护的不是「请求」,而是「瓶颈资源」。在网关按 QPS 限流,保护的是后端线程;在客户端按并发限流,保护的是下游连接池;在数据库代理按连接数限流,保护的是数据库。限流点要放在瓶颈之前,越靠前越便宜:在网关拒绝一个请求的成本,比让它走到数据库再超时低两个数量级。 限流还有一层常被忽略的语义:它限制的是「成本」,不是「次数」。一个查询 10 万行的请求和一个按主键查一行的请求,都算 1 QPS,但对数据库的压力差几个数量级。生产系统通常给请求分配 cost(按行数、字节数、下游 fan-out),令牌桶按 cost 扣减。
11.4.1 令牌桶:允许突发,限制均值
令牌桶有两个参数:速率 (每秒补充多少 token)和桶容量 (最多攒多少)。请求消耗 token,不够就拒绝或排队。空闲时桶攒满,允许一次 的突发;持续打满时,长期速率被限制在 。
import threading
import time
class TokenBucket:
"""线程安全的最小令牌桶;返回 (是否放行, 建议等待秒数)。"""
def __init__(self, rate_per_s: float, burst: float):
self.rate = float(rate_per_s)
self.burst = float(burst)
self._tokens = float(burst)
self._updated = time.monotonic()
self._lock = threading.Lock()
def _refill(self, now: float) -> None:
self._tokens = min(self.burst, self._tokens + (now - self._updated) * self.rate)
self._updated = now
def allow(self, cost: float = 1.0) -> tuple[bool, float]:
if cost <= 0 or cost > self.burst:
raise ValueError(f"bad cost: {cost}")
now = time.monotonic()
with self._lock:
self._refill(now)
if self._tokens >= cost:
self._tokens -= cost
return True, 0.0
return False, (cost - self._tokens) / self.rate
三个细节:
- 用单调时钟:墙上时钟被 NTP 调整时,令牌会突然多出或少掉;
- 用锁保护读-算-写:多线程下先读后写会超发,和分布式场景的分两次
GET/SET是同一个错误; cost > burst直接报错:一个永远不可能被放行的请求,应该在配置校验时发现,而不是在线上永远 429。
一个算例:rate=100/s、burst=200,空闲 10 秒后突然来 250 个请求。桶最多攒 200 个,前 200 个通过,后 50 个被拒;被拒的请求等待约 0.5 秒后可再通过。如果客户端立刻重试 50 次,等于把拒绝又打回去,所以拒绝必须带 Retry-After。
11.4.2 漏桶与滑动窗口
漏桶(leaky bucket)把请求放进一个有固定流出速率的队列:不管进来多快,出去总是匀速。它和令牌桶的区别是「整形」与「限速」(shaping vs policing):漏桶让下游看到平滑流量,代价是排队延迟;令牌桶允许突发通过,不引入额外排队。
固定窗口最简单:每个窗口一个计数器,超过阈值拒绝。它的经典问题是边界双倍:限制「每分钟 100 次」,客户端在 12:00:59 打 100 次、12:01:00 再打 100 次,两秒内通过了 200 次。对下游来说,这和没限流差不多。
滑动窗口计数用前一窗口的加权值修正边界:
例:限制 100/min,上一窗口用了 80,当前窗口已过 15 秒(25%),本窗口已用 30,则估计值为 ,本窗口最多再放 10 个。它只需要存两个计数,精度足够大多数 API 限流。
| 算法 | 突发 | 是否整形 | 内存 | 分布式实现 | 适用 |
|---|---|---|---|---|---|
| 固定窗口 | 边界可 2× | 否 | O(1) | INCR + EXPIRE | 粗粒度配额 |
| 滑动窗口日志 | 精确 | 否 | O(请求数) | ZSET | 低频、强精确 |
| 滑动窗口计数 | 近似 | 否 | O(1) | Lua | 高频 API |
| 令牌桶 | 允许 | 否 | O(1) | Lua | 通用限流 |
| 漏桶 | 不允许 | 是 | O(队列) | 较难 | 出口整形、保护下游 |
| 并发限制 | 不适用 | 否 | O(1) | 信号量 | 保护连接池/线程池 |
最后一行值得单独说:并发限制常常比 QPS 限制更可靠。QPS 限制假设每个请求的成本固定;一旦下游变慢,同样的 QPS 会占用更多并发,QPS 限制就挡不住了。并发限制直接约束 ;由 Little’s law ,它会自动随 调整:延迟翻倍时,同样的并发只允许一半的吞吐。第 2.6 节的 Gate 就是并发限制。
11.4.3 分布式限流:本地、全局与热点
单机令牌桶在 N 个副本上会变成 N 倍限额:每个副本都以为自己有 1000/s,实际总量是 N×1000。分布式限流有三种常见结构:
| 结构 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 全局限流 | 所有请求查同一个 Redis/限流服务 | 精确 | 每次请求多一次 RTT;限流器成为单点 |
| 本地分片 | 每个副本分到 ,本地桶 | 无额外 RTT | 负载不均时浪费配额 |
| 两层 | 本地桶(快路径)+ 全局桶(总量) | 兼顾延迟与总量 | 实现复杂;两层参数要配合 |
两层结构的常用参数:本地桶速率 、突发 ;全局桶速率 、突发 。请求先过本地桶,再过全局桶;本地桶挡住大部分突发,只有接近配额时才查全局。分区时,本地桶仍能工作,但总量可能超出 ;这是一个显式的取舍:要可用性就接受短时超发,要精确就接受限流器不可用时拒绝。
Redis Lua 的最小实现:
-- KEYS[1] = bucket key
-- ARGV = rate_per_s, burst, now_ms, cost
local rate = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst
local ts = tonumber(data[2]) or now
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(burst, tokens + delta * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)
return {allowed, math.floor(tokens)}
now 由调用方传入会有时钟偏差;更稳的做法是在脚本里用 redis.call('TIME') 取服务端时间。无论哪种,读、算、写必须在同一个脚本里完成,分两次 GET/SET 在并发下一定超发。
热点 key 是分布式限流的常见故障:一个大租户的 key 落在同一个 Redis 分片上,把该分片打满。解决办法是把配额拆成 个子桶(例如 16 个),按 hash(request_id) % S 选一个,每个子桶速率 ;总量近似 ,误差最多 。代价是每个请求仍只查一个子桶,不会增加 RTT。
11.4.4 限流的响应:429、503 与 Retry-After
限流命中后返回什么,决定了上游会不会立刻再打回来:
| 场景 | 状态码 | 响应头 | 上游动作 |
|---|---|---|---|
| 租户配额耗尽 | 429 | Retry-After、X-RateLimit-Scope: tenant | 退避到指定时间,不要换 key 重试 |
| 全局限流 | 429 | Retry-After、X-RateLimit-Scope: global | 降速或排队 |
| 服务自身过载 | 503 | Retry-After + 抖动 | 退避、切实例、降级 |
| 请求已过期 | 503/504 | 不建议重试 | 放弃,走 unknown 收敛 |
Retry-After 要给一个「估计的恢复时间」,不能给 0(等于让客户端立刻重试),也不能给得比客户端 deadline 还长(等于让它放弃)。客户端应在 [0.8, 1.2] × Retry-After 内随机化,避免所有客户端在同一秒回来。第 2.7 节已经展开过 429/503 的语义,这里只强调:限流器给出的等待时间,必须和重试预算、熔断器一起工作;单独一个 429 挡不住没有预算的重试。
11.5 降级:优先级、熔断、舱壁
降级不是「出错时返回默认值」这么简单。真正的降级是:在容量不足时,有意识地放弃一部分功能,把资源让给核心路径。它需要先回答「什么最重要」,再回答「怎么关」,最后回答「关了之后用户看到什么」。
11.5.1 优先级:先决定放弃什么
降级的前提是排序。一个电商交易链路,从最不重要到最重要可能是:
| 优先级 | 功能 | 降级动作 | 用户影响 |
|---|---|---|---|
| P4 | 行为埋点、推荐曝光上报 | 直接丢弃/采样 1% | 无感,报表略少 |
| P3 | 个性化推荐、猜你喜欢 | 返回热门/空列表 | 转化率下降 |
| P2 | 商品评价、问答 | 只返回缓存的前 3 条 | 信息变少 |
| P2 | 搜索联想 | 关闭,改为提交后搜索 | 多一次操作 |
| P1 | 非核心读(历史订单列表) | 只读缓存,TTL 延长 | 可能看到旧数据 |
| P1 | 写操作(下单、支付) | 限流 + 排队,不轻易关 | 直接资损 |
| P0 | 登录、支付、库存扣减 | 不降级,只扩容/限流 | 不可用即事故 |
这张表没有通用答案,但有一个通用要求:优先级必须提前写进配置和演练,不能在事故现场开会决定。事故现场的信息最少、压力最大,临时排序一定偏向「谁声音大」。
11.5.2 熔断器:三态状态机
熔断器(circuit breaker)是一个有状态的开关:下游错误率过高时,快速失败一段时间,给下游恢复的机会,也释放自己的线程。它有三个状态:
失败率 ≥ 50%(窗口内 ≥ 20 次)
┌──────────┐ ─────────────────▶ ┌──────────┐
│ CLOSED │ │ OPEN │
└──────────┘ ◀───────────────── └──────────┘
▲ 连续 3 次探测成功 │
│ │ open_timeout 到期
│ ┌────────────┐ │
└────── │ HALF_OPEN │ ◀──────────┘
探测成功│ 只放 3 个 │
└────────────┘
│ 任一探测失败 → OPEN(退避 ×2,封顶 60s)
三个状态的行为:
- CLOSED:正常放行,统计滑动窗口内的成功/失败。窗口内请求数达到最小值(例如 20),且失败率超过阈值(例如 50%),转到 OPEN。
- OPEN:所有请求立即失败,不碰下游。持续
open_timeout(例如 10 s),到期转到 HALF_OPEN。 - HALF_OPEN:只放少量探测请求(例如 3 个)。全部成功则回到 CLOSED;任一失败则回到 OPEN,并把
open_timeout翻倍(封顶 60 s),避免下游还没恢复就被反复冲击。
import enum
import random
import threading
import time
from collections import deque
class State(enum.Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, fail_threshold=0.5, min_requests=20, window_s=10.0,
open_s=10.0, probes=3, max_open_s=60.0):
self.fail_threshold = fail_threshold
self.min_requests = min_requests
self.window_s = window_s
self.open_s = open_s
self.max_open_s = max_open_s
self.probes = probes
self._state = State.CLOSED
self._events = deque() # (monotonic, ok)
self._open_until = 0.0
self._open_for = open_s
self._probe_left = 0
self._probe_ok = 0
self._lock = threading.Lock()
def allow(self) -> bool:
with self._lock:
now = time.monotonic()
if self._state is State.OPEN:
if now < self._open_until:
return False
self._state = State.HALF_OPEN
self._probe_left = self.probes
self._probe_ok = 0
if self._state is State.HALF_OPEN:
if self._probe_left <= 0:
return False
self._probe_left -= 1
return True
def record(self, ok: bool) -> None:
with self._lock:
now = time.monotonic()
if self._state is State.HALF_OPEN:
if ok:
self._probe_ok += 1
if self._probe_ok >= self.probes:
self._state = State.CLOSED
self._open_for = self.open_s
self._events.clear()
else:
self._trip(now)
return
self._events.append((now, ok))
while self._events and now - self._events[0][0] > self.window_s:
self._events.popleft()
if self._state is State.CLOSED and len(self._events) >= self.min_requests:
fails = sum(1 for _, ok in self._events if not ok)
if fails / len(self._events) >= self.fail_threshold:
self._trip(now)
def _trip(self, now: float) -> None:
# CLOSED 的第一次跳闸用 open_s;HALF_OPEN 再失败才指数退避
if self._state is State.HALF_OPEN:
self._open_for = min(self.max_open_s, self._open_for * 2)
else:
self._open_for = self.open_s
self._state = State.OPEN
self._open_until = now + self._open_for * random.uniform(0.8, 1.2)
self._events.clear()
四个实现细节比状态机本身更容易出错:
- 失败要分类:400、404、422 是业务结果,不是下游故障;把它们算进失败率,会在流量结构变化时误熔断。只统计超时、连接错误、5xx。
- 窗口要有最小请求数:低流量时 1 个失败就是 100% 失败率,会反复跳闸。
min_requests=20挡住这种噪声。 - HALF_OPEN 只放探测:如果半开状态放开全部流量,下游还没恢复就被再次打满,等于没有熔断。
- OPEN 时间要抖动:所有实例同时到期、同时探测,会形成新的同步脉冲;
uniform(0.8, 1.2)是最低要求。
11.5.3 舱壁:把故障关在隔间里
舱壁(bulkhead)来自船体设计:一个舱进水,其他舱还能浮。软件里的舱壁是按依赖/租户/优先级隔离资源池,让一个慢依赖只耗尽自己的那部分线程,而不是全公司的线程。
| 结构 | 无舱壁 | 有舱壁 |
|---|---|---|
| 线程池 | 全局 200 线程,一个慢依赖占满 | 每个依赖 40 线程,互不侵占 |
| 连接池 | 所有下游共用一个池 | 每个下游独立池 + 独立超时 |
| 队列 | 一个全局 FIFO,慢请求堵住快请求 | 按优先级/租户分队列 |
| 部署 | 所有租户共享实例 | 大租户独立实例/分片 |
数字算例:5 个下游,全局 200 线程。按 500 req/s 到达,一个下游从 20 ms 变成 2 s,每个请求占线程 2 s,200 线程在 0.4 s 内被占满;其他 4 个下游的正常请求全部排队。改成每个下游 40 线程后,慢下游最多占 40 个,另外 160 个线程继续服务健康下游。舱壁的代价是资源利用率下降(每个池都可能有空闲),换来的是故障不扩散;这是值得的交易,但要按依赖的实际流量分配,不能平均分。
11.5.4 降级策略:关什么、怎么关、怎么让用户知道
| 策略 | 做法 | 适用 | 风险 |
|---|---|---|---|
| 缓存兜底 | 返回过期缓存,TTL 可延长 | 读多写少、可容忍旧值 | 数据陈旧,必须标记 |
| 默认值 | 返回空列表/热门/默认配置 | 推荐、联想、非核心展示 | 用户以为「没有」 |
| 只读模式 | 关闭写入口,读继续 | 存储故障、主从切换 | 用户无法下单 |
| 异步受理 | 返回 202 + 查询 ID | 写操作可延后 | 客户端要轮询 |
| 采样/截断 | 只返回前 N 条、采样日志 | 大响应、报表 | 结果不完整 |
| 关闭非核心 | 关闭推荐、评价、动画 | 容量不足 | 体验下降 |
| 快速失败 | 503 + Retry-After | 自身过载 | 需要上游配合退避 |
降级要满足四个条件,否则它会把「可用性问题」变成「正确性问题」:
- 可观测:响应里带
degraded=true或X-Degraded: cache,指标里单独计数; - 有界:缓存兜底必须有 TTL 上限,不能把一小时前的库存当成现在;
- 可逆:降级开关要能一键恢复,不需要重新发布;
- 演练过:没演练过的降级路径,大概率在真正需要时抛异常。
大促期间库存服务超时,网关触发降级:读库存返回本地缓存的旧值。缓存 TTL 原本 1 秒,但降级代码为了「更可用」把 TTL 改成了 10 分钟。结果 10 分钟内所有用户都看到「有货」,下单量超过真实库存 3 倍;写路径没有降级,超卖在支付后才暴露。
修复不是「不要降级」,而是:读降级只允许 5 秒 TTL、返回 degraded=true、库存扣减仍以数据库条件更新为准、并在降级期间对下单做并发限制。降级可以牺牲新鲜度,不能牺牲不变量。
11.6 自动扩缩容的陷阱
自动扩缩容看起来是容量问题的银弹:流量涨了加副本,流量降了减副本。它确实能处理分钟级的趋势变化,但处理不了秒级尖峰,也会引入一批新的失败模式。
t=0s 流量 2×,p99 开始上升,队列增长 t=15s 指标采集窗口还没满,扩缩容控制器看不到 t=30s 窗口触发,决定扩容(目标副本 +6) t=35s 调度器创建 Pod,拉镜像/挂卷 t=60s 尖峰已经结束(很多尖峰只有 30–60s) t=75s Pod Ready(镜像已缓存;冷启动 JVM 要 120s+) t=90s 新 Pod 开始接流量,但本地缓存是冷的 t=150s 缓存预热完成,单 Pod 达到额定容量
这张时间线的意思是:扩容的响应时间通常是分钟级,而尖峰的生命周期常常是秒级。等扩容完成,尖峰已经过去;如果尖峰没过去,新副本又会在冷缓存状态下被压垮。
11.6.1 七个常见陷阱
| 陷阱 | 现象 | 根因 | 对策 |
|---|---|---|---|
| 指标延迟 | 尖峰结束才扩容 | 采集 + 窗口 + 冷却 | 用短窗口 + 预测式预扩容 |
| 冷启动 | 新副本一上来就超时 | JIT、缓存、连接池未预热 | 预热接口、最小副本、启动探针 |
| 缩容抖动 | 副本数来回跳 | 指标在阈值附近波动 | 稳定窗口 5–10 min、步长限制 |
| 有状态服务 | 扩容触发 rebalance/选主 | 分片迁移、日志重放 | 不按 CPU 扩缩;预分片、垂直扩容 |
| 指标选错 | CPU 低但队列满 | IO 密集/等下游 | 用并发、队列深度、p99 做信号 |
| 依赖上限 | 副本加了,数据库崩了 | 连接数/配额是全局瓶颈 | max 副本 = 依赖容量 / 单副本连接 |
| 反馈振荡 | 扩容→缓存冷→更慢→再扩容 | 扩容降低单副本命中率 | 预热、限制扩容速度、保留热副本 |
11.6.2 扩缩容的边界条件
自动扩缩容有三个必须手算的边界:
- 最小副本数由故障域决定,不由流量决定。三可用区、允许丢一个区,最小副本至少 3(每区 1),且要保证剩余两区能接住峰值。
- 最大副本数由下游容量决定。100 个副本、每个 20 条数据库连接,就是 2000 条连接;数据库上限 500 时,第 26 个副本开始就会连接失败。max 副本必须满足 。
- 缩容速度由在途请求和连接 draining 决定。副本被摘除时,正在处理的请求可能还要跑几秒;缩容太快会把它们变成 5xx。
11.6.3 扩缩容不是容量规划
自动扩缩容解决「趋势变化」,容量规划解决「上限和故障」:秒级尖峰、单可用区失效、下游瓶颈,扩缩容都来不及或解决不了,只能靠提前留 headroom、按故障域算副本、按依赖容量设 max。把自动扩缩容当容量规划,等于把安全带当刹车。
11.7 故障演练:假设、爆炸半径、game day
故障演练(chaos engineering / game day)不是「随机杀几个 Pod 看会不会挂」,而是用一个可证伪的假设,去验证系统在特定故障下的行为。没有假设的演练,最多叫「故障注入」;没有停止条件的演练,叫「制造事故」。
11.7.1 假设怎么写
一个好的演练假设包含五要素:故障、范围、时长、可观测的预期、机制。
在单可用区 10% 的实例上注入 200 ms 网络延迟、持续 5 分钟,支付接口的 p99 不超过 800 ms、错误率不超过 0.5%,因为客户端 deadline 为 1 s、重试预算 5%、且熔断器在下游错误率超过 50% 时会打开。
这句话里每一项都可检验:
| 要素 | 例子 | 不可检验的写法 |
|---|---|---|
| 故障 | 200 ms 延迟、2% 丢包 | 「网络抖动一下」 |
| 范围 | 单可用区、10% 实例 | 「小范围」 |
| 时长 | 5 分钟 | 「一会儿」 |
| 预期 | p99 ≤ 800 ms、错误率 ≤ 0.5% | 「不能挂」 |
| 机制 | deadline、重试预算、熔断 | 「应该没事」 |
假设必须是可证伪的:如果结果是 p99 1.2 s,假设就被推翻,这就是一次有效演练。反之,如果假设写成「系统应该保持稳定」,无论发生什么都能解释成「稳定」,那演练没有信息量。
11.7.2 爆炸半径:从假故障到真生产
爆炸半径(blast radius)是「这次演练最多能影响多少用户/数据/钱」。它必须逐级放大,每一级都有独立的价值和独立的停止条件:
| 级别 | 环境/范围 | 能验证什么 | 风险 |
|---|---|---|---|
| L0 | 单元测试 + fake 依赖 | 熔断/重试/降级逻辑 | 无 |
| L1 | staging 全量 | 配置、部署、告警链路 | 无真实用户 |
| L2 | 生产 canary 1% | 真实流量下的行为 | 1% 用户 |
| L3 | 生产单可用区 | 故障切换、容量余量 | 一个 AZ |
| L4 | 生产 game day(多故障) | 组织协同、runbook | 受控的全站风险 |
| L5 | 区域级切换 | 灾备、DNS、数据恢复 | 极高,需单独审批 |
大多数团队应该先把 L0–L2 做扎实,再碰 L3。直接上 L4 的「勇敢」,通常来自对爆炸半径的低估。演练的每一步都要能独立回滚;不能回滚的演练,不是演练,是发布。
11.7.3 注入手段
常用手段:tc netem 做延迟/丢包/乱序,iptables 或网络策略做分区,kill -9 做进程崩溃,fallocate 做磁盘满,故障注入代理返回 5xx,容器时钟或 libfaketime 做时钟偏移。每种手段都要先确认爆炸半径:tc 影响整块网卡,分区可能造成双主,杀进程可能丢数据。
11.7.4 一个可用的 netem 注入脚本
下面这个脚本把「爆炸半径」写进了代码:网卡白名单、拒绝 loopback、到时自动清理、支持 dry-run。
#!/usr/bin/env bash
# netem_drill.sh — 在指定网卡注入延迟/丢包,到时自动清理。
# 用法:
# DRY_RUN=1 ./netem_drill.sh apply # 只打印计划,不改内核
# IFACE=eth0 DURATION_S=300 ./netem_drill.sh apply
# ./netem_drill.sh clear
set -euo pipefail
IFACE="${IFACE:-eth0}"
ACTION="${1:-apply}"
DELAY_MS="${DELAY_MS:-120}"
JITTER_MS="${JITTER_MS:-30}"
LOSS_PCT="${LOSS_PCT:-2}"
RATE="${RATE:-100mbit}"
DURATION_S="${DURATION_S:-300}"
DRY_RUN="${DRY_RUN:-0}"
ALLOW_IFACES="${ALLOW_IFACES:-eth0 ens5 enp0s3}"
die() { echo "error: $*" >&2; exit 1; }
need_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
die "需要 root 或 CAP_NET_ADMIN;可先用 DRY_RUN=1 检查计划"
fi
}
check_iface() {
local allowed=0 candidate
for candidate in $ALLOW_IFACES; do
[[ "$IFACE" == "$candidate" ]] && allowed=1
done
[[ "$allowed" -eq 1 ]] || die "IFACE=$IFACE 不在白名单: $ALLOW_IFACES"
[[ "$IFACE" != "lo" ]] || die "拒绝在 loopback 注入,会打断本机所有服务"
[[ -d "/sys/class/net/$IFACE" ]] || die "网卡不存在: $IFACE"
}
run() {
if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$*"
else
"$@"
fi
}
cleanup() {
run tc qdisc del dev "$IFACE" root 2>/dev/null || true
}
check_iface
[[ "$DRY_RUN" == "1" ]] || need_root
case "$ACTION" in
apply)
run tc qdisc replace dev "$IFACE" root netem \
delay "${DELAY_MS}ms" "${JITTER_MS}ms" \
loss "${LOSS_PCT}%" rate "$RATE"
run tc -s qdisc show dev "$IFACE"
if [[ "$DURATION_S" -gt 0 && "$DRY_RUN" != "1" ]]; then
trap cleanup EXIT INT TERM
echo "注入中:${DURATION_S}s 后自动清理(Ctrl-C 立即清理)"
sleep "$DURATION_S"
fi
;;
clear)
cleanup
;;
show)
tc -s qdisc show dev "$IFACE"
;;
*)
die "用法: $0 {apply|clear|show}"
;;
esac
脚本里有四个安全设计,值得抄到任何注入工具里:
- 白名单:只允许预声明的网卡,避免
IFACE=eth1打错到管理网; - 拒绝 loopback:本机所有服务共用
lo,注入等于自杀; - 自动清理:
trap cleanup EXIT INT TERM,即使脚本被 Ctrl-C 也能恢复; - dry-run:先打印将执行的
tc命令,确认参数再真跑。
11.7.5 Game day:角色、时间线、停止条件
Game day 是一次有组织的演练,不是一个人的即兴操作。最小配置四个角色:
| 角色 | 职责 | 不能兼任 |
|---|---|---|
| 指挥(IC) | 决定开始/停止、对外沟通 | 注入操作者 |
| 注入者 | 执行脚本、记录时间点 | 指挥 |
| 观察者 | 看指标、判断假设是否成立 | 注入者 |
| 记录者 | 时间线、决策、异常 | — |
时间线通常分四段:
- 准备(T−7 天):写假设、评审爆炸半径、准备回滚、通知相关团队、冻结变更;
- 基线(T−10 min):记录当前 SLO、错误率、容量,确认没有正在进行的故障;
- 注入(T0):按计划注入,观察者盯住预设指标,指挥有权随时叫停;
- 恢复与复盘(T+):清理注入、确认指标回到基线、24 小时内出复盘。
停止条件必须提前写死,至少包括:
- SLO 燃烧率超过阈值(例如 1 小时烧掉 10% 错误预算);
- 真实用户错误率超过 1%,且持续 2 分钟;
- 注入脚本无法清理,或清理后指标不恢复;
- 出现数据损坏/重复扣款迹象;
- 关键依赖(数据库、DNS、认证)出现非预期错误。
演练的第一原则是「可停止」。如果一次注入无法在 1 分钟内撤销,它就不该在生产做。第二原则是「可观测」:如果连「假设是否成立」都判断不了,演练只会产生争论,不会产生知识。
- 把注入期间的时间线、指标截图、告警记录归档;
- 逐条对照假设,标记「成立/不成立/无法判断」;
- 对每个「不成立」写出根因、修复项、负责人和复验日期;
- 把这次演练的脚本和参数收进仓库,下一次从同一基线复跑;
- 如果没有任何发现,先怀疑「故障太轻」或「观测太粗」,而不是庆祝系统完美。
11.8 失败模式:重试风暴(重复请求)与过载
重试风暴不是「流量太大」,而是「系统用重试把一次变慢变成了持续过载」。它的形状固定:某个依赖变慢 → 超时率上升 → 客户端重试 → 总到达率上升 → 队列变长 → 完成时间更长 → 超时率更高。每一轮都在给正反馈加柴,而原始用户流量可能一点没涨。
11.8.1 一条具体的时间线
假设一个服务有 200 个工作线程,正常时每个请求占用线程 80 ms(其中 40 ms 等下游),容量 2500 req/s;平时流量 1500 QPS,利用率 60%。客户端超时 1 s,失败后最多重试 1 次(最多 2 次尝试),没有重试预算,也没有熔断。 某天下午,一个下游的 p99 从 40 ms 涨到 400 ms,平均占用线程时间变成 440 ms,容量塌缩到约 455 req/s。下面五轮的数字是示意(最后一行是干预后的对比),但数量级符合真实系统:
| 轮次 | 原始 QPS | 依赖延迟 | 有效容量 | 超时率 p | 重试 QPS | 总到达 | goodput | 队列 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1500 | 120 ms | 1250 | 2% | 30 | 1530 | 1230 | 小 |
| 2 | 1500 | 250 ms | 690 | 18% | 270 | 1770 | 680 | 增长 |
| 3 | 1500 | 400 ms | 455 | 42% | 630 | 2130 | 450 | 接近满 |
| 4 | 1500 | 600 ms | 312 | 62% | 930 | 2430 | 310 | 溢出 |
| 5 | 1500 | 600 ms | 312 | 65% | 975 | 2475 | 305 | 丢弃 |
| 6(熔断+降载) | 1500 | 600 ms | 312 | 65% | 0 | 320 | 312 | 下降 |
看第二列和第七列:原始流量一直是 1500,涨的是重试。系统不是被更多用户压垮的,是被自己对失败的补偿压垮的。到第 5 轮,总到达是原始的 1.65 倍,goodput 只剩 305——多出来的 975 个请求几乎全在浪费线程和带宽。
11.8.2 正反馈环
① 下游变慢
│
▼
② 完成时间变长 ─────▶ ③ 超时率上升
▲ │
│ ▼
⑥ 有效容量下降 ◀── ⑤ 队列/并发上升 ◀── ④ 客户端重试
│
└────────────────▶ 回到 ①(下一轮更慢)
这个环的增益可以写成(不限制重试次数时):
其中 是失败率, 是每个失败请求平均再发的次数。 时没有有限的不动点,系统只能靠外部干预(限流、熔断、关重试)退出。 时也有放大: 时 , 时 。 真实系统的 不是常数,它随负载上升。把 代进去,稳定性条件是在不动点处 ;接近饱和时 急剧变大,这个乘积很容易越过 1。这就是为什么 70% 时一次抖动可能自愈,90% 时同样的抖动会直接雪崩。 代入数字:失败率 42% 时无限重试的放大倍数是 ,65% 时是 ;重试放大不是线性的,失败率越高,每一次重试越可能触发下一次重试。
11.8.3 止损顺序:先关放大器,再谈扩容
事故现场最常见的错误是「先扩容」。扩容慢(分钟级),如果瓶颈是下游或数据库,扩容只会把更多请求送进同一个瓶颈。正确顺序:
| 顺序 | 动作 | 目的 | 期望信号 |
|---|---|---|---|
| 1 | 关闭客户端自动重试,或把预算降到 1% | 切断正反馈 | 总到达立刻下降 |
| 2 | 入口限流到当前有效容量 | 让队列停止增长 | 队列长度、排队时间下降 |
| 3 | 返回 503 + Retry-After + 抖动 | 让上游也降速 | 429/503 上升,goodput 稳定 |
| 4 | 熔断最慢依赖,走降级 | 释放线程 | 线程池等待下降 |
| 5 | 扩容/恢复依赖 | 恢复容量 | 延迟与错误率同步下降 |
| 6 | 逐步恢复重试 | 避免二次冲击 | 重试比例 < 5% |
第 1 步经常被跳过,因为「重试是客户端的事」。但大多数团队同时拥有客户端和服务端;即使只有服务端,也可以用 Retry-After、GOAWAY、连接关闭来迫使客户端退避。重试策略是系统级配置,不是某个 SDK 的私有选项。
11.8.4 亚稳态:原因消失了,系统还没好
重试风暴最危险的形态是亚稳态故障(metastable failure):触发因素已经消失,系统却停在坏状态里。原因有三:队列积压还没排空;故障期间排下的重试定时器在几十秒后同时到达;缓存大量失效,恢复期每次未命中都要回源。退出亚稳态不能只靠「等」,必须主动把入口压到系统能处理的水平,等队列排空、缓存预热、重试定时器消化完,再逐步放量。恢复期的容量不是稳态容量。
某服务 300 个副本、每副本 50 并发,正常 QPS 12,000。一个下游出现 30 秒抖动,p99 从 30 ms 涨到 2 s。客户端超时 800 ms、重试 2 次;健康检查每 5 秒一次,超时也重试。 30 秒内,超时率从 1% 涨到 55%,总到达被重试放大到 2.5 倍;健康检查失败导致一半副本被摘除,剩余副本的并发被瞬间打满。依赖 30 秒后恢复,但队列和重试定时器又花了 40 分钟才排空。最终 SLO 燃烧掉当天 60% 的错误预算。 修复用了四件事:客户端重试预算 5%、熔断器(错误率 50% 打开 10 秒)、健康检查不重试且失败阈值从 1 次调到 3 次、入口限流按「当前健康副本 × 单副本容量」动态计算。同样的故障注入在复验中只造成 90 秒的 p99 上升。
11.9 观测:tc/netem、wrk、qdisc、metrics
容量和演练的观测有两个方向:从外面看系统能承受多少(压测器、延迟分位、错误率),从里面看瓶颈在哪(qdisc、连接、队列)。只做前者,你会知道「撑不住」,但不知道为什么;只做后者,你会看到很多曲线,但不知道离过载还有多远。
11.9.1 qdisc:队列和丢包在哪里
tc -s qdisc show dev eth0
一次 netem 注入期间的输出:
qdisc netem 8002: root refcnt 2 limit 1000 delay 120ms 30ms loss 2% rate 100Mbit
Sent 184320000 bytes 152000 pkt (dropped 3040, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
怎么读:
| 字段 | 含义 | 正常 | 异常 |
|---|---|---|---|
delay/jitter/loss | 当前注入参数 | 与演练计划一致 | 与计划不符,说明规则被覆盖 |
dropped | qdisc 丢弃的包 | 接近配置的 loss 比例 | 远大于配置,可能是 limit 溢出 |
backlog | 当前排队字节/包数 | 接近 0 或短时波动 | 持续增长,队列已满 |
overlimits | 超过 qdisc 限额的次数 | 0 | >0 说明整形能力不足 |
backlog 是最有用的一个:它是「内核里正在排队的包」。如果 backlog 持续增长,说明出口带宽或 qdisc 处理能力不够,应用层的 p99 会跟着涨;此时加应用副本没用,瓶颈在网卡/整形规则。
11.9.2 ss:连接和端口
ss -s
输出:
Total: 412
TCP: 389 (estab 301, closed 52, orphaned 3, timewait 33)
UDP: 7
RAW: 0
FRAG: 0
| 字段 | 含义 | 正常 | 异常 |
|---|---|---|---|
estab | 已建立连接 | 与并发量同量级 | 远高于并发,连接泄漏 |
timewait | 主动关闭后的等待 | 数千以内 | 数万,端口耗尽风险 |
orphaned | 应用已关闭但内核仍持有 | 接近 0 | 持续增长,fd 泄漏 |
timewait 高不一定是故障,但配合「新建连接失败/cannot assign requested address」就是端口耗尽。压测器上尤其常见:短连接 + 高 QPS 会在几十秒内把本地端口用光,看起来像服务端拒绝连接。
11.9.3 wrk:压测器的视角
wrk -t4 -c200 -d30s --latency -s post.lua http://127.0.0.1:8081/v1/search
输出:
Running 30s test @ http://127.0.0.1:8081/v1/search
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.82ms 12.31ms 250.10ms 91.22%
Req/Sec 9.87k 1.21k 12.34k 72.31%
Latency Distribution
50% 2.91ms
75% 4.12ms
90% 8.90ms
99% 42.10ms
1182341 requests in 30.07s, 1.21GB read
Socket errors: connect 0, read 12, write 0, timeout 0
Requests/sec: 39321.55
Transfer/sec: 41.22MB
怎么读:
- p50 与 p99 的比值:健康系统通常 < 10;这里 42.1/2.91 ≈ 14.5,尾部偏长,要看下游或 GC。
+/- Stdev是「落在均值 ± 一个标准差内的请求比例」:91% 说明大部分请求稳定,少数长尾拉高了均值。Socket errors:connect非零是建连失败(端口/防火墙);read非零是服务端提前断开;timeout非零是客户端等不到响应。Non-2xx(未显示时为 0):任何非 2xx 都要按错误码分类,不能只看 Requests/sec。
必须记住:wrk -c200 是闭环压测。它测的是「200 并发下能跑多快」,不是「到达率 40,000 时延迟多少」。要测后者,用固定速率的压测器(如 wrk2 -R)或 11.3.3 的开放压测器;否则会在系统变慢时自动减压,漏掉最关键的过载区间。
11.9.4 metrics:错误率与排队时间
PromQL 查错误率:
sum(rate(http_server_requests_total{job="api", code=~"5.."}[1m]))
/
sum(rate(http_server_requests_total{job="api"}[1m]))
示例输出 0.004,即 0.4% 的 5xx,低于 1% 的警戒线;但要结合流量看:如果总 QPS 从 12,000 掉到 3,000,0.4% 可能只是「大部分请求根本没进来」。
查排队时间:
histogram_quantile(0.99,
sum by (le) (rate(http_server_queue_wait_seconds_bucket{job="api"}[5m])))
示例输出 0.018,即 p99 排队 18 ms,相对 200 ms 的服务时间不算高;如果超过服务时间的 50%(100 ms),说明已接近膝点,应该扩容或限流,而不是继续加负载。
11.10 真实案例:2021-12-07 AWS us-east-1 故障
2021 年 12 月 7 日,AWS 位于北弗吉尼亚的 us-east-1 区域发生了一次影响面很大的故障。AWS 官方的复盘把原因写得很克制:
| 项 | 公开事实 |
|---|---|
| 时间 | 2021-12-07 |
| 触发 | 内部网络中的一次自动化活动触发了非预期行为 |
| 直接影响 | 连接主 AWS 网络的网络设备出现拥塞 |
| 影响范围 | 控制面与多项服务受到影响 |
| 恢复 | 停止自动化、恢复网络设备、逐步恢复流量 |
| 复盘 | 官方 post-event summary |
这场故障不是「某台机器坏了」,而是自动化触发了网络拥塞。它和本章的关系,不是「网络细节」,而是四个容量与治理问题:
- 控制面在数据面的恢复路径上。控制面受损时,自动扩缩容、健康检查、服务发现、甚至控制台本身都可能不可用;依赖「出问题后自动扩容」的系统,恰好失去了扩容能力。
- 自动化的爆炸半径没有上限。一条自动化动作可以在几秒内影响大量设备;如果没有速率限制、分批执行、人工确认和回滚开关,自动化就是最高效的故障放大器。
- 重试与健康检查会放大恢复期负载。服务不可达时,客户端重试、负载均衡健康检查、内部依赖的探活会同时增加;恢复期系统最脆弱,却要面对高于稳态的请求量。
- 恢复容量不等于稳态容量。设备恢复后,路由收敛、连接重建、缓存预热都需要时间;按稳态流量一次性放量,会把刚恢复的系统再次推倒。
| 本章机制 | 在这次故障中的角色 | 缺失时的后果 |
|---|---|---|
| 容量余量 | 故障期间可用容量下降,需要 headroom 吸收 | 剩余容量被瞬间打满 |
| 限流/降载 | 恢复期把入口压到可控水平 | 队列与重试把恢复拖长 |
| 熔断/降级 | 依赖不可用时快速失败,保住核心路径 | 线程被慢依赖耗尽 |
| 自动化边界 | 限制单次变更的影响范围 | 一次动作波及全区域 |
本节只引用 AWS 官方复盘中的公开结论:时间、内部网络自动化活动、网络设备拥塞、控制面与多项服务受影响、停止自动化并逐步恢复。具体设备、命令、时间线和内部拓扑不复述,也不把「自动化」写成唯一根因——公开复盘强调的是自动化触发了非预期行为。原文链接:AWS post-event summary。
11.11 重试导致重复副作用:压测回放打到生产
第 10 章讲过回放:把生产流量录下来,在别处重放,用来验证新版本或复现问题。回放很有用,但它有一个致命前提:回放的是请求,不是请求的后果。如果回放工具没有隔离写路径、没有复用幂等键,它会把「测试流量」变成真实的订单、扣款和发货。
11.11.1 一次回放事故的时间线
某团队要验证新的搜索排序,从生产网关录制了 30 分钟流量(约 21,000 个请求,其中 1,400 个是写请求),准备在 staging 回放。回放工具的目标地址来自环境变量 BASE_URL;staging 的配置里漏了这一项,工具回退到默认值——生产网关的内部域名。回放又恰好从生产跳板机启动,DNS 解析到生产。
| 时间 | 事件 | 结果 |
|---|---|---|
| t0 | 录制 30 分钟生产流量 | 21,000 请求,含 1,400 写 |
| t1 | 回放工具启动,BASE_URL 未设置,回退到生产 | 目标指向生产 |
| t2 | 回放工具为「避免去重干扰」重新生成幂等键 | 每个请求都是新逻辑请求 |
| t3 | 以 50 rps 回放 7 分钟 | 21,000 请求打到生产 |
| t4 | 写请求全部执行 | 380 个重复订单、190 笔重复支付 |
| t5 | 次日财务对账发现 | 退款、客诉、PSP 争议 |
这里有三层错误,每一层单独看都「有理由」:
- 配置回退:
BASE_URL为空时回退到生产,理由是「方便本地调试」; - 重新生成幂等键:理由是「复用原键会被服务端去重,测不出真实行为」;
- 没有写保护:回放工具只区分「读/写」用于统计,不用于拦截。
三层叠加,测试流量就变成了真实副作用。事故的根因不是回放,而是回放工具默认拥有生产写权限。
11.11.2 三种回放模式,三种隔离级别
回放不是一种模式。至少要把「影子流量」「正确性回放」「压力回放」分开,每种模式的写路径和幂等键策略不同:
| 模式 | 目的 | 写路径 | 幂等键 | 数据 |
|---|---|---|---|---|
| 影子(shadow) | 比较新旧逻辑的输出 | 全部丢弃或只写影子表 | 不需要 | 脱敏、只读 |
| 正确性回放 | 复现 bug、验证修复 | 只允许测试租户/测试环境 | 复用原键,让服务端去重 | 脱敏,测试账号 |
| 压力回放 | 验证容量 | 禁止打到生产;测试环境用合成数据 | 确定性生成,按逻辑请求稳定 | 合成/脱敏 |
关键区别在幂等键:
- 正确性回放必须复用原键。原请求的键是
K1,回放还用K1;服务端要么返回原结果,要么按原键去重。这样即使回放打到了有真实数据的环境,也不会产生第二次副作用。 - 压力回放不能复用原键,否则所有回放请求都命中去重表,测不到真实负载;但新键必须是确定性的(例如
HMAC(secret, original_request_id)),保证同一逻辑请求的多次重试仍用同一个键。 - 影子模式根本不该有写路径。写请求在代理层丢弃,或改写到一个只读的影子存储;任何「先写再删」的方案都不算影子。
11.11.3 在工具层堵住,而不是靠人记得
回放工具应该内置三道闸门:环境白名单、租户白名单、写路径开关。下面是一个最小检查器:
import hashlib
import hmac
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
TEST_TENANTS = {"tenant-test", "tenant-canary"}
def replay_key(req, mode: str, tenant: str, secret: bytes) -> str:
if mode not in {"shadow", "correctness", "load"}:
raise ValueError(f"unknown mode: {mode}")
if req.method in WRITE_METHODS:
if mode == "shadow":
raise PermissionError("shadow 模式不允许写请求")
if tenant not in TEST_TENANTS:
raise PermissionError(f"租户 {tenant} 不在回放白名单")
if mode == "correctness" and req.method in WRITE_METHODS:
if not req.original_idempotency_key:
raise ValueError("正确性回放缺少原始幂等键")
return req.original_idempotency_key # 复用,服务端去重
if mode == "load" and req.method in WRITE_METHODS:
digest = hmac.new(secret, req.request_id.encode(), hashlib.sha256).hexdigest()
return "replay:" + digest[:32] # 确定性新键
return "" # 读请求/shadow 不写
这段代码的重点不是 HMAC,而是默认拒绝:shadow 遇到写请求直接抛异常,未知租户直接拒绝,correctness 没有原键也拒绝。默认允许的工具,迟早会在某个配置缺失的夜晚打到生产。
除此之外,还需要三层外部约束:
| 层 | 措施 | 能挡住什么 |
|---|---|---|
| 网络 | staging 出站白名单,禁止访问生产网关/PSP | 配置写错也打不到生产 |
| 凭证 | 测试环境使用独立的 PSP key、独立的数据库账号 | 即使请求到达,也无法扣款 |
| 工具 | --dry-run 打印目标/租户/写请求数;生产目标需要二次确认 | 人为失误 |
| 数据 | 脱敏、合成用户、禁止真实手机号/卡号 | 隐私与合规 |
| 运行 | 回放速率上限、kill switch、先 1 rps canary | 影响范围失控 |
某支付团队把生产流量回放到 staging 做压测,回放工具默认连接串来自 PAYMENT_URL。一次发布把 staging 的 PAYMENT_URL 误配成生产 PSP 的地址,且回放工具为每个请求生成了新的幂等键。7 分钟内 190 笔支付被重复扣款,第二天对账才发现。
修复分四层:staging 网络层禁止访问生产 PSP;PSP 凭证按环境隔离;回放工具默认 shadow 模式,写请求必须显式 --allow-write --tenants tenant-test;正确性回放复用原始幂等键。复验时同样的误配只产生了 0 笔扣款,因为请求在出口就被拦住了。
11.12 容量与演练清单
把前面的内容压成两张表。容量清单用于上线前评审,演练清单用于每次 game day 前后对照。
| # | 容量检查项 | 达标线 | 证据 |
|---|---|---|---|
| 1 | 容量口径 | 写明 SLO、负载模型、goodput 定义 | 一页文档 |
| 2 | 单副本容量 | 有基线 + 梯度压测,不是拍脑袋 | 压测报告 |
| 3 | 瓶颈资源 | 指出当前瓶颈和故障后的下一个瓶颈 | 曲线 + 资源表 |
| 4 | 膝点 | 测出 p99 非线性上升的利用率 | 梯度数据 |
| 5 | headroom | 稳态 ≤ 50%,峰值 ≤ 70% | 容量表 |
| 6 | 故障域余量 | 丢一个 AZ 后仍能接住峰值 | 容量测算表 |
| 7 | 依赖上限 | 副本数 × 连接数 ≤ 下游上限 × 0.8 | 连接数测算 |
| 8 | 扩缩容边界 | min 由故障域定,max 由依赖定 | 配置 + 评审 |
| 9 | 限流 | 有租户/全局两层,按 cost 计费 | 配置 + 429 指标 |
| 10 | 过载行为 | 503 + Retry-After,不排队到超时 | 破坏性压测 |
| 11 | 自动扩缩容时延 | 从 0 到满容量的时间有数字 | 冷启动压测 |
| # | 演练检查项 | 达标线 | 证据 |
|---|---|---|---|
| 1 | 假设 | 五要素齐全、可证伪 | 演练文档 |
| 2 | 爆炸半径 | 有级别、有范围、有独立回滚 | 评审记录 |
| 3 | 停止条件 | 量化、提前写死、指挥有权叫停 | runbook |
| 4 | 观测 | 关键指标有面板,能判断假设 | 面板链接 |
| 5 | 数据保护 | 写路径隔离、幂等键策略明确 | 工具配置 |
| 6 | 协同 | IC/注入/观察/记录四角色 | 值班表 |
| 7 | 恢复 | 1 分钟内能撤销注入 | 计时记录 |
| 8 | 复盘 | 每项发现都有负责人和复验日期 | 复盘单 |
11.13 本章陷阱清单
- 用 CPU 利用率当容量口径:CPU 60% 但连接池 100% 时,系统已经在无界排队;先找瓶颈,再算 。
- 只压平均不压峰值:按日均流量规划,晚高峰的 2 倍峰值会直接越过膝点;容量按峰值和故障域算。
- 用闭环压测报开放延迟:系统变慢时闭环自动减压,漏掉 coordinated omission;固定到达率要用开放压测。
- 把压测 mock 当真实依赖:mock 1 ms、生产 300 ms,容量差一个数量级;基线必须接真实依赖或等价延迟。
- 把 headroom 当浪费:稳态跑到 80%,丢一个可用区后剩余节点直接过载;余量是故障预算。
- 限流按请求数不按成本:一个重查询和一次主键查询都算 1 QPS,瓶颈资源仍会被打穿;按 cost 计费。
- 单机限流当全局限流:N 个副本各限 1000/s,总量是 N×1000;要有全局桶或按副本分摊。
- 429 不给
Retry-After或给 0:客户端立刻重试,限流器变成随机数生成器;给估计恢复时间并加抖动。 - 熔断统计业务错误:400/404/422 被算成失败,流量结构一变就误熔断;只统计超时、连接错误、5xx。
- 熔断没有半开或没有退避:恢复瞬间放开全部流量,下游二次崩溃;半开只放探测,OPEN 时间指数退避。
- 舱壁做成全局线程池:一个慢依赖吃光所有线程,其他依赖陪葬;按依赖/租户隔离资源池。
- 回放工具默认有生产写权限:配置缺失、复用新键、无白名单,压测流量变成真实订单;默认 shadow,写路径显式开启。
11.14 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 用 Little’s law 算容量 | 给定 C 和 W 能算吞吐,给定目标能算并发 | 11.1 |
| 算含故障域的副本数 | 丢一个 AZ 后剩余能力 ≥ 峰值 | 11.1 |
| 解释 1/(1-ρ) | 能说出 70%/90% 的放大倍数 | 11.2 |
| 区分开放/闭环 | 能解释 coordinated omission 并给出修正 | 11.3 |
| 设计梯度压测 | 有基线、步长、停止条件、预热 | 11.3 |
| 选限流算法 | 能说出令牌桶/漏桶/滑动窗口/并发限制的边界 | 11.4 |
| 实现分布式限流 | 有 Lua 原子脚本、热点分片、两层结构 | 11.4 |
| 画熔断状态机 | 三态、阈值、半开探测、退避齐全 | 11.5 |
| 设计舱壁 | 按依赖隔离线程/连接,有数字 | 11.5 |
| 说清扩缩容边界 | min 由故障域定,max 由依赖定 | 11.6 |
| 写演练假设 | 五要素齐全、可证伪、能回滚 | 11.7 |
| 解释回放风险 | 能说出三种模式和幂等键策略 | 11.11 |
Q1 一个服务有 240 个工作线程,每个请求占用线程 120 ms。下游变慢后占用时间涨到 600 ms。两种情况下容量各是多少?如果目标流量是 1500 QPS,第二种情况会发生什么?
Q2 服务时间 25 ms,利用率 0.9,到达和服务都近似指数分布。用 Kingman 公式估算平均排队时间;如果把服务变异系数平方提高到 4,排队时间变成多少?
Q3 用 100 个并发客户端做闭环压测,目标 5000 QPS。服务端卡了 2 秒,压测器报告的 p99 是 2 秒。这个数字为什么低估了真实用户的等待?应该怎么测?
Q4 令牌桶 rate=100/s、burst=200,空闲 10 秒后一次来 250 个请求。多少能通过?被拒的请求最短等多久?如果客户端立刻重试,会发生什么?
Q5 熔断器窗口内 30 个请求、失败 12 个,阈值 50%、最小请求数 20。它会不会打开?如果这 12 个失败里有 8 个是 404,结论有什么不同?
Q6 压测回放打到生产,最危险的三件事是什么?分别用什么措施挡住?
答案
A1 第一种 QPS;第二种 QPS。目标 1500 QPS 时,第二种情况的需求是容量的 3.75 倍,队列会无界增长,超时和重试会把有效容量进一步压低。
A2 Kingman: ms; 时 , ms。均值没变,只是变异性变大,排队时间翻了 2.5 倍。
A3 闭环压测在服务端卡住时只发出 100 个请求,另外本应在 2 秒内发出的约 10,000 个请求没有被记录;真实用户会排队,等待时间累积。要用开放压测(固定到达率),并从「计划发送时刻」计算延迟。
A4 前 200 个通过,后 50 个被拒;被拒请求最短等约 0.5 秒(50/100)。如果客户端立刻重试,这 50 个请求会在令牌还没补充时再次被拒,形成无效循环;应返回 Retry-After 并退避。
A5 失败率 ,低于 50%,不会打开。若其中 8 个是 404,真实失败只有 4 个,失败率 13.3%,更不该打开;业务错误必须从熔断统计中排除。
A6 配置回退到生产、写路径未隔离、幂等键被重新生成。对应措施:网络/凭证隔离、回放工具默认 shadow 并对写请求显式授权、正确性回放复用原键(压力回放用确定性新键)。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5/6 章 | 11.9 | 背景 | 只借用「链路/队列/丢包」的视角;TCP 拥塞控制不重写 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章 | 11.7、11.9 | 背景 | tc/qdisc 只讲观测与注入,不展开内核实现 |
| 《Python 网络编程》 | 并发与客户端 | 11.3、11.4、11.5 | 精选 | 借用并发风格;令牌桶、熔断器、开放压测器为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.4–4.11 | 11.7、11.9 | 交叉引用 | 明确不重复 TCP、qdisc 内核路径、容器网络 |
| Google SRE 工作手册 | SLO、错误预算、过载保护 | 11.2、11.5、11.6 | 引用 | 只引用概念与公式,不复制案例 |
| 本书自编 + AWS 公开复盘 | us-east-1 2021-12-07 | 11.10 | 完整 | 只引用公开结论,不编造内部细节 |
下一站:容量、限流、降级、演练都有了,下一步要把它们落到一个能跑的工程里。进入 12 项目一:幂等工具调用后端,用一张
calls表、一个幂等键和一个可注入故障的本地服务,把前面十一章的约束真正跑一遍。