数据源与 HikariCP:连接池原理与调优

bee2026-10-0858 分钟0 次阅读
为什么不能每次请求新建连接?HikariCP 凭什么最快?池大小到底该配多少——用排队论直觉 + 参数表 + 监控手段,把连接池讲成一门可调的手艺。
1 / 140
小节
〇、30 秒看懂
2 / 140

数据库连接是一条「电话线」:拨通它要经过交换中心(TCP 握手)、报上工号密码(认证)、再设置好语言与时区(会话初始化),全套下来 5~20 毫秒;可真正要说的那句话(一条走索引的 SQL)往往不到 1 毫秒。也就是说,如果每个请求都重新拨一次电话,你付的钱九成花在「接通」上,而不是「说话」上。 连接池干的事就一句话:先把一批已经拨通的电话线放在那里,谁要用谁借,说完话挂回去——注意「挂回去」不是把线路剪断,只是标记为空闲。

3 / 140

先给六个词一句话解释(后面全文都用得上):

4 / 140
  • 连接(Connection):你的应用和数据库之间一条已经建立好的通道,底层是一个 TCP 套接字,成本很高,所以珍贵
  • DataSource:JDBC 规范里的一个接口,只有一句契约 getConnection()——「给我一条能用的连接」,至于这条连接是新造的还是借来的,调用方不知道也不需要知道
  • 连接池(Connection Pool):实现 DataSource 的那个「租借站」,负责提前造好、复用、体检、回收连接
  • HikariCP:Spring Boot 默认自带的那个连接池实现,名字来自日语「光」,靠「无锁借还 + 代理包装 + 后台治理」做到极快
  • 借出 / 归还:从池里拿一条连接叫借出(borrow),用完调 close() 叫归还(return);在池里 close() 不等于断开
  • 泄漏(leak):借了不还——连接被某个线程一直攥着,别的线程只能排队,池最终被抽干
5 / 140
类比

连接池就是小区门口的共享单车停车点。骑车本身几乎不花钱,贵的是「制造一辆车 + 把它运到站点」(建连接的 TCP 与认证)。于是有人做了一件事:提前放一批车在那儿,谁扫谁骑,骑完停回桩位就行。 几个对应关系值得记住:停车点的车位上限 = maximumPoolSize;车被骑光了只能干等 = 请求在池里排队(pending);扫码等了半分钟没车、你一怒走人 = connectionTimeout 超时报错;把车骑回家不上锁也不归还 = 连接泄漏,第二天全小区都没车用;调度员半夜回收破损车辆 = HikariCP 的 HouseKeeper 按 maxLifetime / idleTimeout 淘汰旧连接。而「要不要多备两百辆车」的答案很反直觉:车位越多不代表骑得越快——路(数据库)就那么宽,车太多反而堵成一锅粥。

6 / 140
架构图
图 · 每次新建连接 vs 池化复用
图 · 每次新建连接 vs 池化复用
7 / 140

左边是「每次都新拨一条连接」的真实账单,右边是「池化复用」后的样子。这一篇剩下的所有小节,本质上都在回答两件事:为什么必须选右边,以及右边的旋钮该拧到哪一格。

8 / 140

学完这一篇,你应该能回答三个问题:

9 / 140
  • 为什么 Spring Boot 什么都不配也能连上数据库?那个「默认的池」到底是谁?
  • maximumPoolSize 是不是越大越好?如果是,为什么官方给的经验公式反而是「核数 × 2 + 磁盘数」这么小的数字?
  • 线上突然大面积报 Connection is not available, request timed out after 30004ms,第一步该看哪个指标、哪一行配置?
10 / 140

先把旋钮拧一拧,找找手感——这是本篇唯一的沙盘,改一个参数,右侧三行数据立刻变:

11 / 140
沙盘
沙盘maximumPoolSize 该拧到哪一格
运行结果
pending: 0
P99 获取连接耗时: 2ms
DB Threads_connected: 10/200
hikaricp.connections.usage ≈ 6.8ms/次
刚好:没有线程排队,数据库也远没被打满。这就是 Boot 默认值 10 在多数小中型系统里的位置——不是随手写的,而是「够用且不打爆」的区间
12 / 140
说明

