事务保证了操作的原子性,但当多个事务并发执行时,相互之间可能产生干扰。数据库通过隔离级别和锁机制来解决并发问题,Spring 则提供了声明式的配置方式,让开发者在业务层就能精细控制这些底层行为,而无需编写与数据库相关的样板代码。
10.3.1 并发事务带来的问题
理解隔离级别之前,先要清楚并发场景下到底会出现哪些异常现象。SQL 标准定义了四种典型问题:
- 脏读(Dirty Read):一个事务读到了另一个事务尚未提交的修改。如果后一个事务回滚,读到的数据就是无效的“脏数据”。
- 不可重复读(Non-Repeatable Read):同一个事务内,两次读取同一行数据,结果却不同。因为中间有其他事务修改了该行并提交。
- 幻读(Phantom Read):同一个事务内,两次执行同样的条件查询,结果集的行数不同。因为中间有其他事务插入了满足条件的新行。
- 丢失更新(Lost Update):两个事务同时读取同一行,基于读取的值进行修改后提交,后提交的事务覆盖了前者的修改,导致前者的更新丢失。
不同数据库对这些问题的解决程度不同,而隔离级别就是一套标准的防护等级。
10.3.2 四种事务隔离级别
SQL 标准定义了四种隔离级别,从宽松到严格依次为:
- READ UNCOMMITTED(读未提交)
- 允许事务读取其他事务未提交的数据。
- 存在脏读、不可重复读、幻读风险。
- 性能最高,但一致性最差,极少在生产中使用。
- READ COMMITTED(读已提交)
- 事务只能读取其他事务已提交的数据,解决了脏读。
- 不可重复读和幻读仍然可能发生。
- 是 Oracle、PostgreSQL 的默认级别,许多场景下的合理选择。
- REPEATABLE READ(可重复读)
- 保证同一个事务内多次读取同一行数据的结果一致,解决了不可重复读。
- 幻读仍可能出现(部分数据库如 MySQL InnoDB 通过 MVCC 和间隙锁对幻读也有一定抑制,但不完全符合 SQL 标准的幻读定义)。
- MySQL 的默认级别,适合对读一致性要求较高的场景。
- SERIALIZABLE(串行化)
- 强制事务串行执行,所有读写都加锁,彻底避免脏读、不可重复读和幻读。
- 并发性能急剧下降,只在对数据一致性要求极高且并发量较低的场合使用。
实际项目中,绝大多数场景使用 READ COMMITTED 或 REPEATABLE READ。前者并发度更好,后者能避免同一事务内读数据的前后不一致。
10.3.3 Spring 中声明隔离级别
Spring 事务抽象将上述隔离级别对应为 @Transactional 注解的 isolation 属性:
@Service
public class OrderService {
@Transactional(isolation = Isolation.READ_COMMITTED)
public void processOrder(Long orderId) {
// 该事务使用 READ COMMITTED 隔离级别
Order order = orderRepository.findById(orderId).orElseThrow();
// ...
}
}
Isolation 枚举值包括:
DEFAULT:使用底层数据库的默认隔离级别(最常用,保持与数据库一致)。READ_UNCOMMITTEDREAD_COMMITTEDREPEATABLE_READSERIALIZABLE
重要提示:并非所有数据库都完整支持所有级别,具体行为取决于数据库实现。修改隔离级别会增加数据库开销,应仅在明确需要时覆盖默认值。
10.3.4 锁机制:悲观锁与乐观锁
隔离级别本质上是数据库通过锁或多版本并发控制(MVCC)实现的。Spring 除了让开发者声明隔离级别,还允许在应用层控制锁策略,形成互补。
悲观锁(Pessimistic Lock)
悲观锁假定并发冲突极可能发生,因此在访问数据时立即加锁,阻止其他事务同时修改。
- 共享锁(读锁):允许其他事务读,但不允许写。
- 排他锁(写锁):不允许其他事务读取或写入。
在 Spring Data JPA 中,可以通过 @Lock 注解使用悲观锁:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdForUpdate(@Param("id") Long id);
}
对应的 SQL 会发出 SELECT ... FOR UPDATE,锁住查询到的行,直到事务结束才释放。
使用场景:争抢激烈的资源(如扣减库存、账户扣款),冲突概率高的操作。缺点是会降低并发度,容易引发死锁,需要严格控制事务范围。
乐观锁(Optimistic Lock)
乐观锁假设冲突很少发生,不在数据库层面加锁,而是通过版本号或时间戳来检测冲突。
JPA 提供了简单的实现方式:在实体中增加一个用 @Version 注解的字段:
@Entity
public class Product {
@Id
private Long id;
private String name;
private Integer stock;
@Version
private Long version; // 版本号
}
每次更新数据时,JPA 会自动检查 version 字段。更新语句会形如:
UPDATE product SET stock = ?, version = version + 1
WHERE id = ? AND version = ?
如果 WHERE 条件影响的行数为 0,说明在读取之后数据已被其他事务修改,此时 JPA 会抛出 OptimisticLockException,开发者可以捕获后重试或提示用户。
使用场景:读多写少、冲突概率低的业务,如用户资料更新、文章编辑等。乐观锁不会阻塞读操作,并发性能高,但需要处理重试逻辑。
10.3.5 真实场景下的组合运用
不同业务对一致性和并发性的需求差异很大,开发中经常混合使用隔离级别和锁机制:
- 账户转账:使用
REPEATABLE READ+ 悲观锁SELECT ... FOR UPDATE,既保证事务内多次读取余额一致,又防止并发扣款超额。 - 秒杀扣库存:通常采用乐观锁,在扣减时检查版本号,失败后快速重试;也可以使用数据库行锁,但要控制事务粒度,避免长事务锁竞争。
- 报表统计:多数情况下
READ COMMITTED足够,如果需要快照式一致性,可以提升到REPEATABLE READ或使用只读事务,避免幻读影响统计结果。 - 订单状态流转:单纯的状态更新并发冲突不高,乐观锁最为合适,无需提升隔离级别。
10.3.6 常见陷阱与最佳实践
- 不要滥用
SERIALIZABLE:它会导致大量锁等待和超时,绝大多数业务可通过其他手段(如乐观锁、合理的事务边界)达到数据一致性要求。 - 注意数据库默认隔离级别的差异:MySQL 默认为
REPEATABLE READ,而 PostgreSQL、Oracle 默认为READ COMMITTED。Spring 的DEFAULT会跟随数据库设置,迁移数据库时需要验证行为差异。 - 悲观锁要与事务边界配合:加锁操作必须包裹在事务内,并且事务应尽快提交,否则锁持有时间过长,严重影响并发。避免在锁内调用外部服务或执行耗时计算。
- 乐观锁的重试与幂等:自动重试时要确保操作幂等性,防止重复扣款。可使用本地重试结合唯一索引或分布式锁兜底。
- 隔离级别只影响读:即使设置了最高隔离级别,更新操作依然可能产生丢失更新。对写操作的保护更多依赖于锁机制(悲观或乐观)。
- 结合日志与监控:生产环境中应监控数据库锁等待、死锁日志,适时调整事务边界和索引,保证吞吐量。
掌握了事务隔离级别和锁机制的用法,就可以在 Spring 中灵活地为不同业务场景配置合适的数据保护策略,既保数据一致性,又兼顾系统并发能力。