数据源与 HikariCP:连接池原理与调优
数据库连接是一条「电话线」:拨通它要经过交换中心(TCP 握手)、报上工号密码(认证)、再设置好语言与时区(会话初始化),全套下来 5~20 毫秒;可真正要说的那句话(一条走索引的 SQL)往往不到 1 毫秒。也就是说,如果每个请求都重新拨一次电话,你付的钱九成花在「接通」上,而不是「说话」上。 连接池干的事就一句话:先把一批已经拨通的电话线放在那里,谁要用谁借,说完话挂回去——注意「挂回去」不是把线路剪断,只是标记为空闲。
先给六个词一句话解释(后面全文都用得上):
- 连接(Connection):你的应用和数据库之间一条已经建立好的通道,底层是一个 TCP 套接字,成本很高,所以珍贵
- DataSource:JDBC 规范里的一个接口,只有一句契约
getConnection()——「给我一条能用的连接」,至于这条连接是新造的还是借来的,调用方不知道也不需要知道 - 连接池(Connection Pool):实现
DataSource的那个「租借站」,负责提前造好、复用、体检、回收连接 - HikariCP:Spring Boot 默认自带的那个连接池实现,名字来自日语「光」,靠「无锁借还 + 代理包装 + 后台治理」做到极快
- 借出 / 归还:从池里拿一条连接叫借出(borrow),用完调
close()叫归还(return);在池里close()不等于断开 - 泄漏(leak):借了不还——连接被某个线程一直攥着,别的线程只能排队,池最终被抽干
连接池就是小区门口的共享单车停车点。骑车本身几乎不花钱,贵的是「制造一辆车 + 把它运到站点」(建连接的 TCP 与认证)。于是有人做了一件事:提前放一批车在那儿,谁扫谁骑,骑完停回桩位就行。 几个对应关系值得记住:停车点的车位上限 = maximumPoolSize;车被骑光了只能干等 = 请求在池里排队(pending);扫码等了半分钟没车、你一怒走人 = connectionTimeout 超时报错;把车骑回家不上锁也不归还 = 连接泄漏,第二天全小区都没车用;调度员半夜回收破损车辆 = HikariCP 的 HouseKeeper 按 maxLifetime / idleTimeout 淘汰旧连接。而「要不要多备两百辆车」的答案很反直觉:车位越多不代表骑得越快——路(数据库)就那么宽,车太多反而堵成一锅粥。

左边是「每次都新拨一条连接」的真实账单,右边是「池化复用」后的样子。这一篇剩下的所有小节,本质上都在回答两件事:为什么必须选右边,以及右边的旋钮该拧到哪一格。
学完这一篇,你应该能回答三个问题:
- 为什么 Spring Boot 什么都不配也能连上数据库?那个「默认的池」到底是谁?
maximumPoolSize是不是越大越好?如果是,为什么官方给的经验公式反而是「核数 × 2 + 磁盘数」这么小的数字?- 线上突然大面积报
Connection is not available, request timed out after 30004ms,第一步该看哪个指标、哪一行配置?
先把旋钮拧一拧,找找手感——这是本篇唯一的沙盘,改一个参数,右侧三行数据立刻变:
pending: 0P99 获取连接耗时: 2msDB Threads_connected: 10/200hikaricp.connections.usage ≈ 6.8ms/次
沙盘里唯一需要你记牢的一条约束是 池大小 × 应用实例数 < 数据库 max_connections。数据库的连接上限才是全局瓶颈,池只是把瓶颈切成了若干份。
「为什么不能每次请求都新建一条数据库连接?」——先算一笔账。建立一条真正可用的数据库连接,要依次完成:
| 阶段 | 大致耗时 | 发生了什么 |
|---|---|---|
| TCP 三次握手 | 1~3ms(同机房) | 客户端与数据库之间建立可靠通道 |
| TLS 握手(如启用) | 2~10ms | 证书校验、密钥协商 |
| 身份认证 | 2~5ms | 数据库校验账号 / 密码、权限 |
| 会话初始化 | 1~3ms | 设置字符集、时区、连接属性 |
| 合计 | 约 5~20ms | 每一次新建,都要把上面全部走一遍 |
而一次普通的化查询呢?命中索引的情况下往往不到 1ms。也就是说,如果每个请求都新建连接,「建立连接」比「真正干活」贵十倍以上——这就是最核心的矛盾。
连接的建立成本高昂,但它本身又是可复用的长连接。于是最自然的优化出现了:提前建好一批连接,来回循环使用,谁要用谁来借、用完再还。这就是「池化」思想——数据库连接池、线程池、对象池,本质都是同一个套路:把昂贵资源的创建次数摊薄到 N 次复用上。