沙盘里唯一需要你记牢的一条约束是 池大小 × 应用实例数 < 数据库 max_connections。数据库的连接上限才是全局瓶颈,池只是把瓶颈切成了若干份。

13 / 140
小节
一、一次 TCP + 认证的成本账
14 / 140

「为什么不能每次请求都新建一条数据库连接?」——先算一笔账。建立一条真正可用的数据库连接,要依次完成:

15 / 140
对照表
阶段大致耗时发生了什么
TCP 三次握手1~3ms(同机房)客户端与数据库之间建立可靠通道
TLS 握手(如启用)2~10ms证书校验、密钥协商
身份认证2~5ms数据库校验账号 / 密码、权限
会话初始化1~3ms设置字符集、时区、连接属性
合计约 5~20ms每一次新建,都要把上面全部走一遍
16 / 140

而一次普通的化查询呢?命中索引的情况下往往不到 1ms。也就是说,如果每个请求都新建连接,「建立连接」比「真正干活」贵十倍以上——这就是最核心的矛盾。

17 / 140

连接的建立成本高昂,但它本身又是可复用的长连接。于是最自然的优化出现了:提前建好一批连接,来回循环使用,谁要用谁来借、用完再还。这就是「池化」思想——数据库连接池、线程池、对象池,本质都是同一个套路:把昂贵资源的创建次数摊薄到 N 次复用上。

18 / 140
架构图
图 1 · 连接池的借还与治理
图 1 · 连接池的借还与治理
19 / 140
小节
二、DataSource 与实现家族
20 / 140

上一篇文章埋了个伏笔:JdbcTemplate 自己从不创建连接,它只向 DataSource 要。DataSource 正是 JDBC 规范定义的标准接口,getConnection() 就是全部契约——至于返回的是新建连接、还是从池里借来的连接,调用方完全不用关心。这就是「面向接口」的美:换实现,业务代码一行不用改。

21 / 140

主流的实现有四家:

22 / 140
对照表
实现Boot 默认性能监控能力特点 / 生态
HikariCP✅ 是极高(无锁设计)内置 Micrometer 指标 + JMX轻量、启动快、依赖少,Spring Boot 首选
Druid否高极强(内置监控页 + SQL 防火墙)阿里出品,监控与防御见长,配置项多
DBCP2否中基础 JMXApache 老牌,稳定但功能与性能偏保守
Tomcat JDBC Pool否高基础 JMX与内嵌 Tomcat 同源,支持异步获取连接
23 / 140
提示

Spring Boot 2.x 起把 HikariCP 作为默认连接池——只要 classpath 里有它,DataSourceAutoConfiguration 就会自动装配。所以「Spring Boot 用什么连接池」的答案就是 HikariCP,除非你显式用 spring.datasource.type 指定别的实现。

24 / 140
小节
三、HikariCP 内部结构:快在哪
25 / 140

HikariCP 之所以快,不是因为用了什么黑魔法,而是把「高并发下连接池的每一处开销」都压到了极致。几个关键设计:

26 / 140
代码对照
代码text
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 的性能秘密可以浓缩成一句话——用「无锁 + 代理 + 后台治理」把借还路径上的一切竞争和系统调用都省掉,并把连接的体检工作挪到后台线程异步完成。

27 / 140

上面这几个名字看着都很眼熟,功能却各管一段,第一次见几乎一定配错。来玩一局:先点部件,再点它到底替你省掉了什么——配错了当场告诉你为什么。

28 / 140
配对闯关
闯关HikariCP 的部件配它的职责已配对 0/6 · 配错 0
六个名字都很眼熟、职责却互不相干,别靠位置猜——两列都打乱了
先点左边一个
29 / 140

而「一条连接的一生」并不从 getConnection() 开始,也不在 close() 结束——它何时出生、何时体检、何时被强制退役,全由后台线程排班。下面这张环形图一格一格点着看,重点是第 ③ 格:

30 / 140
交互图解
回路一条连接的一生(点着看)1 / 6
从 ① 点到 ⑥ 再回到 ①;连接不是永生的,何时退役由后台排班决定
→
→
→
→
→
↻
HikariCP 池
① 出生:一次完整握手
池空时新建一条连接,要付第一节那笔 5~20ms 的账:TCP、认证、会话初始化。minimumIdle 决定启动时先备好几条,冷启动的第一波请求就是靠它免于排队。
全部看懂了六格里只有第 ③ 格是你的代码在决定它什么时候还回来,其余五格都归后台线程管。
31 / 140
小节
四、借还连接的完整流程
32 / 140

