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

JDBC 与事务:连接池、PreparedStatement、隔离、批处理和泄漏

JDBC(Java Database Connectivity)是一组由 Java 定义的数据库访问接口。它规定了 ConnectionPreparedStatementResultSet、事务控制和异常类型等抽象,但不规定数据库如何实现锁、MVCC(多版本并发控制)、连接池或 SQL 优化器。

因此,JDBC 代码通常同时处在三层约束中:

  1. JDBC 规范:例如 Connection#setAutoCommit(false)commit()rollback() 的语义。
  2. 数据库实现:例如 PostgreSQL、MySQL、Oracle 对隔离级别、锁和批处理的具体处理。
  3. 连接池与驱动实现:例如连接归还池时如何重置事务状态,驱动如何改写批量 INSERT。

理解这三层的边界,是排查“事务明明提交了却看不到数据”“偶尔脏读”“连接越来越少”“批处理反而更慢”等问题的前提。


一、JDBC 的基本对象和一次查询的数据流

典型的数据流如下:

flowchart LR
    A[业务代码] --> B[DataSource]
    B --> C[连接池中的物理连接]
    C --> D[Connection]
    D --> E[PreparedStatement]
    E --> F[数据库服务器]
    F --> G[ResultSet 或更新计数]
    G --> A

1. DataSourceConnection 和物理连接

DataSource 是获得数据库连接的标准接口:

DataSource dataSource = ...;
Connection connection = dataSource.getConnection();

这里的 Connection 可能有两种含义:

  • 直连模式:它代表一个驱动创建的物理数据库连接。
  • 连接池模式:它通常是一个代理对象;close() 不会断开 TCP 连接,而是把连接归还连接池。

连接池不是 JDBC 规范的一部分。JDBC 只定义了 DataSource 接口,具体池可以由 HikariCP、应用服务器或其他实现提供。

Connection 的生命周期应该被理解为:

getConnection()
    -> 设置或继承连接状态
    -> 创建 Statement
    -> 执行 SQL
    -> 消费 ResultSet
    -> commit 或 rollback
    -> close:直连时断开,连接池时归还

在连接池中,“关闭连接”仍然必须调用。否则池无法知道这个逻辑连接已经使用完,最终表现为连接泄漏。

2. StatementPreparedStatementResultSet

JDBC 常见的执行对象有:

  • Statement:直接执行完整 SQL 字符串。
  • PreparedStatement:SQL 模板与参数分离,可以绑定类型化参数。
  • CallableStatement:调用存储过程,是 PreparedStatement 的扩展。

查询结果通过 ResultSet 逐行读取:

try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(
             "select id, name from account where id = ?");
) {
    ps.setLong(1, 42L);

    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            long id = rs.getLong("id");
            String name = rs.getString("name");
            System.out.println(id + ": " + name);
        }
    }
}

rs.next() 的初始位置在第一行之前。每次调用成功后,游标移动到下一行;返回 false 表示没有更多行。

try-with-resources 依赖 AutoCloseable。离开代码块时,即使查询或业务处理抛出异常,也会按声明的逆序关闭:

  1. ResultSet
  2. PreparedStatement
  3. Connection

这不是代码风格问题,而是数据库资源回收的控制结构。


二、PreparedStatement:参数绑定、类型和边界

1. 它解决了什么问题

不安全的字符串拼接:

String sql = "select * from account where name = '" + name + "'";

name 是:

' OR '1'='1

最终 SQL 可能变成:

select * from account where name = '' OR '1'='1'

这会改变 SQL 的语义,形成 SQL 注入。

使用 PreparedStatement

String sql = "select id, name from account where name = ?";

try (PreparedStatement ps = connection.prepareStatement(sql)) {
    ps.setString(1, name);

    try (ResultSet rs = ps.executeQuery()) {
        // ...
    }
}

这里的 name 是值,不是 SQL 语法的一部分。驱动会根据参数类型和数据库协议传输它。

因此,PreparedStatement 的核心作用有两个:

  1. 将 SQL 结构与数据值分开,避免把用户输入当作 SQL。
  2. 向驱动提供参数类型,驱动可以选择更合适的编码、解析和执行路径。

它不保证所有性能收益。数据库是否真正复用执行计划,取决于驱动、数据库和配置。不能仅凭“使用了 PreparedStatement”断言一定发生了服务端预编译。

