适用读者:做过扩容决策、被 p99 曲线折磨过、或者想搞明白"为什么加机器不管用"的后端工程师
技术栈:无。这是纯理论文章,公式和表格都由闭式解算出,没有任何实测数字
阅读时间:约 30 分钟 | 难度:中级到高级 | 收获:一套判断"该不该扩容、扩到几台、留多少余量"的定量框架
前置阅读:无。会四则运算和一点概率就能读完
目录
- 前言:一次扩容事故的解剖
- Little's Law:三个量测两个就够了
- 利用率:延迟的放大器
- Kendall 记号与两个基础模型
- 方差比均值重要:P-K 公式
- 为什么"多几台小的"胜过"一台大的"
- 尾部放大:p99 为什么不能求平均
- Amdahl 定律:串行部分的税
- 通用可扩展定律:回退效应
- 重试风暴与亚稳态故障
- 协调遗漏:为什么你的压测在撒谎
- 容量规划实操:余量该留多少
- FAQ
- 结语
前言:一次扩容事故的解剖
一个接口,4 台机器,CPU 利用率 45%,p99 是 80ms。流量翻了一倍,运维把机器加到 8 台。
预期:利用率回到 45%,p99 不变。
实际:利用率确实回到了 46%,但
p99 涨到了 115ms。
机器翻倍、负载翻倍、利用率不变——延迟却变差了 44%。这不是玄学,也不是"机器有问题"。这是排队论里一个可以直接算出来的结果,只是大部分人在扩容时不会去算它。
这篇文章要建立一套定量的直觉,回答四个具体问题:
- 利用率到多少算危险? 为什么是 70%,不是 90%?
- 该看均值还是方差? 为什么两个平均响应时间一样的服务,p99 能差 5 倍?
- 加机器什么时候不管用? 什么情况下会越加越慢?
- p99 到底怎么算才对? 为什么它不能求平均,扇出为什么会让它爆炸?
先把结论放前面,后面逐个推导:
1. Little's Law:三个量测两个就够了
先给最有用、也最常被忽略的定律。
$$L = \lambda W$$
- $L$:系统里平均有多少个任务(并发数)
- $\lambda$:平均到达率(任务/秒)
- $W$:一个任务平均待多久(秒)
它成立的条件弱得惊人:只要系统处于稳态,不丢任务、不漏任务,且长期平均存在。不需要泊松到达,不需要指数服务时间,不需要任何分布假设。
这意味着你
测两个量就能算出第三个。实践中最有用的两个推论:
推论一:从并发和吞吐反推延迟。
λ = 1200 req/s(从日志数出来)
L = 60(从服务端 in-flight 计数读出来)
→ W = L / λ = 60 / 1200 = 50ms
当你的服务端没埋延迟指标,但你能数出每秒请求数、又能读到当前并发连接数时,这条推论直接给你平均延迟。
推论二:估算容量缺口。
如果 SLA 要求 p50 延迟 50ms,而你的服务平均处理时间是 20ms,那么:
$$L = \lambda W = \lambda \times 0.05$$
在 1000 req/s 下,$L = 50$ 个并发。如果线程池只有 32 个线程,你会在 640 req/s 就开始排队——
Little's Law 直接告诉你线程池该开多大。
一个常见的误用:把 $W$ 当成"响应时间指标"。Little's Law 说的是
长期平均,它对尾部分布一无所知。一个 $W = 50$ms 的系统,p99 可能是 5 秒。所以 Little's Law 用来定容量,不用来定 SLA。
2. 利用率:延迟的放大器
这是整篇文章最核心的一张图。
定义利用率:
$$\rho = \frac{\lambda}{\mu} = \lambda \cdot \mathbb{E}[S]$$
其中 $\mu$ 是服务率(每秒能处理多少个),$\mathbb{E}[S]$ 是平均服务时间。注意 $\rho$ 是
负载强度,不是"CPU 利用率"——虽然两者通常接近。
对最基础的 M/M/1 队列(单服务台、泊松到达、指数服务时间),平均响应时间是:
$$W = \frac{1}{\mu - \lambda} = \frac{\mathbb{E}[S]}{1 - \rho}$$
把 $\mathbb{E}[S]$ 归一化成 1,看看延迟怎么随利用率变化:
这张表要读出三件事:
第一,它不是线性的。从 10% 加到 50%,延迟只涨了 80%;从 70% 加到 90%,延迟涨了 200%。同样的 20 个百分点,代价完全不同。
第二,利用率就是"排队时间占比"。在 $\rho = 0.7$ 时,任务有 70% 的时间在排队,只有 30% 在真正被处理。这个对应关系是精确的:$W_q/W = \rho$。
第三,最后那段是悬崖。$\rho \to 1$ 时 $W \to \infty$。从 95% 到 99%,延迟又翻 5 倍。
所以"70% 是安全线"这个经验值不是拍脑袋的——它是"延迟放大 3.3 倍"这个可接受代价反推出来的。如果你能接受 5 倍,那就是 80%;如果要求严格,就得压到 50%。
2.1 为什么扩容后延迟会变差
回到前言那个事故。关键在于
排队是全局的,而服务时间不是。
4 台机器、流量 1x 时:单台利用率 45%,延迟 $1/(1-0.45) = 1.82$ 倍。
8 台机器、流量 2x 时:单台利用率还是 45% 左右,延迟应该还是 1.82 倍。
但延迟涨了。所以问题不在排队模型里——而在模型之外。可能的原因有三类:
- 共享资源没跟着扩容。数据库连接池、Redis、下游服务还是原来那么多。你的 8 台机器把 2 倍的并发压到了同一个数据库上,瓶颈从应用层转移到了数据库层。
- 协调成本上升。8 台机器之间的缓存失效广播、分布式锁竞争、心跳——这些是节点数的超线性函数(后面第 8 节会算)。
- 负载均衡不均。长连接、连接池预热、GC 时间不同步,都会让"平均 45%"变成"有的 30%、有的 70%"。而延迟是凸函数,70% 那台的延迟贡献远超均值。
第 3 点值得展开:
利用率的不均衡会放大延迟。假设 8 台机器平均 45%,但其中一台 70%:
$$\text{均衡时延迟} = \frac{1}{1-0.45} = 1.82, \qquad \text{最忙那台} = \frac{1}{1-0.70} = 3.33$$
那台机器慢 83%。如果请求会扇出(比如一个页面要问多个分片),整体延迟由最慢的那台决定。
所以扩容时要看的是最大利用率,不是平均利用率。
3. Kendall 记号与两个基础模型
排队模型的命名约定是 $A/S/c/K/N/D$:
所以
M/M/1 就是"泊松到达、指数服务、单服务台、无限队列、FCFS"。
M/M/c 是多服务台。
3.1 M/M/1 的完整解
$$L = \frac{\rho}{1-\rho}, \quad W = \frac{\mathbb{E}[S]}{1-\rho}, \quad W_q = \frac{\rho \cdot \mathbb{E}[S]}{1-\rho}, \quad L_q = \frac{\rho^2}{1-\rho}$$
四个量之间的关系全部由 Little's Law 串起来:$L = \lambda W$,$L_q = \lambda W_q$,$W = W_q + \mathbb{E}[S]$。
只要记住 $W$ 那一个公式,其余三个都能推出来。
还有一个容易被忽略的量——
系统里超过 $n$ 个任务的概率:
$$P(L > n) = \rho^{n+1}$$
$\rho = 0.9$ 时,系统里有超过 10 个任务的概率是 $0.9^{11} = 31\%$。有超过 20 个的概率是 $0.9^{21} = 10.9\%$。
这就是为什么尾延迟对利用率极其敏感:$\rho$ 每上升一点,长队列的概率指数级上升。
3.2 M/M/c:多服务台
对 $c$ 个服务台,总负载 $a = \lambda/\mu$(单位 Erlang),利用率 $\rho = a/c$。
等待概率(Erlang C 公式):
$$C(c, a) = \frac{\dfrac{a^c}{c!}\cdot\dfrac{c}{c-a}}{\displaystyle\sum_{k=0}^{c-1}\frac{a^k}{k!} + \frac{a^c}{c!}\cdot\frac{c}{c-a}}$$
排队时间:
$$W_q = \frac{C(c,a)}{c\mu - \lambda} = C(c,a) \cdot \frac{\mathbb{E}[S]}{c - a}$$
这个公式的工程含义在第 5 节展开——它解释了
为什么"多几台小的"比"一台大的"好。
4. 方差比均值重要:P-K 公式
M/M/1 假设服务时间是指数的,也就是变异系数 $C_s = \sigma_S / \mathbb{E}[S] = 1$。
真实系统的服务时间很少是指数的。数据库查询、缓存未命中、GC 暂停——这些都是长尾分布,$C_s$ 常常在 2 到 5 之间。
对 M/G/1(一般服务时间分布、单服务台),排队时间由
Pollaczek-Khinchine 公式给出:
$$W_q = \frac{\rho}{1-\rho} \cdot \frac{1 + C_s^2}{2} \cdot \mathbb{E}[S]$$
对比一下就能看出关键:M/M/1 的 $W_q = \frac{\rho}{1-\rho}\mathbb{E}[S]$,正好是 $C_s = 1$ 时 $(1+C_s^2)/2 = 1$ 的特例。
$(1+C_s^2)/2$ 这个因子是方差放大器:
这张表是这篇文章里最值得记住的一张。
两个平均服务时间完全相同的服务,如果方差不同,队列长度能差 26 倍。$C_s = 0$ 和 $C_s = 5$ 的比值是 $13/0.5 = 26$。
所以:
- "平均响应时间一样"不等于"性能一样"。降均值收益有限,降方差收益是乘性的。
- GC 暂停是双重伤害。它既拉高了 $\mathbb{E}[S]$,又把 $C_s$ 从 1 推到 5 以上。同一个暂停,在队列上的代价是平方级的。
- 批处理和小请求混跑会毒化整个队列。一个 500ms 的批处理任务混在 5ms 的在线请求里,$C_s$ 会从 0.5 冲到 5。这是"隔离批处理和在线流量"最硬的数学理由。
4.1 一个反直觉的推论:随机路由比轮询好
如果 $C_s$ 很大,把请求
随机分给多台机器,比
轮询更稳。
轮询追求每台机器拿到相同数量,但当服务时间方差很大时,某一台可能恰好连续拿到几个慢请求,而轮询不会自我纠正。随机路由下,慢请求的分布更均匀,$C_s$ 的放大效应被摊薄。
这是"最少连接数"(least-connections)策略优于轮询的原因之一:它用的是
当前状态(连接数)而不是
历史计数。
5. 为什么"多几台小的"胜过"一台大的"
用 Erlang C 算一个具体例子。目标:处理 $\lambda = 18$ req/s,每请求 $\mathbb{E}[S] = 0.5$s,所以总负载 $a = 9$ Erlang。
方案 A:1 台大机器,能力 10 req/s。
$$a = 9, \quad c = 1, \quad \rho = 0.9$$
M/M/1 下 $W_q = \frac{0.9}{0.1} \times 0.5 = 4.5$s,总响应时间 $W = 5.0$s。
方案 B:10 台小机器,每台能力 1 req/s。
$$a = 9, \quad c = 10, \quad \rho = 0.9$$
先算 Erlang C:
$$C(10, 9) = \frac{\frac{9^{10}}{10!}\cdot\frac{10}{10-9}}{\sum_{k=0}^{9}\frac{9^k}{k!} + \frac{9^{10}}{10!}\cdot 10}$$
数值计算:$\frac{9^{10}}{10!} = \frac{3486784401}{3628800} = 960.8643$,乘以 $10$ 得 $9608.643$。
$\sum_{k=0}^{9}\frac{9^k}{k!} = 1 + 9 + 40.5 + 121.5 + 273.375 + 492.075 + 738.113 + 949.000 + 1067.627 + 1067.627 = 4759.818$
所以:
$$C(10,9) = \frac{9608.643}{4759.818 + 9608.643} = \frac{9608.643}{14368.461} = 0.6687$$
排队时间:
$$W_q = 0.6687 \times \frac{0.5}{10 - 9} = 0.334\text{s}$$
总响应时间 $W = 0.334 + 0.5 = 0.83$s。
对比:
同样的总能力、同样的利用率,拆成 10 台的响应时间快 6 倍。
这就是
资源池化(pooling)效应。直觉解释:10 台机器里,任何时刻大概率有几台是空闲的,空闲的机器可以立刻接活;而单台机器只要在忙,所有新任务都得等。空闲资源的"平均"没有意义——
关键是"至少有一个空闲"的概率。
Erlang C 曲线还有一个更实用的推论。看同一套负载下,单台和 10 台的对比(都用 $W_q/\mathbb{E}[S]$,即排队时间相当于多少个服务时间):
这张表要横着读,也要竖着读:
竖着读(相对倍数):利用率越低,池化的相对收益越大。50% 时单台的排队时间是 10 台的 138 倍。这是反直觉的——低利用率下池化"看起来"收益更大。
横着读(绝对差):利用率越高,池化省下的绝对时间越多。95% 时单台要排队 19 个服务时间,10 台只要 1.65 个。
但真正的重点是最后一列的形状。单台那一列从 1.00 一路冲到 19.00,而 10 台那一列从 0.01 只涨到 1.65。
池化把延迟曲线的那段悬崖推平了。这就是为什么高利用率系统必须做水平扩展:不是为了"更快",而是为了让延迟在利用率上升时
不要爆炸。单台在 95% 利用率下已经是 19 倍的排队时间,10 台只有 1.65 倍——
同样的负载,一个能扛住,一个不能。
反过来看那个扩容事故:如果 4 台加 8 台之后延迟反而变差,说明
收益被别的东西吃掉了(第 2.1 节的三类原因),而不是排队模型本身出了问题。
6. 尾部放大:p99 为什么不能求平均
6.1 百分位数不是线性算子
先看一个具体例子。两分钟的监控数据:
错误的做法:$(10 + 1000)/2 = 505$ms。
正确的做法:合并全部 10100 个请求再算 p99。第 2 分钟有约 100 个请求超过 1000ms,第 1 分钟有约 1 个超过 10ms。合并后第 101 个最慢的请求来自第 2 分钟,所以:
$$p99_\text{合并} \approx 1000\text{ms}$$
505ms 和 1000ms,差了一倍。而"平均各窗口的 p99"这个做法在监控系统里极其常见。
正确做法只有一条:保留直方图,合并直方图,再算分位数。
✗ 存 p50/p99 标量 → 无法合并,只能平均(错)
✓ 存直方图(HDR Histogram / t-digest / DDSketch)→ 可合并,可重算任意分位
代价是存储和传输量,但这是唯一能正确聚合尾延迟的办法。Prometheus 的 histogram 类型就是这个思路——它存的是桶计数,不是分位数。
6.2 扇出:尾延迟的乘性放大
这是分布式系统里最反直觉、也最致命的一个效应。
假设一个页面要调 $N$ 个后端服务,每个服务有 $p$ 的概率变慢(比如 $p = 0.01$,即各自的 p99)。假设独立:
$$P(\text{页面变慢}) = 1 - (1-p)^N$$
100 个后端,每个只有 1% 的慢请求,页面就有 63% 的时间是慢的。
注意:这个页面的"p99"看起来应该是"每个后端 p99 的某种组合",但实际上它连 p50 都不是——
多数请求都是慢的。
这解释了几个现象:
- 微服务拆得越细,尾延迟越差。拆成 20 个服务,即使每个都很好,组合起来也容易崩。
- "每个服务 p99 都达标"不等于"页面达标"。SLA 要按扇出路径算,不能按服务算。
- 降尾比降均值重要得多。把每个后端的 $p$ 从 1% 降到 0.1%,扇出 100 时页面慢的概率从 63.4% 降到 9.5%。而把均值降低 20% 对尾部毫无帮助。
工程上的对策有三条:
- 减少扇出。合并接口、批量化、在边缘做聚合。$N$ 从 100 降到 10,概率从 63% 降到 9.6%。
- 对冲请求(hedged requests)。对慢请求发第二份到另一台机器,取先返回的。代价是额外负载,收益是尾部直接砍掉。适合幂等的读操作。
- 不让慢请求拖累快请求。超时 + 降级返回部分结果,比等全部返回好。
7. Amdahl 定律:串行部分的税
前面讲的都是"排队",现在换成"并行加速"的视角。
如果一个任务有 $p$ 的比例可以并行化,用 $n$ 个执行单元:
$$S(n) = \frac{1}{(1-p) + \dfrac{p}{n}}$$
$n \to \infty$ 时的上限:
$$\lim_{n \to \infty} S(n) = \frac{1}{1-p}$$
读法:5% 的串行部分,无论加多少机器,最多只能快 20 倍。而从 4 台加到 8 台,收益只有 1.7 倍。
Amdahl 定律最重要的应用不是算加速比,而是判断"该不该并行化":
- 如果串行部分是 50%($p=0.5$),加机器最多 2 倍——别费劲了,去优化串行部分。
- 如果串行部分是 1%($p=0.99$),加机器有 100 倍空间——值得投入。
后端系统里常见的"串行部分":
Amdahl 给扩容设了一个天花板:如果 10% 的请求路径是串行的,那么扩容到 10 倍之后就没收益了。这时候要做的不是加机器,而是
把串行部分变成并行或异步——而这通常是架构改动,不是运维改动。
8. 通用可扩展定律:回退效应
Amdahl 定律有个缺陷:它假设加机器没有副作用。现实中不是——机器越多,协调成本越高。
Gunther 的
通用可扩展定律(Universal Scalability Law, USL)加了两项:
$$C(N) = \frac{N}{1 + \alpha(N-1) + \beta N(N-1)}$$
- $\alpha$:争用系数(contention)——串行化导致的排队,线性增长
- $\beta$:一致性系数(coherency)——节点间的数据同步/通信,二次增长
$\beta = 0$ 时它退化成 Amdahl 的形式。$\beta > 0$ 时,曲线不再是单调上升的。
对 $C(N)$ 求极值,得到
最优节点数:
$$N^\ast = \sqrt{\frac{1-\alpha}{\beta}}$$
超过 $N^\ast$ 之后,吞吐
下降——这叫
回退扩展(retrograde scaling)。
8.1 算一个例子
取 $\alpha = 0.05$(5% 的争用),$\beta = 0.001$(一致性成本):
$$N^\ast = \sqrt{\frac{1 - 0.05}{0.001}} = \sqrt{950} \approx 30.8$$
从 31 台加到 100 台,吞吐从 9.04 掉到 6.31——比 20 台还差。
这就是"加机器反而更慢"的数学形式。它的现实对应是:
- 缓存一致性广播:每台机器的写操作都要通知其他 $N-1$ 台,总通信量是 $O(N^2)$
- 分布式锁竞争:$N$ 个节点抢同一把锁,冲突概率随 $N$ 上升
- 心跳与成员管理:全互联心跳是 $O(N^2)$
- 共享数据库连接:$N$ 个应用节点共享一个连接池上限
- 负载均衡的会话同步
所以"扩容"和"加节点"不是一回事。扩容的前提是 $\beta$ 足够小。如果 $\beta$ 显著,正确做法是:
- 减小 $\alpha$:去掉串行瓶颈(换掉全局锁、拆分热点)
- 减小 $\beta$:减少节点间同步(分片而非共享、去掉全局缓存失效广播、改成最终一致)
分片(sharding)之所以有效,就是因为它把 $\beta$ 从"全局"降到"片内":100 个节点分成 10 个片,每片 10 个节点,一致性成本从 $O(100^2)$ 降到 $10 \times O(10^2)$——从 10000 降到 1000。
9. 重试风暴与亚稳态故障
排队论假设到达率 $\lambda$ 是外生的、与系统状态无关。
重试打破了这个假设。
设原始失败率 $f$,最多重试 $k$ 次(即最多 $k+1$ 次尝试),每次重试的负载期望是:
$$\text{负载倍数} = \sum_{i=0}^{k} f^i = \frac{1 - f^{k+1}}{1 - f}$$
注意这张表里"最多重试 3 次"这一列
远小于"无限重试"的理论值 $\frac{1}{1-f}$。$f = 0.9$ 时无限重试的负载倍数是
10 倍,而限制 3 次重试只把倍数压到 3.44 倍。
设上限本身就是最重要的保护措施——它把指数增长截断成常数。
(也要注意这张表低估了真实风险:它假设重试一定失败到上限。如果失败是
瞬时的,重试可能成功,实际负载倍率会更低;但如果是
过载导致的失败,重试会继续失败,倍率就朝上限走。
过载场景下要按最坏情况算。)
这里的关键是正反馈:
负载上升
→ 队列变长(第 2 节:ρ 接近 1 时延迟爆炸)
→ 超时开始触发
→ 重试(第 9 节:负载倍率上升)
→ 负载进一步上升
→ 更多超时
→ ...
这是一个
正反馈环。它会导向
亚稳态故障(metastable failure):
触发事件(比如一次短暂的网络抖动)消失之后,系统不会自动恢复。因为此时"重试产生的负载"本身就已经超过了容量。
亚稳态故障的特征:
- 系统有两个稳态:正常态和故障态
- 一旦进入故障态,移除初始触发不能恢复
- 必须主动减载(重启、限流、熔断)才能回到正常态
对策:
"有界队列"这一条特别重要,它值得单独说。
9.1 为什么无界队列是个陷阱
无界队列看起来"友好"——它不拒绝任何请求。但它做了三件坏事:
- 把吞吐问题变成延迟问题。请求不会失败,但会等很久——等到超时为止,最后还是失败。用户等待时间变长,成功率不变。
- 掩盖过载。队列在涨,但错误率不涨,监控看不出问题。等到队列爆了才发现,此时已经晚了。
- 内存风险。队列里的每个任务都要占内存,无界队列在过载时会 OOM。
有界队列 + 主动拒绝的行为更诚实:
队列未满 → 接收,正常处理
队列已满 → 立即返回 503
好处是:
快速失败给了客户端一个明确的信号,客户端可以退避、可以降级、可以让用户重试。而无限排队只是把所有人在慢动作里一起拖死。
从排队论看:无界队列把 $\rho$ 推到 1 以上(系统不稳定),延迟无界增长;有界队列把 $\rho$ 强行压回 1 以下(通过丢弃),延迟有界。
丢请求是过载时唯一能保住延迟的办法。
10. 协调遗漏:为什么你的压测在撒谎
这是 Gil Tene 提出的经典问题,也是"压测结果好、线上却崩"的最常见原因。
问题出在闭环压测工具上。
闭环(closed-loop)压测的行为是:
发请求 → 等响应 → 收到响应 → 发下一个请求
考虑一个服务,每 100 个请求会出现一次 1 秒的停顿。
闭环压测会记录到:
看起来 p99 ≈ 1005ms,可以接受。
但真实世界的客户端不会等。它是开环的——不管服务端是否响应,都按固定速率发请求。在那 1 秒停顿里,真实的客户端会发出 200 个请求(按 200 req/s 算),这 200 个请求全部要排队。它们的延迟是:
第 1 个排队者:等待 1000ms
第 2 个排队者:等待 995ms
...
第 200 个排队者:等待 5ms
真实的 p99 是 1000ms 左右,但受影响的是 200 个请求,而不是 1 个。闭环压测只记录到了 1 个。
更糟的是:闭环压测
少发了请求。那 1 秒里它只发了 1 个(被阻塞住了),而真实情况应该发 200 个。所以它测出来的吞吐也是虚高的。
修正办法有两个:
办法一:记录"意图发送时间"。
t_intended = time.now() # 本来该发的时间
response = send(request) # 实际发送
t_returned = time.now()
latency = t_returned - t_intended # 用意图时间算,不是用实际发送时间
这样被阻塞的请求会体现出真实的排队延迟。
办法二:开环压测。用一个独立的调度器按固定速率发请求,不受响应时间影响。这是 wrk2、k6(
constant-arrival-rate 模式)的做法。
判断你的压测有没有协调遗漏:看吞吐曲线。如果"增加并发数"时吞吐一直线性上升、延迟几乎不变,说明要么系统容量真的很大,要么
工具在少发请求。真实的系统在接近容量时,吞吐会饱和、延迟会起飞。
11. 容量规划实操:余量该留多少
把前面所有内容收成一套可操作的流程。
11.1 目标利用率怎么定
从第 2 节的表反推。问自己:"我能接受延迟放大几倍?"
注意这是 M/M/1 的数字。如果你的服务时间方差大($C_s > 1$),实际放大倍数要乘上第 4 节的因子。$C_s = 3$ 时,70% 利用率下的延迟放大不是 3.3 倍,而是:
$$W_q = \frac{0.7}{0.3} \times \frac{1 + 9}{2} \times \mathbb{E}[S] = 2.33 \times 5 \times \mathbb{E}[S] = 11.7 \times \mathbb{E}[S]$$
同一个 70%,方差大的服务延迟放大是 11.7 倍而不是 3.3 倍。所以先测你的 $C_s$。
11.2 容量计算公式
需要的实例数:
$$N = \left\lceil \frac{\lambda \cdot \mathbb{E}[S]}{\rho_\text{target}} \right\rceil$$
例:峰值 500 req/s,平均处理 80ms,目标利用率 70%:
$$N = \left\lceil \frac{500 \times 0.08}{0.7} \right\rceil = \left\lceil 57.1 \right\rceil = 58$$
但这个数字有三个陷阱:
- $\lambda$ 要用峰值,不是均值。峰值可能是均值的 3-5 倍。按均值算容量是经典的翻车方式。
- 要留 N+1 的故障余量。58 台里坏 1 台,剩下 57 台的实际利用率变成 $40/57 = 70.2\%$——刚好顶到目标线上,没有余量了。如果只按 100% 利用率规划(40 台),坏 1 台就是 $\rho = 1.0$,队列长度无界,直接雪崩。
- 要留发布余量。滚动发布时,一部分实例在重启,可用容量会下降。蓝绿或金丝雀发布期间容量要额外打折。
实践中常见的做法:按"峰值负载 + 单实例故障 + 发布余量"算,得到的目标利用率通常在 50-60%,而不是 70%。
11.3 自动扩缩容的三个坑
坑一:按 CPU 扩容会滞后。CPU 上升是
结果,不是原因。等到 CPU 到 80% 才扩容,队列已经排起来了,而新实例启动还要 30-60 秒。
更好的信号:队列深度、请求排队时间、并发数(Little's Law 里的 $L$)。这些是
领先指标——它们上升时,CPU 可能还没到阈值。
坑二:扩得快、缩得慢要不对称。扩容要激进(宁可多开),缩容要保守(宁可多留)。缩容太快会在流量回升时来不及扩,进入振荡。
坑三:没有滞回就会抖动。扩容阈值 70%、缩容阈值 70% 会导致系统在阈值附近反复扩缩。标准做法是留滞回带(比如扩到 60% 以下才缩)加冷却时间。
11.4 一张检查清单
扩容决策前,按顺序问:
- [ ] 瓶颈在哪里?先确认瓶颈是应用层 CPU,而不是数据库、缓存、下游服务。如果是后者,加应用实例没用(第 2.1 节)。
- [ ] $\mathbb{E}[S]$ 和 $C_s$ 是多少?没有这两个数,所有计算都是猜的。
- [ ] $\lambda$ 用的是峰值吗?峰值/均值比是多少?
- [ ] 当前最大实例利用率是多少?不是平均值(第 2.1 节)。
- [ ] $\beta$ 大不大?如果节点间有全局同步(缓存广播、全局锁),先减小 $\beta$,否则加节点可能倒退(第 8 节)。
- [ ] 有重试吗?重试预算是多少?重试会放大负载,可能触发亚稳态(第 9 节)。
- [ ] 队列有界吗?无界队列会把过载变成延迟(第 9.1 节)。
- [ ] 压测有协调遗漏吗?看吞吐曲线是否饱和(第 10 节)。
- [ ] p99 是怎么聚合的?如果是"各窗口 p99 求平均",这个数字是错的(第 6.1 节)。
12. FAQ
Q:这些公式假设泊松到达,真实流量不是泊松的,还能用吗?
A:能用,但要知道误差方向。真实流量通常
比泊松更突发(突发性可以用 $C_a^2$ 描述,泊松是 $C_a^2 = 1$)。更突发的到达会让队列更长,所以 M/M/1 的公式是
乐观估计。有个经验公式(Kingman 近似)同时考虑两个变异系数:
$$W_q \approx \frac{\rho}{1-\rho} \cdot \frac{C_a^2 + C_s^2}{2} \cdot \mathbb{E}[S]$$
$C_a^2 = C_s^2 = 1$ 时它退化成 M/M/1。
这个公式比 M/M/1 实用得多,因为它把"流量有多突发"和"服务时间有多不稳"都算进去了。
Q:利用率 70% 是绝对的吗?
A:不是。它对应"延迟放大 3.3 倍"这个假设。如果你能接受 5 倍,可以到 80%;如果要求严格,压到 50%。另外它是 M/M/1 的数字,$C_s$ 大会显著恶化(第 11.1 节)。
Q:为什么不用"平均响应时间"做监控?
A:因为平均对尾部分布不敏感。99 个 5ms + 1 个 5s,平均是 54.95ms——看起来完全正常,但 1% 的用户等了 5 秒。而且如第 6.2 节所示,尾部在扇出场景下会变成多数。
看 p99、p999,以及"超过阈值的请求比例"。
Q:p999 和 p99 该看哪个?
A:取决于扇出。如果每个请求扇出 $N$ 个后端,那么"用户体验到的 p99"约等于"后端的 $1 - (1-0.99)^{1/N}$ 分位"。$N = 100$ 时,就是后端的 p50 左右。
扇出越大,越要往深里看。
Q:Little's Law 能用来做容量规划吗?
A:能,而且是最实用的工具。$L = \lambda W$ 只要测两个量。最常用的场景:
用并发数和吞吐量反推延迟,或者
用目标延迟和预期吞吐反推需要多少并发(进而决定线程池/连接池大小)。注意它只给长期平均,不给尾部分布。
Q:为什么加机器有时候会变慢?
A:三种可能,按常见程度排序:
1.
瓶颈不在应用层(数据库、缓存、下游),加应用实例只是把压力往下游压(第 2.1 节)。
2.
节点间协调成本超线性增长,USL 的 $\beta$ 项,超过 $N^\ast$ 后吞吐下降(第 8 节)。
3.
负载不均,平均利用率没变但最大利用率上升,延迟是凸函数(第 2.1 节)。
Q:文章里没有实测数字,这些公式可信吗?
A:这些是排队论的
闭式解,不是估算。M/M/1、Erlang C、P-K、Amdahl、USL 都是教科书结论,可以查证。文章里所有表格都是把公式代进去算出来的,你可以自己验算。但要注意:
闭式解的可信度取决于假设是否成立——所以第 12 节第一个问题(用 Kingman 近似)和第 4 节(先测 $C_s$)比公式本身更重要。
结语
如果只从这篇文章带走三件事:
第一,利用率是延迟的放大器,而且是非线性的。70% 对应 3.3 倍,90% 对应 10 倍。这条曲线解释了大部分"为什么慢"的问题——不是代码慢,是排队。
第二,方差和均值一样重要。两个平均响应时间相同的服务,$C_s$ 从 1 涨到 3,队列长度差 5 倍。降方差(隔离批处理、消 GC 暂停、减少长尾查询)的收益是乘性的,而降均值的收益是加性的。
第三,加机器有天花板。Amdahl 给的是串行部分的天花板,USL 给的是一致性成本的天花板。超过 $N^\ast$ 之后加机器会让系统
变慢。这时候要做的不是扩容,是改架构。
最后回到前言那个事故:4 台加到 8 台,p99 反而涨了 44%。现在你有工具去诊断它了——先看瓶颈是不是转移到了共享资源,再看最大利用率而非平均利用率,最后算一下 $\beta$ 是不是已经大到让扩容失效。
不要靠加机器解决问题,要靠算清楚再决定加不加。
本文为纯理论文章,所有数字均由排队论闭式解算出,不含任何实测。涉及的主要模型:Little's Law、M/M/1、M/M/c(Erlang C)、M/G/1(Pollaczek-Khinchine)、Kingman 近似、Amdahl 定律、Gunther 通用可扩展定律(USL)。