文档目录

7.6 配套代码:连接池计算与对象池成本对比

对应小节:7.6 池化与复用 两件事:① 用 Little’s Law 算连接池大小;② 用实验证明「对象池在 JVM 上常是负优化」。

一、连接池大小计算器

# tools/pool-size-calculator.py
"""
用 Little's Law 推导连接池大小。

核心公式:所需连接数 = 目标 QPS × 单次操作耗时(秒)
"""
import sys


def calculate(
    peak_qps: int,
    p50_ms: float,
    p99_ms: float,
    instances: int,
    db_max_connections: int,
    headroom: float = 0.3,
):
    print("═" * 78)
    print("连接池大小计算(Little's Law)")
    print("═" * 78)
    print()
    print(f"输入:")
    print(f"  峰值 QPS            : {peak_qps}")
    print(f"  单次查询 P50/P99    : {p50_ms} / {p99_ms} ms")
    print(f"  应用实例数           : {instances}")
    print(f"  数据库 max_connections: {db_max_connections}")
    print(f"  headroom            : {headroom:.0%}")
    print()

    # ① 用 P50 和 P99 分别计算
    l_optimistic = peak_qps * (p50_ms / 1000)
    l_pessimistic = peak_qps * (p99_ms / 1000)

    print("【所需连接数(全集群)】")
    print(f"  按 P50(乐观)      : {l_optimistic:8.1f}")
    print(f"  按 P99(悲观)      : {l_pessimistic:8.1f}")
    print(f"  差距                : {l_pessimistic / l_optimistic:.1f} 倍")
    print()

    # ② 每实例
    per_instance_opt = l_optimistic / instances
    per_instance_pess = l_pessimistic / instances

    print("【每实例所需连接数】")
    print(f"  按 P50               : {per_instance_opt:8.1f}")
    print(f"  按 P99               : {per_instance_pess:8.1f}")
    print()

    # ③ 建议值:取 P95 附近 + headroom
    # 用对数插值近似 P95
    p95_ms = p50_ms + (p99_ms - p50_ms) * 0.7
    l_p95 = peak_qps * (p95_ms / 1000)
    per_instance_suggested = l_p95 / instances * (1 + headroom)

    print("【建议配置】")
    print(f"  取 P95(≈{p95_ms:.1f}ms)并加 {headroom:.0%} headroom:")
    print(f"    全集群            : {l_p95 * (1 + headroom):8.1f}")
    print(f"    每实例            : {per_instance_suggested:8.1f} → 取整 {int(per_instance_suggested) + 1}")
    print()

    # ④ 检查数据库上限
    recommended_per_instance = int(per_instance_suggested) + 1
    total_needed = recommended_per_instance * instances

    print("【数据库侧检查】")
    print(f"  总连接需求          : {recommended_per_instance} × {instances} = {total_needed}")
    print(f"  数据库上限          : {db_max_connections}")
    # 留 5 个给运维/监控
    available = db_max_connections - 5
    if total_needed > available:
        print(f"  ❌ 超出上限(可用 {available})")
        print()
        print("  解决方案:")
        print(f"    ① 降低每实例池大小到 {available // instances}({available // instances} × {instances} = {available // instances * instances})")
        print("    ② 或引入 PgBouncer 等连接池代理")
        print("    ③ 或减少实例数、提高单实例能力")
        print("    ④ ⭐ 最优:优化查询本身(让 P99 降低,所需连接数自然下降)")
    else:
        print(f"  ✅ 在上限内(余量 {available - total_needed})")
    print()

    # ⑤ 关键提醒
    print("═" * 78)
    print("配置时必做的三件事:")
    print("  ① 设置【获取连接超时】(如 3 秒)—— 避免无限等待(第 7.5 节)")
    print("  ② 监控 pending —— 它是判断「池是否足够」的直接指标")
    print("  ③ 先确认查询合理(有索引、无 N+1)—— 否则加大池只是转移压力")
    print()
    print("⚠️  最重要的一条:")
    print("  连接池是数据库的【并发闸门】—— 它决定了最多有多少个查询同时在数据库上执行。")
    print("  池设小了 → 应用排队(延迟高,但数据库健康)")
    print("  池设大了 → 数据库排队(锁竞争加剧、IO 队列变长)→ 所有查询都变慢")
    print("  所以「池越大越好」是错的。")
    print("═" * 78)