理解了结构,再看一次完整的「借还」就顺理成章:

33 / 140
原理动画
动图 · 拿一条连接要过几关
动图 · 拿一条连接要过几关
34 / 140

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

35 / 140
原理动画
动图 · 一次 getConnection() 的内部解剖
动图 · 一次 getConnection() 的内部解剖
36 / 140
  1. 业务请求 getConnection():JdbcTemplate 在执行 SQL 前发起
  2. 池内查找空闲连接:ConcurrentBag 优先从当前线程的本地缓存里取,取不到再去共享区找
  3. 命中则直接借出:把 PoolEntry 用 ProxyConnection 包一层返回——业务拿到的是代理
  4. 未命中且未达上限则新建:走一次完整的 TCP + 认证(第一节的那笔成本账)
  5. 达到上限则排队等待:等待不超过 connectionTimeout,超时抛 SQLTransientConnectionException
  6. 用完 close() 归还:代理拦截 close(),把连接还给池(而不是关闭),并重置会话状态
37 / 140
类比

这条借还路径和图书馆借书一模一样。书架上就那么多本书(maximumPoolSize);管理员不会在你眼前现编一本书,只会把在架的书递给你(命中空闲连接);书被借光了你就在柜台登记等叫号(排队 pending);你拿着书不还、也没人催 = 泄漏;而「还书」这个动作本身不销毁任何书,只是把它放回流通状态——还书 ≠ 烧书,归还 ≠ 断开。至于顺带一提的 ConnectionProxy,就是图书证:它让你以为自己在「用一张卡」,实际上每一次刷卡背后都是同一本被登记在你名下的实体书。

38 / 140

全篇最该记住的一句:在连接池里,Connection.close() 从来不等于「断开连接」,它只等于「归还」。 物理连接会被下一个请求继续使用,直到被 maxLifetime / idleTimeout 淘汰。

39 / 140
代码对照
代码java
// 这也是为什么这段代码是正确的:try-with-resources 的 close 实际是「归还」try (Connection conn = dataSource.getConnection()) {    // ... 执行 SQL}   // 到这里连接被归还给池,物理连接依然活着
解读
  • 业务层「关闭」连接的写法完全不变,这正是连接池设计的高明之处:对上层透明
  • 只有真正归还了,其它线程才可能借到;借了不还(见第十节)就会把池抽干
  • 归还时 HikariCP 会重置 autoCommit、只读标记等会话状态,避免污染下一个借阅者
40 / 140
小节
五、核心参数逐个调
41 / 140

连接池调优,说到底就是调下面这张表里的参数。先理解每个参数「管什么」,再谈调多少。

42 / 140
对照表
参数含义默认值建议
maximumPoolSize池中最大连接数10按公式估算,不是越大越好
minimumIdle最小空闲连接数= max与 max 相等可避免动态伸缩抖动
connectionTimeout等待连接的最长时间30000ms3000~10000ms;别设 0(会无限等待)
idleTimeout空闲连接存活上限600000ms仅在 minimumIdle < maximumPoolSize 时生效
maxLifetime连接最大存活时间1800000ms必须小于数据库的 wait_timeout
validationTimeout连接有效性检测超时5000ms应小于 connectionTimeout
keepaliveTime心跳间隔0(关闭)需小于 maxLifetime 才有意义
43 / 140

这七个旋钮分属两个世界:前四个站在借还路径上,出错时是应用 in 等连接;后三个归后台治理,出错时是你拿到一条死连接。分不清这两组,改参数就只能靠运气:

44 / 140
架构图
图 · 两组旋钮:借还路径 vs 后台治理
图 · 两组旋钮:借还路径 vs 后台治理
45 / 140

那 maximumPoolSize 到底配多少?HikariCP 官方文档引用了一条反直觉的经验公式:

46 / 140
text
maximumPoolSize ≈ CPU 核心数 × 2 + 有效磁盘数
47 / 140

