Java 基础体系 · 第 84/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 数据库连接池:HikariCP、容量、超时、泄漏和监控

数据库连接池解决的不是“让数据库更快”这一单一问题,而是复用数据库连接、限制并发连接数,并把连接创建、借用、归还和失效检测纳入统一生命周期管理。

在 Java 中,应用通常通过 JDBC 的 DataSource 获取连接:

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement =
             connection.prepareStatement("select id, name from account where id = ?")) {

    statement.setLong(1, 42L);

    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
            System.out.printf("%d %s%n",
                    resultSet.getLong("id"),
                    resultSet.getString("name"));
        }
    }
}

ConnectionPreparedStatementResultSet 都实现了 AutoCloseable。这里的 try-with-resources 不只是语法简化:它保证异常路径也会执行关闭操作。对于连接池来说,关闭池化连接通常意味着“归还连接”,而不是立刻断开底层 TCP 连接。

JDBC 规范定义了应用访问数据库的基础抽象,但没有规定连接池必须如何实现。连接池是 JDBC DataSource 之上的常见实现。Jakarta Persistence(JPA)则通过 EntityManager、事务和持久化上下文提供 ORM 抽象;JPA 实现通常再使用一个 JDBC DataSource,因此 JPA 事务边界最终仍然会影响连接的借用时长。


连接池到底复用了什么

一次数据库访问至少涉及以下对象和资源:

  1. 应用线程。
  2. 池中的逻辑连接对象。
  3. 底层数据库连接,通常包含 TCP 连接、认证状态、会话状态。
  4. 数据库端的会话、工作内存和执行资源。
  5. SQL 执行期间的锁、游标、结果集和事务状态。

“连接池复用”主要复用第 3 项及其相关数据库会话。若每次请求都新建连接,流程通常是:

应用线程
  -> 创建 TCP 连接
  -> TLS 握手(如果启用)
  -> 数据库认证
  -> 创建数据库会话
  -> 执行 SQL
  -> 关闭连接

连接池把它变成:

应用启动或首次需要时创建底层连接
  -> 应用借用逻辑连接
  -> 执行 SQL
  -> 关闭逻辑连接并归还池
  -> 后续请求重复使用底层连接

因此,连接池减少的是连接建立成本,并且为数据库并发设置了一个上限。它不会绕过 SQL 执行、锁竞争、磁盘 IO 或数据库 CPU 饱和。

连接的几个状态

可以把一个池化连接抽象成以下状态:

stateDiagram-v2
    [*] --> Creating
    Creating --> Idle: 创建并通过校验
    Creating --> Failed: 创建失败
    Idle --> Borrowed: getConnection()
    Borrowed --> Idle: close()
    Borrowed --> Evicting: 超过 maxLifetime 或发现失效
    Idle --> Evicting: idleTimeout / keepalive 检查失败
    Evicting --> Closed: 物理关闭
    Failed --> Closed: 放弃创建
    Closed --> Creating: 池仍需要补足容量

几个状态转换容易被误解:

  • Idle 表示连接在池中可立即借用,不表示数据库事务一定已经提交。
  • Borrowed 表示连接已经交给应用代码,连接池通常无法控制应用是否正在执行 SQL。
  • 调用池化连接的 close() 通常触发归还。
  • 连接因 maxLifetime 被淘汰时,通常要等它归还后再安全关闭;正在使用的连接不会被简单地强行切断。
  • 如果应用没有关闭连接,连接会一直处于 Borrowed,这就是连接泄漏的典型表现。

HikariCP 的核心模型

HikariCP 是一个 JDBC DataSource 实现。应用不应依赖 HikariCP 的内部连接类,而应依赖标准接口:

DataSource dataSource;
try (Connection connection = dataSource.getConnection()) {
    // 使用 JDBC
}

HikariCP 管理的关键数量包括:

  • 总连接数:池当前拥有的底层连接数。
  • 空闲连接数:当前未被借用的连接数。
  • 活动连接数:当前已被应用借用的连接数。
  • 等待线程数:正在等待连接的应用线程数。

它们满足近似关系:

Total=Active+Idle\text{Total} = \text{Active} + \text{Idle}

在没有连接创建或淘汰瞬间时,这个关系最直观。Total 通常不超过 maximumPoolSize,而 Active 达到上限且没有连接归还时,新的 getConnection() 调用就会等待,直到拿到连接或超时。

