0.1 三类问题必须分开处理
上一节:无(这是全书的起点) | 下一节:0.2 吞吐、延迟、成本的三方权衡 配套代码:00-introduction/01-three-kinds-of-problems
一句话结论
「性能」不是一个问题,而是三个不同的问题:这段代码本身快不快、这个服务能扛多少、它为什么慢。三者需要三套不同的方法,混用就会得到错误的答案。
一、从一个真实的对话开始
同事:我们的订单接口太慢了,你能不能看一下? 你(凭直觉):我先压一下看看。
这次「我先压一下」大概率会浪费半天。因为你并不知道自己在回答哪个问题:
- 你是想验证「订单接口的代码写得不好」吗?→ 那应该做微基准,把某个函数单独拎出来测。
- 你是想知道「它能扛多少流量、什么时候垮」吗?→ 那应该做负载测试。
- 你是想知道「它现在为什么慢、时间花在哪」吗?→ 那应该做性能剖析。
这三件事的输入、输出、工具、时间成本完全不同。 先搞清楚要做哪一件,再动手。
二、三类问题对照表
| 微基准(Microbenchmark) | 负载测试(Load Test) | 性能剖析(Profiling) | |
|---|---|---|---|
| 你在问 | 这段代码本身有多快? | 这个系统能扛多少? | 现在为什么慢? |
| 典型提问 | 「copy() 和手动复制哪个快?」 |
「上线前它能撑住 1000 QPS 吗?」 | 「P99 从 80ms 涨到 400ms,卡在哪?」 |
| 是否隔离 | 完全隔离,只测一个函数 | 不隔离,全链路一起测 | 不隔离,在生产同构环境上采样 |
| 典型工具 | JMH、kotlinx-benchmark | k6、Gatling、wrk2 | async-profiler、JFR、火焰图 |
| 主要输出 | ns/op 或 ops/s |
吞吐-延迟曲线、拐点、崩溃点 | 时间花在哪条调用链上 |
| 测量粒度 | 纳秒 | 毫秒 | 采样栈(可以是函数级) |
| 主要风险 | 被 JIT 优化掉,测出假数据 | 压测机自己成为瓶颈 | 只看到 CPU 时间,漏掉等待 |
一个通俗的类比:医院检查
| 检查项目 | 对应 | 能回答 | 不能回答 |
|---|---|---|---|
| 化验一滴血 | 微基准 | 这个指标本身正常吗? | 你能不能跑完马拉松 |
| 跑步机压力测试 | 负载测试 | 你的心肺能撑多大强度、什么时候出问题 | 是哪根血管堵了 |
| 拍 CT / 造影 | 性能剖析 | 到底是哪里堵了 | 你能跑多快 |
最常见的错误,是拿「化验结果」去回答「能跑多快」的问题。
比如:「我测过 JSON 序列化只用 2 μs,一个请求要 10 ms,所以序列化不是瓶颈。」——这句话在化验层面是对的,但结论是错的。因为在真实并发下,序列化可能因为线程调度、锁竞争、GC 而放大几十倍,最终占到 30% 的 CPU。
三、逐个看清楚
3.1 微基准:测「一滴血」
目的:在完全隔离的条件下,比较两个实现、或验证某个操作的成本量级。
必须满足的条件(缺一条数据就是假的):
- 预热:JVM 需要跑足够多次让代码升到 C2 编译,否则你测的是解释器。
- 消费结果:计算结果必须被使用,否则 JIT 会把整个计算删掉。
- 输入不可预测:输入是编译期常量会被折叠;输入分布太规律会让分支预测过于理想。
- 隔离执行:每个基准跑在独立 JVM 里(JMH 的
@Fork),避免上一个基准的 profile 污染。
产出:ns/op、ops/ms、分配速率(gc.alloc.rate)。
它不能回答:这个服务能扛多少 QPS。微基准里没有网络、没有连接池、没有 GC 压力、没有排队。
第 2 章会专门讲微基准,因为它是最容易测错的一类。
3.2 负载测试:测「能跑多快」
目的:给系统施加可控的流量,观察它在不同负载下的表现,找出容量与崩溃点。
关键设计(后面章节详述):
- 流量模型:是「固定并发数」还是「固定到达率」?这个选择会系统性地改变结论(见第 6 节)。
- 负载曲线:阶梯上升、恒定、尖峰、长时间浸泡——每种用来发现不同的问题。
- 观测点:客户端观测的延迟和服务端观测的延迟不是一回事,不能混用比较。
产出:一条「到达率 → P99 / 错误率」的曲线;曲线开始非线性上升的位置叫拐点,那就是这套配置的实用容量上限。
它不能回答:为什么慢。负载测试只告诉你「在 800 QPS 时 P99 是 210 ms」,不告诉你这 210 ms 花在数据库、GC 还是锁上。
3.3 性能剖析:测「为什么慢」
目的:在系统运行(或接近真实负载)时,采集「时间花在哪里」的证据。
两种完全不同的场景:
- CPU 高 → 用 on-CPU 采样(火焰图),找最「宽」的函数。
- CPU 不高但很慢 → 时间是花在等待上的,必须用 wall-clock 采样或 off-CPU 分析。这是新手最容易漏掉的一类:只看 CPU 火焰图会觉得「图很空,没瓶颈」,而真正的瓶颈是等数据库、等锁、等下游。
产出:带证据链的根因结论,例如「P99 的 60% 来自 PostgreSQL 查询,其中 select ... where created > ? 走了全表扫描」。
它不能回答:集群需要几台机器。剖析给出的是「为什么」,不是「够不够」。
四、三者怎么配合:一个正确的排查顺序
① 负载测试 ── 发现「800 QPS 之后 P99 开始飙升」
↓
② 性能剖析 ── 在这个负载下采集火焰图,发现瓶颈是 N+1 查询
↓
③ 微基准 ── 修复方案有两个:批量查询 vs 加缓存,用微基准/组件基准快速比一比
↓
④ 负载测试 ── 回到同样的负载,验证拐点是否后移、P99 是否真的下降
注意这个循环里的顺序:先用负载测试定位「哪里」,再用剖析定位「为什么」,用微基准比较「怎么改」,最后回到负载测试验证收益。
反过来的顺序(先微基准、再优化、最后才发现压测没变化)是最常见的浪费。
五、本节小结
- 「性能」是三个问题:代码本身快不快 / 系统能扛多少 / 现在为什么慢。
- 三套方法对应三种工具和三种产出物,混用会得到错误结论。
- 拿微基准的结论去推测线上容量,是最典型的方法误用。
- 正确的协作顺序:负载测试找位置 → 剖析找原因 → 微基准比方案 → 负载测试验收益。
六、自测
- 同事说「这个接口在压测时 P99 是 800 ms,你优化一下」。这句话里缺了哪个关键信息,导致你无法直接开始?(提示:想想三类问题里,这句话实际上同时混了几类)
- 「我用 JMH 测出这个算法比另一个快 3 倍,所以我们要换掉它。」这个推论缺了什么?
- 服务 CPU 只有 25%,但 P99 很高。你应该先做哪一类测试?用什么工具?为什么 CPU 火焰图这次帮不上忙?
- 缺「哪一类问题」。是想知道为什么慢(剖析),还是想知道能扛多少(负载测试)?而且「800 ms」需要前提:多少 QPS、什么数据量、测于客户端还是服务端。没有这些,这个数字无法解释。
- 缺「这个算法在整体延迟中的占比」。如果它只占 1%,快 3 倍也只省 0.7%。需要用剖析确认它是不是瓶颈,用负载测试确认改动后整体指标是否改善。
- 做性能剖析,用 wall-clock 采样(async-profiler 的
wall事件)或 off-CPU 分析。CPU 火焰图只记录「CPU 在执行什么」,而这里的时间花在等待上,所以 CPU 图会很空,看不出问题。