它的含义比数字本身更重要:

48 / 140
  • 瓶颈往往不在数据库 CPU,而在磁盘 IO 与锁等待。连接开得再多,最终都得排队等磁盘,池大了只是让更多线程一起排队
  • 连接数是「并发」而非「吞吐」。8 核机器上,池配 16~20 通常足够,盲目调到几百只会让数据库在上下文切换上耗尽
  • 这条公式是起点而非答案:真正该做的是压测——从公式值开始,看监控指标调整
49 / 140

公式只是起点,真正的手感来自把数字拖一遍。下面这台调节台用的就是本篇的主题参数:从 1 拖到 200,读数与结论会一路变脸——注意右端那一档,「不排队」和「变快」根本不是同一件事:

50 / 140
参数调节台
调节台池子该开几条连接
spring.datasource.hikari.maximum-pool-size
10条当前 1 – 200
Boot 默认档:多数中小系统刚好
  • 公式「核数 × 2 + 磁盘数」在 4~8 核机器上算出来就是这个量级
  • pending 基本为 0,数据库 Threads_connected 也远没到上限
  • 这不是随手写的默认值,而是「不排队也不打爆」的区间
  • 想改之前先问一句:是 pending 涨了,还是 DB 侧吃紧了?
排队等待8%
数据库压力28%
拖之前记住:这条滑块决定的是「多少人能同时进厨房」,不是「厨房炒得有多快」。
51 / 140
坑

maximumPoolSize 的默认值 10 并不是「太小」,而是「多数场景刚好」。先测再调——很多「连接不够」的告警,根因其实是一条没走索引的慢 SQL,加池只是掩盖问题。

52 / 140

随堂测一下(第一题很轻,第二题要你判断现场):

53 / 140
随堂自测
随堂自测判断题:把 maximumPoolSize 从 10 调到 200,接口一定会更快。
先自己选一个,选中立刻告诉你对不对
54 / 140
小节
六、池太小的症状 vs 池太大的症状
55 / 140

两种极端都危险,但表现完全不同,对照着看就能快速判断方向:

56 / 140
对照表
池太小池太大
现象请求排队、偶发 connectionTimeout、接口周期性卡顿数据库 CPU / 内存告警、连接数被打满
关键监控指标hikaricp.connections.pending ↑、获取连接耗时 ↑数据库 Threads_connected 逼近上限、系统上下文切换 ↑
连锁反应Web 线程池被占满 → 整站雪崩数据库扛不住 → 所有实例一起变慢
调整方向适度增大池 + 排查慢 SQL减小池 + 优化 SQL / 加缓存
57 / 140
说明

「池太小」是应用在等连接,「池太大」是数据库被连接压垮。 前者看连接池指标就能定位,后者要去数据库侧看 Threads_connected 与 max_connections 的关系——尤其注意:池大小 × 应用实例数 不能超过数据库的 max_connections,否则扩容反而制造事故。

58 / 140

判断出「是排队」之后,下一个旋钮决定的是你能容忍排多久。connectionTimeout 是唯一一个「设错不会立刻出事、出事就是大面积 500」的参数:设太短,高峰期正常抖动也会被误杀;设 0,线程就永远站在那条队列里,直到 Tomcat 线程被占干。拖一遍看边界在哪:

59 / 140
参数调节台
调节台等一条连接,最多等多久
spring.datasource.hikari.connection-timeout
30000毫秒当前 0 – 60000
HikariCP 默认:偏长,适合容忍度高的后台任务
  • 30 秒是默认值,不是推荐值——它来自「别轻易失败」的取向
  • 同步接口等 30 秒,等于把上游的超时链全部拉长一遍
  • 批量任务、消息消费这类不追求响应速度的场景可以留着
  • 在线接口建议显式降到 3~10 秒,与网关超时对齐
等待时长82%
可见性80%
这条滑块管的不是快慢,而是「故障时你希望看到什么」——一个有限、可解释的超时,比一条永远挂着的请求线更好。
60 / 140
小节
七、监控:连接池的「体检报告」
61 / 140

调优的前提是「看得见」。HikariCP 通过 Micrometer 暴露了一组指标,配合 Actuator 即可查询:

62 / 140
代码对照
代码text
# 当前活跃连接数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.max
解读
  • hikaricp.connections.pending 是最灵敏的预警指标:它长期大于 0,说明有请求在排队,池该加了(或 SQL 该优化了)
  • hikaricp.connections.acquire 的分位数(P99)能直接反映获取连接的抖动
  • 把连接池指标和慢 SQL 日志关联排查:慢 SQL 增多 → 连接占用变长 → pending 上升,这条因果链一旦对上,根因就清楚了

提示:这些指标需要引入 spring-boot-starter-actuator 并暴露 metrics 端点。生产环境建议把 hikaricp.connections.* 接入 Prometheus + Grafana,配一条「pending > 0 持续 1 分钟」的告警,比等用户投诉早得多。

63 / 140
小节
八、Spring Boot 配置示例
64 / 140

一个可直接抄的配置,含全部关键参数:

65 / 140
yaml
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 分钟一次心跳,保活
66 / 140

启动时,日志会清晰地告诉你池的状态:

67 / 140
代码对照
代码text
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 / 账号密码 / 网络不通——「连不上数据库」先在启动日志里找这几行
68 / 140

上面那份是别人写好的。你自己项目里到底需要哪几行,别照抄——勾一遍,看它生成什么、少勾一项会缺哪一行。先只勾「数据源」看最小可用配置,再叠加「日志」与「Actuator」,对照第七节那些 hikaricp.connections.* 指标是怎么被暴露出来的:

69 / 140
生成器
生成器把数据源该配的几行配全application.yml2 / 4
只勾「数据源」得到能连上的最小片段;加上「日志」才有 SQL 与耗时可查;加上「Actuator」才看得到 pending / acquire;「profile」演示同一份 yml 怎么按环境分开配池参数——生产那份请把 leak-detection-threshold 打开
产物
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 }
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
70 / 140
警告

生成的 actuator 片段里如果有 exposure.include: '*',请手工改成白名单。/actuator/env 会把 spring.datasource.password 明文吐出来——第七节和第十二节那个 act 实验演示的就是这个事故。

71 / 140
小节
九、多数据源场景一句话
72 / 140

需要读写分离或多库时,思路不是「配多个池」这么简单,而是用一个路由数据源在多个池之间切换:基于 AbstractRoutingDataSource 重写 determineCurrentLookupKey,配合 ThreadLocal 记录当前该走哪个库;或者直接用 dynamic-datasource-spring-boot-starter,在方法上加 @DS("slave") 即可一键切换。核心认知不变:每个数据源背后都是一个独立的 HikariCP 池,池大小要分别估算。

73 / 140
小节
十、两个必踩的坑
74 / 140
坑

连接泄漏——借了不还,池很快耗尽。 症状是应用跑一阵子后突然大面积 connectionTimeout,重启即恢复、过会儿再犯。定位手段有两把:一是打开 spring.datasource.hikari.leak-detection-threshold=20000(毫秒),借出超过 20 秒未归还就打印带堆栈的警告,直接指出是谁借的;二是导出堆转储,找 ProxyConnection 的引用链。根因九成是没写 try-with-resources,或者手动 getConnection() 后某个异常分支提前 return 漏掉了 close()。

75 / 140
坑

maxLifetime 必须小于数据库的 wait_timeout。 MySQL 默认 wait_timeout 是 28800 秒(8 小时),会单方面关闭空闲超时的连接。如果池里的 maxLifetime 比它大,就会出现「HikariCP 以为连接还活着、数据库那头已经断了」,业务拿到一条死连接执行时报 Communications link failure。HikariCP 默认 maxLifetime 30 分钟,通常安全;但一旦 DBA 把 wait_timeout 调小,maxLifetime 必须同步调得更小——留足余量,让池先于数据库淘汰连接。

76 / 140

第二个坑(maxLifetime 大于 wait_timeout)在现场表现为「拿到一条死连接」,第一个坑(泄漏)则是一段慢慢抽干的过程——它不会当场报错,所以最难在第一时间认出来。把这六帧连看一遍,你会记住那个「重启就好、过会儿再犯」的指纹到底是怎么形成的:

77 / 140
原理动画
动图 · 一次泄漏是怎么慢慢抽干池子的
动图 · 一次泄漏是怎么慢慢抽干池子的
78 / 140

