文档目录

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 主从)。所以绝对数字不可迁移,相对比较才有意义。