37.7 扩展瓶颈识别

一句话

扩容没效果,说明瓶颈不在你扩的那一层;找瓶颈只靠“分层压测 + 利用率对照”,不靠猜。

判据:加一台机器,看两个数

先给判据,再讲方法:

🔑 加一台机器,吞吐不涨、延迟不降 → 瓶颈在共享资源或串行段。

这条判据的来源是阿姆达尔定律(见 37.5 加速比与阿姆达尔定律):可并行的部分再怎么加,串行部分决定了上限。N 台机器加速比上限是 1/(s + (1−s)/N),s 是串行比例;s = 0.05 时,N→∞ 也只有 20 倍。

把现象拆细,三种组合对应三种结论:

加机器之后 结论 下一步
吞吐不涨,延迟不降 瓶颈在共享资源:单主 DB、单分片 Redis、全局锁、单表 找出那个共享资源,去掉共享而不是加机器
吞吐不涨,延迟反而涨 加的那层在做无用竞争:连接池争抢、锁排队、缓存同步 先减并发再谈扩容
吞吐涨了,延迟也涨 还在 USL 拐点之前,扩容有效 继续加,但要盯住下一层

最容易踩的是第一种的变体:延迟降了、吞吐没涨。这时所有人都会说“扩容有效果啊”,实际只是把某一层的排队擦掉了,天花板一寸没动——把 service 副本扩 8 倍只换来 3.2% 吞吐,就是这一幕。

分层瓶颈清单

瓶颈有十三层,每层都有自己的上限、观测指标和打满时的症状。查瓶颈的动作是逐层对照,不是从最可疑的那层开始猜:

层 典型上限 观测指标 打满时的症状
CPU 核数 × 单核能力 cpu utilization、load average 延迟线性上涨,GC/调度抖动
内存 / GC 堆大小、GC 频率 RSS、GC pause P99 周期性卡顿,P99 尖刺
磁盘 IOPS 云盘 IOPS 配额、iops iostat await、%util 写放大,日志/落盘阻塞
网络带宽 网卡 1/10/25 Gbps 出口 Mbps、重传率 大响应体超时,小请求反而正常
连接数 max_connections 活跃连接、Threads_running too many connections
文件句柄 ulimit -n lsof \| wc -l too many open files
端口与 TIME_WAIT 本地端口范围 ss -s 的 timewait 数 连接建立失败,cannot assign requested address
线程 / 协程数 内存、调度器 线程数、goroutine 数 调度延迟,内存暴涨
锁竞争 临界区长度 锁等待时间、mutex profile CPU 高但吞吐低
单表 / 单库 / 单主 单机写能力 主库写入 QPS、复制延迟 写 QPS 到顶,从库追不上
DNS 解析 QPS、缓存 TTL 解析耗时 P99 偶发 5s 卡顿(重试超时)
证书与 TLS 握手 握手 CPU 开销 握手耗时、QPS 新建连接慢,复用连接正常
云配额 NAT 端口、SLB 连接数、安全组规则数 云监控配额用量 新连接静默失败,扩容无效

流量怎么分到这些层上,本身也会改变单实例的上限:长连接粘性会让请求集中在少数实例,见 42.4 服务发现与客户端负载均衡;入口的 SLB/NAT 配额见 8.9 四层与七层负载均衡。

每层一个具体数字

数字比清单好用,因为它能直接算出天花板:

项 数值 来源 怎么算天花板
文件句柄 soft / hard 1024 / 65535 Linux 系统默认(ulimit -n / -Hn) 每 TCP 连接占 1 个 fd,soft 1024 扣掉进程自身十几个 fd,撑不到 1000 长连接
本地端口范围 32768–60999,共 28,232 个 net.ipv4.ip_local_port_range 默认值 TIME_WAIT 保留 60s → 对单个目标地址约 470 连接/秒
MySQL 连接数 151 max_connections 默认值 32 实例 × 池 20 = 640 > 151,扩容即报错
NAT 端口 单 IP 约 6.4 万 RFC 6056 端口分配范围减去保留 到同一目标约 6.4 万并发短连接
TCP 单连接吞吐 窗口 / RTT 可推导 64KB 窗口 + 40ms RTT = 1.6MB/s(约 13Mbps);1MB 窗口 = 25MB/s
单机 PPS 单核 1M 量级 量级估算 10GbE 64 字节小包线速 14.88 Mpps,先撞 PPS 再撞带宽
单表建议行数 千万级 经验判断 超过后索引层级加深、DDL 窗口不可控

