2024 年我们一个订单服务在一次大促中"假死"了:接口大面积超时,但看 CPU、内存、数据库负载,全都不高,数据库自己甚至很闲。线程 dump 一抓,发现几百个业务线程齐刷刷卡在同一个地方——等待从数据库连接池里拿一个连接。连接池被占满了,新的请求拿不到连接,只能排队,排着排着就超时了。可数据库明明很空闲,连接池怎么会满?排查了很久才把这条因果链理清:有慢 SQL、有连接泄漏、有连接池参数配得离谱。投了几天把数据库连接池彻底梳理了一遍,用的是 HikariCP,本文复盘这次实战。
问题背景
业务:订单服务,Spring Boot + HikariCP + MySQL
事故现象:
- 大促时接口大面积超时,服务近乎"假死"
- CPU、内存都不高,MySQL 自身负载也很低
- 报错: HikariPool - Connection is not available,
request timed out after 30000ms
现场排查:
# 1. 抓线程 dump,看业务线程都卡在哪
$ jstack | grep -A 5 "http-nio"
"http-nio-8080-exec-50" ... WAITING
at com.zaxxer.hikari.pool.HikariPool.getConnection
# 几百个线程全卡在 getConnection —— 在等连接池给连接
# 2. 看连接池配置
spring.datasource.hikari.maximum-pool-size=10 # 才 10 个?
# 大促 QPS 上千,连接池只有 10 个连接
# 3. 看慢 SQL
# 有个订单列表查询,没走索引,单次要 2-3 秒
# 一个连接被一条慢 SQL 占住 2-3 秒不放
# 4. 看代码,发现有地方手动拿连接没关
Connection conn = dataSource.getConnection();
// ... 用完没有 close,连接泄漏
根因:
1. 连接池 maximum-pool-size 太小,扛不住大促并发
2. 慢 SQL 长时间占用连接,连接周转率极低
3. 有连接泄漏,连接被借走再也没还回来
4. 连接池关键参数(超时、泄漏检测)基本没配
修复 1:看懂连接池为什么会"满"
=== 连接池的本质 ===
数据库连接的建立很贵(TCP 握手 + 认证 + 会话初始化),
连接池预先建好一批连接放着,业务用时借一个、用完还回来。
池里的连接总数有上限(maximum-pool-size)。
=== "连接池满"是什么意思 ===
不是"连接坏了",而是【池里所有连接都被借走了,
没有一个空闲的能给你】。
后来的请求只能排队等别人还连接,
等超过 connection-timeout 还等不到,就抛超时异常。
=== 连接池满的三种根因(本质都是"连接还得太慢")===
1. 并发量 > 连接数:同时来的请求比连接多,正常排队
-> 连接数配置太小
2. 单个连接占用时间太长:慢 SQL、大事务,
一个连接被借走后迟迟不还 -> 周转率低
3. 连接泄漏:连接借走后【永远】不还(代码没 close)
-> 池子被一点点蚕食,最后一个不剩
=== 关键公式:连接池吞吐 ≈ 连接数 / 平均持有时间 ===
连接数 10、每个连接平均占用 2s -> 每秒只能服务 5 个请求。
所以"连接池满"往往不是连接数问题,
而是【每个连接被占用太久】—— 要么慢 SQL,要么泄漏。
加大连接数能缓解,但治本要去缩短"连接持有时间"。
修复 2:HikariCP 核心参数怎么配
spring:
datasource:
hikari:
# === 1. 连接池大小 ===
# 最大连接数:不是越大越好!每个连接在 MySQL 侧
# 也占一个线程和内存,几百个连接反而拖垮数据库。
maximum-pool-size: 20
# 最小空闲连接:HikariCP 官方推荐 = maximum-pool-size
# (即固定大小池),避免频繁创建销毁连接的抖动
minimum-idle: 20
# === 2. 超时参数 ===
# 从池里拿连接的最长等待,超时就抛异常快速失败,
# 不要设太大,否则线程长时间挂着
connection-timeout: 3000 # 3s
# 连接空闲多久被回收(minimum-idle < max 时才有意义)
idle-timeout: 600000 # 10min
# 连接最大存活时间,到点强制重建,
# 必须【小于】数据库的 wait_timeout,否则会用到被
# MySQL 单方面关闭的死连接
max-lifetime: 1800000 # 30min
# === 3. 连接泄漏检测(排查泄漏的利器)===
# 连接被借出超过这个时间还没还,就打一条警告日志
leak-detection-threshold: 60000 # 60s
# === 4. 连接保活与有效性检测 ===
keepalive-time: 300000 # 定期探活,防连接被中间件掐断
connection-test-query: SELECT 1 # 借出前校验连接是否可用
=== maximum-pool-size 到底设多大 ===
一个广为流传的经验公式(HikariCP 官方也引用):
连接数 = ((核心数 * 2) + 有效磁盘数)
对一台 4 核机器,大约就是 10 上下。
听起来很小?但这是有道理的:
数据库真正能【并行】处理的请求是有限的,
连接数远超数据库的并行能力时,多出来的连接只是
在数据库内部排队 + 上下文切换,反而更慢。
实践建议:
- 不要凭感觉设几百,先从一个不大的值(如 20)起步
- 结合压测,观察连接池等待数、SQL 耗时来调
- 单机连接数 * 机器数,总和不能超过 MySQL 的
max_connections,否则 MySQL 会拒绝连接
修复 3:连接泄漏 —— 借了不还
// === 连接泄漏:连接被借走,永远没还回连接池 ===
// 表现:连接池可用连接越来越少,最终归零、彻底卡死。
// 和"慢 SQL"的区别:慢 SQL 是还得慢,泄漏是【根本不还】。
// === 错误写法:手动拿连接,异常路径没关 ===
public void badQuery() {
Connection conn = dataSource.getConnection(); // 借出
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT ...");
process(rs); // 这里抛异常?
conn.close(); // 就执行不到了 -> 泄漏
}
// === 正确写法:try-with-resources 确保归还 ===
public void goodQuery() {
try (Connection conn = dataSource.getConnection();
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT ...")) {
process(rs);
} catch (SQLException e) {
throw new DaoException("查询失败", e);
}
// try 块无论正常还是异常退出,conn 都会 close,
// 注意:连接池的连接 close() 不是真关闭,而是【归还池子】
}
// === 用 leak-detection-threshold 把泄漏点抓出来 ===
// 配了 leak-detection-threshold 后,连接借出超时未还,
// HikariCP 会打印警告 + 完整堆栈,直接定位到泄漏代码:
// Connection leak detection triggered for conn ...,
// stack trace follows
// at com.xxx.OrderDao.badQuery(OrderDao.java:42)
// === 实践建议 ===
// 1. 优先用框架(MyBatis/JdbcTemplate/JPA),它们会
// 自动管理连接的获取与归还,别自己手动 getConnection
// 2. 必须手动管理时,一律 try-with-resources
// 3. 测试 / 预发环境常开 leak-detection,尽早发现泄漏
修复 4:慢 SQL 与大事务长时间占用连接
// === 慢 SQL:连接没泄漏,但被占用太久,周转不过来 ===
// 一条 2s 的慢 SQL,就让一个连接 2s 内服务不了别人。
// 高并发下,连接很快被慢 SQL"占光"。
// 治理:回到 SQL 本身 —— 加索引、避免全表扫描、
// 分页别用深度 offset(参考索引优化的实践)。
// 连接池的健康,根子上取决于 SQL 够不够快。
// === 大事务:事务里夹了不该有的东西 ===
// @Transactional 一旦开启,连接就被这个事务独占,
// 直到事务提交/回滚才归还。事务越长,连接占用越久。
// === 错误:事务里夹了远程调用和等待 ===
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(req); // DB 操作
String result = httpClient.callRisk(req); // 错!远程调用
// 远程调用慢(几百 ms ~ 几 s),这期间连接一直被事务占着
inventoryMapper.deduct(req); // DB 操作
}
// === 正确:事务里只放数据库操作,把外部调用挪出去 ===
public void createOrder(OrderReq req) {
String result = httpClient.callRisk(req); // 锁外:先做远程调用
checkResult(result);
doCreateInTx(req); // 事务只包 DB 操作
}
@Transactional(rollbackFor = Exception.class)
public void doCreateInTx(OrderReq req) {
orderMapper.insert(req);
inventoryMapper.deduct(req);
// 事务范围尽量小,连接占用时间就短
}
// === 原则 ===
// 1. 事务里【只放数据库操作】,远程调用、MQ、文件 IO
// 全部挪到事务外
// 2. 事务粒度尽量小,别用一个大事务包住一长串逻辑
// 3. 连接持有时间短了,同样的连接数能扛更高的并发
修复 5:连接有效性 —— 避免拿到死连接
=== 坑:从池里拿出来的连接,其实已经是死的 ===
连接在池里空闲时,可能已被外部单方面关闭:
- MySQL 的 wait_timeout(默认 8 小时)到了,
服务端主动关掉了这条空闲连接
- 中间的防火墙 / LVS / 云网关,对空闲 TCP 连接
有自己的超时,会悄悄把连接掐掉
连接池这边却不知道,以为它还是好的。
下次借出去用 -> 报 "Communications link failure" 之类的错。
=== 解法 1:max-lifetime 小于数据库 wait_timeout ===
HikariCP 的 max-lifetime 控制连接最长存活时间,
到点连接会被主动重建。
必须保证 max-lifetime < MySQL 的 wait_timeout,
这样连接池会赶在 MySQL 关它之前先重建,
就不会用到被 MySQL 关掉的死连接。
经验:max-lifetime 比 wait_timeout 小几十秒到几分钟。
=== 解法 2:keepalive-time 定期探活 ===
HikariCP 会按 keepalive-time 间隔,对空闲连接发探测,
让连接保持活跃,防止被中间网络设备判定为空闲而掐断。
=== 解法 3:连接借出前做有效性校验 ===
现代 JDBC 驱动支持 Connection.isValid(),
HikariCP 在把连接借给业务前会校验其有效性,
校验不通过的连接直接丢弃、重建,不会给到业务。
老驱动可用 connection-test-query: SELECT 1 兜底。
=== 一句话 ===
连接池里"放着"的连接不等于"能用"的连接,
要靠 max-lifetime + keepalive + 借出校验,
保证给到业务手里的一定是活连接。
修复 6:连接池监控告警
// === HikariCP 自带 MXBean,可暴露连接池实时指标 ===
// Spring Boot 接 Micrometer 后,自动暴露这些指标:
// hikaricp_connections 连接总数
// hikaricp_connections_active 活跃(被借出)连接数
// hikaricp_connections_idle 空闲连接数
// hikaricp_connections_pending 正在排队等连接的线程数 <- 重点
// hikaricp_connections_timeout 获取连接超时的次数
// hikaricp_connections_usage 连接被持有的时长分布
# 连接池监控:pending 和 timeout 是最关键的两个信号
groups:
- name: db-connection-pool
rules:
# 1. 有线程在排队等连接(池子开始不够用了)
- alert: HikariPoolPending
expr: hikaricp_connections_pending > 5
for: 2m
annotations:
summary: "有线程在等数据库连接,排查慢 SQL / 连接泄漏 / 池容量"
# 2. 活跃连接接近上限(池子快被占满)
- alert: HikariPoolNearFull
expr: hikaricp_connections_active
/ hikaricp_connections_max > 0.85
for: 3m
annotations:
summary: "连接池使用率 > 85%,接近耗尽"
# 3. 获取连接超时(已经有请求拿不到连接了)
- alert: HikariConnectionTimeout
expr: increase(hikaricp_connections_timeout_total[5m]) > 0
annotations:
summary: "出现获取数据库连接超时,连接池已耗尽,立即排查"
# 4. 连接持有时间过长(慢 SQL / 大事务的信号)
- alert: HikariUsageHigh
expr: hikaricp_connections_usage_seconds{quantile="0.99"} > 2
for: 5m
annotations:
summary: "连接持有 P99 > 2s,排查慢 SQL 或事务范围过大"
优化效果
指标 治理前 治理后
=============================================================
maximum-pool-size 10(拍脑袋) 20(压测确定)
连接泄漏 有,池被蚕食 try-with-resources,0 泄漏
泄漏检测 未开 leak-detection 60s
慢 SQL 占用连接 2-3s,周转极慢 SQL 优化后 < 50ms
事务范围 夹远程调用 事务只包 DB 操作
连接持有 P99 2-3s 30-80ms
获取连接超时 大促频发 0
max-lifetime 未配,用到死连接 30min < wait_timeout
连接池可观测 无 pending/timeout/usage 监控
治理过程:
- jstack 定位线程卡在 getConnection:0.5 天
- 排查并修复连接泄漏:1 天
- 慢 SQL 优化 + 事务范围收窄:1.5 天
- 连接池参数体系化配置 + 压测调优:1 天
- max-lifetime/探活 + 监控接入:1 天
避坑清单
- "连接池满"不是连接坏了,是所有连接都被借走、没有空闲的可分配
- 线程卡在 getConnection 时先 jstack,再判断是连接不够还是占用太久
- 连接池吞吐 ≈ 连接数 / 平均持有时间,治本要缩短连接持有时间
- maximum-pool-size 不是越大越好,过大反而拖垮数据库,按压测确定
- 单机连接数乘以机器数不能超过 MySQL 的 max_connections
- 连接泄漏是借了不还,用 try-with-resources,并开 leak-detection 抓泄漏点
- 慢 SQL 会长时间占用连接拖垮周转,连接池健康根子上取决于 SQL 快慢
- 事务里只放数据库操作,远程调用、MQ、文件 IO 一律挪到事务外
- max-lifetime 必须小于数据库 wait_timeout,否则会用到已被关闭的死连接
- 重点监控 pending(排队线程数)和 timeout(获取连接超时),它们最早预警
总结
这次数据库连接池的事故复盘,给我最深的一个教训是:当一个系统"假死"了,而它的 CPU、内存、下游数据库却全都很空闲时,答案往往就藏在某一层"池子"里。我们一开始百思不得其解——接口大面积超时,服务几乎不响应,可所有的资源指标都很健康,数据库自己甚至闲得发慌。这种"明明有力气却使不出来"的诡异状态,直到我们抓了一份线程 dump 才真相大白:几百个业务线程,齐刷刷地卡在同一行代码上,都在等待从连接池里借出一个数据库连接。连接池被占满了,没有一个空闲连接可以分配,后来的请求只能排队,排队超过了等待上限就抛超时——服务不是没力气,是它的每一个线程都被堵在了连接池这道窄门前面。理解这件事的关键,是想明白"连接池满"到底意味着什么。它不是说连接坏了,而是说池子里所有的连接此刻都被借走了,没有一个还回来。那为什么连接还不回来?根子上只有两种可能:要么是借走的人用得太慢、迟迟不还,要么是借走的人压根就不打算还。"用得太慢"对应的是慢 SQL 和大事务——一条没走索引、要跑两三秒的查询,就让一个连接在这两三秒里彻底不能服务别人;一个把远程调用、消息发送都一股脑塞进去的大事务,会让连接从事务开始一直被独占到事务结束。"压根不还"对应的就是连接泄漏——代码里手动借了连接,却在某个异常路径上没有 close,这个连接就永远地从池子里消失了,池子被这样一个一个地蚕食,直到一个不剩、彻底卡死。想清楚这条因果链,治理就有了清晰的层次。第一层是参数:连接池的大小要配得合理,但这里有个反直觉的点——连接数绝不是越大越好,因为数据库本身能并行处理的请求是有限的,连接数远超这个限度,多出来的连接只会在数据库内部排队和做上下文切换,反而更慢,所以正确的做法是从一个不大的值起步,再用压测去校准。第二层、也是更治本的一层,是缩短每个连接被持有的时间:把慢 SQL 优化掉,把事务的范围收窄到只包含数据库操作本身,把一切远程调用都挪到事务外面去。因为连接池的真实吞吐,约等于连接数除以平均持有时间,持有时间这个分母一旦降下来,同样数量的连接就能扛起高得多的并发。第三层是堵住泄漏,而 HikariCP 的 leak-detection-threshold 是个特别好用的工具,它会在连接借出超时未还时直接把泄漏点的堆栈打出来,让你一眼定位到那段忘了关连接的代码。还有一个容易被忽略的细节:连接池里"放着"的连接,并不等于"能用"的连接——MySQL 的 wait_timeout 到期会单方面关掉空闲连接,中间的网络设备也会掐断长时间空闲的 TCP,所以必须让 max-lifetime 小于数据库的 wait_timeout,赶在数据库动手之前先把连接重建,才不会把一条已经死掉的连接交到业务手里。这次复盘之后,连接池在我心里的地位变了:它不再是 application.yml 里几行抄来的、配好就再不去看的配置,而是一个需要被认真监控的核心组件——尤其是它的 pending 指标,也就是正在排队等连接的线程数,这个数字一旦开始持续大于零,就是连接池在向你发出最早、也最明确的求救信号。
—— 别看了 · 2026