第二道题要你判断现场(这题答错,线上多半要出事):

79 / 140
随堂自测
随堂自测应用每隔十几分钟就出现一批 connectionTimeout,同时 hikaricp.connections.pending 冲高、active 却很低;重启后立刻恢复,过一会儿又犯。最可能的原因与正确动作是?
先自己选一个,选中立刻告诉你对不对
80 / 140
小节
十一、动手体验:数据源的现场
81 / 140

下面这个演示展示自动配置如何装配 DataSource。切换「用户已自定义 DataSource」开关,观察 @ConditionalOnMissingBean 如何让自动配置让位给用户定义——这正是第八节配置能生效的底层机制。

82 / 140
内核实验
83 / 140
小节
十二、把连接池拆开看:四个内核实验 + 两个辅助实验
84 / 140

前面全是描述,这一节动手。连接池本身就是一个可以按按钮的东西——下面五个实验跑的是真内核(WASM),点一下参数就重算一次,你可以亲眼看到「命中」「新建」「排队」「超时」「泄漏」五种完全不同的画面。建议顺序:先跑第一个建立直觉,再依次往后跑,最后用第四、第五个理解线上告警。

85 / 140
小节
12.1 借还全过程(`pool`)
86 / 140

先看最顺的一种情况:池里有空闲连接,getConnection() 立刻拿到,用完 close() 归还。注意输出里那条物理连接的标识始终没变——这就是「复用」。

87 / 140
内核实验
TeaVM连接池借还现场:命中 · 新建 · 排队 · 超时 · 泄漏未启动
切到「达到上限排队」和「等待超时」,看应用被卡住的样子;再看「泄漏检测」里带堆栈的警告
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
88 / 140

逐个参数该怎么读:

89 / 140
对照表
参数你会看到的画面对应现实
命中空闲连接微秒级返回,物理连接对象是同一个正常状态;绝大多数请求都走这条路
池空新建多出一段 TCP + 认证耗时,日志出现 Added connection冷启动、minimumIdle 配小了、突发流量
达到上限排队pending 开始累加,后续请求阻塞在 getConnection()池偏小 或 有慢 SQL 长期占着连接
等待超时抛 SQLTransientConnectionException: Connection is not available, request timed out after 30004ms排够 30 秒还没轮到——接口大面积失败
泄漏检测打开 leakDetectionThreshold 后打印带堆栈的警告,指出是谁借的没还「重启就好、过会儿又犯」的经典症状
90 / 140
提示

这五个参数不是并列的选项,而是同一条链路的五个阶段。把「排队」和「超时」连着点两遍,你就理解了 90% 的连接池告警是怎么发生的:慢 SQL 或泄漏让连接周转不动 → 排队 → 排队的人等到超时。

91 / 140
小节
12.2 没有池的时候,JDBC 自己在干什么(`jdbc`)
92 / 140

JdbcTemplate 从不创建连接,它只向 DataSource 要。把 jdbc 实验切到「忘记释放会怎样」,你会看到裸 JDBC 手写代码里那个最容易漏的 finally:一旦异常分支跳过 close(),连接就永远回不了池。这正是 try-with-resources 存在的理由。

93 / 140
内核实验
TeaVMJdbcTemplate 执行骨架与漏掉的 close未启动
切到「忘记释放会怎样」,看清哪一行本该把连接还给池
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
94 / 140

对照关系:查询一条 = query、影响行数 = update、批量 = batch、ResultSet → 对象 = map、泄漏 = leak。前四项是第 27 篇的内容,本篇只需要盯住最后一项:同一份代码,加了 finally/try-with-resources 与不加,池的命运完全不同。

95 / 140
小节
12.3 池子的健康报告从哪来(`act`)
96 / 140

调优的前提是看得见。spring-boot-starter-actuator 把第七节那些 hikaricp.connections.* 指标做成了可点击的端点,同时演示了「全暴露」的风险——/actuator/env 会把你的数据库密码明文吐出来。

97 / 140
内核实验
TeaVMActuator 端点与健康指示器未启动
切到「全暴露的风险」看 env 端点泄露什么,再切到「指标怎么取」找 hikaricp.connections.pending
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
98 / 140

