《Java 编程实战》3.2 HikariCP:连接池参数与泄漏排查

每条请求都新建物理连接会拖慢 libraryd,也很快耗尽 PostgreSQL 的连接数。本节用 HikariCP 建立有界连接池,讲清 maximumPoolSize、connectionTimeout、maxLifetime 与 leakDetectionThreshold 的含义,并用真实日志定位连接泄漏和池耗尽。

本节目标:把 3.1 的 DriverManager 换成 HikariCP,理解池大小、超时、生命周期与泄漏检测参数,并能从池指标和异常信息定位「连接不够用」的真实原因。
适用版本:JDK 25、HikariCP 7.1.0、PostgreSQL JDBC 42.7.14、PostgreSQL 17。

承接的问题

3.1 每次调用 DriverManager.getConnection 都会新建一条物理连接:TCP 握手、密码认证、会话初始化,全部重来。单机跑几条查询看不出问题,一旦 libraryd 接上 HTTP,几十个请求同时到达,数据库会先被连接建立拖慢,然后被连接数压垮。

PostgreSQL 的连接不是免费的。本机容器里的默认配置是:

max_connections = 100
shared_buffers  = 128MB

每个连接都有独立的后端进程和内存开销。100 个连接听起来不少,但如果部署 10 个 libraryd 实例,每个实例开 10 条连接,正好把默认额度吃满,连运维登录查问题都没有余量。连接池的第一个作用不是「复用连接更快」,而是给连接数设一个上限。

第二个作用是让借还连接变成常数时间。池启动时建好一部分连接,请求到来时直接从池里借,用完还回去。只要池里有空闲连接,获取连接就不需要再做认证。

核心机制

池的关键参数

HikariCP 的参数很多,真正需要先定下来的只有下面几组。其余参数保持默认通常比乱调更安全。

参数libraryd 取值作用常见误用
maximumPoolSize10池里最多同时存在多少条连接直接设成数据库 max_connections
minimumIdle2空闲时保留多少条连接和最大值一样大,却以为更省资源
connectionTimeout3000 ms等待空闲连接的最长时间当成 SQL 执行超时
validationTimeout1000 ms借出前校验连接可用的最长时间设得比 connectionTimeout 还大
idleTimeout600000 ms空闲连接多久后回收在 minimumIdle = maximumPoolSize 时期待生效
maxLifetime1800000 ms连接的最长寿命,到期后替换大于中间代理的空闲断开时间
keepaliveTime60000 ms定期探测空闲连接大于 maxLifetime 导致配置无效
leakDetectionThreshold10000 ms借出多久未还就打印泄漏栈小于 2000 ms,被 Hikari 直接禁用
initializationFailTimeout1000 ms启动建连失败多久后放弃设成负数,让应用带病启动

maximumPoolSize 怎么定?Hikari 文档给过一个起点公式:连接数 = CPU 核数 * 2 + 有效磁盘数。它假设数据库是主要瓶颈,连接多了只会增加上下文切换。这个公式不是定律,但对 libraryd 这种读多写少的服务,10 条是一个合理的起点。真正上线前要用压测确认,而不是把池开到 100 条赌吞吐。

minimumIdle 和 maximumPoolSize 的关系决定池的形状。minimumIdle = 2 表示空闲时只保留两条连接,突发流量来了再逐步建到 10 条。这样启动快、空闲资源少,代价是突发时前几条请求要等建连接。如果延迟比资源更敏感,可以把 minimumIdle 调到和最大值一样,让池固定大小;Hikari 官方也倾向于固定池,因为动态扩缩本身的收益有限。

maxLifetime 要小于「中间层主动断开空闲连接」的时间。直连 PostgreSQL 时默认没有 idle_session_timeout,30 分钟很安全;如果前面有负载均衡器或连接代理,它们可能 5 分钟就断开空闲连接,此时 maxLifetime 必须更短,并且留出至少 30 秒余量。否则应用会借到一条已经被对端关闭的连接,第一次查询报 An I/O error occurred while sending to the backend。

借与还:连接不是新建的

