周一早上九点五十,我刚在工位坐稳,值班同学把一段日志甩进群里,后面跟着三个感叹号:
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 慢、事务长、事务里夹了别的耗时操作。我用下面这张图来分流:
三条线互相印证: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 多倍。这是典型的“级联故障”:下游慢 → 事务长 → 连接被占满 → 上游排队超时 → 用户重试 → 更多排队。
五、止血与修复
止血
- 网关对相关接口限流 / 降级(支付结果查询这种可异步补偿的步骤,先降级成“稍后同步”);
- 支付系统那边恢复之后,池里的连接会自己归还,不必重启。若已经无法恢复,才滚动重启实例(先保存 3 份 jstack 与 processlist 输出);
- 不要第一时间把
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]) > 0pending > 0 这条最灵敏:正常情况下几乎不该有人排队,出现排队就是池子紧张的最早信号,比接口超时告警早几十秒到几分钟。
七、复盘
| 做法 | 为什么 |
|---|---|
先看池快照 total / active / idle / waiting | active=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+盘数起步、压测校准。
这次事故之后,我们组里多了一条不成文的规矩:事务是用来保护数据的,不是用来陪下游聊天的。连接池也是同理——它像会议室,借了不开会,就是纯纯的占座。
参考资料
- HikariCP 项目主页(README)——maximumPoolSize、connectionTimeout、leakDetectionThreshold、maxLifetime 的默认值与含义,以及不建议设置 minimumIdle。
- HikariCP Wiki:About Pool Sizing——连接池大小公式 ((核数 × 2) + 有效磁盘数) 及为什么小池子更快。
- HikariCP Wiki:JMX 监控与管理——registerMbeans、MBean 名称、可运行时调整的属性与指标快照的说明。
- Spring Boot 参考文档:Actuator Metrics——通过 Micrometer 自动注册数据源连接池指标,以及暴露 Prometheus 端点的方法。
- HikariCP Issue #923:Connection is not available / Apparent connection leak detected——同时出现超时与“疑似泄漏”警告的真实案例。
一起聊聊