Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 公里/小时」,却不说路况和油耗。 先看一个最小模型。一个有 CC 个并发槽位的服务(线程、协程、连接都算),每个请求平均占用槽位 WW 秒。稳定状态下,槽位要么在忙要么空闲,所以每秒能完成的请求数最多是:

λmax⁡=CW \lambda_{\max} = \frac{C}{W}

这就是 Little’s law 的变形。Little’s law 的原式是

L=λW L = \lambda W

其中 LL 是系统内的平均在途请求数,λ\lambda 是到达率,WW 是平均停留时间。它不假设泊松到达,也不假设服务时间分布,只要求系统稳定(长期看进入等于离开)。正因为它假设这么少,才适合做容量估算的第一把尺子。 代入数字。200 个工作线程,每个请求占用线程 80 ms:

λmax⁡=2000.08=2500 req/s \lambda_{\max} = \frac{200}{0.08} = 2500\ \text{req/s}

下游变慢,每个请求占用线程的时间涨到 400 ms,线程数没变:

λmax⁡′=2000.4=500 req/s \lambda_{\max}' = \frac{200}{0.4} = 500\ \text{req/s}

容量掉了 80%,而 CPU 可能只涨了一点——线程都在等下游,不在计算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。这也是为什么「加 CPU」经常救不了 IO 密集型服务:瓶颈是并发槽位和被拉长的 WW,不是算力。

并发槽位 C平均停留 W吞吐上限 C/W系统状态
20080 ms2500 req/s健康
200200 ms1000 req/s下游开始变慢
200400 ms500 req/s容量塌缩,队列增长
2001 s200 req/s已过载,排队时间远超处理时间
2004 s50 req/s连接/线程被长期占用,接近不可用

这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为 WW 变大了,C/WC/W 就小了。第 2.6 节已经用过这个公式定并发上限;这里把它反过来,用目标吞吐和实测延迟算需要多少并发:

C=λtarget×Wp99×1.2 C = \lambda_{\text{target}} \times W_{p99} \times 1.2

目标 1200 QPS、p99 250 ms,则 C≈360C \approx 360。用 p99 而不是 p50,是因为 p50 只覆盖一半请求;按 p50 算出来的并发,在高峰期一定排队。安全系数 1.2 是给突发和重试留的,不是给「拍脑袋」留的。

定义 11.1.1 · 容量口径

容量(capacity)是在给定工作负载、给定 SLO(延迟分位与错误率)下,一个系统长期可承载的有效请求速率。它由三个自变量共同决定:请求的资源成本(CPU、内存、连接、IO)、请求的停留时间 WW、以及可用的并发槽位 CC。

容量必须以 goodput 计量:只有满足 SLO 的成功请求才算容量。把 500、超时、重试都算进 QPS,会得到一个漂亮但无法交付的数字。

工作负载必须写清楚,否则容量数字不可比。至少固定六项:

维度为什么影响容量压测时必须固定
请求/响应体积序列化、拷贝、带宽、GC用生产 p50 与 p99 两档
读/写比例写有事务、锁、复制开销按生产比例,不要只压读
缓存命中率未命中会放大下游负载冷缓存、热缓存各测一轮
下游延迟直接进入 WWmock 不够,要接真实依赖或等价延迟
依赖调用次数每请求 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%,三可用区、允许丢一个区。先算需要的总能力:

3600×1.3=4680 req/s 3600 \times 1.3 = 4680\ \text{req/s}

再除以单副本能力:

⌈4680/500⌉=10 个副本 \lceil 4680 / 500 \rceil = 10\ \text{个副本}

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 用 C/WC/W 算的是「稳态吞吐上限」,它假设请求来了立刻被处理。真实系统有队列:请求到达时如果没有空闲槽位,就要等。等待时间不是线性的,它随利用率上升急剧变大。理解这条曲线,才能理解为什么 70% 是常见的告警线,而不是 95%。 先定义利用率。对一个有 cc 个服务台、每个服务台服务率 μ\mu 的系统:

ρ=λcμ \rho = \frac{\lambda}{c\mu}

ρ\rho 是瓶颈资源的利用率,不是 CPU 利用率,也不是「平均负载」。如果数据库连接池只有 20 条,每请求占用连接 50 ms,那么连接池的服务率是 20/0.05=40020/0.05 = 400 req/s;到达 360 req/s 时,连接池的 ρ=0.9\rho = 0.9,哪怕 CPU 只有 40%。

利用率与延迟:70% 之后的非线性 横轴利用率 ρ,纵轴相对延迟;ρ 接近 1 时排队时间按 1/(1-ρ) 增长。 利用率与延迟:70% 之后的非线性 利用率 ρ 延迟 50% 70% 90% 膝点 70% 90%:小抖动即过载 留 headroom 不是浪费,是给排队和重试留空间。
读图:容量规划不能只看 CPU 平均值。70% 是常见的告警起点,90% 时一次小抖动就可能把队列推满;重试会在高利用率下进一步放大到达率。

11.2.1 M/M/1:一条 1/(1-ρ) 的曲线

最简单的模型是单队列、单服务台、泊松到达、指数服务时间(M/M/1)。平均停留时间:

W=1μ−λ=1/μ1−ρ W = \frac{1}{\mu - \lambda} = \frac{1/\mu}{1 - \rho}

其中 1/μ1/\mu 是平均服务时间。也就是说,排队把服务时间放大了 1/(1−ρ)1/(1-\rho) 倍。排队等待时间(不含服务)是:

Wq=W−1μ=ρ1−ρ⋅1μ W_q = W - \frac{1}{\mu} = \frac{\rho}{1-\rho} \cdot \frac{1}{\mu}

利用率 ρ放大倍数 1/(1-ρ)服务 20 ms 时的 W其中排队 Wq
0.502.040 ms20 ms
0.703.367 ms47 ms
0.805.0100 ms80 ms
0.9010.0200 ms180 ms
0.9520.0400 ms380 ms
0.99100.02 s1.98 s

这张表就是「70% 开始抖」的来源。70% 时,排队已经是服务时间的 2.3 倍;90% 时是 9 倍。从 70% 到 90%,利用率只涨了 29%,排队时间却涨了 3.8 倍。延迟对利用率的弹性不是 1,而是 ρ/(1−ρ)\rho/(1-\rho)。 更麻烦的是反向关系:延迟上升会让并发占用时间变长,C/WC/W 下降,等效服务能力下降,ρ\rho 进一步上升。这就是正反馈的种子,11.8 会把它接成完整的重试风暴。

11.2.2 M/M/c:加机器能缓解,但不能取消膝点

真实服务通常有多个服务台(线程、副本、连接)。M/M/c 的平均排队时间要用 Erlang C 公式算,但趋势和 M/M/1 一样:ρ→1\rho \to 1 时发散。下面是服务时间 1 个单位、ρ=0.9\rho=0.9 时的计算结果:

服务台数 cErlang C(要排队的概率)平均排队 Wq(服务时间的倍数)平均停留 W
10.9009.0010.00
20.8534.265.26
40.7881.972.97
80.7020.881.88
160.5910.371.37
320.4580.141.14

同样是 90% 利用率,单台要排队 9 个服务时间,32 台只要 0.14 个。池化(pooling)确实能显著降低排队,这也是为什么「把小实例合成大池子」常常有效。但注意:ρ\rho 固定为 0.9 时,c 从 1 加到 32,W 从 10 降到 1.14,仍然比服务时间多 14%;而且一旦 ρ\rho 到 0.99,c=32 的 W 也会涨到约 4 个服务时间。加服务台把曲线压平,但没有取消膝点。

11.2.3 变异性:均值相同,p99 可以差十倍

M/M/1 假设到达和服务都是指数分布(变异系数 Ca=Cs=1C_a = C_s = 1)。真实系统很少满足:流量有批处理、定时任务、重试脉冲;服务时间有 GC、锁等待、磁盘抖动、下游长尾。Kingman 公式给出了一个够用的近似:

