把 maximumPoolSize 从 10 调到 50,接口 P99 反而更高,数据库 CPU 也上去了。这不是调参失误,而是方向错了:连接池不是"越多越快的车道",而是一组被数据库侧容量约束的许可证。许可证发多了,排队从应用侧挪到数据库侧,而后者带着锁、上下文切换和内存压力。
只讨论一件事:给定一个数据库和一个并发模型,池该多大,设错的账单长什么样。
1. 四个数字,各自对应一个真实约束
| 参数(HikariCP 口径) | 含义 | 真正的约束来自 | 设大的后果 |
|---|---|---|---|
maximumPoolSize |
同时存在的物理连接上限 | 数据库核数、内存、max_connections |
数据库侧排队与上下文切换 |
minimumIdle |
保活空闲连接数 | 峰值与均值的差距、建连耗时 | 长期占用数据库会话与内存 |
connectionTimeout |
从池取连接的等待上限 | 请求 SLA 与线程池排队预算 | 等待时间吃掉整条请求的超时预算 |
idleTimeout / maxLifetime |
空闲回收与最长存活 | 中间件空闲断连时间 | 拿到被对端关闭的死连接 |
maxLifetime 最常被忽略:它必须短于数据库、代理、LB 中最小的空闲断连时间,否则池里会有"看着有效、已被对端掐掉"的连接,表现为低峰期随机 Communications link failure。
2. 天花板在数据库侧,不在应用侧
| 约束 | 观察方式 | 决定什么 |
|---|---|---|
max_connections |
SHOW VARIABLES LIKE 'max_connections' |
所有实例池之和的硬上限 |
| 每连接内存 | 按版本与配置核对,量级为数百 KB 到数 MB | 连接数直接吃内存,与 buffer pool 争抢 |
| CPU 核数 | nproc、实例规格 |
同时可执行的活动连接数 ≈ 核数的个位倍数 |
| 磁盘 IOPS / 延迟 | 慢查询、iostat |
长事务会占住连接不放 |
| 锁等待 | sys.innodb_lock_waits |
连接被占着不用,池被无效占用 |
结论直白:上限由数据库的活动并发能力决定,不由应用想开多少决定。起点可参考 核数 × 2 + 有效磁盘数 这一档(PostgreSQL 社区常用口径);MySQL 侧更保守,写路径锁竞争更重。具体数字按实例规格与读写比核对。
3. 为什么池越大越慢
三个机制同时起作用:上下文切换(活动连接超过核数后缓存命中率下降);锁竞争(更多事务挤同一批热点行,行锁与 latch 冲突上升,持锁时间变长);数据库侧排队(应用侧不排了,数据库内部队列更长,单查询变慢又反过来延长占用)。
有效吞吐 ≈ min(应用侧可并发, 数据库侧可并发)
数据库侧可并发 ≈ 核数 × (1 / (1 - 等待占比)) // 等待占比越高,可并发越低
越过"数据库侧可并发"之后,新增连接只抬高响应时间与 P99,吞吐不变或下降。
4. 用排队论口径算"够用就好"
目标是让数据库接近饱和但不排队,不是把连接开满。粗算用 Little 定律:
需要的并发连接数 ≈ 目标 QPS × 单次查询平均持有连接时间(秒)
例:目标 200 QPS,平均 SQL 耗时 15ms → 200 × 0.015 ≈ 3 条并发
再乘安全系数 2~3(应对长尾与抖动) → 池 8~10 条即可
| 场景 | 单请求平均持有时间 | 200 QPS 所需并发 | 建议池大小 |
|---|---|---|---|
| 主键点查 | 1–3 ms | 1 | 5–10 |
| 普通列表查询 | 10–20 ms | 2–4 | 10–15 |
| 聚合/排序报表 | 100 ms 以上 | 20+ | 拆到只读池 |
| 长事务(批量更新) | 秒级 | 极大 | 单独池 + 严格上限 |
这里的"持有时间"是从取连接到归还的全长,含应用侧处理,不只是 SQL 执行时间。
5. 连接的生命周期成本
| 环节 | 典型量级 | 什么时候值得关注 |
|---|---|---|
| TCP 建连 | 同机房亚毫秒到毫秒 | 跨机房时抬到毫秒级 |
| 认证/权限校验 | 毫秒级,可缓存 | 每次新建都做全量认证时明显 |
| TLS 握手 | 数毫秒到数十毫秒 | 强制 TLS 且未启用会话复用 |
| 会话初始化 | 每条连接一次 | 有 initSql、时区与字符集设置时 |
| 语句预热 | 首次执行才有 | 计划缓存未命中 |
频繁建连是把上述成本按请求次数摊销。minimumIdle 设 0 看着省资源,中等并发下会持续建连;设太大则长期占用会话。折中是让它接近低谷期实际并发。
6. 连接泄漏:三种成因与两种检测
# 泄漏检测:借出超过阈值未归还就打印持有栈,仅适合排障期开启
spring:
datasource:
hikari:
leak-detection-threshold: 20000 # 毫秒
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
max-lifetime: 1200000 # 必须短于中间件的空闲断连
keepalive-time: 300000
| 成因 | 表现 | 检测方式 |
|---|---|---|
未关闭(漏 close) |
池占用单调上升直至耗尽 | 泄漏检测栈;SHOW PROCESSLIST 长时间 Sleep |
| 事务未结束 | 连接处于 Sleep 但仍持有事务与锁 |
查 information_schema.innodb_trx 的 trx_started |
| 异步跨线程 | 在 A 线程取、B 线程还,或线程池切换后丢失上下文 | 线程栈里连接对象被非请求线程持有 |
第三种在 CompletableFuture、@Async、响应式混用里最常见。连接是线程绑定资源,跨线程必须显式交接,TransactionSynchronizationManager 的绑定不会跟着线程池走。
7. connectionTimeout:等待还是拒绝
| 取值倾向 | 语义 | 用户看到什么 | 适用 |
|---|---|---|---|
| 很短(几百 ms) | 快速失败 | 部分请求直接失败,错误率上升但延迟可控 | 有降级路径、可重试的读请求 |
| 中等(2–3 s) | 有限等待 | 队列内请求变慢,超时后失败 | 常规 OLTP |
| 很长(10 s 以上) | 无限接近排队 | 请求堆积、线程池被打满、雪崩 | 基本不该出现 |
它必须与线程池队列长度、请求总超时一起算:总超时 3 s 时池等待最多给 1–1.5 s,其余留给 SQL 与网络。不给预算等于把超时判定交给下游,错误会变成"调用方超时、服务端无日志"。
8. 读写分离与池拆分
# 主库只给写与强一致读;只读池单独限流,避免报表把写路径的连接吃光
spring:
datasource:
write:
hikari: { maximum-pool-size: 15, connection-timeout: 3000 }
read:
hikari: { maximum-pool-size: 30, connection-timeout: 2000, read-only: true }
拆分依据是持有时间分布是否可分:报表持有连接比点查长一个数量级,混在一个池里会以极低 QPS 占满池。拆开后各自按自己的持有时间算容量。代价是多一份配置、两套监控,且"读走只读池"必须显式声明。
9. 池与线程池的配比
| 池 / 线程池 | 不匹配的现象 | 判断方向 |
|---|---|---|
| 线程池大、连接池小 | 大量线程阻塞在 getConnection |
线程栈里 HikariPool.getConnection 占比高 |
| 线程池小、连接池大 | 连接空闲,并发上不去 | 池指标 active 远低于 max |
| 两者都大 | 数据库被打满,应用侧看起来很忙 | 数据库侧等待事件与 CPU 同时高 |
方向是线程池 ≥ 连接池,但差值别太大:线程远超连接只会多一层无意义排队。同步阻塞模型取连接池的 1.5–3 倍,异步模型按事件循环数单独算。
10. 实测:压测 + 数据库侧等待事件
# 1) 阶梯压测,每档稳定后再取数
wrk -t4 -c200 -d60s --latency http://TARGET/api/items
# 2) 同期在数据库侧看"谁在等"
mysql -e "SELECT event_name, count_star, sum_timer_wait/1e12 AS sec
FROM performance_schema.events_waits_summary_global_by_event_name
ORDER BY sum_timer_wait DESC LIMIT 10;"
# 3) 连接状态分布
mysql -e "SHOW PROCESSLIST;" | awk '{print $6}' | sort | uniq -c
判据:池加大后行锁/IO 等待上升而 QPS 不涨,说明已越过数据库侧可并发;若应用侧 getConnection 等待仍是主要耗时,则池偏小。只看应用侧指标得不出结论,必须两边对齐时间轴。
11. 多实例部署的总量核算
总连接数 = 实例数 × 单实例池大小
必须满足:总连接数 + 运维/备份/监控连接 < max_connections × 0.8
例:6 实例 × 池 20 = 120,max_connections = 200 → 余量不足,需下调池或扩库
弹性伸缩要把最大副本数代入。"扩容后数据库连不上"的根因多是池按固定副本数设定,一伸缩就击穿 max_connections。对策:池加随副本数收缩的动态上限,或用代理层(ProxySQL)做连接复用。
12. 排错表
| 现象 | 最可能原因 | 核对 |
|---|---|---|
| 池调大后 P99 上升、QPS 不变 | 越过数据库侧可并发 | 数据库等待事件、CPU |
随机 Communications link failure |
maxLifetime 长于中间件空闲断连 |
比对 LB/代理的空闲超时 |
| 池满但数据库很闲 | 连接被跨线程持有或事务未提交 | 泄漏检测栈、innodb_trx |
| 启动后第一批请求慢 | minimumIdle 过小,集中建连 |
建连耗时与首请求线程栈 |
高峰期大量 Connection is not available |
connectionTimeout 太短 + 池偏小 |
池等待耗时分布 |
| 主库连接被报表吃光 | 读写未拆池 | 按 SQL 类型统计持有时间 |
13. 边界:这些场景不该按上面的口径配池
| 场景 | 判断 | 理由 |
|---|---|---|
| 单实例、低并发(个位数 QPS) | 池 5–10 即可,不必精算 | 排队论在小数上无意义 |
| 纯短连接 HTTP | 优化建连成本而非池 | 池的收益来自复用 |
| Serverless / 按请求拉起 | 池反成负担 | 空闲连接占会话、冷启动要建连 |
| 批处理独占时段 | 单独池 + 小上限 | 长事务毁掉 OLTP 的池 |
| 已有代理做连接复用 | 应用侧池可显著缩小 | 复用发生在代理层 |
| 读多写少且读走缓存 | 池按落到库的请求算 | 命中率决定真实并发 |
池大小是数据库容量的函数,不是应用野心的函数。输入只有三个:真实并发、单请求持有时间、数据库侧可并发能力。
14. 自测
maximumPoolSize调大之后吞吐不变、P99 上升,说明越过了哪条线?maxLifetime应该和哪个外部超时比较,设错的症状是什么?- 用 Little 定律估算池大小时,"持有时间"从哪一刻算到哪一刻?
- 池满而数据库空闲,最可能是哪两类原因?
connectionTimeout与请求总超时的关系该怎么分配?- 弹性伸缩场景下,池总量该按当前副本数还是最大副本数核算?