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%

两条读数:

  1. 16 节点时单节点只剩一半效率,加机器仍然有用,但每台贡献在快速下降。
  2. 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% 协调开销。)

三个结论:

  1. 小规模(< 2 万 QPS)两者没差别,垂直运维更简单,优先垂直。
  2. 中等规模(2 万~10 万)水平更省,因为大规格实例的单价是超线性的。
  3. 超过 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 成本模型与单位经济。