if __name__ == "__main__":
    calculate(
        peak_qps=1000,
        p50_ms=8.0,
        p99_ms=60.0,
        instances=6,
        db_max_connections=100,
    )

预期输出(节选):

【所需连接数(全集群)】
  按 P50(乐观)      :      8.0
  按 P99(悲观)      :     60.0
  差距                : 7.5 倍

【每实例所需连接数】
  按 P50               :      1.3
  按 P99               :     10.0

【建议配置】
  取 P95(≈44.4ms)并加 30% headroom:
    全集群            :     57.7
    每实例            :      9.6 → 取整 10

【数据库侧检查】
  总连接需求          : 10 × 6 = 60
  数据库上限          : 100
  ✅ 在上限内(余量 35)

注意「差距 7.5 倍」这个数字:它就是第 0 章 0.4 节讲的「按 P50 配置 vs 按 P99 配置」的风险敞口。

二、对象池成本对比实验

// src/main/kotlin/experiments/ObjectPoolCost.kt
package experiments

import java.util.concurrent.ArrayBlockingQueue
import kotlin.system.measureNanoTime

/**
 * 对比「直接分配 vs 对象池」的成本。
 *
 * 预期结论:对于普通小对象,直接分配更快(因为 TLAB 指针碰撞几乎免费,
 * 而对象池的并发安全队列有同步成本)。
 */
data class SmallDto(val id: Long, val name: String, val amount: Long)
data class LargeBuffer(val bytes: ByteArray)

private var sink = 0L

fun bench(label: String, iterations: Int, body: () -> Unit): Double {
    repeat(iterations / 10) { body() }        // 预热
    val ns = measureNanoTime { repeat(iterations) { body() } }
    val per = ns.toDouble() / iterations
    println("%-42s %10.1f ns/op".format(label, per))
    return per
}

fun main() {
    val n = 2_000_000

    println("═══ 小对象(~56 字节)═══")
    val direct = bench("① 直接分配(new)", n) {
        val dto = SmallDto(1L, "test", 100L)
        sink += dto.id
    }

    // 对象池:用 ArrayBlockingQueue 模拟
    val pool = ArrayBlockingQueue<SmallDto>(1024)
    repeat(1024) { pool.offer(SmallDto(it.toLong(), "test", 100L)) }
    val pooled = bench("② 对象池(取 + 还)", n) {
        val dto = pool.take()
        sink += dto.id
        pool.offer(dto)
    }

    println()
    println("小对象结论:直接分配 / 池化 = %.2f 倍".format(direct / pooled))
    println("  → 比值 < 1 表示【直接分配更快】(池化是负优化)")
    println()

    println("═══ 大对象(1 MB ByteArray)═══")
    val m = 20_000

    val directLarge = bench("③ 直接分配(1MB 数组)", m) {
        val buf = LargeBuffer(ByteArray(1024 * 1024))
        sink += buf.bytes.size
    }

    val bigPool = ArrayBlockingQueue<LargeBuffer>(16)
    repeat(16) { bigPool.offer(LargeBuffer(ByteArray(1024 * 1024))) }
    val pooledLarge = bench("④ 对象池(1MB 数组)", m) {
        val buf = bigPool.take()
        buf.bytes[0] = 1                        // 模拟使用
        sink += buf.bytes.size
        bigPool.offer(buf)                      // 归还(注意:没清零,有状态污染风险)
    }

    println()
    println("大对象结论:直接分配 / 池化 = %.2f 倍".format(directLarge / pooledLarge))
    println("  → 比值 > 1 表示【池化更快】(因为分配+GC 成本高)")
    println()

    println("sink = $sink")
    println()
    println("═".repeat(70))
    println("判读:")
    println("  小对象:分配本身很便宜(TLAB 指针碰撞),池化的同步成本反而更高")
    println("  大对象:分配 + 清零 + GC 成本高(可能触发 Humongous),池化可能值得")
    println()
    println("⚠️  池化的两个风险(本实验没有体现):")
    println("  ① 状态污染:归还时没清理干净 → 比内存泄漏更难查的 bug")
    println("  ② 忘记归还:池耗尽")
    println("═".repeat(70))
}

