HikariCP 连接池频繁耗尽:从一次接口雪崩看连接池调优实录

订单服务隔三差五接口集体超时,日志刷出 Connection is not available, request timed out after 30000ms,连接池被耗尽。一周治理定位连接泄漏和大事务两个根因:开 leakDetectionThreshold 抓泄漏栈、改 try-with-resources、事务只包 DB 操作把 RPC/IO 移出、固定大小池、maxLifetime 小于 wait_timeout。之后再没出现连接耗尽。

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 天

避坑清单

  1. "Connection is not available" 是症状不是病因,要查连接为何不够
  2. 连接池不是越大越好,大小由 DB 处理能力决定,经验值 (核数*2+磁盘数)
  3. HikariCP 推荐固定大小池,minimumIdle 设成等于 maximumPoolSize
  4. 必开 leakDetectionThreshold,泄漏会打出借用堆栈,顺藤摸瓜
  5. 手动 getConnection 必须 try-with-resources,异常路径也要归还
  6. 连接池的 close 是"归还"不是"关闭",借了必须还
  7. maxLifetime 必须小于 MySQL 的 wait_timeout,否则借到死连接
  8. connectionTimeout 调小(如 3s),连接不够时快速失败走降级
  9. 事务内绝不放 RPC、文件上传、远程调用,事务越短越好
  10. 连接池 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Kafka 消费者陷入 rebalance 风暴:消息积压与重平衡治理实录

2026-5-20 12:31:04

技术教程

雪花算法时钟回拨导致 ID 重复:一次凌晨主键冲突的复盘

2026-5-20 12:37:04

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索