两条反直觉的:

  • 端口耗尽是“扩容无效”的经典伪装。10 个实例各建 5,000 条到同一后端的短连接,本地端口很快就周转不开,报 cannot assign requested address;表现是“扩容后错误率没降”,因为瓶颈在每一台机器自己的端口表里,加机器只是加了更多端口表。
  • 连接池上限是吞吐的硬天花板,且有公式:QPS 上限 = 池大小 / RT(池参数怎么配见 44.12 连接池)。池 20、RT 50ms → 上限 400 QPS。应用扩到 32 个实例,每个实例还是 400,但 DB 侧连接总数变成 640,撞 151 那道墙——扩容把瓶颈从“每实例的池”搬到了“DB 的连接数”,两个都不是应用层。

方法一:USE 三问

对每一层只问三个问题,缺一不可:

问题 看什么 典型信号
Utilization(用了多少) 该资源的时间占比 / 配额占比 > 80% 就是候选瓶颈
Saturation(排队多长) 等待队列长度、等待时间 有排队说明已经过了拐点
Errors(出错了没) 错误计数、超时计数、重试数 有错就说明已经打满,不用等利用率

只看 U 会漏:连接数打满时 CPU 只有 20%,利用率指标一片绿。只看 E 会晚:开始报错时已经在丢请求了。三个一起看,才能区分“快满了”和“已经满了”。

方法二:阶梯压测

不问“极限在哪”,问“每一档谁先到 80%”。每加一档压力,把所有层的利用率同时记下来,先到 80% 的那层就是瓶颈。

档位 注入 QPS 实际到达 app CPU app 连接 DB CPU DB 连接 磁盘 IOPS 网络 Mbps 先到 80% 的层
1 200
2 400
3 800
4 1,600
5 3,200

三条纪律:

  1. 开环加压,不要闭环。闭环(固定并发数)在服务变慢时会自动降速,压不出过载;开环(固定到达率)才能看到真实拐点,两种模型的区别见 57.1 压测方法论。
  2. 每档至少跑 5 分钟。前 2 分钟是连接建立、JIT、缓存填充,跑出来的数是假的。
  3. 同步记录压测机自己的利用率。压测机打满时,你测的是压测机的上限。

交叉验证:利特尔法则

并发 = QPS × RT。这条式子最大的用处不是算容量,是判断“请求到底进没进来”:

现象 利特尔法则的解释 结论
QPS 上不去,RT 没涨 并发数不够 → 请求压根没进来 查上游35.7 限流、连接池上限、端口
QPS 上不去,RT 涨了 并发达到了服务端能承受的上限 服务端饱和,去查分层
QPS 涨了,RT 也涨 并发被推高,排队增加 还没有效扩容,或已过拐点

一个能背下来的换算:目标 1,000 QPS、RT 50ms → 需要 50 并发;RT 涨到 200ms 时同样 1,000 QPS 需要 200 并发。同样的 QPS,RT 涨 4 倍,并发需求就涨 4 倍——很多“扩容没用”其实是并发被连接池卡住了,吞吐自然上不去(推导与变形见 34.2 性能建模 (利特尔法则、排队论))。

为什么 80% 就该扩:队列视角

M/M/1 的平均响应时间 R = S/(1−ρ),S 是服务时间,ρ 是利用率。排队时间 Wq = R − S:

利用率 ρ 排队时间是服务时间的几倍 总响应 R
0.5 1 倍 2S
0.8 4 倍 5S
0.9 9 倍 10S
0.95 19 倍 20S

从 0.8 到 0.95 只多了 15 个百分点,排队时间却从 4 倍涨到 19 倍。这就是“80% 就该扩容”的全部理由:不是留余量,是因为再往上延迟不再线性、而是双曲线。曲线与分位数的关系见 34.1 性能指标。

