数据库与缓存排查 列表全部文章
数据库与缓存排查

HikariPool-1 - Connection is not available:连接池被“借了不还”的事务掏空了

接口大面积报 Connection is not available, request timed out after 30000ms。用 Hikari 日志、show processlist 与 jstack 交叉验证,找出事务里调远程接口占着连接的元凶,并讲清 maximumPoolSize 的公式、leakDetectionThreshold 与指标告警。

更多

周一早上九点五十,我刚在工位坐稳,值班同学把一段日志甩进群里,后面跟着三个感叹号:

java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
	at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162)
	at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
	at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:159)
	...

同事的第一反应是“数据库挂了吧”,第二反应是“把连接池调到 200 吧”。我说,先别调,让我们冷静地当一次侦探。因为这条日志的真实含义是:连接池里的所有连接都被借走了,请求排队排了 30 秒还没借到,超时了。数据库可能好好的,问题在于“借了不还”,或者“借了太久”。

一、现象:接口超时,但数据库看起来很闲

先看几个基础事实:

  • 报错集中在某几个接口,恰好耗时约 30 秒(正好是 Hikari 的 connectionTimeout 默认值 30000ms,官方文档:客户端等待连接的最长时间,超时抛 SQLException,最小允许值 250ms);
  • MySQL 所在机器 CPU 30%,IO 正常,没有慢查询爆炸;
  • 应用实例 CPU 也不高,但线程数在涨,接口耗时在涨。

一个规律:CPU 都不高,却全都在等,这基本是“资源池耗尽”的标志——线程在排队,不是在干活。Hikari 在超时时通常会在异常里带上池的快照,看看日志更前面的几行(HikariCP 3.x/4.x/5.x 会有类似输出,具体格式因版本而异):

WARN  com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Timeout failure stats (total=10, active=10, idle=0, waiting=47)

这行是判断问题类型的第一手证据:

  • total=10:池里一共 10 个连接(默认 maximumPoolSize 就是 10);
  • active=10, idle=0:10 个全被借出,一个空闲的都没有;
  • waiting=47:47 个线程正在排队等连接。

作为对照,如果看到 total=17, active=2, idle=15, waiting=0 这种“明明有空闲,却拿不到”的状态,则往往是连接被数据库/防火墙断开后仍在池内的“失效连接”问题,那是另一个方向(此时应缩短 maxLifetime,使其比数据库和中间设备的空闲超时短几秒,官方文档也强烈建议这样做)。本次是前者:连接全部在用中。

二、排查路线图

连接池耗尽只有两种大类原因:借出去的没还(泄漏) 或 借出去太久才还(占用时间过长)。“占用时间长”又分为 SQL 慢、事务长、事务里夹了别的耗时操作。我用下面这张图来分流:

Connection is not availableactive = total,waiting 很高 A. show processlistMySQL 侧连接在干嘛? B. jstack应用线程停在哪? C. leakDetectionThreshold谁借了不还? Query 很慢慢 SQL 占连接→ 优化 SQL / 索引 大量 Sleep 且在事务事务开着没干 SQL→ 事务内有远程调用 栈在 HttpClient/RPC持有连接等远程返回→ 缩小事务边界 Apparent leak 日志栈指向未关闭代码→ try-with-resources 都不是池太小?先算公式

三条线互相印证:MySQL 侧的 processlist 告诉你“连接在做什么”,应用侧的 jstack 告诉你“线程卡在哪”,Hikari 的泄漏检测告诉你“谁借的”。两边对着看,比任何一边单看都可靠。

三、动手:交叉验证

第 1 步:MySQL 侧,连接在干什么

mysql> SELECT USER, HOST, DB, COMMAND, TIME, STATE, LEFT(INFO, 60) AS q
    -> FROM information_schema.PROCESSLIST WHERE USER = 'app' ORDER BY TIME DESC LIMIT 15;
+------+-----------------+----------+---------+------+-------+------+
| USER | HOST            | DB       | COMMAND | TIME | STATE | q    |
+------+-----------------+----------+---------+------+-------+------+
| app  | 10.0.3.21:53412 | order_db | Sleep   |  128 |       | NULL |
| app  | 10.0.3.21:53420 | order_db | Sleep   |  121 |       | NULL |
| app  | 10.0.3.21:53431 | order_db | Sleep   |  119 |       | NULL |
| app  | 10.0.3.21:53455 | order_db | Sleep   |  116 |       | NULL |
...
| app  | 10.0.3.21:53602 | order_db | Query   |    1 | ...   | SELECT ... |
+------+-----------------+----------+---------+------+-------+------+

