本节目标:掌握 maximum-pool-size 的推导方法、max-lifetime/keepalive-time 与数据库超时的配合、连接泄漏与超时的配套,并学会用 Actuator 指标区分「池不够」与「SQL 慢」。
适用版本:Spring Boot 4.1.x(Java 21)
11.1 HikariCP 调优
入门卷讲了怎么让 Book、Member、Loan 跑起来,本章开始处理它们背后的数据库工程问题。第一站是连接池:book-loan 服务上线后,最先暴露的数据库问题往往不是 SQL 慢,而是连接池配置不当——池太小,请求在池外排队;池太大,数据库被连接数压垮。本节先把 HikariCP 的默认值逐个摊开,再给出一套可执行的推导与观测方法。
11.1.1 HikariCP 为什么成了默认
Spring Boot 从 2.0 起把 HikariCP 作为默认连接池,替代了此前的 Tomcat JDBC Pool。它不是「又一个池」,而是针对「高并发、短事务」这个 Web 服务最常见负载做了专门优化:
| 设计 | 解决的问题 |
|---|---|
ConcurrentBag | 借还连接时避免全局锁,降低线程竞争 |
| 字节码代理(Javassist) | 用生成类替代反射调用,减少每次 JDBC 调用的开销 |
FastList | 去掉 ArrayList 的范围检查,微优化 statement 列表 |
| Statement 缓存 | 复用 PreparedStatement,减少数据库解析 |
这些优化的前提是连接被短暂借出、快速归还。反过来说,HikariCP 不擅长长事务或批处理——那种负载下,池的设计优势发挥不出来,真正的问题在于「一条连接被一个长任务独占很久」。所以调 HikariCP 的第一步,是确认自己的负载形态是不是「短事务」。
常见池放在一起对比,便于理解各自的定位:
| 连接池 | 定位 | 何时会选它 |
|---|---|---|
| HikariCP | 高并发短事务,代码路径极简 | 默认,绝大多数 Web 服务 |
| Tomcat JDBC Pool | 功能全,与 Tomcat 同源 | 需要某些特定拦截能力时 |
| DBCP2 | 老牌,配置项多 | 历史项目遗留 |
| Druid | 带监控、SQL 防火墙、防注入 | 需要内置 SQL 审计/监控面板时 |
选型上,除非明确需要 Druid 那套 SQL 审计能力,否则留在 HikariCP。切池的收益通常远小于「把池参数配对」带来的收益。
11.1.2 4.x 的模块与配置入口
Spring Boot 4.0 起工程被模块化,数据访问相关的模块与 starter 名如下:
| 用途 | 模块 | starter |
|---|---|---|
| 纯 JDBC / 连接池 | spring-boot-jdbc | spring-boot-starter-jdbc |
| Spring Data JPA | spring-boot-data-jpa | spring-boot-starter-data-jpa |
| 测试基础设施 | spring-boot-jdbc-test / spring-boot-data-jpa-test | spring-boot-starter-jdbc-test 等 |
只要 classpath 上有 HikariCP,且应用没有自己定义 DataSource bean,自动配置就会用 Hikari 建池。4.1.1 实测对应的 HikariCP 版本是 7.0.2。
配置入口是 spring.datasource.hikari.*。这里有个常被误解的点:这组属性不是 Spring Boot 自己维护的清单,而是宽松绑定到 HikariCP 的 HikariConfig setter 上。也就是说,HikariCP 支持的属性基本都能通过 spring.datasource.hikari.<属性名> 传入,不必等 Spring Boot 逐个「支持」。这既方便,也危险——写错属性名不会报错,只会静默失效。
多数据源场景下要自己声明 DataSource bean,此时 Spring Boot 不再自动配置,需要手工把参数绑上:
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("app.datasource.primary")
public HikariDataSource primaryDataSource() {
return new HikariDataSource();
}
}
@ConfigurationProperties 会把 app.datasource.primary.* 里的键绑定到 HikariDataSource 的 setter 上,等价于 spring.datasource.hikari.* 的宽松绑定。注意 HikariDataSource 一旦实例化就立即建池,所以 url、username、password 必须在这之前就绑好——这也是为什么这里用「先 new 再由容器绑属性」的写法,而不是在构造函数里传参。
11.1.3 maximum-pool-size 怎么推导
网上流传最广的公式是「CPU 核数 × 2 + 磁盘数」。不要照抄它。 那个公式出自 PostgreSQL 官方 wiki,前提是「单机、纯数据库、连接只用来跑 SQL、几乎没有网络延迟」。而我们的池大小是应用侧的概念,它和「应用实例数」强相关,和「数据库服务器有几块盘」基本无关。
正确的推导顺序是从数据库往下倒推:
- 先确定数据库能接受多少连接(PostgreSQL 默认
max_connections = 100,MySQL 默认 151),扣掉给 DBA、监控、备份工具、其他应用的预留。 - 除以你的应用实例数,得到每实例的池上限:
每实例池大小 ≤ (可用连接数) / 实例数。 - 再用延迟目标反推是否需要更多:池大小 ≈ 并发请求数 × 单请求持有连接的时间 / 请求间隔(利特尔法则)。这一步必须靠真实压测,不能拍脑袋。
经验起点:读写混合的 Web 服务,单实例池大小常见落在 10~20 之间。把池开得比 CPU 核数大很多,往往不会提升吞吐——数据库侧的上下文切换和锁等待会先饱和,吞吐反而下降。
spring:
datasource:
url: jdbc:postgresql://db-primary:5432/bookloan
username: bookloan
password: ${DB_PASSWORD}
hikari:
pool-name: bookloan-primary
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
max-lifetime: 1200000
keepalive-time: 300000
leak-detection-threshold: 20000
上面每个值下面逐个解释。
11.1.4 minimum-idle、max-lifetime、keepalive-time 与 wait_timeout
minimum-idle 的默认值是「等于 maximum-pool-size」——也就是说,HikariCP 默认让池保持满员,而不是「用到才建」。这么做是为了避免突发流量时现建连接(建连接要走 TCP + 认证,可能几十毫秒)。想让它弹性收缩就把 minimum-idle 调小,代价是空闲后回收,突发时重新建连会慢一拍。除非内存非常紧张,否则不建议动它。
max-lifetime 默认 1800000 毫秒(30 分钟)。它的作用是:连接活过这个时间就被关闭重建,避免复用一条「已经被服务端或中间设备悄悄关掉」的死连接。这里的关键约束是——max-lifetime 必须短于任何中间层的连接寿命上限:
- MySQL 的
wait_timeout默认 28800 秒(8 小时),空闲连接会被服务端主动断开; - PostgreSQL 的空闲超时
idle_session_timeout默认是 0(关闭),但云数据库的代理、负载均衡器、防火墙常有 5~30 分钟的 idle timeout。
一条经验规则:max-lifetime 比已知的最短超时再小几分钟。比如中间层 5 分钟断空闲连接,max-lifetime 就设 34 分钟(180000240000 毫秒)。
keepalive-time 默认 0(关闭)。设为非 0 后,HikariCP 会周期性地对空闲连接发心跳,防止被中间设备判定为空闲而断开。常见设 300000(5 分钟)。它必须小于 max-lifetime,否则连接还没心跳就先被 max-lifetime 关掉了。
三者关系可以记成一条链:keepalive-time < max-lifetime < 中间层空闲超时。任何一层次序写反,都会出现「连接被服务端关闭但池还以为是好的」这类偶发错误。
11.1.5 connection-timeout 与前端超时的配套
connection-timeout 默认 30000 毫秒(30 秒)。它指的是从池里借连接的最长等待时间,不是 SQL 执行时间,也不是事务时间。
默认 30 秒在生产上是危险的:池被占满时,一个请求可以在这里排队 30 秒才失败,而前端和网关的超时通常只有几秒——用户早就看到转圈或超时,后端却还在等池。合理的做法是把它设成 1~5 秒,让它快速失败,把「要不要重试/熔断」交给上层。
配套关系必须满足:
connection-timeout < 上游(网关 / HTTP 客户端 / 负载均衡器)超时 < 前端超时
如果应用侧的超时反而比上游长,上游会先断开,应用线程却还挂在借连接上,白白占用线程和内存。book-loan 的借阅接口如果前端超时是 5 秒,那 connection-timeout 设 3 秒、网关设 4 秒,层次就顺了。
11.1.6 leak-detection-threshold:连接泄漏检测
leak-detection-threshold 默认 0(关闭)。设为非 0 后,任何连接被借出超过该毫秒数还没归还,HikariCP 就打一条 WARN 日志,并带上当时借连接的调用堆栈。
它的代价是每次借还都多记一次时间戳,开销很小,生产上可以常开一个较大的值(比如 20000,即 20 秒)。看到告警时不要直接断定是泄漏:它只表示「连接被持有超过阈值」,而慢 SQL 会让事务变长,同样触发这条告警。正确顺序是——先看这条连接的 usage 时长和当时的 SQL,确认是「事务真的长」还是「代码忘了关连接 / 忘了结束事务」。
真正由代码造成的泄漏,典型形态是手动 DataSource.getConnection() 后没有在 finally 里 close(),或把连接存进静态字段。用 Spring 的 JdbcTemplate / JPA 时正常不会泄漏,一旦见到泄漏告警,优先怀疑手写的 JDBC 代码。
11.1.7 池大小 × 线程池大小:别把数据库压垮
池大小是每实例的,而应用通常是多实例部署,所以真正压到数据库上的是:
数据库连接总数 = 实例数 × 每实例池大小
10 个实例、每池 20,就是 200 条连接,直接顶爆 PostgreSQL 默认的 100、MySQL 默认的 151。这是生产上最常见的「连接池调优」翻车方式:单个实例看起来没问题,一扩容就把数据库连满。
更隐蔽的是线程池与池大小的乘积。Tomcat 默认有 200 个工作线程,如果连接池只有 20,那么最多 20 个线程能真正干活,另外 180 个在池外排队——这时限制吞吐的是池,不是线程数。反过来,线程池很小、池很大,池里的连接又常年空闲,纯属浪费。两者要一起算:
- 线程数决定「能同时处理多少请求」;
- 池大小决定「能同时有多少请求真的打到数据库」;
- 后者的乘积(再乘实例数)不能超过数据库的连接上限。
把 book-loan 的账算一遍:数据库是 PostgreSQL,max_connections = 100,给 DBA、监控、备份预留 20,剩 80 条可用。应用部署 4 个实例,那么每实例池上限就是 80 / 4 = 20。如果 Tomcat 线程池是默认 200,那么每个实例里最多 20 个线程能真正打到数据库,其余在池外排队——限制吞吐的是池,不是线程。这时盲目把 Tomcat 线程数调到 500 只会让排队更长。反过来说,如果业务峰值确实需要每实例 30 条连接,就必须先把 max_connections 抬上去,或减少实例数,否则扩容到 5 个实例时连接数就超了。
11.1.8 用 Actuator 指标判断该调池还是查慢 SQL
光看配置猜不出瓶颈。加上 spring-boot-starter-actuator 后,HikariCP 的 Micrometer 指标会自动绑定,名字以 hikaricp.connections.* 开头,带一个 pool 标签标识池名。关键指标:
| 指标 | 类型 | 含义 |
|---|---|---|
hikaricp.connections.active | Gauge | 正在被使用的连接数 |
hikaricp.connections.idle | Gauge | 空闲连接数 |
hikaricp.connections.pending | Gauge | 正在等待借连接的线程数 |
hikaricp.connections.max / .min | Gauge | 池上限 / 下限 |
hikaricp.connections.timeout | Counter | 借连接超时的累计次数 |
hikaricp.connections.acquire | Timer | 借连接耗时 |
hikaricp.connections.usage | Timer | 连接被持有的时长 |
hikaricp.connections.creation | Timer | 建立物理连接的耗时 |
查看方式(示例输出,需接入 Actuator 与真实数据库):
# 示例输出
$ curl -s localhost:8080/actuator/metrics/hikaricp.connections.pending
{"name":"hikaricp.connections.pending","measurements":[{"statistic":"VALUE","value":0.0}]}
$ curl -s localhost:8080/actuator/metrics/hikaricp.connections.usage
{"name":"hikaricp.connections.usage","measurements":[{"statistic":"COUNT","value":128340.0},
{"statistic":"TOTAL_TIME","value":612.44},{"statistic":"MAX","value":1.87}]}
判断逻辑只有一条主线:
active长期贴着max,且pending经常大于 0、timeout在涨——池确实吃紧。但先看usage:如果usage的 P99 很高(连接被持有很久),说明是慢 SQL 占着连接,此时调大池只是把等待从应用层挪到数据库层,治标不治本,应该先去查慢 SQL 和索引。pending长期大于 0,但usage的 P99 很低(连接很快归还)——这才是「池该调大」的信号:连接周转很快,只是并发太高。active远小于max,请求却还是慢——问题不在池,在 SQL、索引或应用逻辑。acquire的 P99 高而usage正常——借连接本身慢,看creation是否也高,可能在建连接或网络。
一句话判据:pending > 0 且 usage P99 低 = 调大池;pending > 0 但 usage P99 高 = 查慢 SQL。
把这条判据固化成告警,比人工盯盘可靠。以 Micrometer 的指标为基础,可以配两条互补的规则:
# 示例告警规则(Prometheus 表达风格,指标名对应 Actuator 导出)
# 规则一:连接耗尽风险 —— 等待线程长期存在,且借连接开始超时
rate(hikaricp_connections_timeout_total[5m]) > 0
# 规则二:连接被长期占用 —— 借出的连接使用时长 P99 抬升
hikaricp_connections_usage_seconds_max > 2
两条规则的含义不同:规则一触发说明「请求拿不到连接」,规则二触发说明「连接被拿着不放」。同时触发时,先查规则二——它几乎总是慢 SQL 或长事务,调大池只会掩盖问题。只有规则一单独触发、规则二安静时,才是真正该调大 maximum-pool-size 的信号。
顺带说明指标名在导出侧的变化:Actuator 暴露的是 hikaricp.connections.* 这种带点的名字,Prometheus 等注册表会把点转成下划线、并给 Timer 加上 _seconds 后缀,于是变成 hikaricp_connections_usage_seconds_max。配置告警时要用导出后的名字,不是 Actuator 端点上的名字,这是初次接入监控时的高频错误。
11.1.9 确认配置真的生效
因为 spring.datasource.hikari.* 是宽松绑定,写错一个字母不会报错,只会静默用默认值。上线前必须验证。最直接的办法是在启动日志里找 HikariCP 打印的池信息——它在池首次创建时输出一行 HikariPool-1 - configuration:,随后还有一行 Start completed:
# 示例输出(需真实数据库)
2026-09-30T10:00:03.112+08:00 INFO 51201 --- [ main] com.zaxxer.hikari.HikariDataSource : bookloan-primary - Starting...
2026-09-30T10:00:03.418+08:00 INFO 51201 --- [ main] com.zaxxer.hikari.pool.HikariPool : bookloan-primary - Added connection org.postgresql.jdbc.PgConnection@6f1f2a
2026-09-30T10:00:03.421+08:00 INFO 51201 --- [ main] com.zaxxer.hikari.HikariDataSource : bookloan-primary - Start completed.
如果启用了 logging.level.com.zaxxer.hikari=DEBUG,还会打印完整的 configuration: 行,把 maximumPoolSize、maxLifetime、keepaliveTime 等逐条列出。上线前把这一行和预期值对一遍,比事后从指标反推省事得多。
另一个静态校验点是 Actuator 的 /actuator/configprops 或 /actuator/env,能看到 spring.datasource.hikari 前缀下实际绑定到的值。对多环境配置尤其有用——排查「测试环境生效、生产没生效」时,先比这两个端点。
11.1.10 常见坑
坑一:maximum-pool-size 拍脑袋设成几百。 单实例看似没问题,多实例一扩容就把数据库连接数打满。永远先算「实例数 × 池大小」。
坑二:minimum-idle 设 0 又抱怨首次请求慢。 池从零开始建连接有固定开销,突发流量下第一批请求会明显变慢。除非内存紧张,保持默认(等于 max)更稳。
坑三:max-lifetime 大于数据库 wait_timeout。 连接被服务端断掉后池仍以为可用,复用时报 connection reset 之类的错误。让 max-lifetime 明显小于最短的中间层超时。
坑四:connection-timeout 用默认 30 秒。 它比前端超时还长,导致线程在池外长时间排队。设成 1~5 秒,快速失败。
坑五:只调池不看 usage。 慢 SQL 把连接占满时调大池,会把压力原样转移到数据库,锁等待和上下文切换更严重。
坑六:leak-detection-threshold 一开就断定泄漏。 慢 SQL 同样会触发告警,先看 SQL 再下结论。
小结
- HikariCP 为「高并发短事务」优化,Spring Boot 2.0 起是默认池,4.1.1 对应 HikariCP 7.0.2。
spring.datasource.hikari.*宽松绑定到HikariConfig,HikariCP 支持的属性基本都能用,但写错名字会静默失效。maximum-pool-size从数据库连接上限倒推,不要照抄「CPU×2+磁盘数」;单实例常见 10~20,并乘上实例数校验总量。- 三者的次序是
keepalive-time < max-lifetime < 中间层空闲超时,任何一层写反都会出现偶发的连接失效。 connection-timeout是从池借连接的最长等待,应小于上游超时,设 1~5 秒快速失败。leak-detection-threshold常开一个较大值,但告警要先区分「慢 SQL」与「真泄漏」。- 用
hikaricp.connections.*指标判断:pending> 0 且usageP99 低才该调大池,否则先查慢 SQL。 - 宽松绑定意味着写错属性名不报错,上线前用 HikariCP 的
configuration:日志或/actuator/configprops核对实际生效值。
连接池把单库的并发能力调稳了,下一步才是考虑「一台数据库扛不住时怎么办」。11.2 会讲读写分离,以及为什么它是排在单库优化之后的选项。
阅读导航:上一节:10.3 可靠投递与重试 · 下一节:11.2 读写分离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。