2024 年我们的订单服务隔三差五就抖一下:接口集体超时,日志里刷出 "Connection is not available, request timed out after 30000ms" — 数据库连接池被耗尽,所有请求都在排队等连接。每次持续两三分钟自己恢复,像幽灵一样抓不住。投了一周做 HikariCP 连接池专项治理,定位到连接泄漏和大事务两个根因,重新梳理连接池参数,之后再没出现连接耗尽。本文复盘数据库连接池调优的完整实战。
问题背景
服务:订单服务(Spring Boot + HikariCP + MySQL),6 实例
连接池配置:maximumPoolSize=10
事故现象:
- 接口偶发集体超时,持续 2-3 分钟后自愈
- 日志:HikariPool-1 - Connection is not available,
request timed out after 30000ms
- 同时 MySQL 侧连接数并不高(才 60 个,远没到上限)
现场抓取:
# 1. HikariCP 自带的连接池状态日志(开 DEBUG 能看到)
HikariPool-1 - Pool stats (total=10, active=10, idle=0, waiting=27)
^^^^^^^^ ^^^^^^^^^^
10 个连接全在用 27 个线程在等
# 2. 看这些 active 连接在干嘛(MySQL 侧)
$ SELECT id, time, state, info FROM information_schema.processlist
WHERE command != 'Sleep' ORDER BY time DESC;
# 发现几个连接 time=180s 还在,info 却是空的 → 连接被借走没还!
根因(排查一周定位到两个):
1. 连接泄漏:有段代码手动 getConnection() 后异常路径没 close
→ 连接被借走永不归还,池子被慢慢"漏干"
2. 大事务:一个 @Transactional 方法里夹了 RPC 调用和文件上传
→ 事务期间一直占着连接,RPC 慢时连接被长时间霸占
3. 连接池大小拍脑袋设 10,高峰期不够用
修复 1:读懂 HikariCP 与开启监控
// === 第一步:让连接池"可观测",否则全靠猜 ===
// HikariCP 的核心配置 + 监控接入
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://db:3306/order_db");
config.setUsername("order");
config.setPassword(System.getenv("DB_PASSWORD")); // 密码走环境变量
config.setPoolName("OrderHikariPool"); // 给池命名,日志好认
config.setMaximumPoolSize(20);
config.setMinimumIdle(20); // 见修复 2 说明
// 关键:开启连接泄漏检测
config.setLeakDetectionThreshold(60000); // 借出超 60s 未还 → 打栈
// 接入 Micrometer,把连接池指标暴露给 Prometheus
config.setMetricRegistry(meterRegistry);
return new HikariDataSource(config);
}
// === HikariCP 那行报错到底什么意思 ===
// "Connection is not available, request timed out after 30000ms"
// = 一个线程想拿连接,等了 connectionTimeout(默认 30s)还没拿到
// 它本身不是病因,是症状。真正的问题是:连接为什么不够用?
// 三种可能:
// a. 连接被泄漏了(借出不还)→ 看修复 3
// b. 连接被长时间占用(大事务/慢 SQL)→ 看修复 5
// c. 池子真的太小,扛不住并发 → 看修复 2
// === 开 DEBUG 日志,HikariCP 每 30s 打一次池状态 ===
// logging.level.com.zaxxer.hikari=DEBUG
// Pool stats (total=20, active=18, idle=2, waiting=0)
// active 长期接近 total + waiting > 0 → 连接不够,定位是 a/b/c 哪种
修复 2:连接池大小怎么定
// === 误区:连接池越大越好 ===
// 连接不是免费的:每个连接在 MySQL 侧是一个线程 + 内存
// 连接数 > DB 实际并行处理能力时,多出来的连接只会加剧 DB 上下文切换
// HikariCP 官方观点:小连接池往往比大连接池吞吐更高
// === 经验公式(HikariCP wiki)===
// 连接数 = ((核心数 * 2) + 有效磁盘数)
// 一台 8 核 + SSD 的 DB,单应用合理连接数约 16-20
// 注意:这是"单个应用实例"的池大小;
// 6 个实例 * 20 = 120 个连接,要 ≤ MySQL 的 max_connections
// === minimumIdle 建议等于 maximumPoolSize ===
config.setMaximumPoolSize(20);
config.setMinimumIdle(20);
// 理由:HikariCP 官方推荐固定大小池
// 如果 min < max,空闲时池会缩容,流量突增时又要新建连接
// 新建连接(TCP 握手 + MySQL 认证)要几十 ms,突增瞬间会抖
// 固定大小:连接常驻,突增时直接可用,无新建延迟
// === 真正定大小的方法:压测 ===
// 1. 先按公式给个初始值(如 20)
// 2. 压测,观察 HikariCP 的 active 和 waiting
// - waiting 长期 > 0 且 DB 还没到瓶颈 → 适当调大
// - active 长期远小于 max → 调小,省 DB 资源
// - 调大后 DB 的 CPU/RT 恶化 → 说明 DB 才是瓶颈,加连接没用
// 3. 连接池大小的上限永远是 DB 的处理能力,不是应用的需要
修复 3:连接泄漏排查
// leakDetectionThreshold 生效后,泄漏连接会打出借用时的堆栈:
// "Connection leak detection triggered for connection ...,
// stack trace follows"
// → 顺着堆栈就能找到"借了不还"的代码
// === 我们找到的泄漏代码:手动 getConnection 异常没关 ===
// 错误写法:
public void exportOrders() throws SQLException {
Connection conn = dataSource.getConnection(); // 手动借
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT ..."); // 这里抛异常
// ... 处理 ...
conn.close(); // 永远执行不到!
}
// === 正确:try-with-resources,异常也保证归还 ===
public void exportOrders() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT ...")) {
// ... 处理 ...
} // 无论正常还是异常,conn 一定被 close(归还连接池)
}
// 注意:连接池里的 conn.close() 不是真的关闭物理连接,
// 而是"归还"给池子。借了必须还,这是铁律。
// === 另一类隐蔽泄漏:Mapper 之外手动操作连接 ===
// 用 ORM(MyBatis/JPA)时,框架会自动管理连接归还
// 但只要你绕过框架手动 getConnection(),就要自己负责 close
// 排查手段:全局搜 dataSource.getConnection(),逐个确认有没有 try-with-resources
// === 排查泄漏的通用流程 ===
// 1. 开 leakDetectionThreshold(60s),等泄漏栈打出来
// 2. 没有栈但确实泄漏 → 对比 active 连接数与 DB processlist
// 长期 active 但 DB 侧 state 为空闲的,就是被借走没用的
// 3. 定位到代码,统一改 try-with-resources
// 4. 泄漏修复后,active 不再单调增长,会随负载涨落
修复 4:超时参数体系
// HikariCP 的超时参数各管一段连接生命周期,要配齐
HikariConfig config = new HikariConfig();
// 1. connectionTimeout:从池里借连接最多等多久(默认 30s)
config.setConnectionTimeout(3000);
// 改成 3s:连接不够时快速失败,别让请求干等 30s 拖垮线程
// "宁可快速失败走降级,也不要长时间挂着"
// 2. validationTimeout:连接存活校验的超时(默认 5s)
config.setValidationTimeout(3000);
// 3. idleTimeout:连接空闲多久被回收(默认 10min)
config.setIdleTimeout(600000);
// 注意:minIdle == maxPoolSize(固定池)时,这个参数不生效
// 4. maxLifetime:连接最长存活时间(默认 30min)—— 极其重要
config.setMaxLifetime(1740000); // 29min
// 必须小于 MySQL 的 wait_timeout!
// MySQL 默认 wait_timeout=8h,会主动掐掉空闲太久的连接
// 如果 maxLifetime > wait_timeout:
// 池里留着一个已被 MySQL 单方面关闭的"死连接"
// 下次借到它执行 SQL → 报 "Communications link failure"
// 经验:maxLifetime 设成比 wait_timeout 小几分钟
// 5. keepaliveTime:空闲连接保活探测(默认 0 不探测)
config.setKeepaliveTime(120000); // 每 2min 探活一次
// 防止连接被中间的防火墙/LB 因长期空闲而静默断开
// === 与 MySQL 端参数对齐(查一下 DB 的设置)===
// SHOW VARIABLES LIKE 'wait_timeout'; -- 默认 28800(8h)
// SHOW VARIABLES LIKE 'interactive_timeout';
// maxLifetime < wait_timeout 是硬性要求
修复 5:大事务与慢 SQL 占用连接
// === 根因之二:大事务长时间霸占连接 ===
// 错误:@Transactional 方法里夹了 RPC 和文件上传
@Transactional
public void createOrder(OrderRequest req) {
orderDao.insert(order); // DB 操作
inventoryClient.deduct(req); // RPC!可能耗时几百 ms 甚至超时
couponClient.use(req); // RPC!
fileService.uploadAttachment(req); // 文件上传!可能耗时数秒
orderLogDao.insert(log); // DB 操作
}
// 问题:整个方法在一个事务里 = 整个方法期间都占着一个 DB 连接
// RPC/上传慢的时候,连接被白白占用几秒,并发一高连接池瞬间见底
// 而且大事务还会拉长 MySQL 的 undo log、锁持有时间
// === 正确:事务只包 DB 操作,非 DB 操作移出事务 ===
public void createOrder(OrderRequest req) {
// 1. 事务内:只做必须原子的 DB 操作,快进快出
Long orderId = createOrderTx(req);
// 2. 事务外:RPC、文件上传等慢操作,不占 DB 连接
inventoryClient.deduct(req);
couponClient.use(req);
fileService.uploadAttachment(req);
}
@Transactional
public Long createOrderTx(OrderRequest req) {
orderDao.insert(order);
orderLogDao.insert(log);
return order.getId();
}
// 事务的黄金原则:事务越短越好,事务内绝不放 RPC / IO / 远程调用
// 把连接占用时间从"几秒"压到"几毫秒"
// === 慢 SQL 同样会霸占连接 ===
// 一条跑 3s 的 SQL = 这个连接被占 3s
// 配合 DB 层:开慢查询日志、加索引(见索引优化文)
// 配合应用层:给关键 SQL 设 statement timeout
// MyBatis:
修复 6:监控告警
# HikariCP 通过 Micrometer 暴露指标,Prometheus 告警
groups:
- name: hikari-pool
rules:
# 1. 连接池接近耗尽(active 占比过高)
- alert: HikariPoolNearExhausted
expr: |
hikaricp_connections_active{pool="OrderHikariPool"}
/ hikaricp_connections_max{pool="OrderHikariPool"} > 0.8
for: 3m
annotations:
summary: "{{ $labels.pool }} 活跃连接 > 80%,接近耗尽"
# 2. 有线程在等待连接(连接已不够用)
- alert: HikariPoolPendingThreads
expr: hikaricp_connections_pending{pool="OrderHikariPool"} > 0
for: 1m
annotations:
summary: "{{ $labels.pool }} 有 {{ $value }} 个线程在等连接"
# 3. 获取连接耗时过长(借连接都开始慢了)
- alert: HikariAcquireSlow
expr: |
hikaricp_connections_acquire_seconds{quantile="0.99"} > 0.1
for: 3m
annotations:
summary: "{{ $labels.pool }} 获取连接 P99 > 100ms"
# 4. 连接使用时长过长(大事务/慢 SQL 特征)
- alert: HikariUsageLong
expr: |
hikaricp_connections_usage_seconds{quantile="0.99"} > 2
for: 5m
annotations:
summary: "{{ $labels.pool }} 连接使用 P99 > 2s,排查大事务/慢SQL"
# 5. 连接创建超时(DB 侧问题)
- alert: HikariConnectionTimeout
expr: increase(hikaricp_connections_timeout_total[5m]) > 0
annotations:
summary: "{{ $labels.pool }} 出现连接获取超时,DB 或池容量异常"
优化效果
指标 治理前 治理后
=============================================================
连接耗尽事故 每周 2-3 次 0
连接泄漏 存在(慢漏) 0(全 try-with-resources)
连接使用时长 P99 4.2s 45ms(事务瘦身)
maximumPoolSize 10(拍脑袋) 20(压测得出)
minimumIdle 5 20(固定池)
maxLifetime 未设(默认 30m) 29min(< wait_timeout)
获取连接 P99 偶发 30s 超时 8ms
接口因连接超时失败 周期性爆发 消失
压测(订单接口 6000 QPS):
- 治理前:连接池耗尽,大量请求等连接超时
- 治理后:active 稳定在 14/20,无 pending,P99 60ms
排查与改造:
- 开监控 + 定位连接泄漏栈:1 天
- 修连接泄漏 + 改 try-with-resources:1 天
- 大事务拆分(RPC/IO 移出事务,涉及 9 处):3 天
- 连接池参数压测调优:1 天
- 全链路压测验证:1 天
避坑清单
- "Connection is not available" 是症状不是病因,要查连接为何不够
- 连接池不是越大越好,大小由 DB 处理能力决定,经验值 (核数*2+磁盘数)
- HikariCP 推荐固定大小池,minimumIdle 设成等于 maximumPoolSize
- 必开 leakDetectionThreshold,泄漏会打出借用堆栈,顺藤摸瓜
- 手动 getConnection 必须 try-with-resources,异常路径也要归还
- 连接池的 close 是"归还"不是"关闭",借了必须还
- maxLifetime 必须小于 MySQL 的 wait_timeout,否则借到死连接
- connectionTimeout 调小(如 3s),连接不够时快速失败走降级
- 事务内绝不放 RPC、文件上传、远程调用,事务越短越好
- 连接池 active/pending/acquire/usage 全要上监控,使用时长长就是大事务
总结
这次连接池耗尽的事故,排查过程一波三折,但回头看,它教会我的东西非常本质。最大的认知是:那行刺眼的 "Connection is not available" 报错本身不是问题,它只是一个症状——一个线程想借连接、等了三十秒还没借到。真正要回答的问题是"连接为什么不够用",而答案无非三种:连接被泄漏了(借出去永不归还),连接被长时间占用了(大事务或慢 SQL 霸占),或者池子真的太小了。我们一开始一直盯着"是不是池子太小"想调大连接数,完全是南辕北辙——后来定位到真正的元凶是连接泄漏和大事务。连接泄漏的教训很直接:连接池里的 close() 语义是"归还"而不是"关闭物理连接",借了就必须还,任何手动 getConnection() 的地方,只要异常路径上漏了 close,连接就会被慢慢漏干,而 leakDetectionThreshold 这个参数能在连接借出超时后直接打印出借用时的堆栈,是抓泄漏的神器。大事务的教训更值得反复强调:很多人写 @Transactional 时不会去想"这个事务持续多久",但事务持续多久,连接就被占用多久——一旦在事务方法里夹了 RPC 调用或文件上传这种慢操作,一个本该几毫秒就归还的连接会被霸占好几秒,并发一高池子瞬间见底,所以事务的黄金法则是"越短越好,事务内只放必须原子的 DB 操作,RPC 和 IO 一律移到事务外"。还有一个容易被忽略的隐形杀手是 maxLifetime 和 MySQL 的 wait_timeout 不匹配——MySQL 会单方面掐掉空闲太久的连接,如果连接池里 maxLifetime 设得比 wait_timeout 还大,池子里就会留着已经死掉的连接,下次借到它执行 SQL 就报 communications link failure。连接池是应用和数据库之间的咽喉,它看起来只是几个配置项,但每个参数背后都连着连接的生命周期,把这些机制理解透了,连接池才会从"幽灵般的故障源"变成"稳定的基础设施"。
—— 别看了 · 2026