57.2 容量模型

一句话

容量不是“平时够用”,而是**“峰值 × 冗余 × 增长”三件事乘出来的数**——少乘任何一项,都会在某个具体的时间点(大促、故障、半年后)还债。

一个公式

节点数 = ceil( 峰值QPS × (1 + 余量) / 单节点安全容量 )
        再叠加:故障冗余(N+1)、可用区打散、未来增长
参数 怎么取 常见错误
峰值 QPS 历史峰值 × 2(或压测拐点) 用日均值
余量 20–30% 压到 90% 利用率
单节点安全容量 压测容量的 70%(留抖动空间) 用理论值
故障冗余 N+1(至少) 按 N 配,挂一台就过载

代码:从峰值到节点数

package main

import (
	"fmt"
	"math"
)

func main() {
	type plan struct {
		name      string
		peakQPS   float64
		perNode   float64 // 单节点压测容量
		headroom  float64 // 余量
		redundant int     // 额外冗余节点
		azs       int     // 可用区数
	}
	plans := []plan{
		{"按日均值配", 800, 1000, 0.1, 0, 1},
		{"按峰值配", 3000, 1000, 0.2, 0, 1},
		{"峰值 + 余量 + N+1", 3000, 1000, 0.2, 1, 3},
		{"峰值×2 + 余量 + N+1 + 3AZ", 6000, 1000, 0.2, 1, 3},
	}

	fmt.Printf("%-30s %-10s %-10s %-10s %s\n", "方案", "峰值", "需要节点", "实际节点", "说明")
	for _, p := range plans {
		need := math.Ceil(p.peakQPS * (1 + p.headroom) / (p.perNode * 0.7))
		total := int(need) + p.redundant
		// 3AZ:每 AZ 至少 1 个,且尽量均匀
		if p.azs > 1 && total < p.azs {
			total = p.azs
		}
		note := ""
		switch p.name {
		case "按日均值配":
			note = "大促当天直接过载"
		case "按峰值配":
			note = "挂一台就超载,且没有余量吸收抖动"
		case "峰值 + 余量 + N+1":
			note = "单 AZ 故障仍可服务"
		case "峰值×2 + 余量 + N+1 + 3AZ":
			note = "最稳,成本也最高"
		}
		fmt.Printf("%-30s %-10.0f %-10.0f %-10d %s\n", p.name, p.peakQPS, need, total, note)
	}

	fmt.Println("\n=== 利用率与延迟的关系(排队论直觉)===")
	fmt.Printf("%-10s %-14s %s\n", "利用率", "排队放大", "说明")
	for _, u := range []float64{0.5, 0.7, 0.8, 0.9, 0.95} {
		factor := 1 / (1 - u) // M/M/1 的等待放大
		note := ""
		if u >= 0.9 {
			note = "轻微抖动就雪崩"
		}
		fmt.Printf("%-10.0f%% %-14.1f %s\n", u*100, factor, note)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

方案                             峰值         需要节点       实际节点       说明
按日均值配                          800        2          2          大促当天直接过载
按峰值配                           3000       6          6          挂一台就超载,且没有余量吸收抖动
峰值 + 余量 + N+1                  3000       6          7          单 AZ 故障仍可服务
峰值×2 + 余量 + N+1 + 3AZ          6000       11         12         最稳,成本也最高

=== 利用率与延迟的关系(排队论直觉)===
利用率        排队放大           说明
50        % 2.0            
70        % 3.3            
80        % 5.0            
90        % 10.0           轻微抖动就雪崩
95        % 20.0           轻微抖动就雪崩

读数:

方案 节点数 后果
按日均值 2 大促当天过载
按峰值 6 挂一台超载
峰值+余量+N+1 7 可扛单节点故障
峰值×2+余量+N+1+3AZ 12 可扛区域级波动

🔑 利用率的真相:70% 是舒适区,80% 开始敏感,90% 以上排队放大 10 倍。所以“单节点安全容量”要按压测值的 70% 算,而不是“压到 100% 时能扛多少”。

容量模型要包含的四类资源

资源 是否容易成为瓶颈 说明
CPU 常见 计算密集、序列化
内存 常见 缓存、JVM 堆、连接缓冲
连接数 隐蔽 数据库连接、下游连接池
带宽/IOPS 隐蔽 大对象、日志、备份

⚠️ 只算 CPU 是最常见的错误:一个服务 CPU 40%,但数据库连接池已满,容量实际到顶了。

反模式

反模式 后果
用日均值做容量规划 大促必崩
按“压测最大值”配节点 没有余量吸收抖动
只算应用节点,不算 DB/缓存 应用扩了,DB 先挂
不留故障冗余 单机故障即全站降级
容量一次算完就不管 业务增长半年后重新踩坑
不记录容量假设 后来的人不知道为什么是 12 台

自检

Q1 为什么“单节点安全容量”要取压测值的 70%,而不是压测峰值?

▸ 答案:因为排队系统的延迟随利用率非线性上升:利用率 70% 时等待放大 3.3 倍,90% 时 10 倍,95% 时 20 倍(M/M/1 的 1/(1-ρ))。压测到 100% 时“还没挂”,但生产中任何抖动(GC、慢查询、网络重传)都会把它推过 100%,直接雪崩。70% 留出的不是“浪费”,是吸收抖动的缓冲。

Q2 为什么容量模型必须包含 N+1?

▸ 答案:因为没有冗余的容量在故障时等于“容量除以剩余节点数”。N 台按 70% 利用率配,挂一台后剩 N-1 台要承担全部流量,利用率瞬间超过 100%,引发雪崩。N+1 保证“挂一台后剩余节点仍能扛住峰值”,这是可用性设计的底线(57.3)。

Q3 一个服务 CPU 只有 40%,为什么可能已经到容量上限了?

▸ 答案:因为瓶颈可能在别处:数据库连接池满(等待)、下游接口限流(排队)、磁盘 IOPS 打满、网络带宽打满、GC 停顿。CPU 只是“计算”这一维;容量是木桶的最短板。判断方法:看 P99 延迟与队列长度——如果 QPS 上不去而 CPU 不高,瓶颈几乎一定在 IO 或下游。

Q4 Little 定律怎么用来估并发?

▸ 答案:并发 = 到达速率 × 平均停留时间(L = λW)。例如 1000 QPS、每个请求平均 50ms,则并发约 50;这决定了线程池/连接池的下限。注意要用 P95/P99 的停留时间做保守估计,否则高峰期会排队。

Q5 为什么 “按峰值 QPS × 2” 估容量通常浪费?

▸ 答案:因为峰值往往只持续很短时间,而容量是按 “长期可承载” 买的:按峰值翻倍买,等于为几分钟的尖峰付整月费用。更合理的是 “基线容量 + 弹性伸缩 + 排队/降级”,用弹性覆盖尖峰,用降级兜住极端情况。

关联