Wq≈ρ1−ρ⋅Ca2+Cs22⋅1μ W_q \approx \frac{\rho}{1-\rho} \cdot \frac{C_a^2 + C_s^2}{2} \cdot \frac{1}{\mu}

其中 CaC_a 是到达间隔的变异系数(标准差/均值),CsC_s 是服务时间的变异系数。C2=1C^2 = 1 对应指数分布,C2>1C^2 > 1 表示比指数更「尖峰/重尾」。

服务时间 20 ms、ρ=0.9\rho=0.9 时:

到达变异 Ca2C_a^2服务变异 Cs2C_s^2Wq说明
11180 ms教科书 M/M/1
14450 ms服务端有 GC/锁等待
41450 ms流量成批到达(定时任务、重试脉冲)
44720 ms批到达 + 长尾服务,最糟

这解释了三个常见现象:

  • 同一份代码,白天 p99 比夜里高:白天到达是突发的(Ca2C_a^2 大),夜里是均匀的。
  • GC 一次,p99 抖一片:GC 让服务时间出现长尾(Cs2C_s^2 大),即使平均服务时间没变。
  • 重试会把 Ca2C_a^2 推高:重试不是均匀到达,而是一波一波同步到达;退避和抖动的作用之一就是降低 Ca2C_a^2。
一句话

排队延迟不是「平均服务时间 × 请求数」,而是「服务时间 × 波动」。利用率决定放大器,变异性决定输入信号的幅度;两者相乘,才是你看到的 p99。

11.2.4 70% 不是定律,是经验起点

70% 来自「ρ=0.7\rho=0.7 时排队约 2.3 个服务时间」这一量级,不是普适定律。变异性低、池化程度高、有限流兜底的服务,ρ=0.8\rho=0.8 也可能很稳;有批处理、锁等待、下游长尾的服务,ρ=0.6\rho=0.6 就该警惕。正确的做法不是背 70%,而是在自己的系统上测出膝点:固定变异性,逐级加负载,画出「利用率—p99」曲线,把 p99 开始非线性上升的点当作告警线,把更低的点当作规划线。11.3 的梯度压测就是干这个的。

别用 CPU 利用率代替 ρ

CPU 利用率是「CPU 忙的时间比例」,ρ\rho 是「瓶颈资源的利用率」。两者只有在 CPU 恰好是瓶颈时才接近。一个服务 CPU 60%、数据库连接池 100%,ρ\rho 是 1(连接池),系统已经在无界排队;此时看 CPU 会得出「还有 40% 余量」的错误结论。先找瓶颈,再算 ρ\rho。

11.3 压测:基线、梯度、开放/闭环

压测的目的不是「打出一个好看的 QPS」,而是回答四个问题:

  1. 在目标 SLO 下,单副本/单集群能承载多少 goodput?
  2. 瓶颈资源是什么,它在什么负载下先饱和?
  3. 过载时系统怎么坏:快速失败、排队、雪崩,还是抖动?
  4. 扩容/降级/限流是否按预期生效?

四个问题对应五类压测,而不是一次「打满」。

类型目的负载形状时长主要产出
基线单请求成本、依赖 fan-out1–10 rps5–10 minp50/p99、CPU/请求、调用链
梯度找膝点与最大 goodput25/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):固定 NN 个并发客户端,每个客户端发完一个请求、收到响应后再发下一个。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 压测器本身也会成为瓶颈

开放压测要维持恒定速率,压测器必须有足够的并发和精度:

Cgen≥λtarget×Wp99×1.2 C_{\text{gen}} \ge \lambda_{\text{target}} \times W_{p99} \times 1.2

目标 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 令牌桶:允许突发,限制均值

令牌桶有两个参数:速率 rr(每秒补充多少 token)和桶容量 BB(最多攒多少)。请求消耗 token,不够就拒绝或排队。空闲时桶攒满,允许一次 BB 的突发;持续打满时,长期速率被限制在 rr。

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 次。对下游来说,这和没限流差不多。

滑动窗口计数用前一窗口的加权值修正边界:

estimate=prev×(1−telapsedTwindow)+current \text{estimate} = \text{prev} \times \left(1 - \frac{t_{\text{elapsed}}}{T_{\text{window}}}\right) + \text{current}

例:限制 100/min,上一窗口用了 80,当前窗口已过 15 秒(25%),本窗口已用 30,则估计值为 80×0.75+30=9080 \times 0.75 + 30 = 90,本窗口最多再放 10 个。它只需要存两个计数,精度足够大多数 API 限流。

算法突发是否整形内存分布式实现适用
固定窗口边界可 2×否O(1)INCR + EXPIRE粗粒度配额
滑动窗口日志精确否O(请求数)ZSET低频、强精确
滑动窗口计数近似否O(1)Lua高频 API
令牌桶允许 BB否O(1)Lua通用限流
漏桶不允许是O(队列)较难出口整形、保护下游
并发限制不适用否O(1)信号量保护连接池/线程池

最后一行值得单独说:并发限制常常比 QPS 限制更可靠。QPS 限制假设每个请求的成本固定;一旦下游变慢,同样的 QPS 会占用更多并发,QPS 限制就挡不住了。并发限制直接约束 LL;由 Little’s law L=λWL = \lambda W,它会自动随 WW 调整:延迟翻倍时,同样的并发只允许一半的吞吐。第 2.6 节的 Gate 就是并发限制。

11.4.3 分布式限流:本地、全局与热点

单机令牌桶在 N 个副本上会变成 N 倍限额:每个副本都以为自己有 1000/s,实际总量是 N×1000。分布式限流有三种常见结构:

结构做法优点缺点
全局限流所有请求查同一个 Redis/限流服务精确每次请求多一次 RTT;限流器成为单点
本地分片每个副本分到 R/NR/N,本地桶无额外 RTT负载不均时浪费配额
两层本地桶(快路径)+ 全局桶(总量)兼顾延迟与总量实现复杂;两层参数要配合

两层结构的常用参数:本地桶速率 R/NR/N、突发 B/NB/N;全局桶速率 RR、突发 BB。请求先过本地桶,再过全局桶;本地桶挡住大部分突发,只有接近配额时才查全局。分区时,本地桶仍能工作,但总量可能超出 BB;这是一个显式的取舍:要可用性就接受短时超发,要精确就接受限流器不可用时拒绝。

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 分片上,把该分片打满。解决办法是把配额拆成 SS 个子桶(例如 16 个),按 hash(request_id) % S 选一个,每个子桶速率 R/SR/S;总量近似 RR,误差最多 BB。代价是每个请求仍只查一个子桶,不会增加 RTT。

11.4.4 限流的响应:429、503 与 Retry-After

限流命中后返回什么,决定了上游会不会立刻再打回来:

场景状态码响应头上游动作
租户配额耗尽429Retry-After、X-RateLimit-Scope: tenant退避到指定时间,不要换 key 重试
全局限流429Retry-After、X-RateLimit-Scope: global降速或排队
服务自身过载503Retry-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()

四个实现细节比状态机本身更容易出错:

  1. 失败要分类:400、404、422 是业务结果,不是下游故障;把它们算进失败率,会在流量结构变化时误熔断。只统计超时、连接错误、5xx。
  2. 窗口要有最小请求数:低流量时 1 个失败就是 100% 失败率,会反复跳闸。min_requests=20 挡住这种噪声。
  3. HALF_OPEN 只放探测:如果半开状态放开全部流量,下游还没恢复就被再次打满,等于没有熔断。
  4. 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自身过载需要上游配合退避

降级要满足四个条件,否则它会把「可用性问题」变成「正确性问题」:

  1. 可观测:响应里带 degraded=true 或 X-Degraded: cache,指标里单独计数;
  2. 有界:缓存兜底必须有 TTL 上限,不能把一小时前的库存当成现在;
  3. 可逆:降级开关要能一键恢复,不需要重新发布;
  4. 演练过:没演练过的降级路径,大概率在真正需要时抛异常。
