RepeatableRead 能保证库存条件检查与扣减的并发安全吗?
结论
RepeatableRead 和 ReadCommitted 都不能单独保证这段“读取、检查、修改”逻辑的并发安全。
隔离级别决定事务能看到什么数据,以及如何处理部分并发冲突,但读取实体后,其他事务仍可能修改该实体。具体是等待、覆盖更新还是报冲突,取决于数据库、查询方式和实际执行的 SQL。
在这个库存扣减场景中,优先考虑把条件检查和扣减合并成一条 UPDATE。如果业务必须先读取实体再判断,可以在同一个事务中使用 SELECT ... FOR UPDATE,也可以通过版本字段实现乐观锁。
原代码为什么可能使用过期条件
原代码先把库存读入 productEntity.quantity,再在 Java 中判断,最后将计算结果写回。其他事务可能在这些步骤之间修改库存。
假设库存为 10,两个事务分别扣减 7,可能出现以下过程:
- 两个事务都读到
10。 - 两个事务都通过
10 >= 7的检查。 - 两个事务都在内存中算出新库存
3。 - 两个事务尝试把库存写成
3。
如果数据库和更新方式允许后一次写入覆盖前一次结果,就会发生丢失更新:业务认为两次扣减都成功了,最终库存却只减少了 7。某些数据库配置会让其中一个事务因更新冲突而失败,因此不能把覆盖结果视为所有数据库的固定行为。
即使第二个事务在更新时等待锁,先前通过的 Java if 条件也不会自动重新判断。数据库无法替应用重新执行这项检查。
Spring 的实际枚举名称是 Isolation.REPEATABLE_READ 和 Isolation.READ_COMMITTED,例如:
@Transactional(isolation = Isolation.REPEATABLE_READ)
原示例中的 RepeatableRead 需要根据实际使用的框架和导入进行调整。
首选:使用带条件的原子 UPDATE
对于“库存足够才扣减”这种单行条件,可以直接执行:
UPDATE product
SET quantity = quantity - :val
WHERE id = :productId
AND quantity >= :val;
表名和列名是示例,:val 和 :productId 表示由数据访问框架绑定的参数。这里假设 id 唯一、quantity 非空,并且应用已经验证 val > 0。
应用根据受影响行数判断结果:
1:满足条件并成功扣减。0:没有符合条件的记录,可能是库存不足,也可能是商品不存在。- 执行异常:按实际异常处理,例如死锁或事务并发冲突。
Spring 服务层可以写成:
@Transactional
public void decreaseQuantity(int productId, int val) {
if (val <= 0) {
throw new IllegalArgumentException("val must be positive");
}
int affected = repo.decreaseIfEnough(productId, val);
if (affected == 0) {
throw new IllegalStateException(
"Product does not exist or quantity is insufficient"
);
}
}
repo.decreaseIfEnough 是需要自行实现的仓储方法,执行上面的 SQL 并返回受影响行数。
这个方案由数据库在同一条修改语句中处理库存条件和扣减,不依赖之前读入 Java 对象的库存值。并发时可能出现等待、条件不再满足或事务冲突,应用必须检查执行结果,不能假设每次调用都成功。
单条 UPDATE 本身具备原子性。如果扣减还需要与创建订单、写入业务记录等操作一起提交,应把这些操作放在同一个事务中。
需要先读再判断时,使用 SELECT FOR UPDATE
如果条件比较复杂,必须读取实体后在应用中判断,可以采用以下流程:
BEGIN;
SELECT id, quantity
FROM product
WHERE id = :productId
FOR UPDATE;
-- 应用检查返回的 quantity。
-- 不足时回滚;满足条件时执行下面的更新。
UPDATE product
SET quantity = quantity - :val
WHERE id = :productId;
COMMIT;
这是事务流程示意,条件分支由应用实现,具体 SQL 语法需要匹配数据库。
在支持相应行锁语义的数据库中,SELECT ... FOR UPDATE 会在读取时请求用于后续更新的锁,并通常持有到事务提交或回滚。如果第二个事务也要锁定同一条记录,通常会在锁定查询阶段等待;使用特殊等待选项或遇到事务冲突时,也可能直接失败。
拿到记录后再检查条件。如果数据库因隔离级别或并发冲突要求事务重试,应在新事务中重新读取并检查。
SELECT ... FOR UPDATE 应与 @Transactional 配合使用。事务让读取、检查和更新处于同一个事务边界内,锁定查询负责保护相关记录。如果读取结束后事务就结束,锁也可能随之释放,后续检查与更新便失去保护。
使用 JPA 时,可以通过 LockModeType.PESSIMISTIC_WRITE 表达悲观写锁需求,具体生成的 SQL 和行为仍取决于数据库及 JPA 实现。
第二个事务到底在哪里等待
仅根据原示例无法确定,需要查看 repo.getProduct 和 repo.updateProduct 实际执行的 SQL。
| 访问方式 | 可能发生等待或冲突的位置 |
|---|---|
普通 SELECT | 在使用 MVCC 的常见配置下可能直接读取可见版本;采用读锁的实现也可能等待 |
SELECT ... FOR UPDATE | 请求记录锁时,通常发生在读取阶段 |
UPDATE | 执行修改并请求写锁时 |
| ORM 延迟写入 | 可能在 flush 或事务提交时才真正执行 SQL 并发生等待或冲突 |
调用 repo.updateProduct 的位置不一定就是数据库实际更新的位置。排查时应结合生成的 SQL、事务边界及数据库隔离级别实现。
条件失效后应该怎么办
原代码中的 Java 条件检查一旦通过,不会因为后续等待锁而自动重新判断。可以通过以下并发控制方式处理:
- 条件 UPDATE:检查受影响行数;没有更新成功时返回库存不足或其他符合业务语义的结果。
- 悲观锁:先取得锁,再读取并检查条件,在同一事务内完成修改。
- 乐观锁:通过版本字段检测读取后是否发生过修改,例如使用 JPA 的
@Version。发生冲突时让当前事务失败,再按业务需要重试。
需要重试时,应重新执行整个事务中的读取、判断和修改。不要只重放基于旧值生成的更新,也不要在已经失败的事务中继续操作。普通的库存不足通常应直接返回业务失败,无须反复重试。
对于当前示例,带 quantity >= :val 条件的原子扣减通常最直接。如果使用 JPA 批量更新,还要注意已加载实体可能保留旧值,避免随后用过期实体状态覆盖扣减结果。
备注:内容仅供参考。