2. 参数位置和类型

参数索引从 1 开始:

ps.setLong(1, accountId);
ps.setBigDecimal(2, amount);
ps.setString(3, description);
ps.setTimestamp(4, timestamp);

下面的参数索引 0 是错误的:

ps.setLong(0, accountId); // SQLException

应尽量使用与数据库列一致的 Java 类型。比如金额列通常对应 BigDecimal,而不是 double

ps.setBigDecimal(1, new BigDecimal("10.00"));

double 使用二进制浮点表示,0.1 这类十进制数通常不能被精确表示,不适合作为金额的业务表示。

设置 SQL NULL 时,推荐提供类型:

ps.setNull(1, Types.VARCHAR);

对于驱动无法准确推断类型的场景,也可以使用:

ps.setObject(1, null, Types.BIGINT);

3. PreparedStatement 不能绑定 SQL 标识符

下面的写法不能达到“动态选择表名”的目的:

String sql = "select * from ?"; // 错误:? 只能代表值,不能代表表名

参数占位符通常只能表示表达式中的值,不能表示:

  • 表名
  • 列名
  • ASC / DESC
  • SQL 关键字
  • 一段 SQL 语法

动态列排序必须采用白名单:

String orderBy = switch (requestedOrder) {
    case "name" -> "name";
    case "createdAt" -> "created_at";
    default -> throw new IllegalArgumentException("unsupported order");
};

String sql = "select id, name from account order by " + orderBy;

这里的安全性来自 requestedOrder 只能映射到预先写死的字符串,而不是来自字符串拼接本身。

4. executeQueryexecuteUpdateexecute

常见方法的返回意义:

try (PreparedStatement ps = connection.prepareStatement(
        "update account set status = ? where id = ?")) {
    ps.setString(1, "LOCKED");
    ps.setLong(2, 42L);

    int affectedRows = ps.executeUpdate();
    if (affectedRows != 1) {
        throw new IllegalStateException("account not found or updated unexpectedly");
    }
}
  • executeQuery():预期产生 ResultSet,通常用于 SELECT
  • executeUpdate():返回更新行数,通常用于 INSERTUPDATEDELETE,也可能用于 DDL。
  • execute():当语句可能产生结果集或更新计数时使用,调用者需要进一步检查结果类型。

更新行数是重要的并发证据。例如:

update account
set balance = balance - 100
where id = 1
  and balance >= 100

如果返回 0,它可能意味着账户不存在,也可能意味着余额不足。业务代码必须明确解释这个结果,而不是无条件认为更新成功。


三、事务:从自动提交到明确提交

1. 自动提交的真实含义

新获得的 JDBC 连接通常处于自动提交模式,但应用不能只凭经验猜测,应该显式检查或设置:

connection.setAutoCommit(true);

当自动提交为 true 时,一条 SQL 执行完成后,数据库通常就会提交该语句对应的事务。

例如:

try (Connection c = dataSource.getConnection();
     PreparedStatement debit = c.prepareStatement(
             "update account set balance = balance - 100 where id = 1");
     PreparedStatement credit = c.prepareStatement(
             "update account set balance = balance + 100 where id = 2")) {

    debit.executeUpdate();
    // 这里第一条更新可能已经提交
    credit.executeUpdate();
}

如果第二条失败,第一条可能已经生效,转账就会只扣不加。

2. 手动事务的边界

跨多条 SQL 的原子操作必须在同一个 Connection 上完成:

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

    try {
        try (PreparedStatement debit = c.prepareStatement(
                     "update account set balance = balance - ? where id = ?");
             PreparedStatement credit = c.prepareStatement(
                     "update account set balance = balance + ? where id = ?")) {

            debit.setBigDecimal(1, new BigDecimal("100.00"));
            debit.setLong(2, 1L);
            int debitCount = debit.executeUpdate();

            credit.setBigDecimal(1, new BigDecimal("100.00"));
            credit.setLong(2, 2L);
            int creditCount = credit.executeUpdate();

            if (debitCount != 1 || creditCount != 1) {
                throw new IllegalStateException("account update count is not 1");
            }
        }

        c.commit();
    } catch (SQLException | RuntimeException e) {
        try {
            c.rollback();
        } catch (SQLException rollbackFailure) {
            e.addSuppressed(rollbackFailure);
        }
        throw e;
    } finally {
        try {
            c.setAutoCommit(true);
        } catch (SQLException resetFailure) {
            // 连接池场景中应记录该错误;连接可能需要被池淘汰
        }
    }
}