上一篇文章埋了个伏笔:JdbcTemplate 自己从不创建连接,它只向 DataSource 要。DataSource 正是 JDBC 规范定义的标准接口,getConnection() 就是全部契约——至于返回的是新建连接、还是从池里借来的连接,调用方完全不用关心。这就是「面向接口」的美:换实现,业务代码一行不用改。
主流的实现有四家:
| 实现 | Boot 默认 | 性能 | 监控能力 | 特点 / 生态 |
|---|---|---|---|---|
| HikariCP | ✅ 是 | 极高(无锁设计) | 内置 Micrometer 指标 + JMX | 轻量、启动快、依赖少,Spring Boot 首选 |
| Druid | 否 | 高 | 极强(内置监控页 + SQL 防火墙) | 阿里出品,监控与防御见长,配置项多 |
| DBCP2 | 否 | 中 | 基础 JMX | Apache 老牌,稳定但功能与性能偏保守 |
| Tomcat JDBC Pool | 否 | 高 | 基础 JMX | 与内嵌 Tomcat 同源,支持异步获取连接 |
Spring Boot 2.x 起把 HikariCP 作为默认连接池——只要 classpath 里有它,DataSourceAutoConfiguration 就会自动装配。所以「Spring Boot 用什么连接池」的答案就是 HikariCP,除非你显式用 spring.datasource.type 指定别的实现。
HikariCP 之所以快,不是因为用了什么黑魔法,而是把「高并发下连接池的每一处开销」都压到了极致。几个关键设计:
HikariPool ├── ConcurrentBag<PoolEntry> # 连接仓库:ThreadLocal + 无锁栈,就近取还 ├── FastList<Statement> # 关闭语句时用:去掉边界检查与范围校验的 ArrayList ├── ProxyConnection # 返回给业务的连接:代理包装,拦截 close / 状态校验 └── HouseKeeper # 后台线程:心跳保活、剔除超龄与空闲连接- ConcurrentBag:连接仓库的核心。它用「借用者自己的
ThreadLocal列表 + 一个无锁SynchronousQueue风格的共享栈」,让同一线程借还连接几乎零竞争——这是它比其它池快的重要原因 - FastList:替代
ArrayList存放连接的Statement,省掉了每次remove的边界检查与indexOf扫描,关闭语句时更快 - ProxyConnection:你拿到的
Connection其实是它的代理。调用close()时,代理不是真的关闭物理连接,而是把连接归还池并做状态清理;同时它还拦截了「连接已归还后继续使用」等误用 - HouseKeeper:一个后台线程,周期性做两件事——给空闲连接发心跳(
keepaliveTime)以保活,以及剔除超过maxLifetime或超过idleTimeout的连接,避免拿到已被数据库单方面关闭的「死连接」
要点:HikariCP 的性能秘密可以浓缩成一句话——用「无锁 + 代理 + 后台治理」把借还路径上的一切竞争和系统调用都省掉,并把连接的体检工作挪到后台线程异步完成。
上面这几个名字看着都很眼熟,功能却各管一段,第一次见几乎一定配错。来玩一局:先点部件,再点它到底替你省掉了什么——配错了当场告诉你为什么。
而「一条连接的一生」并不从 getConnection() 开始,也不在 close() 结束——它何时出生、何时体检、何时被强制退役,全由后台线程排班。下面这张环形图一格一格点着看,重点是第 ③ 格:
理解了结构,再看一次完整的「借还」就顺理成章:

上面这条是「池子视角」。下面这张动图换成单个请求线程的视角:同一个 getConnection(),它先摸自己线程的快车道、再翻共享栈、实在没有才新建,够不着就排队——七步走完才知道超时是从哪一步冒出来的。

