PAYATHON 2026

RepeatableRead 能保证库存条件检查与扣减的并发安全吗?

数据阿明

结论

RepeatableRead 和 ReadCommitted 都不能单独保证这段“读取、检查、修改”逻辑的并发安全。

隔离级别决定事务能看到什么数据,以及如何处理部分并发冲突,但读取实体后,其他事务仍可能修改该实体。具体是等待、覆盖更新还是报冲突,取决于数据库、查询方式和实际执行的 SQL。

在这个库存扣减场景中,优先考虑把条件检查和扣减合并成一条 UPDATE。如果业务必须先读取实体再判断,可以在同一个事务中使用 SELECT ... FOR UPDATE,也可以通过版本字段实现乐观锁。

原代码为什么可能使用过期条件

原代码先把库存读入 productEntity.quantity,再在 Java 中判断,最后将计算结果写回。其他事务可能在这些步骤之间修改库存。

假设库存为 10,两个事务分别扣减 7,可能出现以下过程:

  1. 两个事务都读到 10。
  2. 两个事务都通过 10 >= 7 的检查。
  3. 两个事务都在内存中算出新库存 3。
  4. 两个事务尝试把库存写成 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 批量更新,还要注意已加载实体可能保留旧值,避免随后用过期实体状态覆盖扣减结果。

备注:内容仅供参考。