10 个连接(来自同一台应用实例 10.0.3.21),大部分是 Sleep,而且 Time 从 100 多秒往下排。这意味着:数据库这边什么都没在做,连接空闲了一两分钟,可是在应用侧它们仍然是“被借出”状态。这是关键:如果是慢 SQL 占用,你会看到大量 Query 状态、Time 很大、Info 里有那条 SQL;而这里是 Sleep——说明连接被借走后,应用拿着它在做和数据库无关的事,或者忘了还。

再补一刀,确认是否有未提交的事务(也就是“借着连接开了事务却没动静”):

mysql> SELECT trx_mysql_thread_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_query
    -> FROM information_schema.innodb_trx ORDER BY trx_started;
+---------------------+---------------------+------+-----------+
| trx_mysql_thread_id | trx_started         | secs | trx_query |
+---------------------+---------------------+------+-----------+
|              918231 | 2026-09-28 09:48:11 |  128 | NULL      |
|              918244 | 2026-09-28 09:48:18 |  121 | NULL      |
...
10 rows in set (0.00 sec)

innodb_trx 里有 10 个运行中的事务,trx_query 全是 NULL,已经开了两分钟。事务开着、没有在执行语句:应用拿着事务在做别的。

第 2 步:应用侧 jstack,线程停在哪

$ jstack <pid> > /tmp/js1.txt
$ grep -c 'HikariPool.getConnection' /tmp/js1.txt          # 多少线程在排队借连接
47
$ grep -B2 -A18 'PaymentClient' /tmp/js1.txt | head -50    # 看持有连接的线程在干嘛
"http-nio-8080-exec-17" #74 daemon prio=5 os_prio=0 tid=0x00007f... nid=0x2b41 runnable
   java.lang.Thread.State: RUNNABLE
	at java.net.SocketInputStream.socketRead0(Native Method)
	at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
	at java.net.SocketInputStream.read(SocketInputStream.java:171)
	...
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:108)
	at com.demo.order.client.PaymentClient.queryPayResult(PaymentClient.java:63)
	at com.demo.order.service.OrderService.createOrder(OrderService.java:142)
	at com.demo.order.service.OrderService$$FastClassBySpringCGLIB$$...
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:...)
	...

"http-nio-8080-exec-31" #88 daemon prio=5 os_prio=0 nid=0x2b52 waiting on condition
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:151)
	at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:181)
	...

两种线程,对上了:

  • 持有者:栈里有 TransactionInterceptor.invoke(说明处于 @Transactional 方法内),并且栈顶停在 socketRead0、上面是 PaymentClient.queryPayResult——在事务里调用了支付系统的 HTTP 接口,正在等它返回,连接被它抱在怀里;
  • 排队者:栈顶是 HikariPool.getConnection → ConcurrentBag.borrow 里的 parkNanos,就是在等连接(TIMED_WAITING,超时就是 30 秒后报错)。

回头对照 processlist,一切吻合:数据库 Sleep 的连接,就是这些“在事务里等支付回包”的线程借走的。支付系统那天响应变慢(从平时 100ms 涨到 20 秒),10 个连接被 10 个等待中的线程抱死,剩下的请求全部在池外排队。多抓几份 jstack(间隔 5 秒,3 份),确认这批线程一直停在同一个位置,才算“证据”。

第 3 步:leakDetectionThreshold 抓“借了不还”

Hikari 自带泄漏检测,默认关闭(leakDetectionThreshold 默认 0,官方文档:设置后,连接被借出超过该阈值就打印警告;最小可设值 2000ms)。在测试/预发或者排查期间打开:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 10000   # 单位毫秒,借出超过 10 秒就告警
WARN  com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@5f2a1c on thread http-nio-8080-exec-17, stack trace follows
java.lang.Exception: Apparent connection leak detected
	at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
	at org.springframework.jdbc.datasource.DataSourceUtils.doGetConnection(DataSourceUtils.java:159)
	...
	at com.demo.order.service.OrderService.createOrder(OrderService.java:118)

这条日志给出连接是在哪一行借出来的(栈是借出瞬间的栈),配合上面 jstack 中同一个线程的当前栈,就能看清“从哪里借的、现在在干嘛”。注意:“Apparent leak”不一定是真泄漏,慢查询或者事务里夹了远程调用,同样会触发——这次我们就是后者。真正的泄漏(拿到 Connection 没 close())会在阈值告警之后一直不还,日志里还会出现 “Previously reported leaked connection ... was returned to the pool (unleaked)” 说明它最终回来了,反之则真的丢了。要根治真泄漏,就用 try-with-resources:

