《Spring Boot 实战》11.1 HikariCP 调优

HikariCP 是 Spring Boot 的默认连接池,但默认值从不为生产负载而设。本节从数据库连接上限倒推 maximum-pool-size,讲清 minimum-idle、max-lifetime 与数据库 wait_timeout 的关系,给出连接泄漏检测与超时配套,并用 Actuator 的 hikaricp.connections.* 指标判断该调池还是查慢 SQL。

本节目标:掌握 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-jdbcspring-boot-starter-jdbc
Spring Data JPAspring-boot-data-jpaspring-boot-starter-data-jpa
测试基础设施spring-boot-jdbc-test / spring-boot-data-jpa-testspring-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、几乎没有网络延迟」。而我们的池大小是应用侧的概念,它和「应用实例数」强相关,和「数据库服务器有几块盘」基本无关。

正确的推导顺序是从数据库往下倒推:

  1. 先确定数据库能接受多少连接(PostgreSQL 默认 max_connections = 100,MySQL 默认 151),扣掉给 DBA、监控、备份工具、其他应用的预留。
  2. 除以你的应用实例数,得到每实例的池上限:每实例池大小 ≤ (可用连接数) / 实例数。
  3. 再用延迟目标反推是否需要更多:池大小 ≈ 并发请求数 × 单请求持有连接的时间 / 请求间隔(利特尔法则)。这一步必须靠真实压测,不能拍脑袋。

经验起点:读写混合的 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.activeGauge正在被使用的连接数
hikaricp.connections.idleGauge空闲连接数
hikaricp.connections.pendingGauge正在等待借连接的线程数
hikaricp.connections.max / .minGauge池上限 / 下限
hikaricp.connections.timeoutCounter借连接超时的累计次数
hikaricp.connections.acquireTimer借连接耗时
hikaricp.connections.usageTimer连接被持有的时长
hikaricp.connections.creationTimer建立物理连接的耗时

查看方式(示例输出,需接入 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 且 usage P99 低才该调大池,否则先查慢 SQL。
  • 宽松绑定意味着写错属性名不报错,上线前用 HikariCP 的 configuration: 日志或 /actuator/configprops 核对实际生效值。

连接池把单库的并发能力调稳了,下一步才是考虑「一台数据库扛不住时怎么办」。11.2 会讲读写分离,以及为什么它是排在单库优化之后的选项。

阅读导航:上一节:10.3 可靠投递与重试 · 下一节:11.2 读写分离 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计