关键条件是:

扣款 SQL
加款 SQL
必须使用同一个 Connection
并且在 commit 前都没有提交

如果分别从连接池获取两个连接,即使它们访问同一个数据库,也不是同一个本地事务:

Connection c1 = dataSource.getConnection();
Connection c2 = dataSource.getConnection();

c1.commit() 不会提交 c2 上的更新。跨多个数据库连接的原子性属于分布式事务问题,不能靠普通 JDBC commit() 自动获得。

3. 事务状态图

stateDiagram-v2
    [*] --> AutoCommit
    AutoCommit --> ManualTransaction: setAutoCommit(false)
    ManualTransaction --> ManualTransaction: execute SQL
    ManualTransaction --> Committed: commit()
    ManualTransaction --> RolledBack: rollback()
    ManualTransaction --> AutoCommit: setAutoCommit(true)
    Committed --> AutoCommit: setAutoCommit(true)
    RolledBack --> AutoCommit: setAutoCommit(true)

在手动事务期间,SQL 的更新通常只是对当前事务可见。调用 commit() 后,数据库才使修改成为已提交结果;调用 rollback() 则撤销事务中的未提交修改。

setAutoCommit(true) 还有一个容易被忽略的边界:如果当前存在未提交事务,JDBC 规定切换到自动提交模式时通常会提交该事务。因此不要把它当作“无条件清理状态”的安全回滚操作。业务异常路径应先显式 rollback(),然后再恢复连接配置。


四、连接池:复用物理连接,也复用连接状态风险

1. 连接池解决的是什么问题

建立数据库连接通常涉及:

  1. 创建 TCP 连接;
  2. TLS 握手(如果启用);
  3. 数据库认证;
  4. 创建服务端会话;
  5. 初始化会话参数。

连接池提前创建并复用这些物理连接。应用请求到来时,池只需借出一个空闲连接;调用 close() 时,连接被归还。

但连接池没有改变事务的基本语义。借出的连接仍然是一个有状态的数据库会话,可能携带:

  • autoCommit
  • 隔离级别
  • readOnly
  • 当前事务
  • 会话变量
  • 临时表
  • 服务端 prepared statement
  • 锁和游标
  • 数据库 session time zone
  • SET 修改的其他会话参数

因此,连接池必须在归还时重置必要状态,应用也必须正确结束事务。

2. 最危险的连接池污染

错误代码:

Connection c = dataSource.getConnection();
c.setAutoCommit(false);
doSomething(c);
// 发生异常后直接退出,没有 rollback,也没有 close

这可能造成两个问题:

  1. 连接一直被占用,池中的可用连接减少;
  2. 如果连接最终被归还但事务未回滚,后续借到该连接的请求可能看到旧事务状态、锁或未提交修改。

连接池实现通常会检测并回滚未完成事务,但不能把这种行为当作业务正确性的替代品。不同连接池的状态重置策略不同,某些数据库会保留会话级设置,某些驱动也可能有特殊行为。

3. 连接池配置的因果关系

常见配置含义如下:

  • 最大连接数:限制同时向数据库发起工作的物理连接数。
  • 获取连接超时:池耗尽时,调用者最多等待多久。
  • 空闲超时:空闲连接多久后可能被回收。
  • 最大生命周期:连接存在多久后被替换。
  • 连接泄漏检测阈值:借出过久时记录告警,不等于自动修复泄漏。
  • 连接验证:判断物理连接是否仍然可用。

最大连接数不是越大越好。数据库也有工作线程、锁管理、内存和 I/O 能力。如果应用池的连接数远大于数据库可有效并发处理的数量,结果通常是更多排队、上下文切换和锁竞争,而不是线性吞吐增长。

一个请求持有连接的时间包括网络等待、SQL 执行和应用读取 ResultSet 的时间。如果代码把连接拿到后执行远程 HTTP 调用,连接就会被无意义地占用:

try (Connection c = dataSource.getConnection()) {
    // 查询
    callRemoteService(); // 错误边界:外部服务延迟会占用数据库连接
    // 更新
}

事务越长,锁持有时间越长,连接池占用时间也越长;这会把数据库慢查询放大为应用级连接耗尽。


五、PreparedStatement 的资源和生成键