例 11.5.1 · 一次「降级」把库存卖成负数

大促期间库存服务超时,网关触发降级:读库存返回本地缓存的旧值。缓存 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 扩缩容的边界条件

自动扩缩容有三个必须手算的边界:

  1. 最小副本数由故障域决定,不由流量决定。三可用区、允许丢一个区,最小副本至少 3(每区 1),且要保证剩余两区能接住峰值。
  2. 最大副本数由下游容量决定。100 个副本、每个 20 条数据库连接,就是 2000 条连接;数据库上限 500 时,第 26 个副本开始就会连接失败。max 副本必须满足 Nmax⁡×conns per replica≤DB limit×0.8N_{\max} \times \text{conns per replica} \le \text{DB limit} \times 0.8。
  3. 缩容速度由在途请求和连接 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 依赖熔断/重试/降级逻辑无
L1staging 全量配置、部署、告警链路无真实用户
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

脚本里有四个安全设计,值得抄到任何注入工具里:

  1. 白名单:只允许预声明的网卡,避免 IFACE=eth1 打错到管理网;
  2. 拒绝 loopback:本机所有服务共用 lo,注入等于自杀;
  3. 自动清理:trap cleanup EXIT INT TERM,即使脚本被 Ctrl-C 也能恢复;
  4. dry-run:先打印将执行的 tc 命令,确认参数再真跑。

11.7.5 Game day:角色、时间线、停止条件

Game day 是一次有组织的演练,不是一个人的即兴操作。最小配置四个角色:

角色职责不能兼任
指挥(IC)决定开始/停止、对外沟通注入操作者
注入者执行脚本、记录时间点指挥
观察者看指标、判断假设是否成立注入者
记录者时间线、决策、异常—

时间线通常分四段:

  1. 准备(T−7 天):写假设、评审爆炸半径、准备回滚、通知相关团队、冻结变更;
  2. 基线(T−10 min):记录当前 SLO、错误率、容量,确认没有正在进行的故障;
  3. 注入(T0):按计划注入,观察者盯住预设指标,指挥有权随时叫停;
  4. 恢复与复盘(T+):清理注入、确认指标回到基线、24 小时内出复盘。

停止条件必须提前写死,至少包括:

  • SLO 燃烧率超过阈值(例如 1 小时烧掉 10% 错误预算);
  • 真实用户错误率超过 1%,且持续 2 分钟;
  • 注入脚本无法清理,或清理后指标不恢复;
  • 出现数据损坏/重复扣款迹象;
  • 关键依赖(数据库、DNS、认证)出现非预期错误。

演练的第一原则是「可停止」。如果一次注入无法在 1 分钟内撤销,它就不该在生产做。第二原则是「可观测」:如果连「假设是否成立」都判断不了,演练只会产生争论,不会产生知识。

演练结束后必须做的事
  1. 把注入期间的时间线、指标截图、告警记录归档;
  2. 逐条对照假设,标记「成立/不成立/无法判断」;
  3. 对每个「不成立」写出根因、修复项、负责人和复验日期;
  4. 把这次演练的脚本和参数收进仓库,下一次从同一基线复跑;
  5. 如果没有任何发现,先怀疑「故障太轻」或「观测太粗」,而不是庆祝系统完美。

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队列
11500120 ms12502%3015301230小
21500250 ms69018%2701770680增长
31500400 ms45542%6302130450接近满
41500600 ms31262%9302430310溢出
51500600 ms31265%9752475305丢弃
6(熔断+降载)1500600 ms31265%0320312下降

看第二列和第七列:原始流量一直是 1500,涨的是重试。系统不是被更多用户压垮的,是被自己对失败的补偿压垮的。到第 5 轮,总到达是原始的 1.65 倍,goodput 只剩 305——多出来的 975 个请求几乎全在浪费线程和带宽。

11.8.2 正反馈环

       ① 下游变慢
            │
            ▼
       ② 完成时间变长 ─────▶ ③ 超时率上升
            ▲                        │
            │                        ▼
       ⑥ 有效容量下降 ◀── ⑤ 队列/并发上升 ◀── ④ 客户端重试
            │
            └────────────────▶ 回到 ①(下一轮更慢)