- 业务请求
getConnection():JdbcTemplate在执行 SQL 前发起 - 池内查找空闲连接:
ConcurrentBag优先从当前线程的本地缓存里取,取不到再去共享区找 - 命中则直接借出:把
PoolEntry用ProxyConnection包一层返回——业务拿到的是代理 - 未命中且未达上限则新建:走一次完整的 TCP + 认证(第一节的那笔成本账)
- 达到上限则排队等待:等待不超过
connectionTimeout,超时抛SQLTransientConnectionException - 用完
close()归还:代理拦截close(),把连接还给池(而不是关闭),并重置会话状态
这条借还路径和图书馆借书一模一样。书架上就那么多本书(maximumPoolSize);管理员不会在你眼前现编一本书,只会把在架的书递给你(命中空闲连接);书被借光了你就在柜台登记等叫号(排队 pending);你拿着书不还、也没人催 = 泄漏;而「还书」这个动作本身不销毁任何书,只是把它放回流通状态——还书 ≠ 烧书,归还 ≠ 断开。至于顺带一提的 ConnectionProxy,就是图书证:它让你以为自己在「用一张卡」,实际上每一次刷卡背后都是同一本被登记在你名下的实体书。
全篇最该记住的一句:在连接池里,Connection.close() 从来不等于「断开连接」,它只等于「归还」。 物理连接会被下一个请求继续使用,直到被 maxLifetime / idleTimeout 淘汰。
// 这也是为什么这段代码是正确的:try-with-resources 的 close 实际是「归还」try (Connection conn = dataSource.getConnection()) { // ... 执行 SQL} // 到这里连接被归还给池,物理连接依然活着- 业务层「关闭」连接的写法完全不变,这正是连接池设计的高明之处:对上层透明
- 只有真正归还了,其它线程才可能借到;借了不还(见第十节)就会把池抽干
- 归还时 HikariCP 会重置
autoCommit、只读标记等会话状态,避免污染下一个借阅者
连接池调优,说到底就是调下面这张表里的参数。先理解每个参数「管什么」,再谈调多少。
| 参数 | 含义 | 默认值 | 建议 |
|---|---|---|---|
maximumPoolSize | 池中最大连接数 | 10 | 按公式估算,不是越大越好 |
minimumIdle | 最小空闲连接数 | = max | 与 max 相等可避免动态伸缩抖动 |
connectionTimeout | 等待连接的最长时间 | 30000ms | 3000~10000ms;别设 0(会无限等待) |
idleTimeout | 空闲连接存活上限 | 600000ms | 仅在 minimumIdle < maximumPoolSize 时生效 |
maxLifetime | 连接最大存活时间 | 1800000ms | 必须小于数据库的 wait_timeout |
validationTimeout | 连接有效性检测超时 | 5000ms | 应小于 connectionTimeout |
keepaliveTime | 心跳间隔 | 0(关闭) | 需小于 maxLifetime 才有意义 |
这七个旋钮分属两个世界:前四个站在借还路径上,出错时是应用 in 等连接;后三个归后台治理,出错时是你拿到一条死连接。分不清这两组,改参数就只能靠运气:

