3.2 配套代码:组件基准(Testcontainers + 采样循环)
对应小节:3.2 组件基准 这份代码可以直接改造成你自己项目的组件基准。
一、测试容器与数据准备
// src/test/kotlin/bench/ComponentBenchmarkBase.kt
package bench
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.Connection
import kotlin.random.Random
@Testcontainers
abstract class ComponentBenchmarkBase {
companion object {
/**
* 关键:容器在整个测试类生命周期内复用。
* 每次启动容器要几秒到几十秒,会严重污染测量。
*/
@Container
@JvmStatic
val pg: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
.withDatabaseName("bench")
.withUsername("bench")
.withPassword("bench")
.withReuse(true)
}
protected lateinit var ds: HikariDataSource
protected fun startPool(poolSize: Int = 10) {
ds = HikariDataSource(HikariConfig().apply {
jdbcUrl = pg.jdbcUrl
username = pg.username
password = pg.password
maximumPoolSize = poolSize
})
}
/**
* 建表 + 灌数据。
*
* ⚠️ 三个关键决策:
* 1. 数据量 10 万行(与线上同数量级,否则执行计划不同)
* 2. 幂律热点(前 1% 用户占大部分查询)
* 3. 故意留一个【没有索引】的列,作为阳性对照
*/
protected fun seedData(rows: Int = 100_000) {
ds.connection.use { c ->
c.createStatement().execute("""
create table if not exists orders (
id bigserial primary key,
user_id bigint not null,
status text not null,
amount bigint not null,
created_at timestamptz not null default now()
)
""".trimIndent())
// 用幂律分布生成 user_id:少数用户占大部分订单
val rnd = Random(42)
c.prepareStatement("insert into orders(user_id, status, amount) values (?, ?, ?)").use { ps ->
for (i in 1..rows) {
// 幂律:u^3 让值集中在小区间(热点用户)
val u = rnd.nextDouble()
val userId = (1 + (1_000 * Math.pow(u, 3.0))).toLong()
ps.setLong(1, userId)
ps.setString(2, if (i % 10 == 0) "REFUNDED" else "PAID")
ps.setLong(3, rnd.nextLong(100, 100_00))
ps.addBatch()
if (i % 5_000 == 0) ps.executeBatch()
}
ps.executeBatch()
}
// ✅ 建索引:user_id(读路径的主索引)
c.createStatement().execute("create index if not exists idx_orders_user_id on orders(user_id)")
// ❌ 故意不建 status 的索引 —— 作为"缺索引"的阳性对照
c.createStatement().execute("analyze orders")
}
}
protected fun <T> withConn(block: (Connection) -> T): T = ds.connection.use(block)
}
二、采样循环与百分位统计
// src/test/kotlin/bench/Sampling.kt
package bench
/**
* 组件基准的采样工具。
*
* 为什么不用 JMH:
* - 数据库查询有副作用(改变缓存、锁、统计信息),JMH 的"重复执行同一状态"不成立
* - 数据库延迟是重尾的,需要看分布而不是均值
* - 需要统计"查询次数"来发现 N+1
*/
class Sampler(
private val warmup: Int = 5_000,
private val rounds: Int = 30_000,
) {
private var queryCount = 0L
fun <T> run(label: String, body: (Int) -> T): LongArray {
queryCount = 0
// ① 预热:让连接池填满、PG 缓存热起来、JIT 完成编译
repeat(warmup) { body(it) }
// ② 采样:每次单独计时
val samples = LongArray(rounds)
for (i in 0 until rounds) {
val t0 = System.nanoTime()
body(i)
samples[i] = System.nanoTime() - t0
}
samples.sort()
println("%-38s P50 %8.1f us P95 %8.1f us P99 %8.1f us 查询数 %d".format(
label,
pct(samples, 0.50) / 1000.0,
pct(samples, 0.95) / 1000.0,
pct(samples, 0.99) / 1000.0,
queryCount,
))
return samples
}
/** 让被测代码能报告"这一轮执行了几次查询"——用于发现 N+1 */
fun countQuery() { queryCount++ }
companion object {
fun pct(sorted: LongArray, q: Double): Double =
sorted[((sorted.size - 1) * q).toInt().coerceIn(0, sorted.size - 1)].toDouble()
fun report(title: String, sorted: LongArray, queryCount: Long = -1) {
println("$title: P50=%.1f P95=%.1f P99=%.1f us%s".format(
pct(sorted, 0.50) / 1000, pct(sorted, 0.95) / 1000, pct(sorted, 0.99) / 1000,
if (queryCount >= 0) " 查询数=$queryCount" else ""))
}
}
}
三、四件事分开测
// src/test/kotlin/bench/OrderRepositoryBenchmark.kt
package bench
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import kotlin.random.Random
class OrderRepositoryBenchmark : ComponentBenchmarkBase() {
@BeforeAll
fun setUp() {
startPool(poolSize = 10)
seedData(rows = 100_000)
}
private val sampler = Sampler(warmup = 5_000, rounds = 30_000)
// ── ① 主键查询(走主键索引) ──────────────────────────────
@Test
fun `1 - findById`() {
sampler.run("① findById(主键)") { i ->
withConn { c ->
c.prepareStatement("select id, user_id, amount from orders where id = ?").use { ps ->
ps.setLong(1, 1L + Random(i).nextInt(100_000))
ps.executeQuery().use { rs -> while (rs.next()) rs.getLong(3) }
}
}
sampler.countQuery()
}
}
// ── ② 二级索引查询(走 user_id 索引 + 回表) ──────────────
@Test
fun `2 - findByUserId`() {
sampler.run("② findByUserId(二级索引)") { i ->
withConn { c ->
c.prepareStatement(
"select id, amount from orders where user_id = ? order by created_at desc limit 20"
).use { ps ->
ps.setLong(1, 1L + Random(i).nextInt(1_000))
ps.executeQuery().use { rs -> while (rs.next()) rs.getLong(2) }
}
}
sampler.countQuery()
}
}
// ── ③ 无索引列(全表扫描)—— 阳性对照 ────────────────────
@Test
fun `3 - findByStatus(无索引,全表扫描)`() {
sampler.run("③ findByStatus(⚠️ 无索引)") { i ->
withConn { c ->
c.prepareStatement(
"select id, amount from orders where status = ? limit 20"
).use { ps ->
ps.setString(1, if (i % 2 == 0) "PAID" else "REFUNDED")
ps.executeQuery().use { rs -> while (rs.next()) rs.getLong(2) }
}
}
sampler.countQuery()
}
}
// ── ④ 批量查询 vs 循环单查(量化 N+1 的代价) ─────────────
@Test
fun `4a - N+1 循环单查(100 次)`() {
sampler.run("④a 循环单查 ×100(N+1)") { _ ->
val ids = (1L..100L).map { (it * 7919) % 100_000 + 1 }
ids.forEach { id ->
withConn { c ->
c.prepareStatement("select amount from orders where id = ?").use { ps ->
ps.setLong(1, id)
ps.executeQuery().use { rs -> while (rs.next()) rs.getLong(1) }
}
}
sampler.countQuery()
}
}
}
@Test
fun `4b - 批量查询(1 次)`() {
sampler.run("④b 批量查询(= any)") { _ ->
val ids = (1L..100L).map { (it * 7919) % 100_000 + 1 }
withConn { c ->
c.prepareStatement("select id, amount from orders where id = any(?)").use { ps ->
ps.setArray(1, c.createArrayOf("bigint", ids.toTypedArray()))
ps.executeQuery().use { rs -> while (rs.next()) rs.getLong(2) }
}
}
sampler.countQuery()
}
}
}
四、预期输出与判读
① findById(主键) P50 380.2 us P95 890.1 us P99 2104.3 us 查询数 30000
② findByUserId(二级索引) P50 1180.4 us P95 3402.7 us P99 8706.2 us 查询数 30000
③ findByStatus(⚠️ 无索引) P50 18234.6 us P95 42108.9 us P99 96412.5 us 查询数 30000
④a 循环单查 ×100(N+1) P50 182340.0 us P95 402118.0 us P99 512004.0 us 查询数 3000000
④b 批量查询(= any) P50 1804.3 us P95 3102.8 us P99 6208.1 us 查询数 30000
四个结论直接从这张表读出来:
| 对比 | 结论 |
|---|---|
| ① vs ③ | 无索引的 P99 是主键查询的 46 倍——瓶颈就在这里 |
| ② vs ① | 二级索引 + 回表的成本约 3 倍——正常范围 |
| ④a vs ④b | 批量查询的 P99 是循环单查的 1/82,查询次数从 300 万降到 3 万 |
| 查询数一列 | ④a 的查询数 = 轮次 × 100,这就是 N+1 的确凿证据 |
注意「查询数」这一列:它是把「N+1」从猜测变成事实的关键。没有这个数字,你只能说「可能查询有点多」。
五、用 EXPLAIN 确认根因
组件基准告诉你「哪条 SQL 慢」,EXPLAIN 告诉你「为什么慢」:
-- 无索引列的查询:确认是全表扫描
EXPLAIN (ANALYZE, BUFFERS) SELECT id, amount FROM orders WHERE status = 'PAID' LIMIT 20;
Limit (cost=0.00..2345.00 rows=20 width=16) (actual time=0.015..18.234 rows=20 loops=1)
-> Seq Scan on orders (cost=0.00..117250.00 rows=100000 width=16) (actual time=0.012..18.229 rows=20 loops=1)
Filter: (status = 'PAID'::text)
Rows Removed by Filter: 90000
Buffers: shared hit=1234
Planning Time: 0.123 ms
Execution Time: 18.456 ms
读法:
| 关键词 | 含义 |
|---|---|
Seq Scan |
⚠️ 全表扫描(就是慢的原因) |
Rows Removed by Filter: 90000 |
扫描了 9 万行只保留 20 行——绝大部分工作是浪费的 |
cost=0.00..117250.00 |
估算成本很高 |
actual time |
实际耗时 18.2 ms,与组件基准的 P50 吻合 ✅ |
修复并验证:
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
ANALYZE orders;
再跑一次组件基准,P99 应该从 96 ms 降到几毫秒。然后把这个「before/after」写进你的优化报告(第 7 章)。
六、动手改造
| 改动 | 观察什么 |
|---|---|
| 把数据量从 10 万改成 100 万 | 无索引查询的 P99 会变成多少?(提示:全表扫描是线性的,索引查询是对数的) |
| 把幂律分布改成均匀分布 | 缓存命中率与锁竞争如何变化?这就是为什么数据分布必须写进元数据 |
| 把连接池从 10 改成 2 | 单线程基准可能看不出差别(因为没有并发)——说明连接池问题要在集成基准/并发下才能发现 |
给 status 建上索引再跑 ③ |
P99 会降到多少?把这个 before/after 记下来 |
把 Sampler 的 warmup 改成 0 |
前几千个样本会包含连接建立与缓存未命中,P99 明显偏高 |
七、这段代码的局限
- 单线程采样:组件基准测的是「单次操作的成本」,不是「并发下的表现」。并发问题(连接池、锁竞争)要在集成基准里测。
- 没有模拟真实事务边界:真实请求可能包含多个查询与一个事务,这里每个操作是独立的。
withConn每次从池里借还连接:这本身有开销(虽然很小)。如果要测「纯 SQL 执行」,应该复用同一个连接。- Testcontainers 的性能特征与生产数据库不同(本地磁盘 vs 网络存储、单机 vs 主从)。所以绝对数字不可迁移,相对比较才有意义。