3.2 组件基准:性价比最高的一层
上一节:3.1 六层金字塔总览 | 下一节:3.3 集成基准与全链路 配套代码:03-test-pyramid/02-component-benchmark
一句话结论
后端 80% 的性能问题(慢 SQL、N+1、序列化开销、索引缺失、锁竞争)都能在组件基准里被发现,而它的迭代成本只有几十秒。 这是投入产出比最高的一层,也是大多数团队完全缺失的一层。
一、用「台架试验」理解组件基准
汽车厂商测发动机,会把它装在台架上:接上真实的油路、水路、排气,但不装在整车上。
为什么?
| 台架的好处 | 对应组件基准 |
|---|---|
| 环境可控、可重复 | 固定数据集、固定随机种子 |
| 出问题立刻知道是发动机的 | 慢就是这条 SQL 慢,不会怪到网络上 |
| 一次试验几分钟,一天能试几十轮 | 一次基准几十秒,快速迭代 |
| 能测到极限工况(红线转速) | 能测到极端数据(1 亿行、热点 key) |
关键:台架用的是真发动机,不是模型。同理,组件基准必须用真实数据库(Testcontainers 起的 PostgreSQL),不能用 H2 之类替代品——因为索引行为、执行计划、锁机制完全不同。
用 H2 做基准的后果:在 H2 上很快的查询,在 PostgreSQL 上可能因为执行计划不同而慢 100 倍。你会得到「测试通过、上线爆炸」的经典结局。
二、组件基准测什么:四件事分开测
一个接口的真实成本 = SQL 执行 + 驱动/ORM 映射 + 业务逻辑 + 序列化。必须分开测,否则你只知道总数,不知道哪块贵。
| 测什么 | 怎么测 | 常见发现 |
|---|---|---|
| ① 纯 SQL 执行 | 直接用 JDBC 执行,不含映射 | 缺索引、全表扫描、锁等待 |
| ② 驱动/ORM 映射 | 对比「JDBC 结果集 → 对象」与①的差值 | ORM 的反射开销、N+1 |
| ③ 业务逻辑 | 用固定输入调用 service 方法,mock 掉 IO | 算法问题、重复计算 |
| ④ 序列化 | 单独测 JSON 编解码 | 反射型序列化器、大对象 |
为什么必须分开:假设你测出「接口总耗时 60 ms」,你无法判断该优化哪个。分开之后:
SQL 执行 : 8 ms
ORM 映射 : 35 ms ← 问题在这里!
业务逻辑 : 5 ms
JSON 序列化 : 12 ms
结论立刻清楚:优化方向是映射层(换更轻的 ORM、投影查询只取需要的列),而不是加索引或换序列化器。
三、Testcontainers:用真发动机做台架
为什么不用 H2 / 内存数据库:
| 差异 | H2 | PostgreSQL |
|---|---|---|
| 执行计划 | 简单,几乎总是走索引 | 基于统计信息,可能选择全表扫描 |
| 并发与锁 | 简化实现 | MVCC、行锁、死锁检测,行为完全不同 |
| 索引类型 | 有限 | B-tree/GIN/GiST/部分索引等 |
| 数据量行为 | 内存里都很快 | 数据量决定执行计划 |
所以:任何涉及 SQL 的组件基准,都必须用真实数据库。
Testcontainers 的三个实践要点:
| 要点 | 原因 |
|---|---|
| 容器在测试类生命周期内复用 | 每次启动容器要几秒到几十秒,会污染测量 |
| 数据量和分布与线上同数量级 | 1 万行与 1000 万行的执行计划可能完全不同 |
| 用幂律分布造热点 | 均匀分布会让缓存命中率虚高、锁竞争虚低 |
四、为什么组件基准里不该用 JMH
这是一个常见困惑:既然 JMH 是标准工具,为什么组件基准不用它?
因为 JMH 的模型假设「操作是纯的、可重复的、延迟确定的」,而数据库查询:
- 延迟不确定(受缓存、锁、GC 影响)
- 有副作用(会影响下一次查询的缓存状态)
- 需要看分布(P50 和 P99 差很多,平均值没有意义)
正确做法:自己写采样循环,记录每次耗时,排序后报告 P50 / P95 / P99。
JMH 适合:纯算法、序列化、锁的开销(纳秒–微秒级,延迟稳定)
组件基准适合:数据库、缓存、文件 IO(毫秒级,延迟分布重尾)
五、Fake / Mock 的边界
| 用法 | 是否合适 | 说明 |
|---|---|---|
| Mock 掉外部 HTTP 依赖 | ✅ 合适 | 控制变量,避免第三方不稳定 |
| Mock 掉 Repository | ⚠️ 谨慎 | 测业务逻辑可以,但测不出 N+1 与慢 SQL |
| 用 H2 替代 PostgreSQL | ❌ 不行 | 索引与执行计划行为不同,结论不可迁移 |
| 用内存 Map 替代 Redis | ⚠️ 谨慎 | 能测业务逻辑,测不出网络往返与序列化 |
| 用 Fake 时钟 | ✅ 合适 | 保证可重复 |
判断标准:如果你的组件基准「快得不真实」,先怀疑替身掩盖了真实成本。
六、一个完整的组件基准应该输出什么
## 组件基准报告:OrderRepository
**环境**:PostgreSQL 16(Testcontainers)、10 万行订单、幂律分布(前 1% 用户占 35% 请求)、连接池 10
**方法**:预热 5000 次 → 采样 50000 次 → 排序取百分位
| 操作 | P50 | P95 | P99 | 说明 |
| --- | --- | --- | --- | --- |
| findById(主键) | 0.4 ms | 0.9 ms | 2.1 ms | 走主键索引 |
| findByUserId(有索引) | 1.2 ms | 3.4 ms | 8.7 ms | 走二级索引 + 回表 |
| findByStatus(无索引) | 18.3 ms | 42.1 ms | **96.4 ms** | ⚠️ 全表扫描,随数据量线性恶化 |
| findByIds(批量 100 个) | 1.8 ms | 3.1 ms | 6.2 ms | 对比 100 次单查:P99 是 210 ms |
**结论**:
1. `findByStatus` 缺索引,是当前最大瓶颈(P99 96 ms)
2. 批量查询比循环单查快 34 倍(P99 6.2 vs 210 ms),验证了消除 N+1 的收益
注意这份报告的两个特点:
- 给了分布(P50/P95/P99),不是平均值。 因为数据库查询是重尾的。
- 给了对比组。 「批量 vs 循环」的对比直接量化了优化的收益——这就是决策依据。
七、本节小结
- 组件基准是性价比最高的一层:几十秒迭代一次,能发现 80% 的后端性能问题。
- 必须用真实数据库(Testcontainers),不能用 H2——索引与执行计划行为完全不同。
- 四件事分开测:SQL 执行、ORM 映射、业务逻辑、序列化。否则不知道优化哪块。
- 组件基准不该用 JMH,因为它有副作用、延迟不确定、需要看分布。自己写采样循环报告百分位。
- 数据量要与线上同数量级,数据分布要用幂律——否则测出的结论不可迁移。
- Fake 的边界:测业务逻辑可以,测 IO 成本不行。「快得不真实」就是信号。
八、自测
- 你测出「查询接口总耗时 60 ms」,下一步该做什么才能知道优化哪里?
- 为什么用 H2 做的性能测试结论不可信?请举一个具体的例子说明后果。
- 一个团队用 JMH 测 Repository 方法,得到的
Score ± Error波动极大(±40%)。请解释原因,并给出正确的做法。
- 把它拆成四部分分别测:① 纯 SQL 执行(直接用 JDBC);② 驱动/ORM 映射(①的差值);③ 业务逻辑(mock IO);④ 序列化。只有在知道「60 ms 里哪块占大头」之后,才能决定优化方向——是加索引、换 ORM、改算法、还是换序列化器。否则任何优化都是猜。
- 因为 H2 和 PostgreSQL 的执行计划生成方式与索引行为不同。例子:一条
select * from orders where status = 'PAID' order by created_at desc limit 20的查询,在 PostgreSQL 上如果status的选择性差(比如 80% 的行都是 PAID),优化器可能选择「全表扫描 + 排序」而不是走索引,耗时几百毫秒;而 H2 在小数据集上可能总是走索引,几毫秒返回。后果:测试通过、上线后发现这条查询在真实数据量下是全表扫描,P99 爆炸,而此时已经上线了。 - 原因:① JMH 的迭代模型与数据库不匹配——JMH 会在同一份状态上反复执行,而数据库查询会改变缓存、锁、统计信息等状态,导致各轮之间不可比;② 数据库延迟本身是重尾的(受 checkpoint、autovacuum、锁等待影响),JMH 报告的均值/误差无法表达这种分布;③ JMH 默认的多线程模型会让数据库连接池成为瓶颈,测的其实是排队。正确做法:用 Testcontainers 起真实数据库,自己写采样循环(预热 N 次 → 采样 M 次 → 每次记录耗时 → 排序报告 P50/P95/P99),并单独统计查询次数(用来发现 N+1)。