// 手写 JDBC 时,务必这样:
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement("SELECT ... WHERE id = ?")) {
    ps.setLong(1, id);
    try (ResultSet rs = ps.executeQuery()) {
        ...
    }
}   // 自动 close:对连接池而言是“还回池里”,不是真的断开

四、根因:连接被占用的时间 = 事务时间,而事务里夹了个远程调用

看代码,问题一目了然:

@Transactional
public Order createOrder(CreateOrderReq req) {
    Order order = orderMapper.insert(...);                  // ① 开事务,借到连接,开始占用
    stockMapper.deduct(...);                                // ② 扣库存
    PayResult r = paymentClient.queryPayResult(order.getNo()); // ③ 远程 HTTP:平时 100ms,那天 20 秒
    orderMapper.updatePayStatus(order.getId(), r.getStatus());
    return order;                                           // ④ 方法结束才提交 + 归还连接
}

Spring 的 @Transactional 会在进入方法时通过 DataSourceUtils.getConnection 借出连接,直到方法结束提交/回滚后才归还——整个方法期间连接都在“出租”状态。③ 是一个远程调用,它不需要数据库连接,却让数据库连接陪着它等。

用一个粗略的排队论直觉说明问题的量级(利特尔法则:并发数 ≈ 到达率 × 处理时长):

连接占用数 ≈ 每秒事务数 × 每个事务持有连接的时间
平时:   50 tps × 0.15 s  =  7.5 个连接    (10 个池子够用,勉强)
支付慢: 50 tps × 20 s    = 1000 个连接    (池子 10 个,瞬间耗尽,其余排队 30 秒超时)

看,池子从来没小到装不下平时的流量;是某个下游的一点点变慢,被“事务占连接”这个放大器放大了 100 多倍。这是典型的“级联故障”:下游慢 → 事务长 → 连接被占满 → 上游排队超时 → 用户重试 → 更多排队。

支付接口变慢100ms → 20s 事务被拉长@Transactional 内调用 连接被占满active=10 idle=0 请求排队waiting=47 30s 超时用户重试↑ 重试带来更多并发事务,恶性循环 Tomcat 线程也被排队请求占满,接口整体不可用

五、止血与修复

止血

  1. 网关对相关接口限流 / 降级(支付结果查询这种可异步补偿的步骤,先降级成“稍后同步”);
  2. 支付系统那边恢复之后,池里的连接会自己归还,不必重启。若已经无法恢复,才滚动重启实例(先保存 3 份 jstack 与 processlist 输出);
  3. 不要第一时间把 maximumPoolSize 调到 200。下面解释为什么。

根治一:缩小事务边界,事务里不做远程调用

public Order createOrder(CreateOrderReq req) {
    // 1. 先做所有不需要事务的工作(远程调用、计算),不占连接
    PayResult r = paymentClient.queryPayResult(req.getPayNo());   // 有超时:connect 1s / read 2s

    // 2. 事务只包住“纯数据库操作”,时间以毫秒计
    return transactionTemplate.execute(status -> {
        Order order = orderMapper.insert(...);
        stockMapper.deduct(...);
        orderMapper.updatePayStatus(order.getId(), r.getStatus());
        return order;
    });
}

顺手提醒一个 Spring 老坑:同一个类里 this.xxx() 调用带 @Transactional 的方法,代理不生效;用 TransactionTemplate 显式控制边界,比注解更不容易误伤。所有远程调用必须设置连接超时和读超时,否则一个卡住的下游会无限期拖着你的连接。

根治二:maximumPoolSize 不是越大越好

“池子不够就调大”几乎是本能,但 HikariCP 的“About Pool Sizing”专门有一页反着说:连接池经常被设得过大,而过大会让性能变差(页面引用 Oracle 的性能演示,缩小连接池后响应时间大幅下降,差距可达 50 倍量级)。它给出的起步公式是:

connections = ((core_count * 2) + effective_spindle_count)

例:数据库服务器 4 核 + 1 块机械盘 → 4*2+1 = 9,约 10 个连接就足以打满该数据库
    (SSD 阻塞更少,需要的连接反而更少;实际值要用压测校准)

直觉上的解释:一个 CPU 核同一时刻只能真正执行一个线程,连接超过核数很多时,数据库要在大量并发查询之间做上下文切换和锁竞争,吞吐不升反降。池小一点,排队发生在应用层(便宜、可快速失败),而不是把几百个并发查询堆到数据库里(昂贵、会拖垮所有人)。注意这里的核数是数据库服务器的核数,而且是所有应用实例的连接总和——10 个实例每个 20 连接,在库看来就是 200 个连接。