一个最小可运行示例

下面的示例使用 H2 作为演示数据库。生产环境只需替换 JDBC 驱动、JDBC URL、用户名和密码;HikariCP 的使用方式不因数据库类型改变。

Maven 依赖:

<dependencies>
    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>7.0.2</version>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <version>2.3.232</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

示例版本应与项目实际使用的 JDK、驱动和发布渠道核对。Java 25 LTS 不会改变 JDBC 的基本连接生命周期;连接池仍然是普通 Java 库,关键兼容性来自 HikariCP、数据库驱动和数据库服务器本身。

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

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public final class HikariExample {
    public static void main(String[] args) throws SQLException {
        HikariConfig config = new HikariConfig();

        config.setPoolName("orders-pool");
        config.setJdbcUrl("jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setPassword("");

        config.setMaximumPoolSize(8);
        config.setMinimumIdle(8);

        config.setConnectionTimeout(2_000);
        config.setValidationTimeout(1_000);
        config.setMaxLifetime(25 * 60_000L);
        config.setKeepaliveTime(2 * 60_000L);

        try (HikariDataSource dataSource = new HikariDataSource(config)) {
            createSchema(dataSource);
            insertOrder(dataSource, 1001L, "created");
            printOrder(dataSource, 1001L);
        }
    }

    private static void createSchema(DataSource dataSource) throws SQLException {
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement("""
                     create table orders (
                         id bigint primary key,
                         status varchar(30) not null
                     )
                     """)) {
            statement.executeUpdate();
        }
    }

    private static void insertOrder(
            DataSource dataSource, long id, String status) throws SQLException {

        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     "insert into orders(id, status) values (?, ?)")) {

            statement.setLong(1, id);
            statement.setString(2, status);
            statement.executeUpdate();
        }
    }

    private static void printOrder(
            DataSource dataSource, long id) throws SQLException {

        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     "select id, status from orders where id = ?")) {

            statement.setLong(1, id);

            try (ResultSet resultSet = statement.executeQuery()) {
                if (resultSet.next()) {
                    System.out.printf("id=%d, status=%s%n",
                            resultSet.getLong("id"),
                            resultSet.getString("status"));
                }
            }
        }
    }
}

预期输出类似:

id=1001, status=created

try (HikariDataSource dataSource = ...) 很重要。应用关闭时,池需要关闭所有底层连接。Web 应用通常把 HikariDataSource 作为应用级单例,由容器在应用停止时关闭,而不是每个请求创建一个池。


连接池容量:不是越大越快

maximumPoolSize 是池允许创建的最大底层连接数。它同时限制:

  1. 应用最多有多少个线程可以同时持有数据库连接;
  2. 数据库最多可能看到多少个来自该应用实例的连接;
  3. 数据库需要为这些会话保留多少资源。

如果设置为 1000,并不能让一个只能处理 32 个并发数据库操作的数据库变成能处理 1000 个。多余连接通常只会增加排队、锁竞争、上下文切换和数据库内存压力。

连接占用时间和 Little 定律

设:

  • λ\lambda:单位时间内完成的数据库事务数;
  • WW:一个事务从借用连接到归还连接的平均时间;
  • LL:平均同时占用连接的数量。

根据 Little 定律:

L=λWL = \lambda W

例如:

  • 吞吐量为每秒 120 个事务;
  • 每个事务平均占用连接 35 ms,即 0.0350.035 秒。

则:

L=120×0.035=4.2L = 120 \times 0.035 = 4.2

这意味着在稳定状态下,平均约需要 4.2 个连接支撑该吞吐量。不能因此直接把池设为 5,因为平均值没有覆盖尾延迟、突发流量、事务抖动和连接创建过程。

如果同一事务的连接占用时间在高峰期接近 120 ms:

Lp95120×0.120=14.4L_{p95} \approx 120 \times 0.120 = 14.4

此时池规模可能需要接近 16 或更高才能减少应用侧等待,但这只是容量实验的起点,不是普适配置。若数据库在 8 个并发查询时已经饱和,把池增大到 32 只会把更多线程推入数据库内部排队。

另一个重要约束:数据库总连接预算