调用 dataSource.getConnection() 拿到的是池的代理对象。close() 不是关闭物理连接,而是把它还给池。下面这段本机代码连续借两条、还一条,并打印池的实时状态:

try (HikariDataSource pool = pool("libraryd-pool", 2, 1_000, 0)) {
    HikariPoolMXBean mx = pool.getHikariPoolMXBean();
    System.out.println("after start total=" + mx.getTotalConnections()
            + " idle=" + mx.getIdleConnections() + " active=" + mx.getActiveConnections());

    Connection first = pool.getConnection();
    Connection second = pool.getConnection();
    System.out.println("borrowed 2 total=" + mx.getTotalConnections()
            + " idle=" + mx.getIdleConnections() + " active=" + mx.getActiveConnections());

    first.close();
    System.out.println("returned 1 total=" + mx.getTotalConnections()
            + " idle=" + mx.getIdleConnections() + " active=" + mx.getActiveConnections());
    second.close();
}

本机输出:

after start total=1 idle=1 active=0
borrowed 2 total=2 idle=0 active=2
returned 1 total=2 idle=1 active=1

total 是池里当前存在的物理连接总数,idle 是空闲可借,active 是已经借出。启动时 minimumIdle = 1 先建了一条;借第二条时池才扩到 2;还回一条后物理连接没有消失,只是从 active 回到 idle。

这三个数字是排查连接问题的第一手材料。active == total 且 idle == 0,说明所有连接都被占着;如果持续这样,要么池太小,要么有代码借了不还。第 13 章会把它们接到 Micrometer,持续暴露 active、idle、total 和等待线程数;但在没有监控之前,遇到慢请求时先把这三个数打出来,已经能排除一半的猜测。

启动失败与初始化超时

new HikariDataSource(config) 不只是保存配置,它会立刻启动池,并按 minimumIdle 建立初始连接。如果数据库没启动、端口写错或密码不对,构造器就会失败,而不是等到第一条 SQL 才报错。

本机把端口改成 1,initializationFailTimeout 设为 1000 ms:

[main] INFO com.zaxxer.hikari.HikariDataSource - broken-pool - Starting...
class=com.zaxxer.hikari.pool.HikariPool$PoolInitializationException
message=Failed to initialize pool: Connection to 127.0.0.1:1 refused.
Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.
cause=org.postgresql.util.PSQLException: Connection to 127.0.0.1:1 refused.

这个行为对部署是好事:配置错误在启动阶段暴露,进程直接退出,编排系统按重启策略重试,不会出现「进程活着但所有请求都 500」的半死状态。initializationFailTimeout 默认只有 1 ms,含义是尽快尝试一次并失败;把它显式设成 1000 ms,是给容器编排环境一点启动窗口。

反过来,如果业务要求「数据库暂时不可用也要先启动,靠健康检查恢复」,可以设成负数跳过初始建连。libraryd 不选这条路:它没有本地缓存可供降级,启动时连不上数据库就没有任何有意义的工作可做,早失败比晚失败更容易排查。

泄漏检测与池耗尽

连接泄漏的典型写法是借了 Connection 却没有关:方法提前返回、异常路径漏掉 close()、把连接塞进缓存或静态字段。Hikari 的 leakDetectionThreshold 会在连接借出超过阈值仍未归还时打印一条 WARN,并附上借出位置的堆栈。

本机把池大小设为 1、阈值设为 2000 ms,借出后故意持有 3.5 秒:

[leak-pool:housekeeper] WARN com.zaxxer.hikari.pool.ProxyLeakTask -
Connection leak detection triggered for org.postgresql.jdbc.PgConnection@376b4233
on thread main, stack trace follows
java.lang.Exception: Apparent connection leak detected
    at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:99)
    at Check32.main(Check32.java:30)
[main] INFO com.zaxxer.hikari.pool.ProxyLeakTask -
Previously reported leaked connection ... was returned to the pool (unleaked)