推论:如果某一层在 85% 利用率下 P99 还好,说明它的服务时间方差小(不是 M/M/1 那种泊松到达),这时可以适当提高水位;但只要出现“一过 85% 就抖”,就是标准的 M/M/1 行为,别硬扛。

五种真实形态的误判

误判 表面现象 真实原因 数字特征
① 扩了 app 层,瓶颈在 DB 单主 副本 4→32,吞吐只涨 3% DB 单实例 8 并发,天花板 1,520 QPS 没动 app 利用率 95%→12%,DB 95% 纹丝不动
② 扩了 pod,共享一个 Redis 分片 实例数翻倍,命中率反而降 分片数没变,单分片带宽/连接数到顶 分片 CPU 100%,客户端超时
③ 扩了实例,客户端连接池没改 新实例 CPU 只有 10% 池 maxOpen 固定,连接总数撞 DB max_connections 池等待时间 P99 暴涨
④ 压测机自己成了瓶颈 到 3 万 QPS 就“压不动了” 压测机 4 核跑满,被测服务 CPU 只有 40% 压测机 CPU 100%,被测 40%
⑤ 平均值掩盖单实例热点 平均 CPU 45%,看起来很空 32 台里 3 台 95%,长连接粘性或 key 倾斜 平均 45%、P99 尾部炸,扩机器无效

五种里有四种的共同点是:你扩的那层利用率在降,而某一层利用率一直贴着上限。所以识别瓶颈的动作,本质上就是“找那个利用率不动的层”。

把三层管道跑一遍

模型:gateway(64 并发,近乎无限)→ service(可扩,单请求 2.58ms)→ db(单实例 8 并发,5ms)。整体吞吐取各层可交付容量的最小值,延迟用 M/M/c 算。

package main

import (
	"fmt"
	"math"
)

// M/M/c 的 Erlang-C:新请求到达时需要排队的概率
func erlangC(c int, a float64) float64 { // a = λ·S(爱尔兰)
	term := 1.0
	sum := 1.0
	for k := 1; k < c; k++ {
		term *= a / float64(k)
		sum += term
	}
	term *= a / float64(c) // a^c / c!
	rho := a / float64(c)
	if rho >= 1 {
		return 1
	}
	head := term * float64(c) / (float64(c) - a)
	return head / (sum + head)
}

type layer struct {
	name    string
	servers int     // 并发上限:协程池 / 连接数 / 工作线程
	svc     float64 // 单请求服务时间(秒)
}

func (l layer) capacity() float64  { return float64(l.servers) / l.svc }
func (l layer) sustained() float64 { return l.capacity() * 0.95 } // 饱和后超时与重试吃掉 5%

// stats 给定到达率 λ,返回利用率、平均响应、P99 响应(秒)
func (l layer) stats(lam float64) (rho, mean, p99 float64) {
	a := lam * l.svc
	rho = a / float64(l.servers)
	if rho >= 1 {
		return rho, math.Inf(1), math.Inf(1)
	}
	c := erlangC(l.servers, a)
	slack := float64(l.servers)/l.svc - lam // cμ − λ
	mean = c/slack + l.svc
	p99 = l.svc
	if c > 0.01 { // P(Wq > t) = C·e^{−(cμ−λ)t},解 1% 分位
		if v := l.svc + math.Log(c/0.01)/slack; v > p99 {
			p99 = v
		}
	}
	return rho, mean, p99
}

func describe(ls []layer) (tp float64, bottleneck string) {
	tp = math.Inf(1)
	for _, l := range ls {
		if s := l.sustained(); s < tp {
			tp, bottleneck = s, l.name
		}
	}
	return tp, bottleneck
}