设数据库允许应用使用的连接预算为 DD,部署了 NN 个应用实例,每个实例最大池大小为 PP,还要预留管理连接和其他服务连接 RR,则必须满足:

N×P+RDN \times P + R \leq D

例如:

  • 数据库允许 200 个连接;
  • 预留 40 个给管理工具、迁移任务和其他服务;
  • 应用部署 8 个实例。

那么每个实例的最大池大小至少应满足:

8P+402008P + 40 \leq 200

因此:

P20P \leq 20

如果每个实例配置 40 个连接,理论上可能产生 320 个连接,应用扩容后会把数据库连接名额耗尽。连接池配置必须按“实例数乘以池大小”计算,而不是只看单机压测。

minimumIdle 的含义

minimumIdle 是池希望维持的最小空闲连接数。它不等于最小并发数,也不等于数据库必须始终存在的连接数。

minimumIdle 小于 maximumPoolSize 时,池可以在低负载时收缩空闲连接;当流量上升时,再逐步创建连接。这样可以减少低峰连接占用,但突发请求可能承担连接创建延迟。

HikariCP 默认倾向于让 minimumIdlemaximumPoolSize 保持固定大小。官方文档通常建议不要为追求动态收缩而显式设置 minimumIdle,因为固定大小的池具有更稳定的延迟;是否采用固定大小仍取决于实例数量、数据库连接预算和弹性环境。

一个常见取舍是:

config.setMaximumPoolSize(16);

// 固定大小池:启动后逐步维持 16 个连接
config.setMinimumIdle(16);

或者:

config.setMaximumPoolSize(16);

// 允许低峰收缩,突发时可能创建连接
config.setMinimumIdle(2);

不能把 minimumIdle=2 理解为“池最多只使用 2 个连接”;上限仍然是 maximumPoolSize=16


连接借用过程和容量耗尽

当线程调用 dataSource.getConnection() 时,典型流程如下:

sequenceDiagram
    participant T as 应用线程
    participant P as HikariCP
    participant DB as 数据库

    T->>P: getConnection()
    alt 有空闲连接
        P-->>T: 返回池化连接
    else 未达到 maximumPoolSize
        P->>DB: 创建底层连接
        DB-->>P: 连接成功
        P-->>T: 返回池化连接
    else 已达到上限
        T->>P: 等待空闲连接
        alt 其他线程归还连接
            P-->>T: 返回归还的连接
        else 超过 connectionTimeout
            P-->>T: SQLException
        end
    end

    T->>DB: 执行 SQL
    DB-->>T: 结果或错误
    T->>P: close()
    P-->>P: 重置连接状态并回收到池中

当池耗尽时,线程等待的原因可能是:

  • SQL 本身执行很慢;
  • 事务持有连接但等待锁;
  • 代码读取结果集很慢;
  • 连接被借出后没有关闭;
  • 数据库不可用,连接创建长期失败;
  • 数据库连接数已达到服务器上限。

因此,“连接池等待超时”不是数据库查询超时的同义词。它只说明线程在池中没有及时拿到可用连接,真正根因可能发生在更早的 SQL、事务或故障路径上。


超时:不同计时器控制不同阶段

连接池中最容易误配的是超时。每个超时控制的对象不同,不能用一个参数替代全部超时。

connectionTimeout

connectionTimeout 控制调用 getConnection() 时,线程最多等待多久。它覆盖的主要等待包括:

  • 等待其他线程归还连接;
  • 等待池创建一个新连接;
  • 等待池内部完成可用连接分配。

HikariCP 的默认值通常是 30 秒,允许的最小值是 250 ms。示例:

config.setConnectionTimeout(2_000);

这表示拿不到连接时,约 2 秒后抛出 SQLException。它不限制 SQL 执行时间,也不保证数据库服务器在 2 秒后停止一个已经开始执行的查询。

如果设置过短,正常的数据库短暂抖动也会快速转化为请求失败;如果设置过长,请求线程可能长时间堆积,最终形成线程池耗尽。

validationTimeout

validationTimeout 控制连接可用性校验的等待时间,通常必须小于 connectionTimeout,最小值也通常是 250 ms:

config.setValidationTimeout(1_000);

连接校验不等于执行业务 SQL。HikariCP 会优先使用 JDBC 驱动的 Connection.isValid(timeout);某些场景也可以配置测试查询。测试查询应尽量简单,并符合目标数据库语法。