1. 资源关闭的层级

安全结构是:

try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(
             "select id from account where status = ?")) {

    ps.setString(1, "OPEN");

    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            long id = rs.getLong(1);
        }
    }
}

不要把 ResultSet 保存到连接关闭之后再读取:

ResultSet findAccounts() throws SQLException {
    Connection c = dataSource.getConnection();
    PreparedStatement ps = c.prepareStatement("select id from account");
    return ps.executeQuery(); // Connection、Statement 都无法可靠管理
}

如果 API 必须返回数据,应在连接仍然打开时映射成普通对象或集合:

record Account(long id, String name) {}

List<Account> findAccounts(DataSource dataSource) throws SQLException {
    List<Account> result = new ArrayList<>();

    try (Connection c = dataSource.getConnection();
         PreparedStatement ps = c.prepareStatement(
                 "select id, name from account");
         ResultSet rs = ps.executeQuery()) {

        while (rs.next()) {
            result.add(new Account(
                    rs.getLong("id"),
                    rs.getString("name")));
        }
    }

    return result;
}

这种设计把数据库资源的生命周期限制在 DAO 方法内部,避免上层对象间接持有游标。

2. 获取数据库生成的主键

插入后需要主键时,可以请求生成键:

String sql = "insert into account(name, balance) values (?, ?)";

try (PreparedStatement ps = connection.prepareStatement(
        sql, Statement.RETURN_GENERATED_KEYS)) {

    ps.setString(1, "Alice");
    ps.setBigDecimal(2, new BigDecimal("100.00"));
    ps.executeUpdate();

    try (ResultSet keys = ps.getGeneratedKeys()) {
        if (!keys.next()) {
            throw new SQLException("database did not return generated key");
        }
        long id = keys.getLong(1);
        System.out.println("new account id = " + id);
    }
}

是否返回键、返回哪些列以及驱动支持程度,受数据库和 JDBC 驱动影响。需要主键的后续 SQL 应在同一事务中执行,否则插入和后续操作之间可能出现并发问题。


六、隔离级别:并发事务看到什么

事务隔离级别描述一个事务在并发执行时可以观察到哪些其他事务的结果。JDBC 用以下常量表示:

Connection.TRANSACTION_NONE
Connection.TRANSACTION_READ_UNCOMMITTED
Connection.TRANSACTION_READ_COMMITTED
Connection.TRANSACTION_REPEATABLE_READ
Connection.TRANSACTION_SERIALIZABLE

应用可以查询和设置:

int actual = connection.getTransactionIsolation();
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

设置前应确认数据库和驱动是否支持:

DatabaseMetaData meta = connection.getMetaData();

if (!meta.supportsTransactionIsolationLevel(
        Connection.TRANSACTION_REPEATABLE_READ)) {
    throw new SQLException("isolation level is not supported");
}

JDBC 定义的是可观察现象的最低语义;数据库可能把请求的级别提升、降级或用不同机制实现。最终行为必须结合数据库文档和实验验证。

1. 脏读、不可重复读和幻读

设事务 T1、T2 并发执行。

脏读

T1 修改一行但未提交:

T1: update account set balance = 0 where id = 1
T2: select balance from account where id = 1  -> 读到 0
T1: rollback

T2 读到了最终不存在的值,这就是脏读。

不可重复读

T1: select balance where id = 1 -> 100
T2: update balance to 50; commit
T1: select balance where id = 1 -> 50

同一事务两次读取同一行,结果不同,这就是不可重复读。

幻读

T1 读取满足条件的行:

select count(*) from account where balance >= 100;

T2 插入一条满足条件的新行并提交:

insert into account(id, balance) values (3, 200);

T1 再次执行同一个条件查询,结果行数增加。新增的“幻影行”就是幻读。

2. JDBC 级别的典型保证

按 JDBC 文档中的传统定义,可以这样理解:

隔离级别 脏读 不可重复读 幻读
READ_UNCOMMITTED 可能 可能 可能
READ_COMMITTED 不允许 可能 可能
REPEATABLE_READ 不允许 不允许 可能
SERIALIZABLE 不允许 不允许 不允许

这张表描述的是现象边界,不直接规定实现方式。READ_COMMITTED 可能用语句级快照,也可能用读锁;REPEATABLE_READ 在不同数据库中可能表现不同。