func main() {
	fmt.Println("三层管道:gateway(近乎无限) → service(可扩) → db(单实例 8 并发)")
	fmt.Println("整体吞吐 = 各层可交付容量的最小值;服务时间为稳态单请求耗时")

	gateway := layer{"gateway", 64, 0.0002} // 0.2ms、64 并发
	db := layer{"db", 8, 0.005}             // 单实例 8 并发、5ms/请求

	fmt.Printf("\n%-9s %8s %10s %14s %14s\n", "层", "并发上限", "服务时间", "容量 c/S", "可交付 0.95×")
	for _, l := range []layer{gateway, {"service", 4, 0.00258}, db} {
		fmt.Printf("%-9s %8d %9.2fms %13.0f %13.0f\n",
			l.name, l.servers, l.svc*1000, l.capacity(), l.sustained())
	}

	fmt.Println("\n==== 只扩 service:副本 4 → 32(db 不动)====")
	fmt.Printf("%-8s %9s %8s %10s %8s %10s %8s %10s\n",
		"service", "整体QPS", "svc ρ", "svc P99", "db ρ", "db P99", "整体均延", "瓶颈层")
	var first, last, p99First, p99Last float64
	for i, n := range []int{4, 8, 16, 32} {
		svc := layer{"service", n, 0.00258}
		tp, bn := describe([]layer{gateway, svc, db})
		_, mg, pg := gateway.stats(tp)
		rs, ms, ps := svc.stats(tp)
		rd, md, pd := db.stats(tp)
		fmt.Printf("%-8d %9.0f %7.1f%% %9.1fms %7.1f%% %9.1fms %9.1fms %10s\n",
			n, tp, rs*100, ps*1000, rd*100, pd*1000, (mg+ms+md)*1000, bn)
		if i == 0 {
			first, p99First = tp, pg+ps+pd
		}
		last, p99Last = tp, pg+ps+pd
	}
	fmt.Printf("\nservice 4→32:整体吞吐 %.0f → %.0f QPS(%+.1f%%),端到端 P99 %.0f → %.0f ms\n",
		first, last, (last-first)/first*100, p99First*1000, p99Last*1000)

	fmt.Println("\n==== 扩对的那层:db 并发 8→16,同时 service 4→16 ====")
	db2 := layer{"db", 16, 0.005}
	svc2 := layer{"service", 16, 0.00258}
	tp2, bn2 := describe([]layer{gateway, svc2, db2})
	_, _, pg2 := gateway.stats(tp2)
	rs2, _, ps2 := svc2.stats(tp2)
	rd2, _, pd2 := db2.stats(tp2)
	fmt.Printf("整体吞吐 %.0f QPS(瓶颈:%s),svc ρ=%.1f%%,db ρ=%.1f%%,端到端 P99 %.0f ms,吞吐较上一行 %+.0f%%\n",
		tp2, bn2, rs2*100, rd2*100, (pg2+ps2+pd2)*1000, (tp2-last)/last*100)
}

本机实跑输出(GO111MODULE=off go run main.go):

三层管道:gateway(近乎无限) → service(可扩) → db(单实例 8 并发)
整体吞吐 = 各层可交付容量的最小值;服务时间为稳态单请求耗时

层             并发上限       服务时间         容量 c/S      可交付 0.95×
gateway         64      0.20ms        320000        304000
service          4      2.58ms          1550          1473
db               8      5.00ms          1600          1520

==== 只扩 service:副本 4 → 32(db 不动)====
service      整体QPS    svc ρ    svc P99     db ρ     db P99     整体均延        瓶颈层
4             1473    95.0%      60.5ms    92.1%      39.0ms      25.2ms    service
8             1520    49.0%       3.6ms    95.0%      60.4ms      18.4ms         db
16            1520    24.5%       2.6ms    95.0%      60.4ms      18.3ms         db
32            1520    12.3%       2.6ms    95.0%      60.4ms      18.3ms         db

service 4→32:整体吞吐 1473 → 1520 QPS(+3.2%),端到端 P99 100 → 63 ms

==== 扩对的那层:db 并发 8→16,同时 service 4→16 ====
整体吞吐 3040 QPS(瓶颈:db),svc ρ=49.0%,db ρ=95.0%,端到端 P99 35 ms,吞吐较上一行 +100%