SQL 执行超时

SQL 超时属于 JDBC Statement 或数据库驱动层面:

try (PreparedStatement statement = connection.prepareStatement(
        "select id, status from orders where id = ?")) {

    statement.setQueryTimeout(3); // 单位:秒
    statement.setLong(1, 1001L);

    try (ResultSet resultSet = statement.executeQuery()) {
        // 处理结果
    }
}

setQueryTimeout 的实际行为依赖 JDBC 驱动和数据库。它通常会请求驱动在超时后中断查询,但不应假设所有数据库都能在精确时间点强制终止服务器端执行。

数据库还可能有自己的语句超时、锁等待超时、事务空闲超时和网络 socket 超时。这些参数需要与 JDBC 驱动文档一起验证。

maxLifetime

maxLifetime 控制一个物理连接在池中的最长生命周期。HikariCP 默认值通常为 30 分钟;设置为 0 表示不限制,但通常不建议在数据库或网络设备有连接寿命限制时这样做。

config.setMaxLifetime(25 * 60_000L);

如果云数据库、代理、负载均衡器或防火墙会在 30 分钟时关闭连接,把池的 maxLifetime 设为略短于基础设施的限制,可以让池主动轮换连接,而不是等请求碰到被中间设备关闭的连接。

HikariCP 会对连接生命周期做轻微的随机衰减,避免所有连接在同一时刻批量淘汰。这个机制不能替代合理的生命周期配置。

idleTimeout

idleTimeout 控制空闲连接在可收缩池中的回收时间。它主要在 minimumIdle < maximumPoolSize 时有意义;固定大小池中,池不能把连接数降到最小空闲数以下。

config.setMinimumIdle(2);
config.setIdleTimeout(5 * 60_000L);

HikariCP 对 idleTimeout 有最小值限制,并允许回收时间存在一定波动,因此不能把它当作精确的定时器。

keepaliveTime

keepaliveTime 用于周期性检查空闲连接,避免连接被数据库或网络设备因长期空闲而断开:

config.setKeepaliveTime(2 * 60_000L);

它通常只作用于空闲连接,并且必须小于 maxLifetime,还受到最小值限制。Keepalive 不是 SQL 性能优化,也不是连接泄漏检测;它会产生额外网络和数据库请求,因此不应设置得过于频繁。

超时关系可以概括为:

getConnection() 等待连接       -> connectionTimeout
连接可用性检测                 -> validationTimeout
已借到连接后执行 SQL           -> Statement.setQueryTimeout / 驱动 / 数据库参数
连接在池中的最大年龄            -> maxLifetime
空闲连接收缩                    -> idleTimeout
空闲连接保活                    -> keepaliveTime
借出时间过长的诊断              -> leakDetectionThreshold

事务边界决定连接占用时长

连接池容量计算中,最容易漏掉的是:连接通常会从事务开始一直持有到事务结束,而不只是覆盖某条 SQL。

JDBC 事务示例:

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);

    try {
        debit(connection, 1001L, 10);
        credit(connection, 2002L, 10);

        connection.commit();
    } catch (Exception e) {
        connection.rollback();
        throw e;
    } finally {
        connection.setAutoCommit(true);
    }
}

这里的 finally 和外层 try-with-resources 各有职责:

  • rollback() 防止异常路径留下未完成事务;
  • setAutoCommit(true) 把连接状态恢复到池可以安全交给下一个调用者的状态;
  • close() 把连接归还池。

池化连接归还时,HikariCP 会重置它能够识别的部分状态,例如自动提交模式、只读属性、事务隔离级别、网络超时等。但应用不应依赖池替自己修复业务层状态。数据库会话变量、临时表、服务端 prepared statement、特定驱动属性等状态可能需要显式清理或由驱动处理。

以下代码会显著放大连接占用:

try (Connection connection = dataSource.getConnection()) {
    updateOrder(connection);

    Thread.sleep(5_000); // 连接仍被持有
    callRemoteService(); // 网络调用期间仍占用数据库连接

    connection.commit();
}

如果有 16 个并发请求,每个请求都在持有连接后等待远程服务 5 秒,那么池很快就会出现等待,即使真正的 SQL 只用了几毫秒。正确的边界通常是:先完成不需要数据库连接的远程调用和计算,再在尽可能短的事务中执行数据库操作。