此外还要记住两点:

  • HikariCP 官方推荐不要设置 minimumIdle,让池以固定大小运行,以获得最佳性能和对突发流量的响应;
  • 如果业务里存在“一个线程同时持有多个连接”(嵌套事务、REQUIRES_NEW),池太小会出现池内死锁——每个线程都拿着一个连接去要第二个。Hikari 的 Pool Sizing 说明里给的避免公式是 pool size = Tn × (Cm − 1) + 1(Tn 是最大线程数,Cm 是单个线程同时持有的最大连接数)。这也是要“尽量避免事务嵌套”的另一个理由。

本次修改:maximumPoolSize 维持在 20(数据库 16 核、3 个应用实例,留有余量并做了压测),connectionTimeout 从 30 秒调到 3 秒——快速失败比让用户等 30 秒然后失败要好得多,也不会把 Tomcat 线程拖死;同时增加了下游调用的超时与熔断。

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 3000        # 3 秒借不到就失败(最小允许 250ms)
      max-lifetime: 1740000           # 29 分钟,比 MySQL wait_timeout 短
      leak-detection-threshold: 20000 # 借出超过 20 秒告警(≥2000ms 才生效)
      pool-name: order-pool           # 指标和日志更好认

六、指标与告警:别再靠日志发现池耗尽

Spring Boot Actuator 通过 Micrometer 自动为 DataSource 连接池注册指标(官方文档 Metrics 一节列出了“DataSource pools”属于自动配置的指标类别)。Hikari 会暴露这些常用指标(Micrometer 命名,导出到 Prometheus 时点变下划线):

hikaricp_connections_active     当前借出的连接数
hikaricp_connections_idle       空闲连接数
hikaricp_connections_pending    正在等待连接的线程数   ← 最灵敏
hikaricp_connections_max        池上限
hikaricp_connections_timeout_total   借连接超时次数(计数)
hikaricp_connections_acquire_seconds 借到连接花了多久
hikaricp_connections_usage_seconds   连接被占用了多久   ← 定位“占用太久”

需要在配置里打开 management.endpoints.web.exposure.include=prometheus(Actuator 官方示例,抓取路径 /actuator/prometheus)。也可以走 JMX:设置 registerMbeans=true,会注册 com.zaxxer.hikari:type=Pool (poolName),包含空闲、活跃、总数、等待线程数四个指标。官方特别提醒,这些值是瞬时快照,用来监控,不要拿来做程序决策。告警规则我们用:

# Prometheus 告警规则示意
- alert: HikariPoolPending
  expr: hikaricp_connections_pending > 0        # 持续 1 分钟仍有线程排队
  for: 1m
- alert: HikariPoolNearFull
  expr: hikaricp_connections_active / hikaricp_connections_max > 0.8
  for: 3m
- alert: HikariAcquireTimeout
  expr: increase(hikaricp_connections_timeout_total[5m]) > 0

pending > 0 这条最灵敏:正常情况下几乎不该有人排队,出现排队就是池子紧张的最早信号,比接口超时告警早几十秒到几分钟。

七、复盘

做法为什么
先看池快照 total / active / idle / waitingactive=total 是“被借光”;idle 很多却借不到是“失效连接”,两者处理方向完全不同
processlist(库侧)与 jstack(应用侧)交叉验证库侧看到大量 Sleep + innodb_trx 有事务,应用侧看到持有线程停在远程调用,两边一致才能定案
事务里不做远程调用、不做慢计算连接占用时间 = 事务时间;下游一慢,占用被成百倍放大
所有下游调用设置连接/读超时,并加熔断没有超时的调用,会让一个卡住的下游拖走你所有的连接和线程
maximumPoolSize 按公式和压测定,不要“一键 200”连接过多会让数据库上下文切换、锁竞争加剧;排队应该发生在应用侧并快速失败
connectionTimeout 调短(如 3 秒)默认 30 秒会让排队线程拖死 Tomcat,快速失败便于保护上游
预发/排查期打开 leakDetectionThreshold能拿到“连接从哪里借出”的栈;注意长事务也会触发“Apparent leak”
监控 pending、active/max、timeout_total、usage池耗尽有前兆,按指标而不是靠用户投诉发现

一句话备忘:Connection is not available → 看 active=total 与 waiting → processlist+innodb_trx 看库侧是 Query 还是 Sleep → jstack 看持有线程停在哪 → 事务里别调远程;池不是越大越好,按 核数×2+盘数 起步、压测校准。

这次事故之后,我们组里多了一条不成文的规矩:事务是用来保护数据的,不是用来陪下游聊天的。连接池也是同理——它像会议室,借了不开会,就是纯纯的占座。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人