4. JDBC事务与并发控制

在JDBC中,事务控制是保证数据一致性的核心手段。默认情况下,JDBC连接处于自动提交(auto-commit)模式:每执行一条INSERT、UPDATE或DELETE语句,数据库会立即将其作为一个独立事务提交。这在单条语句场景下很方便,但在“转账”这类需要多条语句原子完成的业务中,就会带来严重问题——如果扣款成功而加款失败,数据将处于不一致状态。

事务的四个基本特性即ACID:原子性(Atomicity)要求一组操作要么全部成功、要么全部回滚;一致性(Consistency)要求事务前后数据满足业务约束;隔离性(Isolation)要求并发事务之间互不干扰;持久性(Durability)要求提交后的结果永久保存。JDBC本身不实现ACID,而是通过向数据库发送事务控制命令来驱动数据库完成。

手动提交:把多条语句绑成一个事务

要手动控制事务,第一步是关闭自动提交:

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);   // 开启手动事务
try {
    // 1. 扣减A账户
    try (PreparedStatement ps1 = conn.prepareStatement(
            "UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?")) {
        ps1.setBigDecimal(1, new BigDecimal("100.00"));
        ps1.setLong(2, 1L);
        ps1.setBigDecimal(3, new BigDecimal("100.00"));
        if (ps1.executeUpdate() != 1) {
            throw new IllegalStateException("余额不足或账户不存在");
        }
    }
    // 2. 增加B账户
    try (PreparedStatement ps2 = conn.prepareStatement(
            "UPDATE account SET balance = balance + ? WHERE id = ?")) {
        ps2.setBigDecimal(1, new BigDecimal("100.00"));
        ps2.setLong(2, 2L);
        if (ps2.executeUpdate() != 1) {
            throw new IllegalStateException("目标账户不存在");
        }
    }
    conn.commit();           // 两步都成功才提交
} catch (Exception e) {
    conn.rollback();         // 任一步失败则整体回滚
    throw e;
} finally {
    conn.setAutoCommit(true); // 归还连接前恢复默认状态
    conn.close();
}

这里有两个工程要点:一是提交或回滚必须放在finally之外或之内明确执行,不能依赖连接关闭自动回滚;二是连接池归还连接前应恢复autoCommit为true,否则下一个借用者会意外继承手动事务状态。此外,扣款SQL中加入了balance >= ?条件,用数据库自身的行锁保证“余额充足”判断与扣减在同一原子操作内完成,避免先查后改的竞态。

保存点:部分回滚的精细控制

当事务中包含多个可独立失败的子步骤时,保存点(Savepoint)允许回滚到中间位置而不放弃整个事务。例如批量导入时,每条记录设置一个保存点,失败一条只回滚该条,其余继续:

conn.setAutoCommit(false);
Savepoint sp = null;
for (Order o : orders) {
    sp = conn.setSavepoint("before_" + o.getId());
    try {
        insertOrder(conn, o);
        insertItems(conn, o.getItems());
    } catch (SQLException e) {
        conn.rollback(sp);   // 只撤销当前订单
        log.warn("订单{}导入失败,已跳过", o.getId(), e);
    }
}
conn.commit();

注意:保存点由数据库维护,数量过多会消耗资源;回滚到保存点后,该保存点之后创建的保存点会失效。

隔离级别与并发异常

并发事务可能引发四类异常:脏读(读到未提交数据)、不可重复读(同一行两次读取结果不同)、幻读(同一范围两次查询行数不同)、丢失更新(后写覆盖前写)。SQL标准定义了四个隔离级别,JDBC通过conn.setTransactionIsolation(int level)设置:

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

隔离级别越高,一致性越强,但并发度越低。MySQL InnoDB默认REPEATABLE_READ,Oracle默认READ_COMMITTED。设置隔离级别必须在事务开始前调用,事务进行中修改会抛异常。

乐观锁与悲观锁的JDBC实现

悲观锁假设冲突必然发生,在读取时就锁定数据。JDBC中通过SELECT ... FOR UPDATE实现:

// 悲观锁:锁定账户行,直到事务结束
try (PreparedStatement ps = conn.prepareStatement(
        "SELECT balance FROM account WHERE id = ? FOR UPDATE")) {
    ps.setLong(1, 1L);
    ResultSet rs = ps.executeQuery();
    rs.next();
    BigDecimal balance = rs.getBigDecimal(1);
    if (balance.compareTo(amount) < 0) throw new IllegalStateException("余额不足");
    // 在锁保护下执行更新
    updateBalance(conn, 1L, balance.subtract(amount));
}
conn.commit();

乐观锁假设冲突较少,不加锁读取,更新时校验版本号:

// 乐观锁:version字段作为版本标记
int rows = 0;
while (rows == 0) {
    Account a = queryAccount(conn, 1L);          // 读取当前version
    BigDecimal newBalance = a.getBalance().subtract(amount);
    if (newBalance.signum() < 0) throw new IllegalStateException("余额不足");
    try (PreparedStatement ps = conn.prepareStatement(
            "UPDATE account SET balance = ?, version = version + 1 " +
            "WHERE id = ? AND version = ?")) {
        ps.setBigDecimal(1, newBalance);
        ps.setLong(2, 1L);
        ps.setInt(3, a.getVersion());
        rows = ps.executeUpdate();               // 版本不匹配则影响0行
    }
    if (rows == 0) { /* 重试或抛出并发冲突异常 */ }
}
conn.commit();

选择策略:写冲突频繁、临界区短且需要强一致时用悲观锁;读多写少、冲突概率低、希望避免长时间持锁时用乐观锁。乐观锁的重试次数应设上限,避免高冲突下无限循环。

JDBC事务控制与并发锁机制全景 事务生命周期 setAutoCommit(false) 执行SQL(DML) setSavepoint() commit() rollback() / rollback(sp) setAutoCommit(true) 隔离级别与异常 READ_UNCOMMITTED 脏读 · 不可重复读 · 幻读 READ_COMMITTED 不可重复读 · 幻读 REPEATABLE_READ 幻读 SERIALIZABLE 无并发异常 隔离级别 ↑ 一致性 ↑ 并发度 ↓ 丢失更新 需乐观锁或悲观锁解决 隔离级别无法完全防止 锁策略对比 悲观锁 SELECT ... FOR UPDATE 读取即锁定行 适合写冲突频繁 持锁时间长,并发低 风险:死锁、锁等待 乐观锁 version 版本号校验 更新时 WHERE version=? 适合读多写少 无锁等待,吞吐高 风险:重试风暴
实践建议:在Spring的@Transactional中,隔离级别和传播行为最终都映射到JDBC的setTransactionIsolation与setAutoCommit。理解JDBC层的这些原语,有助于排查“事务不生效”“连接池状态污染”“锁等待超时”等常见问题。例如,若在事务方法内捕获异常后未重新抛出,Spring无法感知失败,事务仍会提交——这与JDBC中忘记调用rollback是同一类错误。
注意:使用SELECT ... FOR UPDATE时,若WHERE条件未命中索引,InnoDB可能升级为表锁或间隙锁,导致大面积阻塞。务必确保锁定条件走索引,并控制事务粒度,避免在锁持有期间执行远程调用或耗时IO。