主题
界面
主题
《基线测试、负载测试与压力测试》找到了拐点("吞吐上限约 69 QPS");本节回答下一个问题:
"为什么只有 69 QPS?瓶颈在哪?"没有这一步,压测只完成了一半 ✗ ——因为"知道慢"不等于"知道为什么慢" ✓
本节的核心方法论:
压测的同时,必须看服务端的指标 ✓ ——事后猜瓶颈,等于猜谜 ✗✗
top / jstat / jstack / Actuator)定位瓶颈 ★★。top / jstack / jstat 吗?(《进程与端口排查》)★★先看一个真实的排查循环:
压测发现 QPS 上不去
→ "是不是数据库慢?" → 加索引 → 重压 → 还是上不去 ✗
→ "是不是内存不够?" → 扩内存 → 重压 → 还是上不去 ✗
→ "是不是线程池太小?" → 调大 → 重压 → 还是上不去 ✗
→ …………问题在于:每次都是"猜一个,改一个,再试一次" ✗ ——成本极高,且可能越改越糟 ✓
正确的方式:
在压测进行中,同时采集服务端指标,直接看"哪个资源先饱和" ✓✓
一次就能定位,而不是试错十次 ✓
从上到下,一层层排除:
| 层 | 看什么 | 工具 |
|---|---|---|
| ① 操作系统 ★ | CPU、内存、磁盘 IO、网络、文件句柄 | top / vmstat / iostat / ss |
| ② JVM ★★ | 堆、GC、线程、类加载 | jstat / jstack / jmap / Actuator |
| ③ 应用 ★★ | 线程池队列、连接池、缓存命中率、QPS 分布 | Actuator / Micrometer ★ |
| ④ 依赖 ★★ | 数据库、缓存、下游服务 | 慢查询(《执行计划与慢查询定位》)/ 客户端指标 |
★ 排查顺序(从外到内,但不绝对):
先看 ①(最常见、最容易看)
→ 都正常?
再看 ②(Java 应用的头号嫌疑:GC)
→ 正常?
再看 ③(应用内部的等待:池、队列、锁)
→ 正常?
最后看 ④(是不是被下游拖慢)⚠️ 但"从①开始"不是铁律:
如果现象是"CPU 低、QPS 上不去、延迟高",那基本可以跳过①②,直接看③ ✓
因为"在等"的瓶颈不在计算资源 ✓✓(《进程与端口排查》 的"CPU 低却假死")
一个极简的排查框架(每个资源都问这三个问题):
| 维度 | 问题 | 例子 |
|---|---|---|
| 利用率(Utilization) | 这个资源有多少时间在忙? | CPU 90%、连接池 80/100 ★ |
| 饱和度(Saturation) | 有没有在排队?排队多长? | 线程池队列长度、DB 连接等待时间 ★★ |
| 错误(Errors) | 有没有出错? | 超时、拒绝、异常计数 ✓ |
★ "饱和度"是最容易被忽略、却最能定位瓶颈的一维:
利用率高但不排队 → 还有余量 ✓
利用率不高但排队严重 → 有"隐形的串行点" ✗✗(锁、连接池上限、单线程消费)
本轮的实测现象(QPS 卡死在 69)正是"饱和度到顶"的典型形态 ✓
三个最常用的工具(《进程与端口排查》 已讲过用法)★:
| 工具 | 看什么 | 什么时候用 |
|---|---|---|
jstat -gcutil <pid> 1000 ★★ | GC 频率与耗时 | 怀疑 GC 拖慢 |
jstack <pid> ★★★ | 线程都在干什么 | 怀疑锁、线程池、等下游 |
jmap -histo:live <pid> ⚠️ | 对象直方图 | 怀疑内存泄漏(会触发 GC) |
jstat 的关键列:
| 列 | 含义 | 异常信号 |
|---|---|---|
FGC / FGCT ★★ | Full GC 次数 / 总耗时 | 持续增长 = 频繁 Full GC ✗ |
YGC / YGCT | 新生代 GC | 增长快但通常无害 ✓ |
O | 老年代占用率 | 接近 100% ⚠️ |
★ 判据:
压测中如果
FGCT快速上升,且与延迟尖峰对应 → 瓶颈在内存/GC ✓如果 GC 很安静 → 排除内存方向,往线程与等待方向看 ✓✓
jstack 的三类关键发现:
| 发现 | 含义 |
|---|---|
大量线程 WAITING 在同一个池的队列上 ★★ | 池太小 / 上游太慢 |
大量线程 BLOCKED ★ | 锁竞争 |
大量 RUNNABLE 卡在 socketRead0 | 等下游响应 ⚠️ |
Found one Java-level deadlock ★★★ | 死锁(jstack 会直接告诉你) |
这张表是本节的核心 ——"形态 → 嫌疑"的对照:
| # | 瓶颈 | 典型现象 | 看哪个指标 |
|---|---|---|---|
| ① | CPU | QPS 卡死、CPU 接近 100%、延迟上升 | top 的 us/sy ★ |
| ② | GC / 内存 | 延迟有周期性尖峰、QPS 忽高忽低 | jstat 的 FGC ★★ |
| ③ | 磁盘 IO | CPU 不高但 wa 高、写入变慢 | top 的 wa、iostat ★ |
| ④ | 锁竞争 ★ | 并发上不去、CPU 有富余 | jstack 的 BLOCKED ★★ |
| ⑤ | 连接池 / 线程池 ★★ | QPS 卡在某个整数附近 | 池的活跃数、等待数 |
| ⑥ | 下游依赖 ★★ | 线程大量等 IO、CPU 空闲 | 下游的 RT、超时计数 |
★ 第 ⑤ 条有个很好用的线索:
QPS 卡在一个"看起来很整齐"的数值上(如正好 100、200)→ 强烈怀疑是某个池的上限 ✓✓
因为池的大小是人设的(如
maximumPoolSize=200、HikariCPmaximumPoolSize=10)✓
本轮的实测现象(T2-003 第 7 节):
并发 16 → 64:QPS 68.7 → 68.8(零增长)★
P50:213 ms → 845 ms(线性上升)★
错误率:0%★ 这种形态能推出什么:
| 观察 | 推断 |
|---|---|
| QPS 完全冻结在一个值 | 存在一个"串行资源"到顶了 ★★(不是"资源快用完",是"已经用完") |
| 延迟随并发线性上升 | 多出来的请求在排队 ✓("排队时间 = 等待时间",符合队列模型) |
| 错误率 0% | 没有崩溃,是"优雅地变慢" ✓ ——说明队列还没满 ✓ |
嫌疑排序(按本环境):
| 排序 | 嫌疑 | 依据 |
|---|---|---|
| 1 | CPU(本机被压测客户端与服务端共享) ★★★ | 同机压测——两侧 JVM 抢同一个 CPU 池 |
| 2 | Tomcat 工作线程数 ★ | 默认 max-threads=200——64 并发不到上限,不太可能是它 ✓ |
| 3 | 锁竞争 | 该接口无共享状态(RawServlet 只做字符串拼接)→ 可能性低 ✓ |
| 4 | GC | 该接口几乎不产生对象 → 可能性低 ✓(但需 jstat 确认) |
★ 关键认识:
"推断"只是缩小范围,"确认"必须靠指标 ✓✓
正确做法:压测时同时开
top(看 CPU)与jstat(看 GC) ✓本轮没有同时采集这两个指标 ✗ ——这是本节留的实验任务 ✓
这是最容易让人困惑、也最值得记住的一条:
修好一个瓶颈后,压力会立刻转移到下一个瓶颈上 ✓
所以"优化"是一个循环,而不是一次性动作 ✓✓
一个真实的过程:
第一轮:QPS 200,瓶颈是 DB 没索引
→ 加索引 → QPS 800
第二轮:QPS 800,瓶颈是 DB 连接池只有 10
→ 调大到 50 → QPS 2000
第三轮:QPS 2000,瓶颈是应用 GC(对象分配太快)
→ 调堆 + 减少对象 → QPS 3500
第四轮:QPS 3500,瓶颈是单实例 CPU
→ 水平扩容 → QPS 7000(4 个实例)每一轮的现象:"和上一轮长得一样"(QPS 卡住 + 延迟上升) ⚠️ ——但原因完全不同 ✗
★ 这就是为什么"看指标"比"猜"重要:
形态相似,原因不同 ——只有指标能区分它们 ✓✓
★ 一个反过来的提醒:
不要在没有指标依据的情况下"提前优化" ✗ ——那是在优化一个可能不存在的瓶颈 ✓
先把当前瓶颈找出来、修掉,再看下一个 ✓
# 三个终端同时开
top -p <服务端 pid> # ① CPU
jstat -gcutil <pid> 1000 # ② GC
curl -s localhost:8080/actuator/metrics/hikaricp.connections.active # ③ 连接池① 现象:并发 16 → 64,QPS 冻结在 69
② top:服务端 CPU 只有 40%,客户端 CPU 55% → ★ 两侧都不满,但加起来 95%
③ jstat:FGC 稳定,FGCT 很小 → 排除 GC ✓
④ jstack:线程多在 RUNNABLE(正常处理中),无 BLOCKED → 排除锁 ✓
⑤ 结论:瓶颈是「两个 JVM 抢同一台机器的 CPU」★
⑥ 修法:压测机与服务分离([T2-003](./T2-003) 的 5.6 ①)★ 第 ② 步就是本轮"应该做但没做"的事 ✓
"QPS 上不去 → 先加内存" ✗ 没依据
"再调大线程池" ✗ 没依据
"再加索引" ✗ 没依据(可能是写操作)结果:可能把环境改得更复杂,而问题依旧 ✗✗
本节没有新增代码——它是一个"实验任务" ✓
★ 诚实说明:
T2-003 的压测只采集了客户端侧的指标(QPS、延迟分位), 没有同时采集服务端的 CPU / GC / 连接池 ✗ —— 所以那一轮"瓶颈在哪"只有推断,没有确认 ✓
留给你的实验(这正是本节要训练的能力):
① 启动服务,记下它的 PID
② 另开终端:top -p <pid> 与 jstat -gcutil <pid> 1000
③ 再开一个终端跑压测(并发 64 那一档)
④ 观察:
· top 的 CPU% 是多少?有没有到 100%?(Java 进程的 CPU 可以是 100% × 核数)
· jstat 的 FGC 有没有增长?FGCT 增长多少?
· 压测客户端的 CPU 又是多少?
⑤ 得出结论:瓶颈是 CPU、GC,还是别的?★ 判读提示:
| 观察 | 结论 |
|---|---|
| 服务端 CPU ≈ 单核 100% | 单线程瓶颈(锁 / 单线程消费)★ |
| 服务端 CPU ≈ 核数 × 100% | CPU 打满 → 扩容或优化算法 ✓ |
| 服务端 CPU 不高、客户端 CPU 很高 | 压测机才是瓶颈 ✗✗(本轮最可能的情况) |
| FGC 持续增长 | GC 瓶颈 → 调堆或减少对象 ★ |
★ 这个实验的价值:它把"推断"变成"证据" ✓✓
top -H -p <pid>:看线程级的 CPU(找出最耗 CPU 的那个线程)★,再用 jstack 把它的十六进制 nid 对上去 ✓;jstat -gcutil 与延迟曲线的对应:看 FGC 的时刻是否是延迟尖峰的时刻 ★;/actuator/metrics 里有 jvm.memory.used、hikaricp.connections.active、http.server.requests ✓;| # | 错误 | 说明 |
|---|---|---|
| 1 | 不看服务端指标就优化 ✗✗ | 试错十次(第 4 节)✗✗ |
| 2 | 只看 CPU 不看饱和度 ⚠️ | 利用率低但排队严重 = 有隐形串行点 ✗ |
| 3 | 忽略压测机自身的负载 ✗✗ | 它也可能满载(本轮就是)✓ |
| 4 | 以为优化一次就够 ✗ | 瓶颈会转移 ✓(5.6) |
| 5 | 在测试环境调优后直接套用到生产 ✗ | 数据量、网络、硬件都不同 ⚠️ |
| 6 | 只看 GC 次数不看 GC 耗时 ⚠️ | "1000 次新生代 GC"可能比"1 次 Full GC"代价小 ✓ |
目标:把"推断瓶颈"变成"确认瓶颈"。
步骤:
验收方式:能说出"服务端 CPU 与客户端 CPU 各是多少",并据此判断瓶颈归属。
扩展任务:给 LoadTestLesson 加"服务端指标采集"能力:
jstat -gcutil,输出 FGC 增量 ✓;压测并发 100:QPS 500,CPU 服务端 35%,客户端 30%
jstat:FGC 0 次
jstack:大量线程 WAITING 在 "HikariPool-1" 相关栈上瓶颈在哪?怎么修? 5. (编程)写一个"压测前后服务端指标对比"的脚本:启动压测前记录 jstat 与 top 快照,压测后再次记录并输出增量。
HikariPool 上大量等待)✗ ——CPU 都不高、GC 正常,说明不是计算资源问题 ✓ 修法:先确认池大小(如 maximumPoolSize=10)→ 适度调大(同时要确认数据库能承受更多连接)⚠️ → 更根本的是看 SQL 是否够快(连接持有时间越长,池越容易被占满)✓jstat / jstack 判断 GC 与线程问题 ★★★;性能测试五节(T2-003 / T2-013 / T2-014 / T2-015 / T2-016)到此完成。TB 剩余:
暂无