本节目标:建立「先测量再优化」的工作流——定义可对比的基线指标,区分延迟问题与吞吐问题,用 JFR、async-profiler、jstack、在线诊断工具和
EXPLAIN ANALYZE把瓶颈钉到具体代码行或 SQL,而不是凭感觉调参。
适用版本:Spring Boot 4.1.x(Java 21)
17.1 性能剖析方法
前面 16 章把「图书借阅管理服务」的可观测性搭了起来:Actuator 暴露指标、Trace 串起调用链、日志汇到一处。但那套东西是事后的——线上报警了你才去看。本节要解决的是事前:在你决定动任何一行配置或代码之前,怎么知道该动哪里。
一句必须刻进肌肉记忆的话:不做基线就调参是赌博。 调了 -Xmx、加了缓存、换了连接池大小,然后觉得「好像快了」——这是本末倒置。性能工作只有两种合法姿势:要么先测出瓶颈再针对它优化,要么先记下基线再验证改动是否真的有效。本节全部围绕这两件事展开。
17.1.1 先建基线:六个必须采集的指标
基线不是「跑一次压测记个 QPS」,而是一组在同一负载、同一数据量、同一版本下可重复采集的指标。少了任何一个,后面的对比都会失真。
| 指标 | 含义 | 采集手段 | 为什么必须有 |
|---|---|---|---|
| 吞吐 QPS/TPS | 单位时间完成的请求数 | 压测工具(见 17.2) | 判断是「吞吐不足」还是「延迟过高」 |
| P95 / P99 延迟 | 长尾请求的耗时 | 压测工具 + Actuator | 平均值会掩盖长尾,用户感知的是 P99 |
| 错误率 | 5xx / 超时 / 拒绝占比 | Actuator + 网关 | 高 QPS 若是靠错误换来的,毫无意义 |
| GC 时间与频率 | 停顿占用的时间 | GC 日志 / JFR | 见 17.3,停顿直接体现为延迟尖刺 |
| CPU 使用率 | 用户态/系统态/负载 | top / 指标系统 | 判断瓶颈是 CPU 密集还是等待 |
| 内存与堆占用 | 堆、直接内存、常驻内存 | jcmd GC.heap_info / JFR | 判断是否在反复 GC 或泄漏 |
关键点:这些指标要一起看。CPU 不高、QPS 也上不去,说明瓶颈在等待(锁、连接池、下游 IO);CPU 打满而 QPS 不动,说明在空转或 GC 太频繁。孤立地看任何一个都会误判。
采集基线要写清楚环境:JVM 参数、实例数、数据库版本、数据量、压测工具与并发数。缺了环境描述的基线,下次没法复现,也就没法对比。
17.1.2 先分清:延迟问题还是吞吐问题
这是剖析的第一分叉,走错方向后面全白干。
- 延迟问题:单请求耗时变长,但系统还能扛住。表现是 P99 从 80ms 涨到 900ms。根因通常是慢 SQL、锁竞争、下游抖动、GC 停顿、线程池排队。
- 吞吐问题:单请求还行,但一加压 QPS 就到顶、错误率飙升。根因通常是资源饱和(CPU、连接池、线程池、数据库连接数)、锁串行化、或压测客户端本身成为瓶颈。
两者的工具和结论完全不同。一个典型的误判:团队发现「QPS 上不去」,第一反应是调大 Tomcat 线程数——结果线程更多、竞争更烈,QPS 反而下降。正确的第一步是先看瓶颈资源是谁:如果 CPU 没满、连接池没满,线程加再多也只是让大家一起等。
区分方法很简单:画一张「并发数 → QPS / P99」的关系曲线(17.2 会讲怎么画)。在拐点之前,加并发能换来 QPS 线性上升;过了拐点,QPS 不再涨而 P99 陡增,说明资源饱和了,此时是吞吐问题,该找饱和的资源;如果拐点之前 P99 就已经很高,那是延迟问题,该找单请求里的慢环节。
17.1.3 Amdahl 定律:别在 5% 的地方使劲
Amdahl 定律说的是一件朴素的事:一个程序的加速比,上限由「未被优化的那部分」决定。 如果某段代码占总耗时 5%,哪怕你把它优化到零耗时,整体最多快 5%;而你花在这上面的时间,远不如去找那占比 60% 的部分。
它的实践含义是:优化要按耗时占比排序,从最大头开始。 剖析工具(火焰图、JFR)的价值就在这里——它们不告诉你「哪里慢」,而是告诉你「时间花在哪」,让你按占比排序,而不是按直觉猜测。
一个常见的反例:有人花两周优化了一个工具类的字符串拼接,占整体 CPU 不到 2%;而真正的瓶颈是每请求一次没走索引的数据库查询,占 70%。两周的收益被两周的浪费抵消。没有火焰图或采样数据的优化,都是 Amdahl 定律的反面教材。
17.1.4 JFR:JDK 自带、低开销的持续采样
JFR(JDK Flight Recorder)是 JDK 内置的采样与事件框架,开销通常在个位数百分比,可以在生产常开。它记录的不只是 CPU,还有 GC、锁、线程、IO、异常、方法采样等事件,是排查「综合症状」的首选。
对运行中的进程临时抓一段(jcmd 是 JDK 自带工具,无需额外下载):
# 抓 60 秒、使用 profile 配置(采样更细),写入文件
jcmd <pid> JFR.start name=loan-profile settings=profile duration=60s filename=/tmp/loan.jfr
# 也可以在任意时刻手动 dump
jcmd <pid> JFR.dump name=loan-profile filename=/tmp/loan-now.jfr
# 停止
jcmd <pid> JFR.stop name=loan-profile
<pid> 用 jcmd -l 或 jps -l 查。注意 settings 有 default(低开销、适合长期开)和 profile(更细、开销略高、适合短时定位)两档。
如果要启动即录,用启动参数:
java -XX:StartFlightRecording=filename=/tmp/loan.jfr,settings=profile,duration=120s \
-jar loan-service.jar
录完的 .jfr 用 JDK 自带的 jfr 工具查看,或用 JDK Mission Control(JMC)图形化打开:
jfr summary /tmp/loan.jfr
jfr print --events jdk.ExecutionSample /tmp/loan.jfr | head
示例输出(jfr summary 的形态,具体数字取决于应用):
Version: 2.1
Chunks: 1
Start: 2026-10-06T09:12:03.114Z
Duration: 60 s
Event Type Count Size (bytes)
=============================================================
jdk.ExecutionSample 15234 234567
jdk.GCPhasePause 218 12345
jdk.JavaMonitorEnter 902 23456
jdk.SocketRead 4412 34567
判读方法:jdk.ExecutionSample 是 CPU 采样,用它回答「CPU 花在哪」;jdk.GCPhasePause 看 GC 停顿次数与时长;jdk.JavaMonitorEnter 的 count 高说明锁竞争严重;jdk.SocketRead/jdk.FileRead 高说明大量时间在等 IO。
17.1.5 async-profiler:火焰图定位到方法
JFR 给的是事件清单,要一眼看出「哪条调用链最宽」,用火焰图最直观。async-profiler 通过采样生成火焰图,能同时抓 CPU、内存分配、锁三类热点,且对安全点偏差处理得比 jstack 采样更准。
# 采集 30 秒 CPU 火焰图(HTML 可直接在浏览器打开)
asprof -d 30 -e cpu -f /tmp/loan-cpu.html <pid>
# 采集内存分配火焰图:谁在制造垃圾,直接决定 GC 压力
asprof -d 30 -e alloc -f /tmp/loan-alloc.html <pid>
# 采集锁竞争
asprof -d 30 -e lock -f /tmp/loan-lock.html <pid>
火焰图的读法:横轴是耗时占比(不是时间先后),纵轴是调用栈深度,方块越宽表示这段代码占用的采样越多。 找最宽的叶子节点,就是热点。-e alloc 的图尤其有用——它直接指出「谁在频繁分配对象」,是 17.3 里判断 GC 压力的依据。
示例输出(asprof 的命令行摘要形态):
Profiling for 30 seconds
Done
Frame buffer usage: 12.3%
Total samples: 48210
Top frames by self:
com.example.loan.BookRepository.searchByTitle 18.4%
java.util.HashMap.get 6.1%
com.example.loan.LoanMapper.toDto 4.7%
判读方法:searchByTitle 自己占了 18.4%,说明瓶颈在数据库访问(多半是慢 SQL);如果换成 HashMap.get 或某个 mapper 占大头,那是纯 CPU 问题,优化方向完全不同。
17.1.6 线程与锁:jstack 与线程状态分布
当 QPS 上不去但 CPU 也不高时,八成是线程在等。jstack 打出某个时刻所有线程的栈与状态,是判断「等什么」最快的工具。
jstack <pid> > /tmp/loan-stack.txt
与其人工翻,不如按状态统计分布:
grep -E "java.lang.Thread.State" /tmp/loan-stack.txt \
| sort | uniq -c | sort -rn
示例输出(一次线上采样的形态):
84 java.lang.Thread.State: WAITING (parking)
37 java.lang.Thread.State: RUNNABLE
12 java.lang.Thread.State: TIMED_WAITING (parking)
6 java.lang.Thread.State: BLOCKED (on object monitor)
3 java.lang.Thread.State: TIMED_WAITING (sleeping)
判读方法:大量 RUNNABLE 且 CPU 打满,是 CPU 瓶颈;大量 WAITING (parking) 要看停在哪个栈帧(线程池排队、LockSupport.park);出现 BLOCKED (on object monitor) 说明有锁竞争,去找那个被争抢的 monitor。注意 jstack 是某一瞬间的快照,要看趋势就得连续采几次,否则一次采样可能正好撞上空闲时刻。
17.1.7 在线诊断:Arthas 这类工具
生产环境往往不方便重启加参数。Arthas 这类 attach 式工具能在不重启的前提下,实时看方法耗时、看入参出参、看调用链,适合「线上某个接口偶尔慢,但复现不了」的场景。
# 启动并 attach 到目标 Java 进程
java -jar arthas-boot.jar
# 看某个方法的调用耗时分布
trace com.example.loan.LoanService findByMember '#cost > 100'
# 观察某个方法的入参和返回值
watch com.example.loan.LoanService borrow '{params, returnObj}' -x 2
# 内置 profiler 生成火焰图
profiler start
profiler stop --format html
示例输出(trace 的形态):
`---ts=2026-10-06 09:20:11;thread_name=http-nio-8080-exec-3;cost=312ms
`---[92.4%] com.example.loan.LoanService:findByMember()
`---[88.1%] com.example.loan.LoanRepository:findByMemberId()
`---[86.7%] org.hibernate...:doQuery()
判读方法:trace 把一次调用的耗时按调用树拆开,括号里的百分比就是占比——直接对应 17.1.3 的 Amdahl 排序。注意 trace 会增强字节码、有性能开销,只适合短时诊断,不要长期挂着。
17.1.8 慢 SQL:EXPLAIN ANALYZE
Web 应用的性能问题里,数据库占的比例通常最大。当火焰图指向某个 Repository 方法时,下一步就是把它对应的 SQL 拿去问数据库「你打算怎么执行」。
PostgreSQL 用 EXPLAIN (ANALYZE, BUFFERS):
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, author
FROM book
WHERE category = 'NOVEL'
ORDER BY created_at DESC
LIMIT 20;
ANALYZE 会真正执行查询并给出真实耗时与行数,BUFFERS 给出缓存命中情况。示例输出(形态):
Limit (cost=0.00..1420.50 rows=20 width=64) (actual time=0.412..8.771 rows=20 loops=1)
-> Index Scan Backward using idx_book_created_at on book (cost=0.00..71025.00 rows=1000 width=64) (actual time=0.401..8.760 rows=20 loops=1)
Filter: (category = 'NOVEL'::text)
Rows Removed by Filter: 9980
Planning Time: 0.210 ms
Execution Time: 8.930 ms
判读方法:看两件事——有没有走索引(Seq Scan 大表基本是坏消息),以及 rows 的估算与 actual rows 差多少(差得离谱说明统计信息过时,优化器选错了计划)。上面的例子里虽然走了索引,但 Rows Removed by Filter: 9980 说明扫了 1 万行才过滤出目标——这提示该给 (category, created_at) 建复合索引。
MySQL 对应 EXPLAIN ANALYZE(8.0.18+)或 EXPLAIN FORMAT=JSON,重点看 type 是否 ALL(全表扫)、rows 估算、以及是否命中索引。
17.1.9 症状 → 可能原因 → 下一步排查
把前面的工具串成一张速查表。遇到症状先查表,别凭感觉猜。
| 症状 | 可能原因 | 下一步该看什么 |
|---|---|---|
| P99 高、平均正常 | 长尾:慢 SQL、偶发 GC、锁排队 | JFR 的 ExecutionSample + GC 停顿事件 |
| QPS 上不去、CPU 不高 | 线程/连接池/下游等待 | jstack 状态分布、连接池指标 |
| CPU 打满、QPS 不涨 | 热点代码或 GC 空转 | async-profiler CPU 图 + GC 日志 |
| 错误率随并发上升 | 资源耗尽(连接/线程/超时) | 连接池活跃数、超时与拒绝计数 |
| 内存持续上涨、不回落 | 泄漏或缓存无界 | jcmd GC.heap_info、堆直方图、分配火焰图 |
| 周期性延迟尖刺 | Full GC 或定时任务 | GC 日志时间戳、调度任务日志 |
| 数据库 QPS 异常高 | N+1 或缓存失效 | SQL 计数、慢查询日志(见 6.3) |
| 单接口慢、整体正常 | 该接口特有逻辑或 SQL | Arthas trace 该接口调用树 |
最后一条纪律:每次优化都要留下「改动前 / 改动后」两组同环境指标。 没有对比的优化无法证明有效,还可能引入新问题。17.2 讲怎么用压测产出这两组指标,17.3 讲拿到瓶颈在 GC 之后怎么调参数。
小结
- 性能工作的铁律是「先测量再优化」:不做基线就调参是赌博,无法证明改动有效。
- 基线要一次采齐六项指标(QPS、P95/P99、错误率、GC 时间、CPU、内存),并记录环境,否则无法复现与对比。
- 第一分叉是分清延迟问题与吞吐问题:前者找单请求里的慢环节,后者找饱和的资源;用「并发 → QPS/P99 曲线」的拐点来判断。
- Amdahl 定律要求按耗时占比排序优化,火焰图和采样数据是排序依据,别在 5% 的地方使劲。
- JFR 低开销、可生产常开,给事件清单;async-profiler 火焰图给调用链热点与分配热点;
jstack看线程状态与锁竞争;Arthas 做在线诊断;EXPLAIN ANALYZE定位慢 SQL。 - 遇到症状先查「症状 → 可能原因 → 下一步」表,把猜测换成有依据的排查路径;每次优化都留改动前后的同环境对比。
阅读导航:上一节:16.3 日志聚合与检索 · 下一节:17.2 压测与容量评估 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。