小白版最小可用配置(三行,抄下来就能用):

99 / 140
yaml
management:  endpoints:    web:      exposure:        include: health,info,metrics     # 只暴露这三类,prod 环境绝不加 env/shutdown  endpoint:    health:      show-details: when_authorized       # DB 连不上时 health 会显示 DOWN 及原因
100 / 140

有了这三行,你就可以直接 curl localhost:8080/actuator/metrics/hikaricp.connections.pending 验证第十二节沙盘里的数字了。

101 / 140
小节
12.4 事务为什么能让「拿连接」这件事变简单(`txprop`)
102 / 140

一个常被忽略的事实:同一个事务内的多次 SQL 复用同一条连接,靠的是 TransactionSynchronizationManager 把连接绑在 ThreadLocal 上。所以下面这段代码只借一次、也只还一次:

103 / 140
java
@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,仍然用同一条连接}
104 / 140

把传播行为切成 REQUIRES_NEW 或 NOT_SUPPORTED,连接的借还次数就会变——这意味着事务边界不仅决定「数据会不会回滚」,还决定「你占了几条连接、占多久」。

105 / 140
内核实验
TeaVM七种传播行为下连接的借与还未启动
对比 REQUIRED 与 REQUIRES_NEW:后者会让内层方法另借一条连接
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
106 / 140
坑

长事务是连接池的头号杀手。事务开着 = 连接一直被攥着,哪怕中间你在调第三方 HTTP 接口、在读大文件。如果你在 @Transactional 方法里看到远程调用,请立刻把它挪到事务外——否则高峰期 pending 飙升的根因就是它,而不是「池太小」。

107 / 140

实验按到最后,可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个内核,回显全部由内核算出来:先 beans 看容器里到底装了哪个 DataSource,再逐条 lab pool 把借还的五个阶段敲一遍:

108 / 140
内核控制台
109 / 140
说明

lab pool queue 和 lab pool timeout 要连着敲才有意义——前者是「已经在排队但还没出事」,后者是「排到点之后的那句报错」。把这两条的输出对着本篇第五节的 pending 与第十三节速查表第一行读,指标和报错就接上了。

110 / 140
小节
十三、常见报错速查
111 / 140

新手最怕的不是概念,是那一屏红字。这张表把连接池相关的报错原文写成可以直接搜索的形式(含关键类名与片段),照第三列自救即可。

112 / 140
对照表
报错原文(片段)真实原因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 classurl / 驱动 / 账号写错,池根本连不上数据库启动日志里找 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 不是你的兜底方案本篇第十节
113 / 140
提示

这些报错的共同点是「说的都是连接,病根却常常在 SQL」。所以排查顺序永远是:指标(pending / acquire P99)→ 慢 SQL 日志 → 泄漏堆栈 → 最后才谈改池参数。

114 / 140

上面第一行的报错,新手十有八九会点错凶手帧——因为栈顶那个方法名看起来最像「罪魁祸首」。下面这段是真实堆栈,先别看答案,点出你认为的凶手行:

115 / 140
报错急救
报错急救SQLTransientConnectionException: Connection is not available
大面积 connectionTimeout 的现场:报错在池子,凶手在你的代码

上线两周的查询接口,晚高峰突然成片 500;重启立刻恢复,四十分钟后又犯。日志里刷的是同一句话,括号里跟着四个数字。

java.sql.SQLTransientConnectionException: order-pool - Connection is not available, request timed out after 3000ms (total=2, active=2, idle=0, waiting=14)
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:181)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:100)
at org.springframework.jdbc.datasource.DataSourceUtils.fetchConnection(DataSourceUtils.java:160)
at com.bee.order.repo.OrderRepository.findRecent(OrderRepository.java:57)
at com.bee.order.web.OrderController.recent(OrderController.java:34)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
116 / 140
小节
十四、动手练习
117 / 140
小节
第一档 · 照做:三分钟搭一个能看见排队的池
118 / 140

目标:用一个 2 连接的小池跑 6 个并发任务,亲眼看一次超时异常长什么样。完整可跑代码:

119 / 140
java
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());        }    }}
120 / 140

预期控制台输出(时间戳会有细微差异,但形状必须一致):

121 / 140
text
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
122 / 140

对着输出确认四件事:

123 / 140
  • 前两条几乎同时完成 = 它们各自借到一条连接(池容量 2)
  • 第 3、4 条晚了整整 2 秒 = 它们在排队,等前面的连接归还
  • 第 5、6 条报的就是速查表第一行的原文,且消息里带着池名 tiny-pool——生产环境务必给池命名,否则多个池一起报错时分不清是谁
  • 结尾 idle=2 证明连接根本没被销毁,只是回到池里
124 / 140
小节
第二档 · 变体:只改三个参数,看三种病
125 / 140

逐项改、每改一项重跑一遍,把观察写进笔记:

126 / 140
  1. maximumPoolSize 从 2 改成 6 → 你会观察到:六条任务全部在 2 秒左右完成,waiting 恒为 0。这就是沙盘里「刚好」那一格的手感。
  2. maximumPoolSize 改成 200、SELECT SLEEP(2) 不变 → 你会观察到:MySQL 侧 SHOW STATUS LIKE 'Threads_connected' 一路涨,数据库 CPU 升高,而你的业务并没有更快——连接数是并发,不是吞吐。
  3. 把 try (var conn = ...) 换成手动 var conn = ds.getConnection();(不调 close())→ 你会观察到:跑几轮之后开始出现 tiny-pool - Connection is not available,且日志里多出 Apparent connection leak detected(先把 leakDetectionThreshold 设为 1000)。这就是泄漏的完整长相:越跑越少、重启即好、过会儿再犯。
  4. 加一行 cfg.setMaxLifetime(60_000),并把 MySQL wait_timeout 临时设成 30 秒 → 你会观察到:空闲一会儿后再访问就偶发 Communications link failure。结论:让池先于数据库淘汰连接。
127 / 140
小节
第三档 · 造一个:给一个接口装上「池子仪表盘」
128 / 140

需求:写一个 /orders/recent 接口(JdbcTemplate 查 20 条订单),并为它配一套能在浏览器里直接看到的观测面板。验收清单:

129 / 140
  • [ ] 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 秒」的告警规则
130 / 140

做完这一档,你就已经从「知道有连接池」升级到「能用数据说话地运营一个连接池」了。

131 / 140
小节
十五、要点自查
132 / 140
自检

Connection.close() 在连接池里到底做了什么?如果答案是「断开 TCP」,回去重读第四节——它是「归还 + 复位会话状态」。

133 / 140
自检

maximumPoolSize 的经验公式是什么?它能直接当答案用吗?公式是 核数 × 2 + 磁盘数,它只是起点,真正的定论来自压测与 pending 指标。

134 / 140
自检

两个实例各配 100 条连接,数据库 max_connections 是 151,会发生什么?高峰期必然出现 Too many connections,且是两台一起抖。记住那条硬约束:池大小 × 实例数 < max_connections。

135 / 140
自检

pending 长期大于 0,你的第一步动作是什么?先查慢 SQL 与泄漏,不是先扩池。扩池只会把压力转交给数据库。

136 / 140
自检

maxLifetime 和 MySQL wait_timeout 谁该更大?wait_timeout 必须更大——让池先主动换掉连接,业务才不会拿到一条死连接。

137 / 140
口诀

建线贵、用线便宜,close 是还不是断;池小先排队、池大打爆库,加池之前先抓慢 SQL 和泄漏。

138 / 140
小节
十六、决策与总结
139 / 140
决策
决策生产环境告警「connect timeout」大面积出现,监控显示连接池 `pending` 飙升。你的第一步是什么?
140 / 140
总结

本篇要记住的是一笔账、一个机制、一条公式、一个动作。一笔账是「建连接约 5~20ms,而查询往往不到 1ms」,所以必须池化;一个机制是「close() 在池里等于归还而非断开,HikariCP 用无锁 ConcurrentBag + 代理 + 后台治理做到极快」;一条公式是「maximumPoolSize ≈ 核数 × 2 + 磁盘数,连接数是并发、不是吞吐,池大小 × 实例数不能超过数据库连接上限」;一个动作是「告警时第一步永远先查泄漏与慢 SQL,而不是无脑扩池」。把连接池当成一门可观察、可调参的手艺,而不是一个默认值,你的线上就少一半「连接」类事故。