这个环的增益可以写成(不限制重试次数时):

A=11−p⋅r A = \frac{1}{1 - p \cdot r}

其中 pp 是失败率,rr 是每个失败请求平均再发的次数。p⋅r≥1p \cdot r \ge 1 时没有有限的不动点,系统只能靠外部干预(限流、熔断、关重试)退出。p⋅r<1p \cdot r &lt; 1 时也有放大:p=0.2,r=1p=0.2, r=1 时 A=1.25A=1.25,p=0.5,r=1p=0.5, r=1 时 A=2A=2。 真实系统的 pp 不是常数,它随负载上升。把 p(A)p(A) 代进去,稳定性条件是在不动点处 ∣r⋅A0⋅p′(A∗)∣<1|r \cdot A_0 \cdot p&#x27;(A^*)| &lt; 1;接近饱和时 p′p&#x27; 急剧变大,这个乘积很容易越过 1。这就是为什么 70% 时一次抖动可能自愈,90% 时同样的抖动会直接雪崩。 代入数字:失败率 42% 时无限重试的放大倍数是 1/(1−0.42)=1.721/(1-0.42)=1.72,65% 时是 1/(1−0.65)=2.861/(1-0.65)=2.86;重试放大不是线性的,失败率越高,每一次重试越可能触发下一次重试。

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):触发因素已经消失,系统却停在坏状态里。原因有三:队列积压还没排空;故障期间排下的重试定时器在几十秒后同时到达;缓存大量失效,恢复期每次未命中都要回源。退出亚稳态不能只靠「等」,必须主动把入口压到系统能处理的水平,等队列排空、缓存预热、重试定时器消化完,再逐步放量。恢复期的容量不是稳态容量。

例 11.8.1 · 一次 30 秒的依赖抖动变成 40 分钟故障

某服务 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当前注入参数与演练计划一致与计划不符,说明规则被覆盖
droppedqdisc 丢弃的包接近配置的 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),说明已接近膝点,应该扩容或限流,而不是继续加负载。

读什么先看 goodput 与 p99 是否同时恶化,再看 qdisc 的 backlog 和连接池等待;不要只看 CPU。
正常p99/p50 < 10,5xx < 0.5%,qdisc backlog 接近 0,队列等待 < 服务时间的 20%。
异常p99/p50 > 20,或 backlog 持续增长,或队列等待 > 服务时间,或重试比例 > 20%。

11.10 真实案例:2021-12-07 AWS us-east-1 故障

2021 年 12 月 7 日,AWS 位于北弗吉尼亚的 us-east-1 区域发生了一次影响面很大的故障。AWS 官方的复盘把原因写得很克制:

项公开事实
时间2021-12-07
触发内部网络中的一次自动化活动触发了非预期行为
直接影响连接主 AWS 网络的网络设备出现拥塞
影响范围控制面与多项服务受到影响
恢复停止自动化、恢复网络设备、逐步恢复流量
复盘官方 post-event summary

这场故障不是「某台机器坏了」,而是自动化触发了网络拥塞。它和本章的关系,不是「网络细节」,而是四个容量与治理问题:

  1. 控制面在数据面的恢复路径上。控制面受损时,自动扩缩容、健康检查、服务发现、甚至控制台本身都可能不可用;依赖「出问题后自动扩容」的系统,恰好失去了扩容能力。
  2. 自动化的爆炸半径没有上限。一条自动化动作可以在几秒内影响大量设备;如果没有速率限制、分批执行、人工确认和回滚开关,自动化就是最高效的故障放大器。
  3. 重试与健康检查会放大恢复期负载。服务不可达时,客户端重试、负载均衡健康检查、内部依赖的探活会同时增加;恢复期系统最脆弱,却要面对高于稳态的请求量。
  4. 恢复容量不等于稳态容量。设备恢复后,路由收敛、连接重建、缓存预热都需要时间;按稳态流量一次性放量,会把刚恢复的系统再次推倒。