预期输出形态:

═══ 小对象(~56 字节)═══
① 直接分配(new)                              2.3 ns/op
② 对象池(取 + 还)                           48.7 ns/op

小对象结论:直接分配 / 池化 = 0.05 倍
  → 比值 < 1 表示【直接分配更快】(池化是负优化)

═══ 大对象(1 MB ByteArray)═══
③ 直接分配(1MB 数组)                      1840.0 ns/op
④ 对象池(1MB 数组)                         312.0 ns/op

大对象结论:直接分配 / 池化 = 5.90 倍
  → 比值 > 1 表示【池化更快】(因为分配+GC 成本高)

这份数据的结论非常清楚:

对象类型 直接分配 池化 结论
小对象(56 B) 2.3 ns 48.7 ns 直接分配快 21 倍 ⭐
大对象(1 MB) 1840 ns 312 ns 池化快 5.9 倍

这就是「对象池在 JVM 上通常是负优化」的量化证据——而且是以 21 倍的差距。

三、HikariCP 的完整配置

// src/main/kotlin/db/DataSourceFactory.kt
package db

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource

fun createDataSource(
    jdbcUrl: String,
    username: String,
    password: String,
    poolSize: Int,              // 由 pool-size-calculator.py 推导
): HikariDataSource {
    val config = HikariConfig().apply {
        this.jdbcUrl = jdbcUrl
        this.username = username
        this.password = password

        // ① 池大小(按 Little's Law 推导)
        maximumPoolSize = poolSize
        minimumIdle = minOf(5, poolSize / 4)      // 保持一定的最小连接

        // ② 获取连接超时 ⭐ 最关键(避免无限等待,第 7.5 节)
        connectionTimeout = 3_000

        // ③ 连接有效性
        validationTimeout = 1_000
        keepaliveTime = 30_000                    // 定期保活(避免被防火墙断开)

        // ④ 连接最大存活(避免长连接导致的负载不均)
        maxLifetime = 1_800_000                   // 30 分钟

        // ⑤ 泄漏检测(测试环境用,生产可关闭以免开销)
        leakDetectionThreshold = if (System.getenv("ENV") == "test") 10_000 else 0

        poolName = "app-pool"
    }
    return HikariDataSource(config)
}

每一项对应的「防什么」:

参数 防止什么 不加会怎样
maximumPoolSize 无限制并发打到数据库 数据库被压垮
connectionTimeout 无限等待 崩溃后无法自愈(第 3 章 3.6)
validationTimeout 拿到已失效的连接 偶发的连接错误
keepaliveTime 连接被中间设备静默断开 定时出现的连接超时
maxLifetime 长连接导致的负载不均 个别连接成为热点
leakDetectionThreshold 忘记归还连接 池逐渐耗尽

四、动手改造

改动 观察什么
用 pool-size-calculator.py 输入你的真实数据 得到你项目的池大小建议(并与当前配置对比)
把 instances 从 6 改成 20 会超出数据库上限——理解多副本场景的约束
把 p99_ms 从 60 改成 200 所需连接数大幅上升——说明优化查询能直接降低池需求
在 ObjectPoolCost 里把大对象改成 100 KB 池化的优势会变小(找到"值得池化"的阈值)
把 ObjectPoolCost 的池容量改成 1 池化会变得极慢(竞争激烈)——理解池的并发成本
在真实压测中把 connectionTimeout 从 3 秒改成 30 秒 模拟"崩溃后的恢复时间"(第 3 章 3.6 的实验)

五、这段代码的局限

  • pool-size-calculator.py 的 P95 是用 P50/P99 插值估的:真实 P95 要从监控里取。
  • Little’s Law 只描述稳态平均,不含排队效应——所以算出来的是下界,实际要考虑排队余量(第 0 章 0.5)。
  • ObjectPoolCost 的池用 ArrayBlockingQueue:这是有锁的实现,会放大池化的成本。生产中的池(如 Netty 的 PooledByteBufAllocator)用更高效的结构——但结论方向不变(小对象不值得池化)。
  • 大对象的池化有状态污染风险:示例里归还时没清零——真实使用必须清理,否则会有难以复现的 bug。
  • 数字来自开发机:看倍数关系,不看绝对值。