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();
选择策略:写冲突频繁、临界区短且需要强一致时用悲观锁;读多写少、冲突概率低、希望避免长时间持锁时用乐观锁。乐观锁的重试次数应设上限,避免高冲突下无限循环。
@Transactional中,隔离级别和传播行为最终都映射到JDBC的setTransactionIsolation与setAutoCommit。理解JDBC层的这些原语,有助于排查“事务不生效”“连接池状态污染”“锁等待超时”等常见问题。例如,若在事务方法内捕获异常后未重新抛出,Spring无法感知失败,事务仍会提交——这与JDBC中忘记调用rollback是同一类错误。
SELECT ... FOR UPDATE时,若WHERE条件未命中索引,InnoDB可能升级为表锁或间隙锁,导致大面积阻塞。务必确保锁定条件走索引,并控制事务粒度,避免在锁持有期间执行远程调用或耗时IO。