例如,某数据库的“重复读”通过事务级快照实现时,普通 SELECT 可能始终读取事务开始时的版本;但当前读、SELECT ... FOR UPDATE、唯一性检查和范围锁可能走另一条路径。不能把隔离级别名称直接等同于“所有 SQL 都使用同一种锁”。

3. 完整并发算例:库存扣减

假设库存为 1,两个事务都执行:

select stock from product where id = 10;
-- 应用判断 stock > 0
update product set stock = stock - 1 where id = 10;

在某些并发时序下:

初始 stock = 1

T1: select -> 1
T2: select -> 1
T1: update -> 0
T1: commit
T2: update -> -1
T2: commit

即使使用 READ_COMMITTED,也不能仅凭两次独立语句保证库存不被扣成负数,因为第二个事务在读取和更新之间发生了竞争。

更可靠的单条条件更新是:

update product
set stock = stock - 1
where id = 10
  and stock > 0;

然后检查更新行数:

try (PreparedStatement ps = connection.prepareStatement(
        "update product set stock = stock - 1 " +
        "where id = ? and stock > 0")) {

    ps.setLong(1, 10L);
    int count = ps.executeUpdate();

    if (count == 0) {
        throw new IllegalStateException("out of stock");
    }
}

这里的推导是:

  1. 数据库在执行单条 UPDATE 时对目标行进行并发控制;
  2. stock > 0 与扣减动作在同一个数据库操作中判断;
  3. 第一个成功事务把 stock1 改为 0
  4. 第二个事务重新判断条件时不再满足;
  5. 第二个执行返回 0,应用识别为库存不足。

如果业务需要先读取多个商品、计算总价再扣减,则可能还需要显式锁:

select id, stock
from product
where id = ?
for update;

FOR UPDATE 的语法和锁行为依赖数据库,不是所有数据库都完全相同。锁定读取通常必须放在手动事务中,并且要控制事务顺序和长度,否则可能增加死锁风险。

4. SERIALIZABLE 不是“自动重试”

SERIALIZABLE 的目标是让并发事务的结果等价于某种串行执行,但数据库可能通过阻塞或检测冲突后报错实现。因此事务可能失败,常见异常包括死锁、序列化失败或锁等待超时。

重试不能简单地把原有操作再执行一次。只有当操作具有幂等性、事务边界完整、外部副作用不会重复时,才适合重试。例如:

  • 数据库事务回滚后重新执行,通常可以重试;
  • 已经发送的邮件、扣款请求、消息发布,不能仅靠数据库回滚撤销。

七、保存点:事务中的局部回滚

保存点允许在一个事务内部回滚到中间位置:

connection.setAutoCommit(false);

Savepoint savepoint = null;
try {
    insertMainRecord(connection);

    savepoint = connection.setSavepoint("after-main");

    try {
        insertOptionalRecord(connection);
    } catch (SQLException optionalFailure) {
        connection.rollback(savepoint);
        // 放弃可选记录,但保留主记录
    }

    connection.commit();
} catch (SQLException e) {
    connection.rollback();
    throw e;
}

保存点不是独立事务。rollback(savepoint) 只撤销保存点之后的工作,最终仍需要 commit() 或整体 rollback()

保存点的成本和支持程度依赖数据库。批量导入时频繁创建保存点可能增加日志和锁管理开销;如果整个批次必须全成全败,直接在一个事务中失败后整体回滚通常更清晰。


八、批处理:减少往返,不自动改变原子性

1. 基本机制

批处理把多次参数绑定和执行合并为一批:

String sql = """
        insert into account(name, balance)
        values (?, ?)
        """;

try (PreparedStatement ps = connection.prepareStatement(sql)) {
    for (int i = 0; i < 3; i++) {
        ps.setString(1, "user-" + i);
        ps.setBigDecimal(2, new BigDecimal("100.00"));
        ps.addBatch();
    }

    int[] counts = ps.executeBatch();

    for (int count : counts) {
        System.out.println(count);
    }
}

addBatch() 只是把当前参数集加入批次,真正执行通常发生在 executeBatch()

批处理主要减少:

  • Java 与数据库之间的网络往返;
  • SQL 解析和协议交互次数;
  • 应用层方法调用开销。

它不保证:

  • 数据库一定一次性执行;
  • 服务端一定使用批量协议;
  • 一定比逐条执行快;
  • 自动具备事务原子性。