逐条读:

  1. service 从 4 扩到 32,吞吐只涨 3.2%(1,473 → 1,520)。八倍的机器换来 47 QPS。多出来的 47 QPS 是“把 service 自己那层的排队擦掉”的收益,不是新容量。
  2. 真正的天花板是 db 的 1,520(8 并发 / 5ms × 0.95)。它的利用率从 92.1% 涨到 95.0% 就再没动过——利用率不动的层就是瓶颈,这句话在表里一眼可见。
  3. 最容易被骗的一行是 P99:端到端 P99 从 100ms 降到 63ms,看起来“扩容有效”。但拆开看,db 的 P99 反而从 39.0ms 涨到 60.4ms——因为吞吐被推高了,更多流量压进同一个 db。延迟降了不等于容量涨了,这两件事要分账。
  4. 扩对那层是 2 倍:db 并发 8→16,吞吐 1,520 → 3,040(+100%)。同样一台机器上把并发从 8 调到 16,收益是扩 8 倍 service 副本的 31 倍。
  5. 瓶颈会搬家:db 扩到 16 并发后,瓶颈层仍是 db(95.0%),但天花板从 1,520 抬到了 3,040;service 在 16 副本时只有 49%,还有余量。再往上加 db 并发,瓶颈就会换到 service——每一轮扩容后都要重新做一次利用率对照,拿着上一轮的结论扩下去,迟早扩错层。

反模式

反模式 后果
先猜瓶颈再验证,直接扩最可疑的那层 八倍机器换 3% 吞吐,钱花了瓶颈还在
只看平均 CPU,不看单实例分布 平均 45% 很健康,实际 3 台打满在丢请求
压测只记录被测服务的指标 压测机自己打满,测出的是压测机的上限
扩容后不重新做利用率对照 瓶颈搬家了还在扩旧的那层,第二轮纯浪费
只扩应用层,不改连接池/端口/配额 应用 CPU 10%,全在等连接或等端口
用同步链路压异步瓶颈 拿 QPS 去判断一个队列型消费者,指标根本不对
把“延迟降了”当作“容量涨了” 排队被擦掉造成的假象,下一次流量增长立刻打回原形
在 90% 利用率上做容量规划 再涨 5 个百分点,排队时间从 9 倍跳到 19 倍

关联

自检

Q1 应用从 8 个实例扩到 32 个,QPS 一点没涨,P99 也没变,应用 CPU 却从 80% 降到 20%。瓶颈在哪一层?下一步做什么?

▸ 答案:瓶颈在应用层之外的共享资源——应用利用率降了说明它不再是约束,“吞吐不涨、延迟不降”这条判据指向下游单点(DB 单主、单个 Redis 分片、全局锁)。下一步不是继续扩应用,而是把 DB/缓存那层的利用率和排队打出来:如果它贴着 90%+,就去扩那一层(加从库、加并发、加分片);如果它也很空,去查连接池、端口、配额这些“看不见的上限”。

Q2 压测到 3 万 QPS 就上不去了,被测服务 CPU 只有 40%,RT 也没涨。最可能是什么问题?

▸ 答案:请求压根没进来,不是服务端不行。按利特尔法则,并发 = QPS × RT,RT 没涨而 QPS 卡住,说明并发被卡在入口:压测机自己打满、客户端连接池上限(池大小/RT 就是 QPS 天花板)、本地端口耗尽,或上游限流。先看压测机自己的 CPU,再看客户端池大小,最后看 ss -s 的 TIME_WAIT。

Q3 为什么利用率 80% 就该扩容,而不是 95%?用数字说。

▸ 答案:M/M/1 的排队时间 Wq = S·ρ/(1−ρ):ρ=0.8 时是 4 倍服务时间,ρ=0.9 时 9 倍,ρ=0.95 时 19 倍。从 80% 到 95% 只多 15 个百分点,排队时间翻了近 5 倍。而且 80% 到 95% 之间没有任何缓冲,一次流量抖动就直接冲过 100% 开始丢请求,连扩容的窗口都没有。

Q4 表格里“平均 CPU 45%”和“P99 从 120ms 涨到 800ms”同时出现,可能是什么?怎么验证?

▸ 答案:平均值掩盖了热点——32 台里少数几台接近 100%,其余很空,请求打到热点实例就排队。常见成因是长连接粘性(客户端负载均衡不均,见 42.4 服务发现与客户端负载均衡)、key 分布倾斜、或分片数不匹配。验证方法:把 CPU 按实例画成 P50/P99 分布而不是平均值,再看入口 SLB 的连接数分布是否均匀(8.9 四层与七层负载均衡)。