乐观锁与悲观锁
在并发场景下,数据库记录可能被多个事务同时读取或修改,若不加以控制,会导致脏写、不可重复读、幻读等数据一致性问题。JPA 规范定义了两种并发控制策略:乐观锁(Optimistic Locking) 和 悲观锁(Pessimistic Locking)。Spring Data JPA 对这两种策略提供了完善的声明式支持。
LockModeType 枚举总览
LockModeType 是 JPA 中定义锁模式的核心枚举,位于 javax.persistence.LockModeType(Jakarta 规范下为 jakarta.persistence.LockModeType)。它包含以下常量:
| 枚举常量 | 锁类型 | 说明 |
|---|---|---|
OPTIMISTIC | 乐观锁 | 事务提交时验证 @Version |
OPTIMISTIC_FORCE_INCREMENT | 乐观锁 | 读取时立即递增版本号,提交时验证 |
PESSIMISTIC_READ | 悲观共享锁 | 读取时加共享锁,禁止其他事务修改 |
PESSIMISTIC_WRITE | 悲观排他锁 | 读取时加排他锁,禁止其他事务读写 |
PESSIMISTIC_FORCE_INCREMENT | 悲观排他锁 + 版本递增 | 加排他锁并在提交时递增版本号 |
NONE | 无锁 | 不加任何锁 |
在 Spring Data JPA 中使用 @Lock 注解即可指定上述任意一种模式。
1. 乐观锁
乐观锁假设数据在大多数情况下不会发生冲突,仅在提交更新时检查数据是否被其他事务修改过。JPA 通过 @Version 注解实现乐观锁机制。
1.1 @Version 注解原理
@Version 注解用于标注实体类中的一个字段,该字段在每次更新时由 JPA 提供者(如 Hibernate)自动递增或更新时间戳。更新操作的核心逻辑是:在 UPDATE 语句的 WHERE 子句中附带版本条件,若更新行数为 0,则说明版本已被其他事务修改,抛出 OptimisticLockException。
版本号方式:
// 使用 int / Integer / long / Long 类型
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Integer version;
// getters and setters...
}时间戳方式:
// 使用 java.sql.Timestamp / java.time.Instant / java.util.Date 类型
@Entity
public class Product {
@Id
private Long id;
@Version
private Instant lastModified;
// getters and setters...
}两种方式的对比:
| 特性 | 版本号(Integer/Long) | 时间戳(Timestamp/Instant) |
|---|---|---|
| 精度 | 精确 | 受限于系统时钟粒度 |
| 并发安全 | 高,单调递增 | 低,时钟回拨会导致失效 |
| 推荐程度 | 强烈推荐 | 不推荐生产使用 |
最佳实践: 始终使用
Integer或Long类型的版本号字段,时间戳方式容易因时钟回拨、同一毫秒内的并发冲突而产生漏检测问题。
@Version 的级联行为
当一个实体包含对子实体的级联关系时,@Version 的递增同样会传播到父实体。Hibernate 保证无论子实体还是父实体发生变更,父实体的版本号都会递增。这一行为通过 Hibernate 的内部事件机制实现:
// 在 FlushEvent 处理过程中,Hibernate 会遍历所有脏实体(Dirty Entities)
// 如果子实体标记为脏且与父实体存在级联关系,父实体的版本号也会被标记为脏
// 从而在生成 UPDATE 语句时确保版本号递增这种级联版本递增机制确保了在父子实体关联场景下,乐观锁的一致性不会被级联更新所破坏。
1.2 底层源码分析:AbstractEntityPersister.getVersionTypes()
Hibernate 在 AbstractEntityPersister 中负责处理版本控制。下面我们剖析其核心方法 getVersionTypes() 的实现。
Hibernate 将版本字段的类型映射存储在一个私有数组中,getVersionTypes() 返回该数组。当执行 UPDATE 语句时,Hibernate 会调用此方法获取版本字段的 Type,进而生成包含版本比较的子句。
// AbstractEntityPersister 相关源码(Hibernate 6.x 简化示意)
public abstract class AbstractEntityPersister
implements EntityPersister, EntityDefinition, SelfIntercepting {
// 版本字段的 Type 数组,通常只有一个元素
private Type[] versionTypes;
/**
* 返回版本字段的 Hibernate Type 数组。
* 如果实体没有 @Version 字段,返回 null。
*/
@Override
public Type[] getVersionTypes() {
return versionTypes;
}
// 在初始化时根据 @Version 注解解析版本类型
protected void initVersionTypes(Mapping mapping) {
if (versionType != null) {
versionTypes = new Type[] { versionType };
}
}
// 生成 UPDATE 语句时,追加 version 条件
protected String generateVersionedUpdateStatement(
String tableName,
String[] columnNames,
String[] idColumnNames,
String versionColumnName) {
// UPDATE product SET name = ?, version = version + 1
// WHERE id = ? AND version = ?
StringBuilder buf = new StringBuilder("update ")
.append(tableName)
.append(" set ");
// ... 拼接 SET 子句 ...
buf.append(versionColumnName).append("=").append(versionColumnName).append("+1");
buf.append(" where ");
// ... 拼接 ID 条件 ...
buf.append(" and ").append(versionColumnName).append("=?");
return buf.toString();
}
}在 UPDATE 执行完毕后,Hibernate 会检查 Statement.executeUpdate() 的返回值。如果返回 0,说明 WHERE 条件中的版本号不匹配(即其他事务已经修改过该行数据),Hibernate 抛出 StaleObjectStateException(OptimisticLockException 的根原因)。
// 在执行器中的核心逻辑(简化示意)
@Override
public int executeUpdate() {
int affectedRows = jdbcCoordinator.getResultSetReturn().executeUpdate(statement);
// 检查乐观锁版本
if (entityDescriptor.isVersioned() && affectedRows == 0) {
throw new OptimisticLockException(
"Row was updated or deleted by another transaction"
);
}
return affectedRows;
}1.3 乐观锁锁定模式
JPA 2.0 定义了两种乐观锁锁定模式,Spring Data JPA 通过 @Lock 注解和 LockModeType 枚举提供支持。
OPTIMISTIC
OPTIMISTIC 模式在事务提交时对使用了 @Version 的实体进行版本检查。它等效于在读取实体时获取普通读锁,在提交时验证版本。
适用场景:读取旧数据不会导致严重问题,但更新时必须保证一致性。
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithOptimisticLock(@Param("id") Long id);
}当调用 findWithOptimisticLock() 时,JPA 会在事务提交时校验版本号。如果在当前事务读取之后、提交之前,有其他事务修改了该记录,则提交时抛出 OptimisticLockException。
OPTIMISTIC_FORCE_INCREMENT
OPTIMISTIC_FORCE_INCREMENT 在读取实体时立即递增版本号,即使实体本身没有被修改。这种方式可以有效防止"不可重复读"——后续读取操作一定能看到版本号已变更。
适用场景:需要对实体的"读取操作"也施加版本控制,确保在两次读取之间没有其他事务提交过该实体。
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithForceIncrement(@Param("id") Long id);两种乐观锁模式的对比:
| 模式 | 版本号递增时机 | 适用场景 |
|---|---|---|
OPTIMISTIC | 仅在实体修改时 | 大部分业务场景,读多写少 |
OPTIMISTIC_FORCE_INCREMENT | 读取时立即递增 | 需要防止不可重复读,或级联版本同步 |
2. 悲观锁
悲观锁假设数据在并发访问时一定会发生冲突,因此在读取数据时就直接加锁,阻止其他事务修改,直到当前事务提交或回滚。
JPA 提供了三种悲观锁模式,均通过 LockModeType 枚举定义。
2.1 PESSIMISTIC_READ
PESSIMISTIC_READ 在读取数据时获取共享锁(Shared Lock)。其他事务可以继续读取该行数据,但无法修改(如果底层数据库支持,如 MySQL 的 SELECT ... LOCK IN SHARE MODE)。
适用场景:允许并发读取,但不允许并发修改。
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithPessimisticRead(@Param("id") Long id);生成的 SQL(MySQL):
SELECT p.* FROM product p WHERE p.id = ? LOCK IN SHARE MODE生成的 SQL(PostgreSQL):
SELECT p.* FROM product p WHERE p.id = ? FOR SHARE2.2 PESSIMISTIC_WRITE
PESSIMISTIC_WRITE 在读取数据时获取排他锁(Exclusive Lock)。其他事务既不能修改也不能读取该行数据(未提交读级别以下),直到当前事务释放锁。
适用场景:需要完全独占数据行,防止其他事务的读写干扰。
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithPessimisticWrite(@Param("id") Long id);生成的 SQL(MySQL):
SELECT p.* FROM product p WHERE p.id = ? FOR UPDATE生成的 SQL(PostgreSQL):
SELECT p.* FROM product p WHERE p.id = ? FOR UPDATE2.3 PESSIMISTIC_FORCE_INCREMENT
PESSIMISTIC_FORCE_INCREMENT 在加排他锁的基础上,还会在事务提交时强制递增 @Version 字段。即使实体未做任何修改,版本号也会变化。
适用场景:需要悲观锁与乐观锁版本检查混合使用的场景,或需要与其他乐观锁事务协同。
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithPessimisticForceIncrement(@Param("id") Long id);三种悲观锁模式的对比:
| 模式 | 锁类型 | 是否递增版本 | SQL 示例 |
|---|---|---|---|
PESSIMISTIC_READ | 共享锁 | 否 | SELECT ... FOR SHARE |
PESSIMISTIC_WRITE | 排他锁 | 否 | SELECT ... FOR UPDATE |
PESSIMISTIC_FORCE_INCREMENT | 排他锁 | 是 | SELECT ... FOR UPDATE + UPDATE ... SET version = version + 1 |
2.4 锁超时设置
在实际系统中,悲观锁可能导致死锁或长时间等待。JPA 支持通过 javax.persistence.lock.timeout 属性设置锁等待超时时间。
使用 @QueryHints 设置超时
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("select p from Product p where p.id = :id")
Optional<Product> findWithTimeout(@Param("id") Long id);注意: JPA 规范早期使用
javax.persistence.lock.timeout,在 Jakarta EE 9+ 中更名为jakarta.persistence.lock.timeout。请根据项目使用的规范版本选择正确的键名。
设置事务级超时
除了查询级别的超时,还可以在 application.yml 中配置默认的悲观锁超时:
spring:
jpa:
properties:
jakarta:
persistence:
lock:
timeout: 3000 # 单位:毫秒超时值说明
| 超时值 | 含义 |
|---|---|
正值(如 3000) | 等待锁的最长毫秒数,超时则抛出 LockTimeoutException |
0 | 不等待,获取不到锁时立即抛出异常 |
负值(如 -1) | 使用数据库默认的超时策略,不覆盖 |
2.5 悲观锁的隔离级别依赖
悲观锁的效果与当前事务的隔离级别密切相关:
- 在
READ_COMMITTED级别下,PESSIMISTIC_READ使用共享锁,PESSIMISTIC_WRITE使用排他锁。共享锁可以在事务内保证可重复读(同一行被多次读取时不会看到其他事务的未提交修改),但在两次读取之间,其他事务的已提交修改仍然可见(不可重复读)。 - 在
REPEATABLE_READ级别下,共享锁和排他锁的语义与数据库实现相关。MySQL InnoDB 的REPEATABLE_READ结合FOR UPDATE可以实现比标准 SQL 规范更强的隔离保证。 - 在
SERIALIZABLE级别下,即使不使用锁注解,数据库也会自动对每个SELECT施加类似范围锁的机制,因此PESSIMISTIC_WRITE在 SERIALIZABLE 级别下的效果与默认行为相差不大。
2.6 悲观锁的传播行为
当在同一个事务中多次调用带有 @Lock 注解的查询时,锁模式遵循覆盖规则:后调用的锁模式会覆盖前一次的锁模式。
// 在同一事务中
Optional<Product> product = repository.findWithPessimisticRead(id);
// 在另一个查询中指定更强的锁
Optional<Product> locked = repository.findWithPessimisticWrite(id);
// 此时该记录的锁已升级为排他锁注意: 锁升级可能会导致数据库死锁,应在设计阶段审慎规划锁的粒度和顺序。
2.7 不同数据库的锁 SQL 差异
虽然 JPA 规范统一了锁的 API,但不同数据库生成的锁 SQL 存在差异,了解这些差异有助于排查生产环境中的锁问题:
| 数据库 | PESSIMISTIC_READ | PESSIMISTIC_WRITE |
|---|---|---|
| MySQL (InnoDB) | SELECT ... LOCK IN SHARE MODE | SELECT ... FOR UPDATE |
| PostgreSQL | SELECT ... FOR SHARE | SELECT ... FOR UPDATE |
| Oracle | SELECT ... FOR UPDATE(默认排他) | SELECT ... FOR UPDATE |
| SQL Server | SELECT ... WITH (HOLDLOCK, ROWLOCK) | SELECT ... WITH (UPDLOCK, ROWLOCK) |
| H2(内存库) | SELECT ... FOR SHARE | SELECT ... FOR UPDATE |
注意: Oracle 不支持
PESSIMISTIC_READ共享锁语义。当在 Oracle 上使用PESSIMISTIC_READ时,Hibernate 会回退为无锁行为或抛出异常,具体取决于 Hibernate 方言的版本。
2.8 通过 EntityManager 显式加锁
除了 Repository 级别的 @Lock 注解外,也可以通过注入 EntityManager 以编程方式加锁:
@Service
public class ProductService {
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void lockProduct(Long productId) {
Product product = entityManager.find(Product.class, productId);
// 显式设置悲观写锁
entityManager.lock(product, LockModeType.PESSIMISTIC_WRITE);
// 也可以在查询时直接指定锁模式
Product locked = entityManager.createQuery(
"select p from Product p where p.id = :id", Product.class)
.setParameter("id", productId)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.getSingleResult();
}
@Transactional
public void refreshWithLock(Long productId) {
Product product = entityManager.find(Product.class, productId);
// 刷新并加锁 — 等效于重新查询一次并加锁
entityManager.refresh(product, LockModeType.PESSIMISTIC_WRITE);
}
}EntityManager 的 lock() 方法在实体已被加载的场景下非常有用,可以避免额外的数据库查询。
3. 实战:电商秒杀库存扣减
电商秒杀是典型的并发扣减库存场景,大量请求在同一时刻竞争有限的库存资源。本节给出基于 @Version + 重试机制的成熟方案。
3.1 实体设计
@Entity
@Table(name = "seckill_stock")
public class SeckillStock {
@Id
private Long productId;
/**
* 剩余库存
*/
@Column(nullable = false)
private Integer stock;
/**
* 乐观锁版本号
*/
@Version
@Column(nullable = false)
private Integer version;
// getters and setters...
public boolean decreaseStock(int quantity) {
if (stock < quantity) {
return false;
}
this.stock -= quantity;
return true;
}
}3.2 Repository 定义
public interface SeckillStockRepository extends JpaRepository<SeckillStock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from SeckillStock s where s.productId = :productId")
Optional<SeckillStock> findByProductIdWithOptimisticLock(@Param("productId") Long productId);
}3.3 带重试机制的服务层
@Service
@Slf4j
public class SeckillService {
@Autowired
private SeckillStockRepository stockRepository;
/**
* 重试次数上限,防止无限循环
*/
private static final int MAX_RETRIES = 3;
@Transactional
public boolean deductStock(Long productId, int quantity) {
RetryCallback<Boolean, OptimisticLockException> retryCallback = context -> {
SeckillStock stock = stockRepository
.findByProductIdWithOptimisticLock(productId)
.orElseThrow(() -> new IllegalArgumentException("库存记录不存在"));
if (!stock.decreaseStock(quantity)) {
log.warn("库存不足: productId={}, currentStock={}, required={}",
productId, stock.getStock(), quantity);
return false;
}
// 保存时,Hibernate 会自动对比 version
// 如果 version 不匹配,抛出 OptimisticLockException
stockRepository.save(stock);
return true;
};
RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(MAX_RETRIES)
.retryOn(OptimisticLockException.class)
.exponentialBackoff(50, 1.5, 200)
.build();
try {
return retryTemplate.execute(retryCallback);
} catch (OptimisticLockException e) {
log.error("秒杀扣减库存 - 重试耗尽仍失败: productId={}", productId, e);
return false;
}
}
}3.4 关于 ABA 问题的规避
在乐观锁机制中,ABA 问题传统上指:值被从 A 改为 B 又改回 A,导致 CAS 操作误以为没有变化。但在 @Version 机制中,这个问题天然不存在,因为版本号是单调递增的:
- 事务 T1 读取
stock=10, version=1。 - 事务 T2 读取
stock=10, version=1,扣减库存后提交,stock=9, version=2。 - 事务 T3(可能是补偿事务)将库存从 9 加回为 10,提交后
stock=10, version=3。 - 事务 T1 尝试提交,
UPDATE语句的条件是version=1,但数据库中version=3,更新行数为 0,抛出异常。
因此,使用 @Version(版本号方式)的乐观锁天然免疫 ABA 问题,无需额外处理。
3.5 重试策略的优化考量
在选择重试策略时,需要注意以下要点:
| 要点 | 说明 |
|---|---|
| 重试次数不宜过多 | 3-5 次为佳,过多会增加数据库压力 |
| 使用退避算法 | 指数退避(如 50ms → 75ms → 112ms → 169ms)比固定间隔更有效 |
| 避免重试风暴 | 所有失败请求同时重试会导致雪崩,可引入随机抖动(jitter) |
| 区分异常类型 | 只重试 OptimisticLockException,不重试业务验证异常 |
| 监控与告警 | 记录重试次数和成功率,重试耗尽应触发告警 |
3.6 从 Hibernate 源码理解 ABA 规避
回顾 AbstractEntityPersister 中的乐观锁实现:
// Hibernate 实际执行的 SQL 形如:
UPDATE seckill_stock
SET stock = ?, version = version + 1
WHERE product_id = ?
AND version = ? -- 此处为读取时的旧版本号版本号在 SET 子句中递增,在 WHERE 子句中作为过滤条件。即使其他事务将其他字段恢复为原始值,版本号的单向递增保证了旧版本条件永远无法匹配已被修改过的行。
4. 锁策略选型建议
4.1 选型对比总览
| 场景 | 推荐锁策略 | 原因 |
|---|---|---|
| 读多写少,冲突概率低 | @Version 乐观锁 | 无需加锁,性能高 |
| 写冲突频繁,如秒杀、抢购 | @Version + 重试 | 避免数据库连接阻塞 |
| 必须保证读取一致性 | PESSIMISTIC_READ | 防止其他事务写入 |
| 金融转账、库存扣减(高一致性) | PESSIMISTIC_WRITE | 完全独占数据行 |
| 跨实体一致性校验 | OPTIMISTIC_FORCE_INCREMENT | 级联版本同步 |
4.2 注意事项
- 乐观锁不适用于长事务:多个实体在长时间事务中保持乐观锁,提交时大量冲突会导致频繁回滚,此时悲观锁更合适。
- 悲观锁需要短事务:持有排他锁的时间越长,数据库死锁和连接池耗尽的风险越大,应尽量将事务控制在 200ms 以内。
- 混合使用需谨慎:同一实体在同一事务中混用
OPTIMISTIC和PESSIMISTIC_WRITE可能导致不可预期的行为。 - MySQL 行锁与索引:悲观锁实际加锁依赖于查询是否走索引。不走索引的
FOR UPDATE会退化为表锁,严重影响并发性能。
// 反例:不走索引的查询会导致行锁升级为表锁
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.name like %:keyword%")
List<Product> searchWithLock(@Param("keyword") String keyword);
// 正例:确保 WHERE 条件命中索引
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);@Version字段不允许手动修改:JPA 规范明确禁止业务代码手动设置版本号,应由 Hibernate 自动管理。手动修改会破坏乐观锁的语义。
4.3 锁行为测试策略
在编写单元测试和集成测试时,验证锁行为需要特别注意测试手法:
@SpringBootTest
@Transactional
class OptimisticLockTest {
@Autowired
private SeckillStockRepository repository;
@Autowired
private PlatformTransactionManager transactionManager;
@Test
void testOptimisticLockConflict() {
// 模拟两个并发事务同时读取同一数据
// 事务 T1:读取
SeckillStock stock1 = repository
.findByProductIdWithOptimisticLock(1L)
.orElseThrow();
// 事务 T2:读取并提交(模拟另一个线程)
SeckillStock stock2 = repository
.findByProductIdWithOptimisticLock(1L)
.orElseThrow();
stock2.decreaseStock(1);
repository.save(stock2);
// 此时 version 已递增
// 事务 T1 尝试提交,由于 version 不匹配会抛出异常
stock1.decreaseStock(1);
assertThrows(OptimisticLockException.class, () -> {
repository.save(stock1);
// 手动刷新以立即触发版本检查
repository.flush();
});
}
}编写锁测试时需要注意:
- 在同一线程中模拟并发事务时,需要显式控制事务边界,可通过
TransactionTemplate手动管理。 - 数据库层面的锁等待测试需要设置合理的超时时间,避免测试用例长时间挂起。
- 建议使用 H2 内存数据库进行集成测试,但需要注意 H2 的锁语义与生产数据库(如 MySQL)存在差异。
- 对于悲观锁测试,需要确保连接池支持多个并发连接,单连接模式下无法模拟锁争用。
4.4 异常处理与恢复策略
在使用锁机制时,可能会遇到以下异常,应针对性地制定处理策略:
| 异常类型 | 触发条件 | 处理建议 |
|---|---|---|
OptimisticLockException | 乐观锁版本冲突 | 重试整个事务,返回 409 Conflict 状态码 |
StaleObjectStateException | Hibernate 层面的版本冲突 | 同 OptimisticLockException,一般会被包装为前者 |
LockTimeoutException | 悲观锁等待超时 | 返回 503 Service Unavailable,或降级处理 |
PessimisticLockException | 悲观锁获取失败(非超时) | 记录日志,释放当前事务资源 |
TransactionRolledBackException | 事务因锁冲突回滚 | 回滚所有操作,通知调用方重试 |
在实际应用中,建议在服务层定义一个统一的锁异常处理切面:
@Aspect
@Component
@Slf4j
public class LockExceptionAspect {
@AfterThrowing(
pointcut = "execution(* com.example..service.*.*(..))",
throwing = "ex"
)
public void handleLockException(Throwable ex) {
if (ex instanceof OptimisticLockException) {
log.warn("乐观锁冲突: {}", ex.getMessage());
// 可在此处收集监控指标
} else if (ex instanceof LockTimeoutException) {
log.error("悲观锁超时: {}", ex.getMessage());
// 触发告警
}
}
}5. 总结
Spring Data JPA 提供了从声明式注解到底层 SQL 生成的完整锁支持链。@Version 注解配合 OPTIMISTIC / OPTIMISTIC_FORCE_INCREMENT 模式组成无锁并发方案,适合大多数互联网业务场景;PESSIMISTIC_READ / PESSIMISTIC_WRITE / PESSIMISTIC_FORCE_INCREMENT 模式则提供数据库级别的行锁能力,适合对一致性要求极高的金融场景。
在实际项目中,应当根据业务对一致性、性能和复杂度的综合要求来选择合适的锁策略,并通过超时设置、重试机制等辅助手段来保障系统的鲁棒性。