那 maximumPoolSize 到底配多少?HikariCP 官方文档引用了一条反直觉的经验公式:
maximumPoolSize ≈ CPU 核心数 × 2 + 有效磁盘数它的含义比数字本身更重要:
- 瓶颈往往不在数据库 CPU,而在磁盘 IO 与锁等待。连接开得再多,最终都得排队等磁盘,池大了只是让更多线程一起排队
- 连接数是「并发」而非「吞吐」。8 核机器上,池配 16~20 通常足够,盲目调到几百只会让数据库在上下文切换上耗尽
- 这条公式是起点而非答案:真正该做的是压测——从公式值开始,看监控指标调整
公式只是起点,真正的手感来自把数字拖一遍。下面这台调节台用的就是本篇的主题参数:从 1 拖到 200,读数与结论会一路变脸——注意右端那一档,「不排队」和「变快」根本不是同一件事:
- 公式「核数 × 2 + 磁盘数」在 4~8 核机器上算出来就是这个量级
- pending 基本为 0,数据库 Threads_connected 也远没到上限
- 这不是随手写的默认值,而是「不排队也不打爆」的区间
- 想改之前先问一句:是 pending 涨了,还是 DB 侧吃紧了?
maximumPoolSize 的默认值 10 并不是「太小」,而是「多数场景刚好」。先测再调——很多「连接不够」的告警,根因其实是一条没走索引的慢 SQL,加池只是掩盖问题。
随堂测一下(第一题很轻,第二题要你判断现场):
两种极端都危险,但表现完全不同,对照着看就能快速判断方向:
| 池太小 | 池太大 | |
|---|---|---|
| 现象 | 请求排队、偶发 connectionTimeout、接口周期性卡顿 | 数据库 CPU / 内存告警、连接数被打满 |
| 关键监控指标 | hikaricp.connections.pending ↑、获取连接耗时 ↑ | 数据库 Threads_connected 逼近上限、系统上下文切换 ↑ |
| 连锁反应 | Web 线程池被占满 → 整站雪崩 | 数据库扛不住 → 所有实例一起变慢 |
| 调整方向 | 适度增大池 + 排查慢 SQL | 减小池 + 优化 SQL / 加缓存 |
「池太小」是应用在等连接,「池太大」是数据库被连接压垮。 前者看连接池指标就能定位,后者要去数据库侧看 Threads_connected 与 max_connections 的关系——尤其注意:池大小 × 应用实例数 不能超过数据库的 max_connections,否则扩容反而制造事故。
判断出「是排队」之后,下一个旋钮决定的是你能容忍排多久。connectionTimeout 是唯一一个「设错不会立刻出事、出事就是大面积 500」的参数:设太短,高峰期正常抖动也会被误杀;设 0,线程就永远站在那条队列里,直到 Tomcat 线程被占干。拖一遍看边界在哪:
- 30 秒是默认值,不是推荐值——它来自「别轻易失败」的取向
- 同步接口等 30 秒,等于把上游的超时链全部拉长一遍
- 批量任务、消息消费这类不追求响应速度的场景可以留着
- 在线接口建议显式降到 3~10 秒,与网关超时对齐
调优的前提是「看得见」。HikariCP 通过 Micrometer 暴露了一组指标,配合 Actuator 即可查询:
# 当前活跃连接数GET /actuator/metrics/hikaricp.connections.active# 正在等待连接的线程数(持续 > 0 就是池偏小的信号)GET /actuator/metrics/hikaricp.connections.pending# 连接获取耗时(最能反映拥塞)GET /actuator/metrics/hikaricp.connections.acquire# 当前空闲 / 总连接数GET /actuator/metrics/hikaricp.connections.idleGET /actuator/metrics/hikaricp.connections.maxhikaricp.connections.pending是最灵敏的预警指标:它长期大于 0,说明有请求在排队,池该加了(或 SQL 该优化了)hikaricp.connections.acquire的分位数(P99)能直接反映获取连接的抖动- 把连接池指标和慢 SQL 日志关联排查:慢 SQL 增多 → 连接占用变长 → pending 上升,这条因果链一旦对上,根因就清楚了
提示:这些指标需要引入 spring-boot-starter-actuator 并暴露 metrics 端点。生产环境建议把 hikaricp.connections.* 接入 Prometheus + Grafana,配一条「pending > 0 持续 1 分钟」的告警,比等用户投诉早得多。
一个可直接抄的配置,含全部关键参数:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} hikari: pool-name: HikariPool-1 maximum-pool-size: 20 minimum-idle: 5 # 允许池在低峰期收缩,高峰再长 connection-timeout: 3000 # 3 秒拿不到连接就快速失败,别让线程干等 idle-timeout: 600000 # 空闲 10 分钟后回收多余连接 max-lifetime: 1200000 # 20 分钟,必须小于 MySQL wait_timeout validation-timeout: 3000 keepalive-time: 300000 # 5 分钟一次心跳,保活启动时,日志会清晰地告诉你池的状态:
HikariPool-1 - Starting...HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@5f1d2b3cHikariPool-1 - Start completed.Starting...:池开始初始化,读取配置Added connection ...:预热连接已建立(minimumIdle决定预热几条)Start completed.:池就绪,getConnection()可以直接借到连接- 如果这里卡住或报
Timeout,基本是url/ 账号密码 / 网络不通——「连不上数据库」先在启动日志里找这几行
上面那份是别人写好的。你自己项目里到底需要哪几行,别照抄——勾一遍,看它生成什么、少勾一项会缺哪一行。先只勾「数据源」看最小可用配置,再叠加「日志」与「Actuator」,对照第七节那些 hikaricp.connections.* 指标是怎么被暴露出来的:
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
生成的 actuator 片段里如果有 exposure.include: '*',请手工改成白名单。/actuator/env 会把 spring.datasource.password 明文吐出来——第七节和第十二节那个 act 实验演示的就是这个事故。
需要读写分离或多库时,思路不是「配多个池」这么简单,而是用一个路由数据源在多个池之间切换:基于 AbstractRoutingDataSource 重写 determineCurrentLookupKey,配合 ThreadLocal 记录当前该走哪个库;或者直接用 dynamic-datasource-spring-boot-starter,在方法上加 @DS("slave") 即可一键切换。核心认知不变:每个数据源背后都是一个独立的 HikariCP 池,池大小要分别估算。
连接泄漏——借了不还,池很快耗尽。 症状是应用跑一阵子后突然大面积 connectionTimeout,重启即恢复、过会儿再犯。定位手段有两把:一是打开 spring.datasource.hikari.leak-detection-threshold=20000(毫秒),借出超过 20 秒未归还就打印带堆栈的警告,直接指出是谁借的;二是导出堆转储,找 ProxyConnection 的引用链。根因九成是没写 try-with-resources,或者手动 getConnection() 后某个异常分支提前 return 漏掉了 close()。
maxLifetime 必须小于数据库的 wait_timeout。 MySQL 默认 wait_timeout 是 28800 秒(8 小时),会单方面关闭空闲超时的连接。如果池里的 maxLifetime 比它大,就会出现「HikariCP 以为连接还活着、数据库那头已经断了」,业务拿到一条死连接执行时报 Communications link failure。HikariCP 默认 maxLifetime 30 分钟,通常安全;但一旦 DBA 把 wait_timeout 调小,maxLifetime 必须同步调得更小——留足余量,让池先于数据库淘汰连接。
第二个坑(maxLifetime 大于 wait_timeout)在现场表现为「拿到一条死连接」,第一个坑(泄漏)则是一段慢慢抽干的过程——它不会当场报错,所以最难在第一时间认出来。把这六帧连看一遍,你会记住那个「重启就好、过会儿再犯」的指纹到底是怎么形成的:

第二道题要你判断现场(这题答错,线上多半要出事):
下面这个演示展示自动配置如何装配 DataSource。切换「用户已自定义 DataSource」开关,观察 @ConditionalOnMissingBean 如何让自动配置让位给用户定义——这正是第八节配置能生效的底层机制。
前面全是描述,这一节动手。连接池本身就是一个可以按按钮的东西——下面五个实验跑的是真内核(WASM),点一下参数就重算一次,你可以亲眼看到「命中」「新建」「排队」「超时」「泄漏」五种完全不同的画面。建议顺序:先跑第一个建立直觉,再依次往后跑,最后用第四、第五个理解线上告警。
先看最顺的一种情况:池里有空闲连接,getConnection() 立刻拿到,用完 close() 归还。注意输出里那条物理连接的标识始终没变——这就是「复用」。
逐个参数该怎么读:
| 参数 | 你会看到的画面 | 对应现实 |
|---|---|---|
| 命中空闲连接 | 微秒级返回,物理连接对象是同一个 | 正常状态;绝大多数请求都走这条路 |
| 池空新建 | 多出一段 TCP + 认证耗时,日志出现 Added connection | 冷启动、minimumIdle 配小了、突发流量 |
| 达到上限排队 | pending 开始累加,后续请求阻塞在 getConnection() | 池偏小 或 有慢 SQL 长期占着连接 |
| 等待超时 | 抛 SQLTransientConnectionException: Connection is not available, request timed out after 30004ms | 排够 30 秒还没轮到——接口大面积失败 |
| 泄漏检测 | 打开 leakDetectionThreshold 后打印带堆栈的警告,指出是谁借的没还 | 「重启就好、过会儿又犯」的经典症状 |
这五个参数不是并列的选项,而是同一条链路的五个阶段。把「排队」和「超时」连着点两遍,你就理解了 90% 的连接池告警是怎么发生的:慢 SQL 或泄漏让连接周转不动 → 排队 → 排队的人等到超时。
JdbcTemplate 从不创建连接,它只向 DataSource 要。把 jdbc 实验切到「忘记释放会怎样」,你会看到裸 JDBC 手写代码里那个最容易漏的 finally:一旦异常分支跳过 close(),连接就永远回不了池。这正是 try-with-resources 存在的理由。
对照关系:查询一条 = query、影响行数 = update、批量 = batch、ResultSet → 对象 = map、泄漏 = leak。前四项是第 27 篇的内容,本篇只需要盯住最后一项:同一份代码,加了 finally/try-with-resources 与不加,池的命运完全不同。
调优的前提是看得见。spring-boot-starter-actuator 把第七节那些 hikaricp.connections.* 指标做成了可点击的端点,同时演示了「全暴露」的风险——/actuator/env 会把你的数据库密码明文吐出来。
小白版最小可用配置(三行,抄下来就能用):
management: endpoints: web: exposure: include: health,info,metrics # 只暴露这三类,prod 环境绝不加 env/shutdown endpoint: health: show-details: when_authorized # DB 连不上时 health 会显示 DOWN 及原因有了这三行,你就可以直接 curl localhost:8080/actuator/metrics/hikaricp.connections.pending 验证第十二节沙盘里的数字了。
一个常被忽略的事实:同一个事务内的多次 SQL 复用同一条连接,靠的是 TransactionSynchronizationManager 把连接绑在 ThreadLocal 上。所以下面这段代码只借一次、也只还一次:
@Transactionalpublic void transfer(Long from, Long to, BigDecimal amount) { jdbc.update("UPDATE t_account SET balance = balance - ? WHERE id = ?", amount, from); jdbc.update("UPDATE t_account SET balance = balance + ? WHERE id = ?", amount, to); auditLog.record(from, to, amount); // 又一次 SQL,仍然用同一条连接}把传播行为切成 REQUIRES_NEW 或 NOT_SUPPORTED,连接的借还次数就会变——这意味着事务边界不仅决定「数据会不会回滚」,还决定「你占了几条连接、占多久」。
长事务是连接池的头号杀手。事务开着 = 连接一直被攥着,哪怕中间你在调第三方 HTTP 接口、在读大文件。如果你在 @Transactional 方法里看到远程调用,请立刻把它挪到事务外——否则高峰期 pending 飙升的根因就是它,而不是「池太小」。
实验按到最后,可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:先 beans 看容器里到底装了哪个 DataSource,再逐条 lab pool 把借还的五个阶段敲一遍:
lab pool queue 和 lab pool timeout 要连着敲才有意义——前者是「已经在排队但还没出事」,后者是「排到点之后的那句报错」。把这两条的输出对着本篇第五节的 pending 与第十三节速查表第一行读,指标和报错就接上了。
新手最怕的不是概念,是那一屏红字。这张表把连接池相关的报错原文写成可以直接搜索的形式(含关键类名与片段),照第三列自救即可。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
Connection is not available, request timed out after 30004ms (SQLTransientConnectionException) | 排了 30 秒都没借到连接:要么池真的小,要么连接被慢 SQL / 泄漏占住 | 先看 hikaricp.connections.pending 是否 > 0,再把 leak-detection-threshold 设成 20000 复现;不要先扩池 | 本篇第十、十二节 |
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure | 池以为连接还活着,数据库那头早就单方面断了(maxLifetime ≥ wait_timeout,或网络/防火墙掐线) | 把 max-lifetime 调到明显小于 MySQL wait_timeout,并开 keepalive-time 保活 | 本篇第十节 |
ERROR 1040 (08004): Too many connections | 池大小 × 应用实例数 超过数据库 max_connections,扩容变成了事故 | 数一遍所有实例的池总和,砍池或加只读库;临时可给运维账号留 extra_max_connections | 本篇第六节 |
Failed to obtain JDBC Connection; nested exception is java.sql.SQLNonTransientConnectionException: Cannot load connection class | url / 驱动 / 账号写错,池根本连不上数据库 | 启动日志里找 HikariPool-1 - Starting... 有没有走到 Start completed.,没走到就是连接串问题 | 第 27 篇 |
java.sql.SQLException: Connection is closed / was already closed | 归还之后又用了那个引用(典型:异步线程拿着主线程已关闭的连接) | 连接不能跨线程传递;要跨线程就在子线程里重新获取,或改用事务托管 | 第 31 篇 |
leak detection triggered on ... , stack trace accompanying this connectivity failure | 借出超过 leakDetectionThreshold 仍未归还,堆栈已经指名道姓 | 顺着警告里的堆栈找到那段没写 try-with-resources 的代码,这是最快的一条线索 | 本篇第十节 |
The last packet successfully received from the server was N milliseconds ago | 空闲连接被服务端超时切断;本质同 Communications link failure | 减小 maxLifetime / 增大 keepaliveTime,并在 URL 上确认 autoReconnect 不是你的兜底方案 | 本篇第十节 |
这些报错的共同点是「说的都是连接,病根却常常在 SQL」。所以排查顺序永远是:指标(pending / acquire P99)→ 慢 SQL 日志 → 泄漏堆栈 → 最后才谈改池参数。
上面第一行的报错,新手十有八九会点错凶手帧——因为栈顶那个方法名看起来最像「罪魁祸首」。下面这段是真实堆栈,先别看答案,点出你认为的凶手行:
上线两周的查询接口,晚高峰突然成片 500;重启立刻恢复,四十分钟后又犯。日志里刷的是同一句话,括号里跟着四个数字。
目标:用一个 2 连接的小池跑 6 个并发任务,亲眼看一次超时异常长什么样。完整可跑代码:
package com.example.pool;import com.zaxxer.hikari.HikariConfig;import com.zaxxer.hikari.HikariDataSource;import org.springframework.jdbc.core.JdbcTemplate;import java.util.concurrent.CountDownLatch;public class PoolDemo { public static void main(String[] args) throws Exception { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl("jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai"); cfg.setUsername("root"); cfg.setPassword("secret"); cfg.setMaximumPoolSize(2); // 故意只有 2 条 cfg.setMinimumIdle(2); // 固定为 2,不伸缩,便于观察 cfg.setConnectionTimeout(3000); // 等 3 秒就放弃,快速失败 cfg.setPoolName("tiny-pool"); try (HikariDataSource ds = new HikariDataSource(cfg)) { JdbcTemplate jdbc = new JdbcTemplate(ds); CountDownLatch done = new CountDownLatch(6); for (int i = 0; i < 6; i++) { // 6 个线程抢 2 条连接 final int no = i; new Thread(() -> { try (var conn = ds.getConnection()) { // try-with-resources = 归还 long start = System.currentTimeMillis(); new JdbcTemplate(conn).queryForObject("SELECT SLEEP(2)", Integer.class); System.out.printf("[task-%d] 拿到连接并执行完,用时 %dms%n", no, System.currentTimeMillis() - start); } catch (Exception e) { System.out.printf("[task-%d] 失败:%s: %s%n", no, e.getClass().getSimpleName(), e.getMessage()); } finally { done.countDown(); } }, "worker-" + i).start(); } done.await(); System.out.println("active=" + ds.getHikariPoolMXBean().getActiveConnections() + " idle=" + ds.getHikariPoolMXBean().getIdleConnections() + " waiting=" + ds.getHikariPoolMXBean().getThreadsWaiting()); } }}预期控制台输出(时间戳会有细微差异,但形状必须一致):
HikariCP version: 5.xtiny-pool - Starting...tiny-pool - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a2b3c4dtiny-pool - Start completed.[task-0] 拿到连接并执行完,用时 2005ms[task-1] 拿到连接并执行完,用时 2006ms[task-2] 拿到连接并执行完,用时 4012ms[task-3] 拿到连接并执行完,用时 4015ms[task-4] 失败:SQLTransientConnectionException: tiny-pool - Connection is not available, request timed out after 3004ms[task-5] 失败:SQLTransientConnectionException: tiny-pool - Connection is not available, request timed out after 3003msactive=0 idle=2 waiting=0对着输出确认四件事:
- 前两条几乎同时完成 = 它们各自借到一条连接(池容量 2)
- 第 3、4 条晚了整整 2 秒 = 它们在排队,等前面的连接归还
- 第 5、6 条报的就是速查表第一行的原文,且消息里带着池名
tiny-pool——生产环境务必给池命名,否则多个池一起报错时分不清是谁 - 结尾
idle=2证明连接根本没被销毁,只是回到池里
逐项改、每改一项重跑一遍,把观察写进笔记:
maximumPoolSize从 2 改成 6 → 你会观察到:六条任务全部在 2 秒左右完成,waiting恒为 0。这就是沙盘里「刚好」那一格的手感。maximumPoolSize改成 200、SELECT SLEEP(2)不变 → 你会观察到:MySQL 侧SHOW STATUS LIKE 'Threads_connected'一路涨,数据库 CPU 升高,而你的业务并没有更快——连接数是并发,不是吞吐。- 把
try (var conn = ...)换成手动var conn = ds.getConnection();(不调close())→ 你会观察到:跑几轮之后开始出现tiny-pool - Connection is not available,且日志里多出Apparent connection leak detected(先把leakDetectionThreshold设为 1000)。这就是泄漏的完整长相:越跑越少、重启即好、过会儿再犯。 - 加一行
cfg.setMaxLifetime(60_000),并把 MySQLwait_timeout临时设成 30 秒 → 你会观察到:空闲一会儿后再访问就偶发Communications link failure。结论:让池先于数据库淘汰连接。
需求:写一个 /orders/recent 接口(JdbcTemplate 查 20 条订单),并为它配一套能在浏览器里直接看到的观测面板。验收清单:
- [ ]
application.yml里显式写出pool-name、maximum-pool-size、connection-timeout、max-lifetime、keepalive-time、leak-detection-threshold六项,并在注释里说明每一项管什么(不许只写数值) - [ ] 启动日志里能指认出
Starting... / Added connection / Start completed.三行,并说清预热了几条连接 - [ ] 引入
spring-boot-starter-actuator,GET /actuator/metrics/hikaricp.connections.pending在空闲时返回 0 - [ ] 用 JMeter / wrk /
ab打 100 并发压这个接口,把maximum-pool-size依次设为 2、10、50,各记录一次:P99 响应时间、pending峰值、数据库Threads_connected - [ ] 根据三组数据写一份 5 行结论:哪个值是拐点?再多给连接为什么不快?瓶颈在应用还是数据库?
- [ ] 人为制造一次泄漏(借出不还),用
leak-detection-threshold的堆栈警告定位到具体代码行,并修复它 - [ ] 额外加分:把
hikaricp.connections.acquire的 P99 做成一条 Grafana 面板曲线,并配一条「pending > 0 持续 60 秒」的告警规则
做完这一档,你就已经从「知道有连接池」升级到「能用数据说话地运营一个连接池」了。
Connection.close() 在连接池里到底做了什么?如果答案是「断开 TCP」,回去重读第四节——它是「归还 + 复位会话状态」。
maximumPoolSize 的经验公式是什么?它能直接当答案用吗?公式是 核数 × 2 + 磁盘数,它只是起点,真正的定论来自压测与 pending 指标。
两个实例各配 100 条连接,数据库 max_connections 是 151,会发生什么?高峰期必然出现 Too many connections,且是两台一起抖。记住那条硬约束:池大小 × 实例数 < max_connections。
pending 长期大于 0,你的第一步动作是什么?先查慢 SQL 与泄漏,不是先扩池。扩池只会把压力转交给数据库。
maxLifetime 和 MySQL wait_timeout 谁该更大?wait_timeout 必须更大——让池先主动换掉连接,业务才不会拿到一条死连接。
建线贵、用线便宜,close 是还不是断;池小先排队、池大打爆库,加池之前先抓慢 SQL 和泄漏。
本篇要记住的是一笔账、一个机制、一条公式、一个动作。一笔账是「建连接约 5~20ms,而查询往往不到 1ms」,所以必须池化;一个机制是「close() 在池里等于归还而非断开,HikariCP 用无锁 ConcurrentBag + 代理 + 后台治理做到极快」;一条公式是「maximumPoolSize ≈ 核数 × 2 + 磁盘数,连接数是并发、不是吞吐,池大小 × 实例数不能超过数据库连接上限」;一个动作是「告警时第一步永远先查泄漏与慢 SQL,而不是无脑扩池」。把连接池当成一门可观察、可调参的手艺,而不是一个默认值,你的线上就少一半「连接」类事故。