加人,反而更慢
上一章说池子越大越好。这一章说反面:有一类代价随参与者数量的平方增长,一旦它超过某个点,加人不是收益递减,是收益为负。这条曲线你在生产系统里一定见过,只是没给它命名。
你实测了一个服务在不同并发数下的吞吐:1 个并发 1142 请求/秒,8 个并发 7702,16 个并发 11669,32 个并发 17070。
曲线已经明显在压平了。现在你把并发加到 128。
吞吐会是多少?
Amdahl 说的还不够悲观
关于「加人的收益会封顶」,标准答案是 Amdahl 定律:如果一个任务有 1−p 的部分必须串行,那么不管加多少人,加速比最多到 1/(1−p)。
Amdahl 定律画出来是一条单调上升、逐渐压平的曲线。它说的是「你会遇到天花板」。
但真实系统经常做出另一种曲线:先上升,到某个点,然后掉头向下。
那条曲线叫 通用可扩展性定律(Universal Scalability Law,USL),由 Neil Gunther 提出。它在 Amdahl 的基础上加了一项:
C(N) = N ÷ ( 1 + α(N−1) + βN(N−1) )
α 争用(contention):大家要排队抢同一个共享资源
★ 这一项就是这本书前十四章讲的东西
只有 α 的话,曲线会压平 —— 那就是 Amdahl
β 一致性(coherency):每个参与者都要和其他所有人对齐
★ 代价按 N² 增长,因为要对齐的**配对数**是 N(N−1)/2
有了 β,曲线会**掉头向下**
峰值出现在 N★ = √((1−α)/β)
关键是那个 N(N−1)。它不是「多一个人多一份开销」,是「多一个人,多 N 份开销」——因为新来的人要和已经在场的每个人对齐。
拟合一份真数据
demo 里那些点不是我画上去的曲线,是一份带噪声的「实测」数据(用已知的真参数生成,再加 2.5% 的随机扰动,模拟真实测量)。然后引擎用最小二乘把 α 和 β 拟合出来:
| α(争用) | β(一致性) | 峰值并发 | |
|---|---|---|---|
| 真值 | 0.035 | 0.00025 | 62.1 |
| 拟合出来 | 0.0345 | 0.000231 | 64.6 |
R² = 0.9986。而拟合出来的曲线告诉你:
- 峰值在 65 个并发附近,吞吐 18381 请求/秒;
- 加到 128 个并发,吞吐掉到 16515——比峰值还少 10.2%;
- 而线性外推会以为 65 个并发能跑 76700,是实际的 4.2 倍。
α(争用)很好理解,就是排队。那 β(一致性)在真实系统里是什么?
- 缓存一致性协议:多核 CPU 上,一个核改了一行缓存,要通知所有其他核。核越多,通知越多。这是 β 最物理的来源。
- 分布式共识:Raft/Paxos 里,每次写要和多数派通信。节点越多,每次写要等的消息越多。
- 数据库的锁管理和 MVCC 可见性检查:并发事务越多,每个事务要检查的其他事务越多。
- gossip 协议 / 服务发现:每个节点要知道其他所有节点的状态。
- 还有最经典的:人。Fred Brooks 在《人月神话》里那句「往一个已经晚了的项目里加人,只会让它更晚」,说的就是 β——沟通路径数是
N(N−1)/2。USL 是《人月神话》的公式版本。
怎么用它
USL 最实用的地方不是「预测吞吐」,而是从几个测点里把 α 和 β 分离出来,因为这两个数指向完全不同的治理动作:
| 症状 | 说明 | 该做什么 |
|---|---|---|
| α 大,β ≈ 0 | 存在一个共享瓶颈,但大家不需要互相协调 | 找到那个瓶颈(一把锁、一个单点、一条队),拆掉它。这是本书前十四章的战场 |
| β 明显大于 0 | 参与者之间在互相对齐 | 减少需要对齐的范围:分片、批量提交、放宽一致性、减少共享状态 |
| 两个都小 | 接近线性扩展 | 放心加机器 |
注意第二行:β 的问题不能靠加机器解决,加机器正是它的病因。这是这一章和上一章的分界线。
而测 α 和 β 只需要五六个测点:在不同并发数下压一遍,记录吞吐,然后拟合。这件事的成本比大多数团队想的低得多,收益是你会知道自己的扩容上限在哪,而不是撞上去才知道。
USL 看起来是个非线性模型,但它可以变形成一个两参数的线性回归,用 Excel 都能做:
# 先把吞吐归一化成"相对容量" C(N) = X(N) ÷ X(1) # 然后定义 y = N ÷ C(N) − 1 # 代进 USL 定义,展开: y = α(N−1) + βN(N−1) # 这是一个**过原点的二元线性回归**: # 自变量 1:(N−1) # 自变量 2:N(N−1) # 因变量: y # 最小二乘一解,α 和 β 就出来了。
Gunther 原始的做法是先除以 (N−1) 再做一元回归,那样更简单但对 N=1 附近的点更敏感。这本书的引擎用的是上面这个二元版本。
一个实践提醒:拟合出来的 β 如果是负的,说明你的数据里没有一致性成本的证据(曲线还没掉头)。这时候不要强行外推峰值——USL 能告诉你「已经掉头了」,不太能可靠地预言「什么时候会掉头」。
「这个系统可扩展性好」是一句几乎没有信息量的话。它至少该被拆成三问:
- α 是多少?——有没有共享瓶颈,天花板在哪。
- β 是多少?——会不会掉头,峰值在哪。
- 扩的是什么?——并发数?数据量?节点数?租户数?这四个的 α 和 β 可以完全不同。
第三条特别容易被忽略。一个系统可能在「加机器」这个维度上扩展得很好(β≈0),但在「加数据」这个维度上很差(每次查询要扫的东西线性增长)。「可扩展」永远是相对某一个维度说的。
连接池调大反而更慢。这是 USL 最常见的现场。数据库连接池从 50 调到 500,QPS 反而掉了——因为数据库内部的锁管理、日志刷盘、MVCC 快照维护都有 β 成分。PostgreSQL 社区那条著名的建议「连接数设成 核数 × 2 + 磁盘数」,本质上就是在峰值附近取值。
加线程不如加队列。很多人遇到吞吐不足就调大线程池。但如果瓶颈在 β(比如线程间共享一个 ConcurrentHashMap),加线程只会加剧缓存行争抢。更好的做法是保持线程数在峰值附近,把多出来的请求放进队列——这样吞吐保持在最高点,而队列长度由第 9 章那条曲线决定。
会议人数。五个人的会能决策,十五个人的会只能通报。这不是比喻,是同一个 N(N−1)/2:五个人有 10 条沟通路径,十五个人有 105 条。亚马逊的「两个披萨团队」是在把 β 摁住。
微服务拆分的边界。拆得越细,单个服务的 β 越小(内部协调少了),但服务之间的协调变成了新的 β(分布式事务、链路追踪、版本兼容)。拆分不是消灭了一致性成本,是把它换了个地方。拆到哪里最优,就是让总的 β 最小的那个位置。
更糟的是实践后果:很多团队的压测报告写着「最大 QPS 16500,对应并发 128」。而真相是最大 QPS 18381,对应并发 65——他们不但报低了 10%,还把生产环境的并发配置设成了一个过了峰值的值。
正确的做法是:压测要覆盖峰值两侧,然后拟合 USL 找出 N★,把生产的并发限制设在 N★ 附近(或者略低一点,留余量)。超过 N★ 的请求应该排队,而不是被放进去互相干扰。
这句话还有一个更一般的版本,值得单独记住:并发限制不是为了保护下游,是为了保护吞吐本身。
D大约 1.65 万——比 64 个并发的时候还少。
拟合出来的 USL 给出:N=65(峰值)时 18381 请求/秒,N=128 时 16515 请求/秒,比峰值少 10.2%。
而线性外推(选项 A)会以为 128 个并发能跑 15 万,是实际的 9 倍。这个偏差不是「有点乐观」,是量级错误。
A 「线性外推」——在 N 很小的时候确实近似成立,这是这个直觉的来源。但题目里给的数据(8→16 只涨了 51%,16→32 只涨了 46%)已经明显偏离线性了。 B 「还在涨」——这是 Amdahl 的答案。如果 β=0,曲线会单调压平到一个渐近线,永远不下降。选这个的人对了一半:他们看出了非线性,但没看出会掉头。 C 「基本封顶」——数值上最接近(2 万 vs 1.65 万),但「封顶」这个描述漏掉了最重要的信息:它不是封顶,是回落。而这个区别决定了你该不该继续加并发。这一章的一句话
加人有两笔账:抢资源(可以靠加机器缓解)和互相对齐(只会被加机器加剧);后者让曲线掉头。
下一章是卷 IV 的最后一章,也是这本书里对现代架构最有杀伤力的一章。你的每台后端都「99% 的请求在 10 毫秒内返回」,听起来很健康。可一个用户请求要扇出到 100 台机器,而用户等的是最慢的那一台——碰到至少一台慢的概率是 63.40%。而对冲请求能把它压到 1.00%。