37.1 垂直扩展与水平扩展
一句话
垂直扩展把机器变大,水平扩展把机器变多;前者延迟最低但有硬上限(单机吞吐渐近线),后者成本接近线性但受协调开销约束(加机器到某个点吞吐反而下降)——工程上的正确答案几乎总是“先垂直到一个甜点,再水平”。
两条曲线:一条封顶,一条先升后降
垂直:吞吐 = 核数 × 单核能力 × 效率(核数)
核越多,内存带宽/NUMA/锁竞争让单核效率下降 → 吞吐有渐近上限
水平:吞吐 = N / (1 + σ(N−1) + κN(N−1)) × 单节点能力 (USL)
σ 是串行争用,κ 是跨节点一致性开销 → 存在峰值,过了峰值加机器反而更慢
| 维度 | 垂直(Scale Up) | 水平(Scale Out) |
|---|---|---|
| 做法 | 换更大规格:更多核、更多内存、更快的盘 | 加实例、加分片 |
| 延迟 | 最低(无跨节点通信) | 受网络与协调影响,尾延迟放大 |
| 上限 | 单机规格上限(几十~几百核) | 受共享资源/一致性约束(USL 峰值) |
| 成本曲线 | 超线性(价格涨得比性能快) | 接近线性 + 运维成本 |
| 停机 | 通常要停机/迁移 | 可滚动、无停机 |
| 故障影响 | 单点,整机故障即全挂 | 单实例故障影响 1/N |
| 前提 | 无 | 无状态或可分片 |
垂直的硬上限:本模型下 100k QPS
设定:单核 2000 QPS,核数超过 8 之后每多一核整体效率衰减 2%(内存带宽、NUMA 跨节点访问、锁竞争)。吞吐:
吞吐(c) = c × 2000 / (1 + 0.02 × (c − 8))
c → ∞ 时,吞吐 → 2000 / 0.02 = 100,000 QPS
也就是说:核数加到 512 也过不了 10 万 QPS。这不是“再买台大的”能解决的,而是单机架构的渐近线。真实系统里这条线由内存带宽、PCIe 通道、单机连接数上限、内核调度共同决定,量级通常在十万到几十万 QPS。
水平的上限:32 节点之后开始下降
USL(Universal Scalability Law):X(N) = N / (1 + σ(N−1) + κN(N−1)),其中 σ 是串行化/争用比例,κ 是跨节点一致性开销(锁、缓存同步、协调)。
| 节点数 | 吞吐 | 单节点效率 |
|---|---|---|
| 1 | 16,000 QPS | 100.0% |
| 4 | 55,191 QPS | 86.2% |
| 8 | 91,769 QPS | 71.7% |
| 16 | 131,823 QPS | 51.5% |
| 32 | 153,128 QPS(峰值) | 29.9% |
| 48 | 148,987 QPS | 19.4% |
| 64 | 138,836 QPS | 13.6% |
两条读数:
- 16 节点时单节点只剩一半效率,加机器仍然有用,但每台贡献在快速下降。
- 32 节点之后总吞吐下降——κ 项是
N²量级,节点越多,跨节点一致性成本涨得越快。此时“加机器”是负收益,正确动作是减少共享:分片、无状态化、把一致性范围缩小。
🔑 水平的敌人不是“机器不够”,是 κ:共享数据库、分布式锁、全局计数器、跨节点缓存同步。要扩展性,先拆共享。
成本对比:64k QPS 时水平省 63%,128k 时垂直不可行
| 目标 QPS | 垂直 | 水平 | 说明 |
|---|---|---|---|
| 16,000 | 8 核 / $0.5/h | 1 节点(8 核)/ $0.5/h | 同一台机器,没区别 |
| 64,000 | 80 核 / $6.2/h | 5 节点(40 核)/ $2.3/h | 水平省 63% |
| 128,000 | 不可行(超单机上限) | 15 节点(120 核)/ $6.9/h | 垂直已到顶,只能水平 |
| 256,000 | 不可行 | 不可行(超 USL 峰值) | 加机器没用,必须减少共享 |
(价格为示例单价:$0.05/核·小时,垂直按 核数^1.10 计价,水平含 15% 协调开销。)
三个结论:
- 小规模(< 2 万 QPS)两者没差别,垂直运维更简单,优先垂直。
- 中等规模(2 万~10 万)水平更省,因为大规格实例的单价是超线性的。
- 超过 10 万 QPS,垂直物理上做不到;而水平也不是无限——256k 在 USL 峰值之上,必须先改架构(分片/无状态/减少共享),否则加机器只会更慢。
什么时候该垂直
| 场景 | 为什么 |
|---|---|
| 数据库主库 | 复制是异步的,写只能落一个主;分片成本高、跨分片事务难 |
| 低延迟交易/撮合 | 跨节点通信 0.1–1ms,对 P99 是致命的 |
| 有状态单例(调度器、协调者) | 水平扩展要先解决选主与状态迁移 |
| 许可证按 socket/核计费 | 加机器 = 加许可证,成本不降 |
| 规模小、团队小 | 一台机器的问题比十台机器好排查 |
什么时候必须水平
| 场景 | 为什么 |
|---|---|
| 无状态 Web/API | 加节点即扩容,成本线性 |
| 流量波动大(大促、活动) | 需要弹性伸缩,垂直做不到分钟级 |
| 单点不可接受 | 一台机器故障不能全站挂 |
| 批处理/离线计算 | 天然可切分 |
| 超过单机上限 | 没有选择 |
混合:先垂直到甜点,再水平
数据库:垂直为主(更大规格的主库 + 只读副本),分片是最后手段
应用层:水平为主(无状态 + 自动扩缩),垂直只用于低延迟特例
缓存:垂直(单实例大内存)+ 水平(分片集群)并存
“甜点”通常出现在 8–16 核:再往上单价陡增、单核效率下降,而 8–16 核的单机延迟仍远优于跨节点。先把这个甜点用满,再考虑加机器。
⚠️ 水平的尾延迟放大:一个请求扇出到 20 个节点,每个节点 1% 概率慢,整个请求慢的概率是
1 − 0.99^20 ≈ 18%。垂直扩展没有这个问题(无扇出),这是低延迟系统偏爱大机器的真正原因之一(见 34.6 性能反模式)。
可运行的两条曲线
package main
import (
"fmt"
"math"
)
const (
qpsPerCore = 2000.0
corePrice = 0.05 // 美元/核·小时(示例单价)
sigma = 0.05 // USL 串行争用系数
kappa = 0.0008
)
// 垂直:核越多,内存带宽与 NUMA 让每核效率下降
func vertical(c float64) (throughput, cost float64) {
eff := 1.0
if c > 8 {
eff = 1 / (1 + 0.02*(c-8))
}
return c * qpsPerCore * eff, corePrice * math.Pow(c, 1.10)
}
// 水平:USL 的通用可扩展性定律,N 个节点还有 15% 的协调开销
func horizontal(n float64) (throughput, cost float64) {
x := n / (1 + sigma*(n-1) + kappa*n*(n-1))
return x * 8 * qpsPerCore, n * 8 * corePrice * 1.15
}
func main() {
fmt.Printf("单机渐近上限:每核 %.0f QPS、效率衰减 2%%/核 → 上限 = %.0f QPS(加核也到不了)\n\n",
qpsPerCore, qpsPerCore/0.02)
fmt.Printf("%-10s %-30s %-30s %s\n", "目标QPS", "垂直", "水平", "说明")
for _, target := range []float64{16000, 64000, 128000, 256000} {
var vc, vCost float64
for c := 8.0; c <= 512; c += 8 {
q, cost := vertical(c)
if q >= target {
vc, vCost = c, cost
break
}
}
var hn, hCost float64
for n := 1.0; n <= 200; n++ {
q, cost := horizontal(n)
if q >= target {
hn, hCost = n, cost
break
}
}
v := "不可行(超过单机上限)"
if vc > 0 {
v = fmt.Sprintf("%.0f 核 / $%.1f/h", vc, vCost)
}
h := "不可行(超过水平峰值)"
note := ""
if hn > 0 {
h = fmt.Sprintf("%.0f 节点(%.0f核) / $%.1f/h", hn, hn*8, hCost)
}
switch {
case vc > 0 && hn > 0 && hn == 1:
note = "同一台机器,没区别"
case vc > 0 && hn > 0:
note = fmt.Sprintf("水平省 %.0f%%", (vCost-hCost)/vCost*100)
case vc == 0 && hn > 0:
note = "垂直已到顶,只能水平"
default:
note = "加机器没用,必须减少共享(分片/无状态化)"
}
fmt.Printf("%-10.0f %-30s %-30s %s\n", target, v, h, note)
}
fmt.Println("\n水平扩展的吞吐(每节点 16k QPS 标称,σ=0.05,κ=0.0008):")
for _, n := range []float64{1, 4, 8, 16, 32, 48, 64} {
q, _ := horizontal(n)
eff := q / (n * 8 * qpsPerCore) * 100
fmt.Printf(" %2.0f 节点 → %7.0f QPS,单节点效率 %4.1f%%\n", n, q, eff)
}
}
输出:
单机渐近上限:每核 2000 QPS、效率衰减 2%/核 → 上限 = 100000 QPS(加核也到不了)
目标QPS 垂直 水平 说明
16000 8 核 / $0.5/h 1 节点(8核) / $0.5/h 同一台机器,没区别
64000 80 核 / $6.2/h 5 节点(40核) / $2.3/h 水平省 63%
128000 不可行(超过单机上限) 15 节点(120核) / $6.9/h 垂直已到顶,只能水平
256000 不可行(超过单机上限) 不可行(超过水平峰值) 加机器没用,必须减少共享(分片/无状态化)
水平扩展的吞吐(每节点 16k QPS 标称,σ=0.05,κ=0.0008):
1 节点 → 16000 QPS,单节点效率 100.0%
4 节点 → 55191 QPS,单节点效率 86.2%
8 节点 → 91769 QPS,单节点效率 71.7%
16 节点 → 131823 QPS,单节点效率 51.5%
32 节点 → 153128 QPS,单节点效率 29.9%
48 节点 → 148987 QPS,单节点效率 19.4%
64 节点 → 138836 QPS,单节点效率 13.6%
参数怎么给
| 你的情况 | 选 | 关键动作 |
|---|---|---|
| < 2 万 QPS、单机能扛 | 垂直 | 选 8–16 核甜点,留 30% 余量 |
| 2 万~10 万 QPS、无状态 | 水平 | 先无状态化,再自动扩缩 |
| 数据库主库 | 垂直 + 只读副本 | 分片留到“写”成为瓶颈再谈 |
| 低延迟(P99 < 10ms) | 垂直优先 | 避免跨节点扇出 |
| 大促弹性 | 水平 | 镜像预热 + 快速扩容 + 入口限流 |
| 超过 USL 峰值 | 改架构 | 减少共享:分片、本地化、缩小一致性范围 |
| 有状态单例 | 垂直 + 主备 | 先解决选主与状态迁移 |
反模式
| 反模式 | 后果 |
|---|---|
| 用“加机器”解决锁竞争 | 节点越多,锁排队越久,吞吐下降 |
| 应用层有状态,却直接水平扩 | 会话丢失、缓存不一致,扩了也不敢用 |
| 数据库主库一直加核 | 超过甜点后单价陡增、单核效率下降,且仍是单点 |
| 小规模就上分布式 | 运维复杂度换不到吞吐,故障点反而变多 |
| 按峰值长期预留大量节点 | 成本线性上涨,利用率 10% |
| 忽略扇出对 P99 的放大 | 平均延迟好看,P99 抖到不可用 |
| 扩容不做容量验证 | 加完节点才发现瓶颈在 DB/连接池 |
| 只扩计算不扩存储/连接池 | 应用能扩,下游先挂 |
自检
Q1 为什么 32 个节点之后再加机器,总吞吐反而下降?USL 里是哪一项造成的?
▸ 答案:USL 的 κN(N−1) 项,代表跨节点的一致性开销(缓存同步、分布式锁、协调、跨节点 RPC)。它随 N² 增长,而吞吐的分子只随 N 线性增长,所以过了某个点,分母涨得比分子快,总吞吐下降。σ(串行争用)只让曲线变平,κ 才会让曲线掉头。对策是减少共享(分片、无状态、把一致性范围缩小到单个分片),而不是继续加节点。
Q2 “单机渐近上限 10 万 QPS”是怎么算出来的?如果单核能力翻倍,上限会翻倍吗?
▸ 答案:模型里单核 2000 QPS,核数超过 8 后整体效率 1/(1+0.02(c−8)),吞吐 c×2000/(1+0.02(c−8));c→∞ 时分母的 0.02c 主导,吞吐 → 2000/0.02 = 100,000。单核能力翻倍到 4000,上限变成 20 万——上限与单核能力成正比,与效率衰减系数成反比。这解释了为什么“优化单核效率/内存访问”对大机器同样重要:它不仅提高当前吞吐,还抬高渐近线。
Q3 128k QPS 时垂直不可行、水平可行;256k QPS 时两者都不可行。为什么“加机器”也会失败?该怎么破?
▸ 答案:因为 USL 峰值约 153k(本模型),256k 在峰值之上,加节点会让 κ 项主导、总吞吐反降。破局不是加机器,而是降低 κ:① 分片,把全局共享变成分片内共享;② 无状态化,把会话/缓存从共享层挪走(见 37.2 无状态化);③ 减少跨节点调用,把多次 RPC 合并/本地化;④ 异步化,把同步一致性换成最终一致。这些动作把 κ 从 0.0008 降到 0.0001 量级,峰值就能推到几十万。
Q4 为什么数据库主库通常优先垂直扩展,而不是一上来就分库分表?
▸ 答案:分片的代价是跨分片事务、跨分片查询、再平衡、全局唯一 ID、运维复杂度,这些成本在写压力没到瓶颈前是纯支出。垂直扩展主库能撑到几千~几万写 QPS,配合只读副本还能分担读;只有“单库写入”确实到顶(连接数、锁、WAL 落盘)时,分片才是正解。先垂直、再读写分离、最后分片,是成本递增的正确顺序(见 37.4 读写分离)。
Q5 低延迟系统为什么偏爱大机器?除了“少一次网络跳转”,还有什么原因?
▸ 答案:尾延迟的扇出放大。垂直扩展没有扇出,请求在进程内完成;水平扩展下,一个请求常常要扇出到多个节点(用户、库存、价格、推荐),每个节点都有 1% 概率慢,扇出 20 个后“至少一个慢”的概率变成 1−0.99^20 ≈ 18%,P99 直接被拖垮。此外还有跨节点时钟、序列化、连接池竞争带来的额外抖动。所以交易/撮合类系统宁可用一台大机器,也不愿把一个请求拆到 20 个节点上。
跨篇相关:分片与再平衡的细节见 37.3 分片与分区;读写分离的适用边界见 37.4 读写分离;阿姆达尔定律与加速比见 37.5 加速比与阿姆达尔定律;弹性伸缩的工程实现见 37.6 弹性伸缩;「先拆哪个单点、按什么顺序拆」见 37.9 单点与拆分;成本侧的单位经济见 39.1 成本模型与单位经济。