2. 批处理与事务的关系

需要全成全败时,应显式关闭自动提交:

try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(
             "insert into audit_event(event_type, payload) values (?, ?)")) {

    c.setAutoCommit(false);

    try {
        for (AuditEvent event : events) {
            ps.setString(1, event.type());
            ps.setString(2, event.payload());
            ps.addBatch();

            if (/* 达到批次大小 */ false) {
                ps.executeBatch();
                ps.clearBatch();
            }
        }

        ps.executeBatch();
        c.commit();
    } catch (SQLException | RuntimeException e) {
        c.rollback();
        throw e;
    }
}

如果自动提交为 true,驱动可能在批次内部按语句提交,失败时前面的记录可能已经持久化。executeBatch() 抛出异常不等于数据库已经自动撤销所有成功项。

3. executeBatch() 的返回值

批量更新计数数组中的元素通常有三类含义:

  • 非负整数:该项影响的行数;
  • Statement.SUCCESS_NO_INFO:成功,但驱动不知道具体行数;
  • Statement.EXECUTE_FAILED:该项执行失败,某些驱动在异常信息中提供。

失败时常见异常类型是 BatchUpdateException

try {
    int[] counts = ps.executeBatch();
} catch (BatchUpdateException e) {
    int[] counts = e.getUpdateCounts();
    // counts 只表示驱动报告的执行进度,不能单凭它推断事务是否已提交
    connection.rollback();
    throw e;
}

如果数据库约束要求全成全败,事务回滚是判断结果的关键;不要只依赖更新计数数组。

4. 批次大小和参数限制

批次过大可能带来:

  • 客户端内存增长;
  • 单个事务日志过大;
  • 锁持有时间变长;
  • 数据库 WAL/redo 日志压力增大;
  • 单次请求超过驱动或数据库的参数、消息大小限制;
  • 失败后重试成本过高。

批次大小不是固定常数。应根据单行大小、数据库日志、锁竞争和实际吞吐测试确定,并在失败时记录批次范围或业务幂等键。

5. executeLargeBatch

当单次批处理的更新总数可能超过 int 范围时,可以使用 JDBC 提供的 executeLargeBatch(),返回 long[]。它解决的是计数类型范围问题,不会改变事务隔离、提交和错误处理语义。


九、泄漏:连接、语句、结果集和事务泄漏的区别

“数据库连接泄漏”不是唯一问题。至少要区分四类资源:

  1. 连接泄漏:没有调用 Connection.close(),连接池中的借出数持续增加。
  2. 事务泄漏:连接关闭前没有提交或回滚,导致锁、未提交状态或连接污染。
  3. 语句泄漏PreparedStatement 没有关闭,可能消耗驱动和数据库服务端资源。
  4. 结果集泄漏ResultSet 没有关闭,可能保持游标、网络流或服务端资源。

最常见的根因是异常路径:

Connection c = dataSource.getConnection();
try {
    PreparedStatement ps = c.prepareStatement("select ...");
    return read(ps.executeQuery());
} catch (SQLException e) {
    throw new RuntimeException(e);
}
// c、ps、ResultSet 都没有可靠关闭

应改成资源结构:

try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement("select ...")) {
    try (ResultSet rs = ps.executeQuery()) {
        return read(rs);
    }
}

如果有事务:

try (Connection c = dataSource.getConnection()) {
    c.setAutoCommit(false);
    try {
        doWork(c);
        c.commit();
    } catch (Throwable failure) {
        try {
            c.rollback();
        } catch (SQLException rollbackFailure) {
            failure.addSuppressed(rollbackFailure);
        }
        throw failure;
    }
}

这里 rollback() 也可能失败,例如数据库连接已经断开。保留 suppressed exception 可以同时看到原始业务错误和回滚失败原因。

1. 泄漏的外在表现

连接泄漏通常按以下顺序暴露:

请求借出连接
    -> 某条异常路径没有 close
    -> 空闲连接数量下降
    -> 新请求等待连接
    -> 获取连接超时
    -> 应用线程堆积

常见日志类似:

Connection is not available, request timed out after ...

这不一定说明数据库宕机,也可能是:

  • SQL 变慢,连接长期被占用;
  • 事务中调用了外部服务;
  • 结果集读取缓慢;
  • 连接池最大连接数过小;
  • 真正的代码泄漏;
  • 数据库锁等待导致所有连接卡住。

