HikariCP 连接池优化:从参数配置到底层原理
一句话结论(30s)
HikariCP 快是因为它用无锁 ConcurrentBag + 字节码精简把连接借还做到零竞争,但”连接池不是给你更多连接,而是让你更高效地用手头的连接”——因为连接数超过 CPU 核数 ×2 + 磁盘数后,MySQL 线程切换开销会反超 SQL 执行本身,所以 pool-size 要按公式算再压测验证,max-lifetime 必须短于数据库 wait_timeout,才能避免连接泄漏与拿已断开的连接。
核心原理(2min)
- 为什么快:无锁 ConcurrentBag(ThreadLocal 缓存热路径零竞争)、150KB 精简字节码、FastList 省范围检查。
- maximum-pool-size:
CPU核数 × 2 + 有效磁盘数,8 核 + 2 SSD = 18;超了徒增线程切换竞争,需压测找拐点。 - max-lifetime < wait_timeout:且
idle-timeout < max-lifetime,否则借到已断开的连接或被半路收回报错。 - 两个坑:别设
connection-test-query(驱动支持isValid(),多设反而每次借出多一次 SELECT 1);URL 加rewriteBatchedStatements=true把批量 INSERT 合成一条,实测提升 50-100 倍。
底层深入(5-10min)
HikariCP 为什么快?
Spring Boot 2.x 把默认连接池从 Tomcat CP 换成了 HikariCP,这不是拍脑袋的决定。HikariCP 的作者 Brett Wooldridge 在字节码级别做了激进的优化:
- 无锁 ConcurrentBag:连接借还不用
BlockingQueue(底层有锁),而是用自定义的ConcurrentBag+ ThreadLocal 缓存,借连接时优先从本线程的缓存取——热路径零竞争。 - 字节码精简:HikariCP 的 jar 包仅 150KB,代理
Connection、Statement等对象时直接生成精简字节码,不反射调用。 - FastList 替代 ArrayList:
get(int)不做范围检查(信任调用方),减少每次取Statement的一次条件判断。
但再快的连接池,参数配错了也是灾难。下面逐个拆解关键参数。
maximum-pool-size:不是越大越好
这是最常被误解的参数。直觉上”连接越多,并发能力越强”——但这是错的。
每个数据库连接都占用:
- MySQL 服务端一个线程(默认
thread_cache_size之外的要创建新线程) - 客户端(应用)一块内存(HikariCP 每个连接约 10KB)
- 数据库的内存(排序缓冲区、读缓冲区等)
连接数过多 → MySQL 线程过多 → CPU 花在”选哪个线程执行”上的时间超过”真正执行 SQL”的时间 → 吞吐不升反降。
科学的计算公式
HikariCP 官方推荐的公式:
maximum-pool-size = (CPU核心数 × 2) + 有效磁盘数
推导逻辑:一个 CPU 核心在任何时刻只能执行一个线程。数据库操作是 IO 密集型——线程大部分时间在等磁盘数据返回,CPU 可以去服务另一个连接。同时活跃的连接数约等于 CPU 核数 × 2 已经是上限了。
举个例子:一台 8 核服务器 + 2 块 SSD → (8 × 2) + 2 = 18。超过 18 个连接后,新增的连接绝大多数时间在”等 CPU”而不是”等磁盘”——徒增竞争。
不是死公式,而要验证。 用 JMeter 压测,从 10 逐步加到 50 个连接,画一张”连接数 vs QPS”曲线——拐点就是你系统的最佳值。业务不同(简单查询 vs 复杂报表),最佳值也不同。
想一想:为什么连接数不是越多越好? 因为每个连接都对应 MySQL 服务端一个线程,线程数超过 CPU 并行能力后,CPU 花在”上下文切换、选哪个线程执行”上的时间会反超”真正执行 SQL”的时间。数据库操作是 IO 密集,线程大部分时间在等磁盘,所以活跃连接数约等于”CPU 核数 × 2 + 磁盘数”就到顶了——再往上加,只是徒增排队和切换竞争,吞吐不升反降。
connection-timeout:借钱不如排队
spring:
datasource:
hikari:
connection-timeout: 30000 # 默认 30 秒
当一个线程向连接池申请连接时,如果所有连接都在被使用,线程等待。connection-timeout 是最大等待时间,超时抛 SQLTransientConnectionException。
这个值绝不要设成 0(无限等)——一个慢查询卡住所有连接 → 新来请求永远等不到连接 → 线程池被打满 → 整个应用雪崩。设成 30000ms 是合理的——30 秒等不到连接说明数据库已经出问题了,快速失败比无限等更安全。
如果这个异常频繁出现,那不是调大超时,而是排查连接泄漏——代码里拿了连接没关(conn.close() 在 finally 里但抛异常没执行到)。
想一想:为什么”快速失败”比”无限等待”更安全? 因为一个慢查询卡住所有连接时,后续请求如果无限等连接,线程池会被逐渐打满、请求越积越多,最终整个应用雪崩。设成 30 秒超时,等于给系统一个”熔断点”——等不到连接就立刻抛异常、走降级,把故障范围限制住,而不是让它像滚雪球一样扩散。
max-lifetime 与 idle-timeout:连接的生命周期
spring:
datasource:
hikari:
max-lifetime: 1800000 # 30 分钟
idle-timeout: 600000 # 10 分钟
这两个参数的逻辑关系:
- idle-timeout:一个连接空闲超过 10 分钟 → 从池中移除(除非池中连接数降到
minimum-idle以下)。防止维护过多无用连接。 - max-lifetime:一个连接从创建到现在超过 30 分钟 → 强制关闭(即使还在被使用,等用完后关)。因为 MySQL 的
wait_timeout默认 8 小时——但中间有各种网络设备(NAT、防火墙)可能在更短的时间内断开空闲连接。HikariCP 推荐max-lifetime比数据库的等待超时短 2-3 分钟,避免拿到已断开的连接。
关键规则:max-lifetime 必须明显小于 MySQL wait_timeout,且 idle-timeout 必须小于 max-lifetime。否则连接被标记为”要关闭”但还没关,又被另一个线程借走——执行 SQL 到一半连接被收回,直接报错。
想一想:为什么
max-lifetime必须小于数据库的wait_timeout? 因为连接池里的连接可能长时间空闲,而 MySQL 或中间的 NAT/防火墙会在某个超时后悄悄断开它。如果池子不知道连接已死、还把它借给业务线程,执行 SQL 到一半就会报”连接已断开”。让max-lifetime比wait_timeout短 2-3 分钟,就能在连接被服务端杀死之前主动把它回收,保证永远借出的是活连接。
connection-test-query:Spring Boot 默认有一个坑
spring:
datasource:
hikari:
connection-test-query: SELECT 1
HikariCP 有自己的一套连接验证机制(isValid() 方法),不需要 connection-test-query。如果数据库 JDBC Driver 支持 Connection.isValid()(MySQL 的 Connector/J 支持,JDBC 4.0 标准),HikariCP 直接调用它——这是一个轻量级的网络 ping,不执行完整 SQL。
设了 connection-test-query: SELECT 1 反而会在每个连接借出时执行一次额外的 SQL 往返——如果你有 1000 QPS、20 个连接,每个连接平均每秒被借用 50 次,那就是每秒 50 × 20 = 1000 次无用的 SELECT 1。
想一想:为什么
isValid()能替代SELECT 1? 因为 JDBC 4.0 的Connection.isValid()只是驱动发一个轻量的网络 ping 来确认连接还活着,不需要真的执行一条完整 SQL、也不产生解析开销;而SELECT 1每次借连接都要多一次完整 SQL 往返。在高 QPS 下,这个”多余的往返”会被放大成千上万次,纯粹是无用功——所以”不配”比”配了”更快。
rewriteBatchedStatements:被忽视的 100 倍优化
这个参数不在 HikariCP 里,在 MySQL JDBC URL 里——但它对连接池场景下的批量操作影响巨大:
spring:
datasource:
url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
MySQL JDBC Driver 默认把多 values 的 INSERT 拆成多条独立 SQL。rewriteBatchedStatements=true 让它把 addBatch() 中的多条 SQL 重写成一条:
-- 默认行为:3 次网络往返
INSERT INTO t VALUES (1);
INSERT INTO t VALUES (2);
INSERT INTO t VALUES (3);
-- rewriteBatchedStatements=true:1 次网络往返
INSERT INTO t VALUES (1),(2),(3);
这个参数和 HikariCP 的关系:连接池让连接的”打开”成本几乎为零,但”网络往返”的成本仍然存在。用这个参数把 1000 次网络 IO 压缩成 1 次——实测性能提升 50-100 倍。
总结:一个生产可用的配置
spring:
datasource:
hikari:
maximum-pool-size: 20 # 8核+2盘 → 18,取整 20
minimum-idle: 10 # 保持 10 个热连接
connection-timeout: 30000 # 30 秒等不到就报错
idle-timeout: 600000 # 10 分钟空闲释放
max-lifetime: 1800000 # 30 分钟强制回收
leak-detection-threshold: 10000 # 连接借出 10 秒未还 → 日志警告(连接泄漏)
url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true&useSSL=false&allowPublicKeyRetrieval=true
HikariCP 的优化哲学是 “最好的优化是不做多余的事”。连接池的核心不是”给你更多连接”,而是”让你更高效地使用手头的连接”。
章末提问
Q1:HikariCP 为什么比 Druid、Tomcat CP 更快? 结论先行:因为它把连接借还做到热路径零竞争——无锁 ConcurrentBag + ThreadLocal 缓存 + 精简字节码。 因为传统连接池用 BlockingQueue,借还都有锁竞争;HikariCP 用 ConcurrentBag 让每个线程优先从自己的 ThreadLocal 缓存取连接,命中时完全无锁。再加上 150KB 的精简字节码、FastList 省掉范围检查,把每个细节的常数开销都压到最低。本质是”最快的代码是不执行多余的代码”。
Q2:maximum-pool-size 怎么算?为什么不是越大越好?
结论先行:按 CPU核数 × 2 + 磁盘数 估算,再压测找拐点;越大反而越慢。
因为每个连接占用 MySQL 一个线程和一批缓冲区,连接数超过 CPU 并行能力后,线程切换开销反超 SQL 执行本身。数据库操作是 IO 密集,活跃连接数约等于 CPU 核数 × 2 就是上限,超出的连接只是在”等 CPU”,徒增竞争。所以连接池优化的核心不是”给更多连接”,而是”更高效地用现有连接”。
Q3:max-lifetime 为什么要小于 wait_timeout,不设会怎样?
结论先行:为了在数据库断开空闲连接之前主动回收,避免把”已断开的连接”借给业务线程报错。
因为 MySQL 的 wait_timeout 和中间 NAT/防火墙都会悄悄断开长时间空闲的连接;如果池子不知情还把它借出去,SQL 执行到一半就会失败。让 max-lifetime 短 2-3 分钟,能保证借出的永远是活连接;同时 idle-timeout 还要小于 max-lifetime,避免连接”被标记关闭但还没关”又被借走。