JPA 中的对应问题

JPA 使用 EntityManager 和事务,但连接的具体借用时机由 JPA 实现、事务类型和连接获取模式决定。应用不应把“打开 EntityManager”简单等同于“必然一直占用一个 JDBC 连接”,也不应据此忽略事务边界。

典型事务代码:

@Transactional
public void changeStatus(long id) {
    Order order = entityManager.find(Order.class, id);
    order.setStatus("PAID");
}

在这种模型中,事务注解通常由框架管理提交和回滚,应用不应手动关闭容器管理的 EntityManager。但以下操作仍可能延长连接或事务生命周期:

  • 在事务中执行复杂查询;
  • 延迟加载实体关联;
  • 在事务内遍历大量结果;
  • 在事务内调用外部 HTTP 服务;
  • 忘记结束事务;
  • 在异步线程中继续访问事务上下文。

JPA 规范和具体实现之间存在边界:JPA 规定持久化语义和事务交互,但不规定 HikariCP 的具体池参数。连接池参数应在应用服务器或框架提供的 DataSource 层配置。


连接泄漏:借出后没有归还

连接泄漏是指应用获取了连接,但在所有执行路径上都没有将其关闭。池不会无限创建连接,因此泄漏的最终表现通常是:

ActiveConnections 持续接近 maximumPoolSize
IdleConnections 接近 0
ThreadsAwaitingConnection 持续上升
getConnection() 最终因 connectionTimeout 失败

最典型的错误代码:

Connection connection = dataSource.getConnection();
PreparedStatement statement =
        connection.prepareStatement("select count(*) from orders");

ResultSet resultSet = statement.executeQuery();
// 忘记关闭 resultSet、statement 和 connection

正确写法:

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement =
             connection.prepareStatement("select count(*) from orders");
     ResultSet resultSet = statement.executeQuery()) {

    if (resultSet.next()) {
        System.out.println(resultSet.getLong(1));
    }
}

资源关闭顺序是反向嵌套顺序:先关闭 ResultSet,再关闭 PreparedStatement,最后关闭 Connectiontry-with-resources 能在正常返回、SQL 异常和运行时异常时执行关闭。

常见泄漏路径

异常路径没有关闭

Connection connection = dataSource.getConnection();
try {
    executeBusinessSql(connection);
} catch (SQLException e) {
    log.error("database error", e);
    return; // connection 没有关闭
}

只关闭了语句,没有关闭连接

try (Connection connection = dataSource.getConnection()) {
    try (PreparedStatement statement = connection.prepareStatement("...")) {
        statement.execute();
    }
} // 正确

而下面是不完整的:

Connection connection = dataSource.getConnection();
try (PreparedStatement statement = connection.prepareStatement("...")) {
    statement.execute();
}
// connection 仍然没有关闭

返回了仍然依赖连接的结果集

ResultSet loadOrders() throws SQLException {
    Connection connection = dataSource.getConnection();
    PreparedStatement statement =
            connection.prepareStatement("select * from orders");
    return statement.executeQuery();
}

调用者拿到 ResultSet 时,连接、语句和结果集的生命周期已经跨越方法边界,异常和提前退出都很难正确处理。更安全的做法是方法内部遍历结果集,转换成领域对象后再关闭全部 JDBC 资源。

把连接交给异步任务

try (Connection connection = dataSource.getConnection()) {
    executor.submit(() -> useConnection(connection));
}

异步任务可能在外层方法关闭连接之后执行,也可能因为队列等待而长时间持有连接。异步任务应在任务内部借用和关闭连接,而不是跨线程传递连接。


泄漏检测的含义和局限

HikariCP 可以通过 leakDetectionThreshold 报告借出时间过长的连接:

config.setLeakDetectionThreshold(10_000);

该阈值通常至少为 2 秒,设置为 0 表示禁用。超过阈值时,HikariCP 会记录连接借出时的调用栈,帮助定位“是谁借用了连接”。

它检测的是:

一个连接被借出后,经过阈值时间仍未归还。

它不能直接证明:

这一定是永久泄漏。

例如,下面的长事务会触发告警,但资源最终可能会归还:

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);
    executeLargeBatch(connection); // 运行 20 秒
    connection.commit();
}