因此不能看到连接池超时就直接增大最大连接数。应同时观察:

  • active connections;
  • idle connections;
  • pending/waiting threads;
  • connection acquisition time;
  • SQL 执行时间;
  • transaction duration;
  • 数据库锁等待;
  • 慢查询;
  • 泄漏检测堆栈。

2. 如何验证是否泄漏

一个可重复的验证方法是:

  1. 启动应用,记录连接池 active、idle、total;
  2. 连续执行固定数量的成功请求;
  3. 连续执行会抛异常的请求;
  4. 等待请求结束;
  5. 检查 active 是否回到稳定基线;
  6. 重复多轮,观察 active 是否单调增加。

如果 active 在异常请求后不下降,优先检查 try-with-resources 和事务回滚路径。

连接池泄漏检测通常只会报告“连接借出时间过长”,它可能把长事务或慢查询误报为泄漏。因此告警堆栈要结合 SQL、事务持续时间和线程状态判断。


十、连接归还前必须恢复的状态

连接池中的连接不能被看作无状态对象。一个方法修改了以下状态,就必须确保不会污染后续调用:

try (Connection c = dataSource.getConnection()) {
    boolean oldReadOnly = c.isReadOnly();
    int oldIsolation = c.getTransactionIsolation();
    boolean oldAutoCommit = c.getAutoCommit();

    try {
        c.setReadOnly(true);
        c.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
        // 查询
    } finally {
        // 实际项目中更推荐由连接池统一重置,并确保事务已结束
        c.setReadOnly(oldReadOnly);
        c.setTransactionIsolation(oldIsolation);
        c.setAutoCommit(oldAutoCommit);
    }
}

但这段示例有一个重要限制:如果当前事务未结束,恢复 autoCommit 可能触发提交。因此正确顺序应当是:

业务成功 -> commit
业务失败 -> rollback
确认事务结束
再恢复连接状态
最后 close/归还连接

会话变量、临时表和数据库特定设置不一定能由通用连接池完整重置。使用数据库特定 SQL 修改会话环境时,应明确由连接初始化和归还钩子处理,或者避免在共享连接池中依赖这种状态。


十一、异常处理和超时边界

1. SQLException 不是一个原因

SQLException 可能代表:

  • SQL 语法错误;
  • 唯一键或外键约束失败;
  • 连接断开;
  • 锁等待超时;
  • 死锁;
  • 序列化失败;
  • 查询超时;
  • 数据类型转换错误。

应记录但不要泄露敏感信息:

catch (SQLException e) {
    logger.error("update account failed, accountId={}", accountId, e);
    throw e;
}

生产诊断通常需要:

  • SQL 模板,而不是包含敏感参数的完整 SQL;
  • 参数类型和业务标识;
  • SQLState;
  • vendor error code;
  • 事务持续时间;
  • 连接池等待时间;
  • 数据库 trace/correlation id。

2. 查询超时不等于数据库已经停止执行

可以设置语句级超时:

try (PreparedStatement ps = c.prepareStatement(
        "select id, name from account where status = ?")) {
    ps.setQueryTimeout(5); // 单位:秒
    ps.setString(1, "OPEN");
    // executeQuery
}

setQueryTimeout 的实际中断精度和驱动支持依赖实现。客户端抛出超时异常后,数据库端语句可能仍在清理或执行。事务连接不应直接带着不确定状态继续复用;通常应回滚,必要时由连接池淘汰异常物理连接。

连接获取超时与 SQL 执行超时是两件事:

  • 获取连接超时:池里没有可借连接;
  • 查询超时:已经拿到连接,但 SQL 执行超过限制。

两者需要分别监控和处理。


十二、一个完整的事务服务示例

下面的服务把库存扣减和审计写入放在同一个事务中:

public record PurchaseResult(long productId, int quantity) {}

public final class PurchaseService {
    private final DataSource dataSource;

    public PurchaseService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public PurchaseResult purchase(long productId, int quantity)
            throws SQLException {

        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }

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