这条日志的价值在堆栈:它直接指向 getConnection() 的调用点。拿到位置后再看那段代码有没有 try-with-resources,通常几分钟就能定位。日志里的 unleaked 表示连接最终还回来了,不是永久泄漏;如果一直没有这行,连接就真的丢了。

阈值本身有下限。HikariCP 7.1.0 在阈值小于 2000 ms 时会打印:

WARN com.zaxxer.hikari.HikariConfig - leak-pool -
leakDetectionThreshold is less than 2000ms or more than maxLifetime, disabling it.

所以不要把阈值设成 500 ms 期待更早发现;它会被静默关掉。10 秒是一个折中:足够覆盖正常查询,又不会把慢查询全部误报。

池耗尽的表现是另一个异常。池大小 1、connectionTimeout = 1000 ms,先借走唯一一条连接,再请求第二条:

class=java.sql.SQLTransientConnectionException
after=1002ms message=small-pool - Connection is not available, request timed out
after 1001ms (total=1, active=1, idle=0, waiting=0)

这行消息已经把现场说清楚了:total=1、active=1、idle=0,没有空闲连接;waiting=0 说明等待队列在超时那一刻也没有排队的请求。看到它不要第一反应去调大池,先确认那一条连接为什么一直不还。

在 libraryd 里落地

连接池只在进程启动时创建一次,由 DataSourceFactory 统一装配:

package com.plume.library.persistence;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

public final class DataSourceFactory {

    private DataSourceFactory() {
    }

    public static HikariDataSource create(
            String jdbcUrl, String user, String password, int poolSize) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(jdbcUrl);
        config.setUsername(user);
        config.setPassword(password);
        config.setPoolName("libraryd-pool");
        config.setMaximumPoolSize(poolSize);
        config.setMinimumIdle(Math.min(2, poolSize));
        config.setConnectionTimeout(3_000);
        config.setValidationTimeout(1_000);
        config.setInitializationFailTimeout(1_000);
        config.setIdleTimeout(600_000);
        config.setMaxLifetime(1_800_000);
        config.setKeepaliveTime(60_000);
        config.setLeakDetectionThreshold(10_000);
        return new HikariDataSource(config);
    }
}

几个取值和前面的表一一对应。connectionTimeout 只有 3 秒,是因为 libraryd 的接口应该在秒级返回;如果池里 3 秒都拿不到连接,继续等 30 秒只会把请求线程一起拖住。minimumIdle 取 Math.min(2, poolSize),是为了让 LIBRARYD_POOL_SIZE=1 的测试配置也能成立。

配置从环境变量读取。AppConfig 放在 libraryd-app 的 com.plume.library.app 包,DataSourceFactory 留在 persistence,两者由 AppMain 装配;persistence 不反向依赖 app。AppConfig 负责把字符串转成有范围的整数:

import java.util.Map;

public record AppConfig(
        String dbUrl,
        String dbUser,
        String dbPassword,
        int httpPort,
        int poolSize,
        String logLevel) {

    public static AppConfig fromEnv() {
        return fromEnv(System.getenv());
    }

    public static AppConfig fromEnv(Map<String, String> env) {
        return new AppConfig(
                env.getOrDefault("LIBRARYD_DB_URL",
                        "jdbc:postgresql://localhost:5432/libraryd"),
                env.getOrDefault("LIBRARYD_DB_USER", "libraryd"),
                env.getOrDefault("LIBRARYD_DB_PASSWORD", "libraryd"),
                parsePort(env.getOrDefault("LIBRARYD_HTTP_PORT", "8080")),
                parsePoolSize(env.getOrDefault("LIBRARYD_POOL_SIZE", "10")),
                env.getOrDefault("LIBRARYD_LOG_LEVEL", "INFO"));
    }

    private static int parsePort(String value) {
        int port = Integer.parseInt(value);
        if (port < 1 || port > 65535) {
            throw new IllegalArgumentException("LIBRARYD_HTTP_PORT 超出范围: " + port);
        }
        return port;
    }

    private static int parsePoolSize(String value) {
        int size = Integer.parseInt(value);
        if (size < 1 || size > 100) {
            throw new IllegalArgumentException("LIBRARYD_POOL_SIZE 超出范围: " + size);
        }
        return size;
    }
}