本章机制在这次故障中的角色缺失时的后果
容量余量故障期间可用容量下降,需要 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影响范围失控
例 11.11.1 · 压测回放导致的 190 笔重复支付

某支付团队把生产流量回放到 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 非线性上升的利用率梯度数据
5headroom稳态 ≤ 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 本章陷阱清单

12 条高频错误
  1. 用 CPU 利用率当容量口径:CPU 60% 但连接池 100% 时,系统已经在无界排队;先找瓶颈,再算 ρ\rho。
  2. 只压平均不压峰值:按日均流量规划,晚高峰的 2 倍峰值会直接越过膝点;容量按峰值和故障域算。
  3. 用闭环压测报开放延迟:系统变慢时闭环自动减压,漏掉 coordinated omission;固定到达率要用开放压测。
  4. 把压测 mock 当真实依赖:mock 1 ms、生产 300 ms,容量差一个数量级;基线必须接真实依赖或等价延迟。
  5. 把 headroom 当浪费:稳态跑到 80%,丢一个可用区后剩余节点直接过载;余量是故障预算。
  6. 限流按请求数不按成本:一个重查询和一次主键查询都算 1 QPS,瓶颈资源仍会被打穿;按 cost 计费。
  7. 单机限流当全局限流:N 个副本各限 1000/s,总量是 N×1000;要有全局桶或按副本分摊。
  8. 429 不给 Retry-After 或给 0:客户端立刻重试,限流器变成随机数生成器;给估计恢复时间并加抖动。
  9. 熔断统计业务错误:400/404/422 被算成失败,流量结构一变就误熔断;只统计超时、连接错误、5xx。
  10. 熔断没有半开或没有退避:恢复瞬间放开全部流量,下游二次崩溃;半开只放探测,OPEN 时间指数退避。
  11. 舱壁做成全局线程池:一个慢依赖吃光所有线程,其他依赖陪葬;按依赖/租户隔离资源池。
  12. 回放工具默认有生产写权限:配置缺失、复用新键、无白名单,压测流量变成真实订单;默认 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
即时自测 · 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 第一种 240/0.12=2000240/0.12 = 2000 QPS;第二种 240/0.6=400240/0.6 = 400 QPS。目标 1500 QPS 时,第二种情况的需求是容量的 3.75 倍,队列会无界增长,超时和重试会把有效容量进一步压低。

A2 Kingman:Wq≈0.90.1×1+12×25=225W_q \approx \frac{0.9}{0.1} \times \frac{1+1}{2} \times 25 = 225 ms;Cs2=4C_s^2=4 时 1+42=2.5\frac{1+4}{2}=2.5,Wq≈562.5W_q \approx 562.5 ms。均值没变,只是变异性变大,排队时间翻了 2.5 倍。

A3 闭环压测在服务端卡住时只发出 100 个请求,另外本应在 2 秒内发出的约 10,000 个请求没有被记录;真实用户会排队,等待时间累积。要用开放压测(固定到达率),并从「计划发送时刻」计算延迟。

A4 前 200 个通过,后 50 个被拒;被拒请求最短等约 0.5 秒(50/100)。如果客户端立刻重试,这 50 个请求会在令牌还没补充时再次被拒,形成无效循环;应返回 Retry-After 并退避。

A5 失败率 12/30=40%12/30 = 40\%,低于 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.1111.7、11.9交叉引用明确不重复 TCP、qdisc 内核路径、容器网络
Google SRE 工作手册SLO、错误预算、过载保护11.2、11.5、11.6引用只引用概念与公式,不复制案例
本书自编 + AWS 公开复盘us-east-1 2021-12-0711.10完整只引用公开结论,不编造内部细节

下一站:容量、限流、降级、演练都有了,下一步要把它们落到一个能跑的工程里。进入 12 项目一:幂等工具调用后端,用一张 calls 表、一个幂等键和一个可注入故障的本地服务,把前面十一章的约束真正跑一遍。