            try {
                int updated;
                try (PreparedStatement ps = c.prepareStatement("""
                        update product
                           set stock = stock - ?
                         where id = ?
                           and stock >= ?
                        """)) {
                    ps.setInt(1, quantity);
                    ps.setLong(2, productId);
                    ps.setInt(3, quantity);
                    updated = ps.executeUpdate();
                }

                if (updated != 1) {
                    throw new IllegalStateException(
                            "product not found or insufficient stock");
                }

                try (PreparedStatement ps = c.prepareStatement("""
                        insert into purchase_audit(product_id, quantity)
                        values (?, ?)
                        """)) {
                    ps.setLong(1, productId);
                    ps.setInt(2, quantity);
                    ps.executeUpdate();
                }

                c.commit();
                return new PurchaseResult(productId, quantity);
            } catch (Throwable failure) {
                try {
                    c.rollback();
                } catch (SQLException rollbackFailure) {
                    failure.addSuppressed(rollbackFailure);
                }
                throw failure;
            }
        }
    }
}

前置条件是数据库中存在:

create table product (
    id bigint primary key,
    stock integer not null check (stock >= 0)
);

create table purchase_audit (
    id bigint generated always as identity primary key,
    product_id bigint not null,
    quantity integer not null,
    created_at timestamp default current_timestamp
);

执行路径为:

  1. DataSource 借出一个连接;
  2. 关闭自动提交;
  3. 使用单条条件更新原子地扣减库存;
  4. 检查更新行数,失败则抛出异常;
  5. 写入审计记录;
  6. 两条 SQL 都成功后提交;
  7. 任一步失败则回滚;
  8. 离开 try 块归还连接。

如果审计写入失败,库存扣减也会回滚。如果业务要求“库存扣减必须成功,但审计稍后异步写入”,那就不应把两者伪装成同一个事务,而应设计可靠消息、事务性发件箱或其他明确的一致性方案。


十三、常见误解和正确边界

误解一:关闭 PreparedStatement 会回滚事务

不会。关闭语句只释放语句相关资源。事务是否提交取决于:

connection.commit();
connection.rollback();

连接关闭时未提交事务通常会回滚,但不应依赖它作为正常控制流;连接池和驱动的处理细节也可能不同。

误解二:连接池会自动修复所有泄漏

连接池只能限制资源总量并报告超时。它不能修复:

  • 永远不调用 close() 的业务代码;
  • 长时间持有连接;
  • 未结束的事务;
  • 持续阻塞的数据库锁;
  • ResultSet 交给长生命周期对象。

误解三:READ_COMMITTED 就不会发生并发覆盖

它通常防止脏读,但不防止“先读后写”的逻辑竞争。库存、余额、状态迁移等操作需要条件更新、锁定读取、唯一约束或更高隔离级别。

误解四:批处理天然全成全败

批处理只是执行组织方式。全成全败来自事务边界和回滚,而不是来自 addBatch()

误解五:PreparedStatement 可以防止所有 SQL 注入

它保护参数值,但不能自动安全处理动态表名、列名和排序方向。标识符必须使用白名单或数据库提供的安全标识符机制。

误解六:事务只属于数据库,不属于连接

JDBC 中事务控制发生在 Connection 上。数据库事务通常与数据库会话绑定,而 JDBC 的会话载体就是连接。换连接就可能换事务;把连接交给另一个线程或跨请求保存,会同时引入事务边界和线程安全问题。


十四、选择和验证的核心原则

可以把一次可靠的 JDBC 数据访问归纳成一条可验证的因果链:

明确 SQL 结构
    -> 使用 PreparedStatement 绑定值
    -> 在需要原子性的操作中使用同一个 Connection
    -> 显式设置事务边界
    -> 根据并发异常选择条件更新或锁
    -> 用更新计数验证数据库实际影响
    -> 对批处理明确失败和回滚策略
    -> 在所有路径关闭 ResultSet、Statement、Connection
    -> 连接归还前确保事务结束且状态未污染

其中任何一环缺失,都可能产生不同的故障:

  • SQL 拼接导致注入;
  • 多连接导致事务不完整;
  • 自动提交导致部分成功;
  • 隔离级别不足导致并发覆盖;
  • 批次过大导致锁和日志压力;
  • 异常路径没有关闭导致连接池耗尽;
  • 未回滚导致锁长期持有;
  • 连接状态污染导致后续请求出现难以复现的行为。

JDBC 本身并不替应用决定事务边界、批次大小或重试策略。它提供的是一组可以精确控制这些行为的低层接口。工程代码的可靠性,取决于是否把数据库操作、连接生命周期、事务状态和失败路径设计成一个完整的系统。


系列导航与关联阅读

官方资料

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