fromEnv(Map) 重载让测试可以传入一张固定的 Map,不依赖进程环境,也不需要在测试里改全局变量。parsePoolSize 把范围限制在 1 到 100:小于 1 没有意义,大于 100 在默认 PostgreSQL 配置下会先撞上 max_connections。

两个解析方法都选择抛 IllegalArgumentException 而不是回退默认值。配置写错时,回退默认值会让服务带着一个「看起来正常」的池启动,问题被推迟到生产流量上来之后;启动即失败则把错误暴露在部署日志里。这个取舍和第 2 章的质量门禁一致:宁可构建失败,也不要静默通过。

装配顺序是:AppConfig.fromEnv() → DataSourceFactory.create(...) → 三个 Jdbc*Repository 共享同一个 HikariDataSource。HikariDataSource 本身是线程安全的,仓储类只持有它的引用,不要每个请求 new 一个池。

进程退出时要关闭池。第 7 章会把它接到 Javalin 的优雅停机钩子上;在那之前,至少在 main 方法的 finally 或 try-with-resources 里调用 close()。close() 会停止后台管家线程并关闭所有物理连接,漏掉它会让测试进程偶尔挂住。

本机的池状态和异常输出都来自 postgres:17-alpine 容器,端口 55433。这些数字不是跨环境基准,只用于说明 total/active/idle 三个计数的含义。

常见错误与边界

「池越大吞吐越高。」 连接数超过数据库并行处理能力后,多出来的连接只是在排队,还会增加内存和上下文切换。默认 max_connections = 100 是给整个数据库实例的额度,不是给单个应用的。按「实例数 × 池大小 + 运维余量 < max_connections」做预算,比拍一个 50 更可靠。

「connectionTimeout 可以当查询超时。」 它只管「从池里拿连接」这一步。连接借到之后,一条慢 SQL 可以跑几分钟,期间这条连接一直占着。查询超时要靠 Statement.setQueryTimeout() 或 PostgreSQL 的 statement_timeout,两者解决的是不同阶段的问题。

「把 Connection 存进字段或 ThreadLocal。」 池连接是有限资源,存起来就等于永久借出。更隐蔽的是在 ThreadLocal 里缓存,线程池复用线程时看起来「还能用」,但连接可能已经被池回收或对端断开。正确做法是每个方法用 try-with-resources 借还,需要跨方法传递时传 DataSource,不传 Connection。

「maxLifetime 比中间层空闲超时还长。」 数据库或代理已经断开,池却以为连接还活着,借出去后第一次使用才报 I/O 错误。把 maxLifetime 设得比中间层的空闲断开时间短,并留 30 秒以上余量。

「用 leakDetectionThreshold 代替修复泄漏。」 它只是诊断工具,不会自动归还连接。阈值调得再低,泄漏的代码仍然占着连接;而且低于 2000 ms 会被 Hikari 禁用。

小结

  • 连接池的首要作用是把数据库连接数变成可控上限,其次才是复用带来的低延迟。
  • maximumPoolSize 按实例数和数据库额度分配,10 是一个起点,不是标准答案。
  • minimumIdle 决定池的形状;固定池和弹性池各有取舍,idleTimeout 只在两者不相等时才有意义。
  • connectionTimeout 只管借连接,SQL 执行超时要单独设置。
  • maxLifetime 要短于中间层的空闲断开时间,并留出余量。
  • leakDetectionThreshold 打印借出点堆栈,低于 2000 ms 会被禁用;池耗尽时先看 total/active/idle,不要先调大池。

连接已经能稳定借还,下一步是把 3.1 里散落的 SQL 和 DataSource 调用收进仓储类。3.3 会定义领域端口、三个 JDBC 实现,以及从 SQLState 到 ErrorCode 的翻译。

阅读导航:上一节:3.1 JDBC 基础:Connection、PreparedStatement 与结果集 · 下一节:3.3 数据访问层:把 SQL 装进 Repository 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发