34.2 性能建模 (利特尔法则、排队论)

一句话

建模不是把延迟算准到毫秒,而是用 4 条运算律 + 1 个排队公式,在压测之前就回答三个问题:理论上限是多少、瓶颈在哪个资源、加机器/优化哪一段有没有用。

建模回答的三个问题

34.1 给了指标定义与 M/M/1 曲线,这一节把曲线变成可计算的模型。

问题 需要输入 模型输出 典型误差
容量:这套配置能扛多少 QPS? 请求的 CPU 时间、并发度、目标利用率 需要的核数 / 连接数 / 实例数 ±20% 到 2 倍
瓶颈:先撑不住的是谁? 每个资源的访问次数 × 单次耗时 各资源利用率,最高者即瓶颈 通常准确到「是哪个资源」
收益:优化 A 还是 B? 各段耗时分解、串行比例 优化后的端到端延迟 / 加速比 方向可靠,数值偏乐观

🔑 模型给数量级和方向,压测给真实数字 (34.5 性能测试)。先建模再压测,能避免「压出问题却不知道看哪里」;只压测不建模,容易把某次环境噪声当成容量上限。

四条运算律:不需要泊松假设

这四条只依赖「计数守恒」,不要求指数分布,因此在真实系统里最耐用。

定律 公式 回答什么 用在哪
利用率定律 (Utilization Law) U = X × S 某资源的利用率 = 到达该资源的吞吐 × 单次占用时间 CPU、磁盘、连接池、DB
利特尔法则 (Little's Law) L = λ × W 在途请求数 = 到达率 × 停留时间 线程池、连接池、队列长度
强制流定律 (Forced Flow Law) X_k = V_k × X_0 下游吞吐 = 入口吞吐 × 每次请求访问它的次数 N+1 查询、缓存、RPC 扇出
交互式响应时间定律 R = N / X - Z 闭环系统里,响应时间 = 用户数 / 吞吐 - 思考时间 并发用户、连接池、压测模型

算一遍:瓶颈到底是谁

下单接口 300 QPS,API 每请求耗 8ms CPU,跑在 4 核 上;每个请求访问数据库 4 次,每次 4ms,DB 是 8 核。

API:U = 300 × 0.008 / 4        = 60%
DB :X = 300 × 4 = 1200 QPS
     U = 1200 × 0.004 / 8       = 60%

两个资源都恰好 60%,看起来「很均衡」。但把 QPS 提到 350:

API:350 × 0.008 / 4 = 70%
DB :350 × 4 × 0.004 / 8 = 70%

仍然同时到 70%。真实的系统不会这么巧:谁的 U 先到 100%,谁就是瓶颈。上例中只要 DB 的访问次数从 4 变成 5 (多一次校验查询),DB 就会先撞线;反过来,把 API 的 8ms 优化到 6ms,瓶颈立刻全部转移到 DB。

🔑 优化的第一步不是改代码,是列出资源清单并各算一个 U。没有这张表,你优化的很可能是那个「看起来最忙」而不是「最先饱和」的资源。

强制流定律:N+1 为什么是灾难

一个列表页返回 100 条记录,每条记录再查一次作者 (N+1),DB 每次查询 0.2ms,DB 有 4 核,入口 100 QPS:

V = 1 + 100 = 101 次查询/请求
X_db = 100 × 101 = 10100 QPS
U = 10100 × 0.0002 / 4 = 50.5%

把 N+1 改成一次批量查询 (V=2):

X_db = 200 QPS,U = 200 × 0.0002 / 4 = 1%

同样的功能,DB 利用率从 50% 掉到 1%,50 倍差距。这就是为什么「加索引」救不了 N+1:索引让单次查询从 2ms 变 0.2ms,只快 10 倍;减少查询次数快 50 倍。两者叠加才是 500 倍。

交互式响应时间定律:并发用户为什么越用越慢

闭环系统里,N 个用户、思考时间 Z、系统吞吐 X,则平均响应时间:

R = N / X - Z

200 个在线用户,思考时间 20s,系统吞吐 8 QPS:

R = 200 / 8 - 20 = 5s

吞吐掉到 4 QPS 时:

R = 200 / 4 - 20 = 30s

吞吐只降了一半,用户感受到的延迟涨了 6 倍。因为用户不会因为系统慢就少点一次,他们只是把更多时间花在等待上。这也解释了闭环压测的陷阱:并发数固定时,系统变慢会自动降低发压速率,吞吐曲线看起来很「稳」,但延迟已经爆了 (34.5 性能测试与负载模型 的 coordinated omission)。

利特尔法则:能用,但有四个前提

8.3 已经用 并发 = QPS × 延迟 估过连接池。这里补的是它什么时候不成立。

L = λ × W
前提 含义 被违反时的现象
稳态 统计窗口内 λ、W 基本不变 启动、发布、促销峰值时算出的 L 毫无意义
守恒 请求不凭空产生/消失,重试算新请求 客户端自动重试让 λ 翻倍,但你没把它算进去
同一系统边界 L、λ、W 必须描述同一段 用网关 QPS 配服务端处理时间,差值就是排队与网络
同一时间窗 三个量取同一窗口 用峰值 QPS 配平均延迟,低估 3–5 倍

三种用法,一个式子

已知 求 式子 例子
QPS、延迟 并发 / 池大小 L = λW 500 QPS × 50ms = 25 个在途请求
池大小、延迟 吞吐上限 X = L / W 20 连接 / 50ms = 400 QPS
并发、QPS 延迟 W = L / λ 100 在途 / 800 QPS = 125ms

⚠️ 「并发」不等于「线程数」。100 个线程里有 90 个在等锁、等 IO,它们都算在途;反过来,异步 IO 用一个线程也能有 1000 个在途请求。用线程数代替 L,会把连接池算大一个数量级。

代码验证:模拟出来的 L 是否等于 λW

下面的程序模拟 M/M/c 队列 (泊松到达、指数服务),用对 N(t) 积分得到时间平均在系统数,再和 λeff × W 对比。

package main

import (
	"container/heap"
	"fmt"
	"math"
	"math/rand"
	"sort"
)

type freeTimes []float64

func (f freeTimes) Len() int           { return len(f) }
func (f freeTimes) Less(i, j int) bool { return f[i] < f[j] }
func (f freeTimes) Swap(i, j int)      { f[i], f[j] = f[j], f[i] }
func (f *freeTimes) Push(x any)        { *f = append(*f, x.(float64)) }
func (f *freeTimes) Pop() any          { old := *f; v := old[len(old)-1]; *f = old[:len(old)-1]; return v }

type event struct {
	t float64
	d int
}

// simulate 模拟 M/M/c:lam 到达率(个/秒),mu 服务率(个/秒),c 个服务台。
// 返回时间平均在系统数 L、平均停留时间 W、平均排队时间 Wq、模拟总时长。
func simulate(lam, mu float64, c, n int, seed int64) (L, W, Wq, horizon float64) {
	rng := rand.New(rand.NewSource(seed))
	free := make(freeTimes, c) // 每个服务台的下一次空闲时刻
	arr := make([]float64, n)
	start := make([]float64, n)
	fin := make([]float64, n)
	t := 0.0
	for i := 0; i < n; i++ {
		t += rng.ExpFloat64() / lam // 下一次到达
		svc := rng.ExpFloat64() / mu
		earliest := heap.Pop(&free).(float64) // 最早空闲的服务台
		arr[i] = t
		start[i] = math.Max(t, earliest) // 排队:等到有服务台空出来
		fin[i] = start[i] + svc
		heap.Push(&free, fin[i])
	}
	horizon = 0
	for _, f := range fin { // 最后一个到达的请求不一定最后结束
		if f > horizon {
			horizon = f
		}
	}

	// 对 N(t) 做积分,得到时间平均在系统数
	evs := make([]event, 0, 2*n)
	for i := 0; i < n; i++ {
		evs = append(evs, event{arr[i], +1}, event{fin[i], -1})
	}
	sort.Slice(evs, func(i, j int) bool { return evs[i].t < evs[j].t })
	area, cur, last := 0.0, 0, 0.0
	for _, e := range evs {
		area += float64(cur) * (e.t - last)
		cur += e.d
		last = e.t
	}
	for i := 0; i < n; i++ {
		W += fin[i] - arr[i]
		Wq += start[i] - arr[i]
	}
	return area / horizon, W / float64(n), Wq / float64(n), horizon
}

// erlangC 返回 M/M/c 的排队概率 (到达后需要等待的概率),a = lam/mu。
func erlangC(c int, a float64) float64 {
	sum, fact := 0.0, 1.0
	for k := 0; k < c; k++ {
		if k > 0 {
			fact *= float64(k)
		}
		sum += math.Pow(a, float64(k)) / fact
	}
	fact *= float64(c) // 补上 c!
	top := math.Pow(a, float64(c)) / fact * float64(c) / (float64(c) - a)
	return top / (sum + top)
}

func main() {
	// 服务时间 S=10ms (mu=100),每台利用率 80%,各模拟 100 万个请求
	fmt.Printf("%-2s %-5s %-9s %-9s %-9s %-8s %-8s\n", "c", "λ", "Wq实测", "Wq理论", "W实测", "L", "λeff×W")
	for i, c := range []int{1, 2, 4} {
		lam := 80.0 * float64(c)
		L, W, Wq, horizon := simulate(lam, 100, c, 1000000, int64(42+i*1000))
		rho := lam / (100 * float64(c))
		wqTheory := erlangC(c, lam/100) * 0.01 / (float64(c) * (1 - rho))
		lambdaEff := 1000000 / horizon
		fmt.Printf("%-2d %-5.0f %-9.2f %-9.2f %-9.2f %-8.3f %-8.3f\n",
			c, lam, Wq*1000, wqTheory*1000, W*1000, L, lambdaEff*W)
	}
}

输出:

c  λ     Wq实测    Wq理论    W实测     L       λeff×W
1  80    40.83     40.00     50.84     4.070   4.070
2  160   17.64     17.78     27.63     4.419   4.419
4  320   7.40      7.46      17.40     5.568   5.568

三点结论:

  • L 与 λeff×W 完全一致:这是同一时间窗下的恒等关系,不能用来证明模拟器正确;它真正说明的是必须用有效到达率 λeff = n/horizon,不能直接套参数 λ。
  • Wq 与 Erlang C 的偏差在 2.5% 以内:这才是对模拟器的检验;偏差来自有限样本与随机波动,不影响趋势判断。
  • c 从 1 增到 4,Wq 从 40.8ms 降到 7.4ms:模拟与 Erlang C 都给出同样趋势,这就是下一节「合并队列」的量化收益。

排队模型怎么选:先看 Kendall 记号

A/S/c/K/N/D 依次是:到达过程 / 服务时间分布 / 服务台数 / 队列容量 / 顾客数 / 调度规则。工程上常用的只有下面几种。

模型 假设 公式 什么时候用 注意
M/M/1 泊松到达、指数服务、单服务台、无限队列 Wq = ρS/(1-ρ),W = S/(1-ρ) 快速估算单队列 指数服务 Cs=1 是常见基准;Cs<1 时偏悲观,Cs>1 时低估长尾
M/M/c c 个服务台共享一个队列 Erlang C:Wq = C(c,a)·S/(c(1-ρ)) 连接池、线程池、多副本 需要查/算 Erlang C,a = λS
M/D/1 服务时间恒定 Wq = ρS/(2(1-ρ)) 定长任务、固定大小包 只有 M/M/1 的一半等待
M/G/1 服务时间一般分布 P-K 公式:Wq = ρS(1+Cs²)/(2(1-ρ)) 已知服务时间变异系数 Cs 是服务时间标准差/均值
M/M/1/K 队列长度上限 K 用生灭过程求阻塞率 有界队列、限流 队列满后请求被拒绝
G/G/1 一般到达、一般服务 Kingman 近似 真实流量 (非泊松) 见下节的变异性公式

💡 为什么 M/M/1 常被当成「悲观估计」:指数分布的 Cs=1,而真实批处理、缓存命中、固定 SQL 的 Cs 往往小于 1。但 GC 停顿、慢查询、锁等待会让 Cs 远大于 1,这时 M/M/1 反而低估长尾。

多服务台:合并队列是免费的午餐

4 台服务器、总吞吐 320 QPS、每台 80% 利用率、S=10ms。两种排队方式:

方式 结构 平均排队 Wq 平均停留 W
各自排队 4 个独立的 M/M/1,每个 80 QPS 40.0ms 50.0ms
共享队列 1 个 M/M/4,总 320 QPS 7.5ms 17.5ms

同样的机器、同样的利用率,共享队列把排队时间砍到 1/5.4。Erlang C 的完整曲线:

服务台 c 总利用率 每台利用率 排队概率 C(c,a) Wq (S=10ms) W
1 80% 80% 0.800 40.0ms 50.0ms
2 80% 80% 0.711 17.8ms 27.8ms
4 80% 80% 0.596 7.5ms 17.5ms
8 80% 80% 0.458 2.9ms 12.9ms

这不是数学游戏,它对应四个工程决策:

场景 各自排队 (差) 共享队列 (好)
连接池 每个 worker 一个池,空闲连接浪费 全局池,按需分配
任务队列 每个消费者一个队列,忙闲不均 一个队列 + 多消费者
负载均衡 按连接数分发,长连接导致倾斜 按请求分发 / 最少连接 / P2C
数据库 每个服务一个连接池,总量失控 统一代理层 / 全局配额

🔑 共享队列的收益来自「让空闲的服务台去处理别人的积压」。代价是共享队列本身要有锁或原子操作,规模极大时会变成新瓶颈;同时会破坏局部性 (缓存亲和、会话粘滞)。所以不是「越共享越好」,而是「在共享成本 < 排队收益时共享」。

变异性:被忽略的第二个参数

排队时间不只取决于平均服务时间,还取决于服务时间的波动。M/G/1 的 P-K 公式:

Wq = ρS/(1-ρ) × (1 + Cs²)/2

ρ=0.8、S=10ms 时,不同变异系数下的排队时间:

服务时间分布 Cs 倍数 Wq 典型来源
定长 0 0.5× 20ms 固定大小包、定时任务
低变异 0.5 0.625× 25ms 缓存命中、简单查询
指数 1.0 1× 40ms M/M/1 基准
高变异 2.0 2.5× 100ms 混合快慢查询、锁竞争
极高变异 5.0 13× 520ms GC 停顿、慢 SQL、跨机房重试

到达侧同样有变异。G/G/1 的 Kingman 近似把两边合起来:

Wq ≈ ρS/(1-ρ) × (Ca² + Cs²)/2
到达 Ca 服务 Cs Wq (ρ=0.8, S=10ms)
1.0 1.0 40ms
0.5 0.5 10ms
2.0 2.0 160ms
2.0 0.5 85ms

把 Ca 从 2 降到 1,和把 Cs 从 2 降到 1,收益一样大。这解释了为什么下面这些手段有效:

手段 作用在哪个参数 效果
限流 / 流量整形 Ca ↓ 削掉突发,排队时间下降
连接池预热、JIT 预热 Cs ↓ 消除冷启动的长服务时间
超时截断 尾部截断 防止单个慢请求拖住队列,但会产生重试 (新的变异)
请求合并 / 批处理 单次 S ↑、次数 ↓ 平均吞吐升,但单请求等待增加,需权衡
隔离舱 (bulkhead) 限制故障传播 把高变异限制在小池子里

⚠️ 超时不是免费的:超时把「慢请求」变成「失败 + 重试」,如果重试没有预算控制,Ca 反而更高,形成重试风暴 (35.5 / 35.7)。

闭环系统与扩展性上限

闭环:并发用户模型

压测工具用固定并发 (闭环) 时,测的是「N 个用户能跑出多少吞吐」,不是「入口有 1000 QPS 时会怎样」。两者用不同的模型:

维度 开环 (Open) 闭环 (Closed)
谁控制到达 外部固定速率 N 个用户/协程,完成后再发下一个
适合描述 真实入口流量、网关 会话型业务、连接池、数据库连接
系统变慢时 到达率不变,队列堆积 发压自动变慢,吞吐下降
风险 把系统压垮 掩盖容量问题 (34.5 性能测试)
典型工具 wrk2、vegeta、k6 constant-arrival-rate ab、JMeter 固定线程、Locust 固定用户

扩展性:USL 曲线

Amdahl 定律只考虑「串行比例」,预测的是「无限加核也超不过 1/s」。真实系统还有跨核协调成本 (缓存一致性、锁、跨节点通信)。USL (Universal Scalability Law):

C(N) = N / (1 + σ(N-1) + κN(N-1))
  • σ:串行争用比例 (排队拿同一把锁、走同一段单线程逻辑)
  • κ:交叉通信/一致性开销 (缓存行同步、共识、跨节点 RPC)

取 σ=0.05、κ=0.001:

核数 N 1 2 4 8 16 32 48 64
相对吞吐 C(N) 1.00 1.90 3.44 5.69 8.04 9.03 8.56 7.82
每核效率 100% 95% 86% 71% 50% 28% 18% 12%

32 核是峰值,之后加核反而更慢。三种典型形状:

现象 主导参数 对策
加机器吞吐线性涨,但效率越来越低 σ 大 减少共享资源、缩短临界区、无状态化
加机器到某点后吞吐下降 κ 大 分片、异步、减少跨节点同步、批量合并
16 核只有 8 倍 σ+κ 共同作用 先测单核效率,再决定是否值得加

🔑 USL 的实用价值:它告诉你「这套架构的容量上限是几核」,而不是「还能加几台机器」。如果 32 核后曲线掉头,继续扩容是给云厂商送钱。

把模型落到容量决策:四步

步骤 动作 产出
① 画资源链 列出每个可能饱和的资源:CPU、内存带宽、磁盘 IOPS、网络、连接池、DB、下游 资源清单
② 取参数 每请求的 V (访问次数)、S (单次耗时)、c (并发能力)、目标 ρ 参数表
③ 算 U 与 W U = X·V·S/c,找最大 U;用 Little 算池大小 瓶颈与容量
④ 敏感性分析 哪个参数 ±20% 会翻转结论 风险清单

算一遍:500 QPS 的 API 需要几核

CPU:500 QPS × 8ms = 4.0 核 → 4 核机器 U=100%,已经饱和
按 70% 红线:500 × 0.008 / 0.7 = 5.71 → 至少 6 核
若把单请求 CPU 优化到 5ms:500 × 0.005 / 0.7 = 3.57 → 4 核够用
资源 计算 U 结论
API CPU 500 × 8ms / 6 核 66.7% 达标 (≤70%)
DB 500 × 3 × 2ms / 8 核 37.5% 有余量
连接池 500 × 50ms = 25 在途 — 池 35–40
网络 500 × 20KB = 10MB/s 80Mbps 千兆网 8%

优化 8ms → 5ms 省下 2 核 (33%);如果只加核不优化,6 核只是把 U 压到 70%,下一次流量涨 20% 又要扩。模型在这里的作用是:先判断「该优化还是该扩容」。

💡 估算误差 2 倍完全可以接受。模型的价值不是精确,而是排除「差 10 倍」的数量级错误:比如算出需要 600 核,那一定是架构问题,不是买机器能解决的。

模型失效的五个原因

原因 表现 修正
非稳态 发布、冷启动、促销峰值时模型全错 用瞬时模型 / ramp 压测,不用稳态公式
重尾分布 P99 由 0.1% 的慢请求决定,指数假设完全失效 用实测分布或对数正态/帕累托拟合
缓存改变 S 命中率随负载变化,S 不是常数 分层建模:命中/未命中两条路径
资源耦合 GC、超线程、邻居噪声、CPU 降频 留 30% 冗余;用压测校准
观测偏差 只统计「已发出」的请求 (CO) 用开环定速 + 按计划时间计算延迟

反模式

反模式 后果
用 并发 = 线程数 套 Little 法则 把阻塞线程也算成处理能力,池子开大 10 倍
用平均延迟算容量 忽略长尾,按平均值配的池在 P99 时排队
只看 CPU 利用率,不看队列长度 CPU 70% 但 runqueue 已经单调增长,拐点早到了
认为「加机器 = 线性扩容」 USL 峰值后加机器更慢,钱花了容量还降
对每个资源都按 100% 利用率规划 没有冗余,一次尖峰全站超时
用单机 M/M/1 模型算分布式系统 忽略网络、协调、共享存储的额外 V 和 S
把闭环压测的 QPS 当入口容量 系统变慢时压测自动减速,容量被高估 2–5 倍
优化非瓶颈资源 按 Amdahl,收益接近 0,还增加复杂度
忽略服务时间方差 平均 10ms 但 Cs=5 时,排队时间是 M/M/1 的 13 倍

自检

Q1 一个服务 4 核,500 QPS,每请求 8ms CPU。CPU 利用率是多少?按 70% 红线需要几核?如果 QPS 翻倍到 1000,需要几核?

▸ 答案:U = 500×0.008/4 = 100%,已经饱和 (不是「刚好用满」,是排队已经发散)。按 70%:500×0.008/0.7 = 5.71,需要 6 核。1000 QPS 需要 1000×0.008/0.7 = 11.4,即 12 核,不是 8 核——因为 70% 红线是乘在分母上的,容量与核数是线性关系,但 70% 的余量不能省。另外必须先确认 DB、连接池、网络没有先饱和,否则加 CPU 只是把瓶颈推给下游。

Q2 4 台服务器,每台 80% 利用率,S=10ms。方案 A:每台一个队列;方案 B:一个共享队列。排队时间差多少?共享队列的代价是什么?

▸ 答案:A 是 4 个 M/M/1,每个 Wq=40ms;B 是 M/M/4,Wq≈7.5ms,差 5.4 倍,机器数量和总吞吐完全相同。代价:① 共享队列需要锁/原子操作,高并发下队列本身可能成为瓶颈;② 破坏缓存亲和性和会话粘滞;③ 一个慢消费者可能拖长整个队列的尾延迟。所以只在「共享成本 < 排队收益」时合并,而不是无条件合并。

Q3 监控显示某接口平均延迟 50ms、P99 500ms,QPS 200,连接池 20。按 Little 法则池子够不够?为什么线上仍可能报「连接池等待超时」?

▸ 答案:按平均算 L = 200×0.05 = 10,20 个连接够。但池子够不够取决于「慢请求的比例 × 持续时间」:若只有 1% 请求各占 500ms,平均在途约 2×0.5 = 1 个,单靠长尾压不满 20 个连接;真正危险的是慢请求比例突然放大——比如缓存集中失效或下游抖动让 20% 请求都变慢 (约 40 QPS × 0.5s = 20 个在途),20 个连接瞬间排满,后续请求的排队时间又被计入延迟,P99 进一步恶化。正确做法是按尾部场景 (慢请求比例上升) 估池,并监控连接池等待时间;或者用 bulkhead 隔离长请求。

Q4 某服务 8 核 QPS 8000,16 核 9000,32 核 8500。用 USL 解释,并给出两条可执行的改进方向。

▸ 答案:曲线在 16 核附近见顶、32 核下降,说明 κ (跨核一致性/通信开销) 已经大于并行收益,不是 CPU 不够。两条方向:① 减少共享——把全局锁/全局计数器/共享缓存分片,降低缓存行争用;② 减少跨节点协调——把同步 RPC 改成批量/异步,或按用户分片让请求落在单核/单节点内完成。继续加核只会让 κN(N-1) 项更大,容量继续降。

Q5 为什么说 M/M/1 通常是「悲观估计」,但在 GC 场景下又可能「低估」长尾?

▸ 答案:M/M/1 假设服务时间服从指数分布,Cs=1;定长或低变异服务的 Cs<1,实际排队更短,所以 M/M/1 偏悲观。但 GC 停顿、慢查询会让服务时间出现极端长尾 (Cs 远大于 1),P-K 公式里 (1+Cs²)/2 会放大等待,这时用 Cs=1 的 M/M/1 会低估 P99。结论:平均值模型只能用于容量量级判断,长尾必须用实测分布。