因此告警日志的正确解读是:

  1. 查看借出位置的堆栈;
  2. 找出该调用是否最终关闭连接;
  3. 检查期间是否执行了慢 SQL、锁等待或外部调用;
  4. 对照 ActiveConnections、等待线程数和数据库活动会话;
  5. 决定是修复泄漏,还是缩短合法但过长的事务。

不应在生产环境把阈值设置得极低并把每条告警都当成泄漏。阈值应高于正常请求和事务的合理连接占用时间,同时低于能够耗尽池的危险时长。它是诊断工具,不是释放泄漏连接的回收器。


HikariCP 的连接状态重置

连接池把一个连接归还给下一个调用者之前,必须尽量消除前一个调用者留下的状态。下面这些状态尤其危险:

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);
    connection.setReadOnly(true);
    connection.setTransactionIsolation(
            Connection.TRANSACTION_SERIALIZABLE);

    // 业务操作
}

如果这些设置没有被正确恢复,下一个请求可能意外地:

  • 处于手动提交模式;
  • 以只读模式执行;
  • 使用过高的隔离级别;
  • 继承前一个请求的事务状态;
  • 看到未预期的会话级配置。

应用应在自定义 JDBC 操作中明确事务处理和异常回滚,不应假设“连接池一定知道所有数据库特有状态”。对于数据库特有的 session 设置,应统一封装并验证归还后的行为。


连接失效、数据库重启和网络中断

连接池中的连接可能因为以下原因变得不可用:

  • 数据库重启;
  • 数据库主动关闭空闲会话;
  • 防火墙或负载均衡器清理空闲 TCP 连接;
  • NAT 映射失效;
  • 网络短暂中断;
  • 数据库达到连接上限;
  • 驱动或 TLS 层发生错误。

一次典型故障路径如下:

数据库或网络关闭连接
  -> 池中仍保留该物理连接
  -> 应用借用连接
  -> 校验发现失败,或首次 SQL 失败
  -> 连接被淘汰
  -> 池尝试创建替代连接
  -> 数据库不可用时,新的借用请求等待并最终超时

maxLifetime 可以减少遇到基础设施强制断开的问题,但不能修复数据库不可用。keepaliveTime 可以减少空闲连接被网络设备静默清理的概率,但不能保证网络永远可靠。

应用层应把数据库异常分类处理:

  • 连接获取超时:通常是池耗尽、连接创建失败或数据库不可用;
  • SQL 执行异常:可能是语法、约束、锁、超时或连接失效;
  • 事务提交异常:不能简单重试,必须判断事务是否已经在数据库端提交;
  • 可重试错误:必须结合数据库错误码、幂等性和事务边界设计。

尤其要注意:提交请求在网络断开时可能出现“客户端不知道结果”的不确定状态。盲目重试可能造成重复扣款、重复写入等业务错误。


监控:先看池,再看数据库,再看请求

仅记录 SQL 日志不足以判断连接池问题。至少需要同时观察三层指标。

HikariCP 连接池指标

启用 JMX:

config.setRegisterMbeans(true);

随后可以通过 JMX 查看池的运行状态。常见属性包括:

  • ActiveConnections
  • IdleConnections
  • TotalConnections
  • ThreadsAwaitingConnection

实际 MBean 名称与池名相关,通常可在 JConsole、VisualVM 或其他 JMX 客户端中查看。例如池名设置为 orders-pool 后,名称会包含该池名和 HikariCP 的池对象。

也可以在应用中暴露指标。HikariCP 支持通过指标注册表或指标追踪工厂接入监控系统,但具体方式取决于使用的是 Micrometer、Dropwizard Metrics 还是框架集成,不应把某个框架的配置写法当成 HikariCP 标准 API。

如何解释指标组合

现象 更可能的原因
Active 接近上限,等待线程持续上升 SQL 慢、事务过长、锁等待或连接泄漏
Active 很低,但连接获取仍然失败 连接创建失败、数据库拒绝连接、连接校验异常
Idle 长期为 0,Active 波动到上限 池可能偏小,也可能数据库操作过慢
Total 不断下降后无法补足 数据库不可用、认证失败、连接数达到上限
泄漏告警增加,但 Active 不到上限 合法长事务或慢 SQL 可能被误判为泄漏
数据库端活动会话少,应用等待线程多 可能是连接创建、池锁争用或应用线程使用方式异常

