文档目录

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 的收益

注意这份报告的两个特点:

  1. 给了分布(P50/P95/P99),不是平均值。 因为数据库查询是重尾的。
  2. 给了对比组。 「批量 vs 循环」的对比直接量化了优化的收益——这就是决策依据。

七、本节小结

  1. 组件基准是性价比最高的一层:几十秒迭代一次,能发现 80% 的后端性能问题。
  2. 必须用真实数据库(Testcontainers),不能用 H2——索引与执行计划行为完全不同。
  3. 四件事分开测:SQL 执行、ORM 映射、业务逻辑、序列化。否则不知道优化哪块。
  4. 组件基准不该用 JMH,因为它有副作用、延迟不确定、需要看分布。自己写采样循环报告百分位。
  5. 数据量要与线上同数量级,数据分布要用幂律——否则测出的结论不可迁移。
  6. Fake 的边界:测业务逻辑可以,测 IO 成本不行。「快得不真实」就是信号。

八、自测

  1. 你测出「查询接口总耗时 60 ms」,下一步该做什么才能知道优化哪里?
  2. 为什么用 H2 做的性能测试结论不可信?请举一个具体的例子说明后果。
  3. 一个团队用 JMH 测 Repository 方法,得到的 Score ± Error 波动极大(±40%)。请解释原因,并给出正确的做法。
  1. 把它拆成四部分分别测:① 纯 SQL 执行(直接用 JDBC);② 驱动/ORM 映射(①的差值);③ 业务逻辑(mock IO);④ 序列化。只有在知道「60 ms 里哪块占大头」之后,才能决定优化方向——是加索引、换 ORM、改算法、还是换序列化器。否则任何优化都是猜。
  2. 因为 H2 和 PostgreSQL 的执行计划生成方式与索引行为不同。例子:一条 select * from orders where status = 'PAID' order by created_at desc limit 20 的查询,在 PostgreSQL 上如果 status 的选择性差(比如 80% 的行都是 PAID),优化器可能选择「全表扫描 + 排序」而不是走索引,耗时几百毫秒;而 H2 在小数据集上可能总是走索引,几毫秒返回。后果:测试通过、上线后发现这条查询在真实数据量下是全表扫描,P99 爆炸,而此时已经上线了。
  3. 原因:① JMH 的迭代模型与数据库不匹配——JMH 会在同一份状态上反复执行,而数据库查询会改变缓存、锁、统计信息等状态,导致各轮之间不可比;② 数据库延迟本身是重尾的(受 checkpoint、autovacuum、锁等待影响),JMH 报告的均值/误差无法表达这种分布;③ JMH 默认的多线程模型会让数据库连接池成为瓶颈,测的其实是排队。正确做法:用 Testcontainers 起真实数据库,自己写采样循环(预热 N 次 → 采样 M 次 → 每次记录耗时 → 排序报告 P50/P95/P99),并单独统计查询次数(用来发现 N+1)。