这些指标必须结合时间窗口观察。单次看到 Active=maximumPoolSize 不足以证明池配置错误;高峰瞬间达到上限可能是正常现象,持续等待才说明容量或执行时间存在问题。

数据库端验证

数据库端必须查看真实活动会话、锁等待、执行计划和连接上限。不同数据库命令不同,例如 PostgreSQL 可以查询:

select
    application_name,
    state,
    wait_event_type,
    wait_event,
    count(*)
from pg_stat_activity
where datname = current_database()
group by application_name, state, wait_event_type, wait_event
order by count(*) desc;

该 SQL 是 PostgreSQL 专用示例,不能直接用于 MySQL、Oracle 或 SQL Server。它可以帮助区分:

  • active:正在执行;
  • idle:连接存在但没有执行语句;
  • 锁等待;
  • 客户端空闲但事务未结束的状态。

如果 HikariCP 显示大量 Active,而数据库显示大量锁等待,增大池通常会加重锁竞争;应该先检查事务顺序、索引和锁持有时间。


一个可重复的诊断过程

假设日志中出现:

Connection is not available, request timed out after 2000ms

不要直接把 maximumPoolSize 从 16 改成 64。可以按以下顺序验证。

第一步:确认是否真的池耗尽

在同一时间窗口查看:

ActiveConnections
IdleConnections
TotalConnections
ThreadsAwaitingConnection

如果 Active=16Idle=0、等待线程增长,说明池确实没有空闲连接,但仍不能区分慢 SQL、长事务和泄漏。

第二步:查看连接借用时长

临时启用:

config.setLeakDetectionThreshold(10_000);

如果出现借出堆栈,检查这些位置是否:

  • 使用 try-with-resources
  • 在异常路径回滚;
  • 关闭 ResultSetStatement
  • 把连接传给异步任务;
  • 在持有连接时调用外部服务。

第三步:对照数据库端活动

检查数据库是否存在:

  • 大量锁等待;
  • 慢查询;
  • 全表扫描;
  • 数据库 CPU 或 IO 饱和;
  • 达到数据库连接上限;
  • 大量“空闲但事务未结束”的会话。

第四步:测量连接占用时间

应记录至少两个时间点:

t0 = getConnection() 开始
t1 = getConnection() 成功
t2 = 最后一条 SQL 完成
t3 = close() 完成

其中:

  • t1t0t1-t0 是池等待或连接创建时间;
  • t2t1t2-t1 是借用后的数据库使用时间;
  • t3t2t3-t2 反映归还前的清理时间。

如果 t1t0t1-t0 高,先看池容量和创建连接;如果 t2t1t2-t1 高,先看 SQL、锁和事务;如果 t3 没有出现,优先怀疑泄漏或线程卡死。

第五步:再做容量调整

只有在确认数据库仍有处理能力、事务和 SQL 已经合理、连接确实是主要瓶颈后,才增加池容量。增加后必须重新检查数据库总连接预算:

实例数×maximumPoolSize\text{实例数} \times \text{maximumPoolSize}

并重新观察数据库 CPU、锁等待、响应时间和错误率。


固定池与弹性池的取舍

固定池的特点是连接数更稳定:

config.setMaximumPoolSize(16);
config.setMinimumIdle(16);

适合流量相对稳定、数据库连接预算明确的服务。代价是低峰期仍保留连接。

弹性池的特点是空闲时可以收缩:

config.setMaximumPoolSize(16);
config.setMinimumIdle(2);
config.setIdleTimeout(300_000);

适合实例数量变化大、低峰时间长或数据库连接成本较高的环境。代价是突发流量可能等待新连接建立,且连接频繁创建和销毁会增加数据库认证与网络开销。

无论哪种模式,都应避免每个请求创建一个 HikariDataSource

// 错误:每次调用都创建一个连接池
void query() throws SQLException {
    try (HikariDataSource dataSource = createDataSource();
         Connection connection = dataSource.getConnection()) {
        // ...
    }
}

这段代码不仅失去池的复用价值,还会频繁创建和关闭数据库连接。正确结构通常是应用启动时创建一个 DataSource,请求只借用其中的连接。


监控与告警的边界

合理的告警不应只设置“连接数达到上限”。更有用的信号包括:

  • ThreadsAwaitingConnection 持续大于 0;
  • 连接获取等待时间的 p95、p99 上升;
  • Active/Total 长期接近 1;
  • 连接创建失败次数增加;
  • 泄漏检测告警出现;
  • 数据库端锁等待和慢查询增加;
  • 事务持续时间超过业务预期;
  • 连接池耗尽错误与接口错误率同时上升。

告警恢复也需要验证。比如数据库恢复后,应确认:

  1. 新连接能够成功创建;
  2. TotalConnections 能补回目标范围;
  3. IdleConnections 不再长期为零;
  4. 等待线程数下降;
  5. 接口错误率恢复;
  6. 没有残留的长事务或泄漏连接。

直接重启应用可能让指标暂时恢复,但会掩盖根因,并在所有实例同时重启时对数据库造成连接创建高峰。重启应作为故障止损手段,而不是连接池诊断的替代品。


常见误解

“连接池越大,吞吐量越高”

错误。连接池只提供并发连接,数据库真正的处理能力受 CPU、IO、锁、索引、执行计划和事务冲突限制。池超过数据库有效并发后,通常增加排队而不是吞吐量。

connectionTimeout 能终止慢 SQL”

错误。它只控制借用连接的等待时间。已经拿到连接的线程执行慢 SQL 时,必须使用 JDBC、驱动或数据库层面的语句超时。

maxLifetime 能避免所有断连”

错误。它只能主动轮换达到生命周期上限的连接。数据库宕机、网络分区、认证失败和服务器连接上限仍会导致创建或执行失败。

“出现泄漏检测日志,就一定是代码忘记关闭”

不一定。长事务、锁等待、大结果集处理和外部调用都会让连接合法地持有很久。泄漏检测日志提供的是借出堆栈和超时证据,需要结合最终是否归还来判断。

“关闭池化连接会关闭数据库物理连接”

通常不是。对池化连接调用 close() 的语义是归还池。只有关闭 HikariDataSource,或连接被淘汰、失效时,才会关闭底层物理连接。

“JPA 管理事务后就不需要理解连接池”

错误。JPA 隐藏了部分 JDBC 细节,但事务持续时间、查询性能、延迟加载和外部调用仍会影响底层连接占用。连接池耗尽时,JPA 请求同样会失败。


一套可解释的初始配置

下面是一套具有明确含义的起点,而不是适用于所有系统的固定答案:

HikariConfig config = new HikariConfig();
config.setPoolName("orders-pool");
config.setJdbcUrl(jdbcUrl);
config.setUsername(username);
config.setPassword(password);

config.setMaximumPoolSize(16);
config.setMinimumIdle(16);

config.setConnectionTimeout(2_000);
config.setValidationTimeout(1_000);
config.setMaxLifetime(25 * 60_000L);
config.setKeepaliveTime(2 * 60_000L);

// 诊断阶段启用;正常运行时按长事务基线调整
config.setLeakDetectionThreshold(10_000);

config.setRegisterMbeans(true);

这套配置表达了以下假设:

  • 单实例最多同时占用 16 个数据库连接;
  • 池倾向于固定大小;
  • 等待连接超过 2 秒就快速失败;
  • 连接校验不应等待超过 1 秒;
  • 基础设施可能在约 30 分钟左右清理连接,因此池提前轮换;
  • 空闲连接每 2 分钟进行保活检查;
  • 借用超过 10 秒会触发诊断日志。

它仍然需要通过压测和生产观测修正。最终配置应满足三个条件:

应用所需并发池可提供并发\text{应用所需并发} \leq \text{池可提供并发}

实例数×池上限数据库连接预算\text{实例数} \times \text{池上限} \leq \text{数据库连接预算}

池并发数据库在目标延迟下的有效并发能力\text{池并发} \leq \text{数据库在目标延迟下的有效并发能力}

连接池设计的核心不是记住某个“推荐值”,而是把连接占用时间、数据库处理能力、实例数量、超时语义和故障恢复路径放在同一个模型中。HikariCP 负责高效管理连接生命周期,但 SQL、事务、数据库容量和应用资源关闭责任仍然属于应用工程本身。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。