《Spring Boot + JPA 实战》第14招 库存并发怎么锁

01、痛点

假设一次秒杀活动,商品库存只有10件。活动开始瞬间,100个线程同时涌进来。如果没有并发控制,最终可能卖出12件甚至更多,或者库存变成负数。代码一般是:先查库存,判断大于0,然后减库存,保存。这一串操作在并发下会发生“丢失更新”。两个请求同时读到stock=10,都判断可以买,然后都执行stock=9并更新,结果只少了一件,却卖了两件。更糟糕的是,可能出现死锁:事务A锁住行1等待行2,事务B锁住行2等待行1。大促时数据库连接池耗尽、CPU飙高、用户看到超卖或支付成功却没发货。

我是老李,曾经在团队里处理过一次库存扣减事故。复盘时发现,这个问题本质上是“最后10件商品面对100个并发请求”。这个场景既简单又尖锐,特别适合用来理解JPA与MySQL的并发控制。现在讲它,是因为类似问题不只出现在秒杀,任何涉及余额、积分、优惠券、计数器的系统都会遇到。早一点把锁的边界和选型搞清楚,能避免很多深夜报警。

02、是什么

围绕这个场景,我们需要理解几组概念。

丢失更新(Lost Update):两个事务同时读同一行,基于旧值做修改,后提交的覆盖先提交的,导致前一次更新丢失。在库存场景中,两个请求都读到stock=10,都扣减后写回9,最终只少了1件,但实际卖了两件。

乐观锁与@Version:JPA中的乐观锁通常用版本号或时间戳实现。实体上增加@Version字段,更新时带上版本条件:UPDATE inventory SET stock=?, version=version+1 WHERE id=? AND version=?。如果影响行数为0,说明版本已变,事务抛出OptimisticLockException,应用需要重试。乐观锁假设冲突少,先干活再检查。

悲观锁:JPA通过@Lock(LockModeType.PESSIMISTIC_WRITE)或者原生SQLSELECT ... FOR UPDATE实现。事务一开始就锁定行,其他事务必须等待。悲观锁假设冲突多,先锁住再干活。

SELECT FOR UPDATE:InnoDB提供的行级排他锁。在事务内执行SELECT * FROM inventory WHERE id=? FOR UPDATE,会对该行加X锁,直到事务提交或回滚。其他事务如果也要FOR UPDATE同一行,会阻塞。

原子UPDATE:直接执行UPDATE inventory SET stock=stock-1 WHERE id=? AND stock>0。数据库在更新时会对行加锁,并且stock>0条件在同一个语句内完成检查和扣减,避免先查后改的竞态。这个语句可以利用数据库的原子性,不需要应用层检查,但需要判断影响行数。

死锁(Deadlock):两个或多个事务互相等待对方释放锁。例如事务A锁住行1,请求行2;事务B锁住行2,请求行1。InnoDB有死锁检测,会回滚其中一个事务,报Deadlock found when trying to get lock。应用需要捕获并重试。

InnoDB行锁:InnoDB在索引上实现行锁,如果查询没有走索引,可能会锁更多行甚至表。库存扣减一般按主键或唯一索引定位行,所以是行级锁。

03、为什么

很多人觉得数据库默认隔离级别就能避免并发问题,其实不是。MySQL InnoDB默认REPEATABLE READ,它能解决不可重复读和幻读(依赖间隙锁),但解决不了“丢失更新”。原因是多个事务可以同时读同一行,基于相同的旧值进行修改。比如两个事务都读到stock=10,在各自事务内做扣减,后提交的覆盖先提交的。隔离级别保证了读的一致性,但不会阻止两个事务基于同一快照做写操作。

如果只使用JPA默认的脏检查,没有@Version也没有显式锁,更新时Hibernate通常生成UPDATE inventory SET stock=? WHERE id=?,不会检查版本,因此会直接覆盖,产生丢失更新。所以必须显式做并发控制。

还有一层原因:数据库行锁是宝贵的资源。如果悲观锁锁住行后,事务内还有远程调用、复杂计算或长事务,锁持有时间变长,其他事务排队,连接池可能打满。乐观锁虽然不加数据库锁,但冲突多时大量事务回滚,浪费CPU和连接。理解这些机制,是为了在正确场景做出权衡。

04、架构

我们搭建一个简化架构:Spring Boot应用通过JPA/Hibernate访问MySQL。核心表inventory:

CREATE TABLE inventory (
  id BIGINT PRIMARY KEY,
  product_name VARCHAR(100),
  stock INT NOT NULL,
  version INT NOT NULL DEFAULT 0
);

应用层有一个InventoryService,提供deductStock(Long id, int quantity)方法。事务边界在Service方法上使用@Transactional。数据库连接池假设20个连接,100个并发请求会排队。

三种方式的实现路径不同:

  1. 乐观锁:实体上使用@Version,扣减逻辑为“读实体 -> 扣减 -> 保存”。冲突时捕获OptimisticLockException并重试。
  2. 悲观锁:使用@Lock(PESSIMISTIC_WRITE)的查询,事务内“锁行 -> 读当前值 -> 扣减 -> 保存”。其他事务在查询时阻塞。
  3. 原子UPDATE:直接执行一条UPDATE语句,通过影响行数判断是否成功,不需要先读实体。事务非常短,只有一条更新。

从架构上看,方案1依赖应用层重试机制;方案2依赖数据库行锁和连接池;方案3依赖SQL的原子性,减少与ORM的交互。

05、实战

我们使用JUnit并发测试,模拟100个线程同时抢最后10件库存。初始stock=10,每个线程尝试扣1件,最终成功扣减的请求数应该等于10,库存为0。记录总请求数、成功数、冲突数、平均耗时。

方案一:乐观锁@Version

实体:

@Entity
public class Inventory {
    @Id
    private Long id;
    private String productName;
    private Integer stock;
    @Version
    private Integer version;
    // getters and setters
}

Service扣减方法:

@Transactional
public boolean deductWithOptimisticLock(Long id) {
    Inventory inv = repo.findById(id).orElseThrow();
    if (inv.getStock() <= 0) {
        return false;
    }
    inv.setStock(inv.getStock() - 1);
    repo.save(inv);
    return true;
}

重试逻辑放在外层调用处:

public boolean executeWithRetry(Long id) {
    int retries = 3;
    while (retries-- > 0) {
        try {
            return deductWithOptimisticLock(id);
        } catch (OptimisticLockException e) {
            // 等待极小随机时间,继续重试
        }
    }
    return false;
}

压测结果:初始10件,100个并发请求全部尝试扣减。由于乐观锁不加数据库锁,所有请求都能读到最后一次提交的版本,但只有部分请求能成功更新版本。每次更新版本+1,只有10个请求能成功。其余请求捕获异常并重试。最终成功数10,库存0。但重试次数很多,接近90次。CPU消耗在高冲突下的版本检查和回滚。

方案二:悲观锁

Service方法:

@Transactional
public boolean deductWithPessimisticLock(Long id) {
    Inventory inv = repo.findByIdForUpdate(id);
    if (inv.getStock() <= 0) {
        return false;
    }
    inv.setStock(inv.getStock() - 1);
    repo.save(inv);
    return true;
}

Repository方法使用悲观锁查询:

@Lock(LockModeType.PESSIMISTIC_WRITE)
Inventory findByIdForUpdate(Long id);

压测时,100个线程同时进入,第一个获取行锁后,其他线程在findByIdForUpdate处阻塞,等待锁释放。每个事务持有锁的时间非常短,很快释放,后续线程继续。最终只会成功10个,其余90个在获得锁后发现stock=0返回false。没有重试,但阻塞时间取决于锁队列长度和临界区耗时。锁等待超时可能导致LockTimeoutException或连接池耗尽。

方案三:原子UPDATE

Service方法:

@Transactional
public boolean deductWithAtomicUpdate(Long id) {
    int rows = repo.atomicDeduct(id);
    return rows > 0;
}

Repository执行SQL:

UPDATE inventory SET stock = stock - 1 WHERE id = ? AND stock > 0;

压测时,数据库执行UPDATE会对行加锁,但每个语句执行极快,事务只有一条更新。100个并发UPDATE进入InnoDB,逐个执行。由于条件stock>0,第11个事务时stock=0,影响行数为0,返回false。最终成功10个,库存0。没有应用层重试,也没有显式悲观锁的长时间持有。

实验结果对比:

方案 成功数 冲突/失败 重试 平均耗时 库存最终值
乐观锁@Version 10 90异常需重试 90+ 较高(由于重试) 0
悲观锁FOR UPDATE 10 90排队等待后返回false 0 排队耗时 0
原子UPDATE 10 90返回false 0 最低 0

从结果看,原子UPDATE性能最好,因为临界区最短,没有事务先读后写。乐观锁产生了大量冲突重试,悲观锁阻塞了连接。

06、洞见

很多人以为悲观锁更安全,乐观锁性能更高。但实战结果告诉我们:悲观锁不等于更安全,乐观锁也不等于性能一定更高。

悲观锁确实通过行锁强制串行化,保证同一时刻只有一个事务修改。但“安全”不止于数据一致性,还包括可用性。如果锁持有的临界区过长(比如事务内调用了外部RPC、进行了复杂计算),其他事务会长时间阻塞,连接池被占满,系统吞吐量骤降,甚至引发连锁故障。另外,悲观锁容易触发死锁。如果事务内先锁A再锁B,另一个事务先锁B再锁A,就会死锁。InnoDB会自动回滚一个事务,但应用收到死锁异常如果不处理,用户看到的是失败。所以悲观锁不等于系统更安全,它只是把并发冲突转化为等待和可能死锁。

乐观锁通过版本号避免加锁,在低冲突场景下表现出色。但冲突概率高时,大量事务在提交时发现版本不一致而回滚,应用程序不断重试,数据库CPU被无效更新浪费,连接也被重试占满。极端情况下,甚至可能出现“活锁”:某个请求每次都抢不到。因此,乐观锁也不等于性能一定更高。它的性能优势依赖于“冲突少”这个前提。

锁选择取决于三个变量:冲突概率、临界区长度、失败重试成本。

  • 冲突概率高(如秒杀最后几件):建议原子UPDATE或悲观锁。原子UPDATE最短临界区,推荐。
  • 临界区长(读后要经过复杂计算、调用外部服务):乐观锁可能更合适,因为乐观锁不长期占用数据库锁,但要做好重试。
  • 失败重试成本低(幂等操作,重试不会产生副作用):乐观锁重试可以接受;如果失败涉及用户体验或外部副作用,悲观锁或原子条件更稳。

以最后10件商品100并发场景为例,冲突概率极高,但临界区可以做到极短——一条UPDATE即可。所以原子UPDATE完胜。如果用乐观锁,就会看到大量重试和异常风暴;如果用悲观锁,排队也可能成为瓶颈,但比乐观锁好。没有一种锁是银弹,关键是匹配场景。

07、行动

针对三个典型场景,我们可以给出选型建议。

库存场景:使用原子UPDATE,必要时加乐观锁兜底。
库存扣减通常是高频、短事务,要求强一致。首选UPDATE inventory SET stock=stock-1 WHERE id=? AND stock>0,判断影响行数。若业务上需要在内存中读取实体做校验(例如获取商品信息给用户展示),可以先读,但扣减动作必须走原子UPDATE,避免先读后写。对于需要审计或版本冲突检测的场景,可以在表上加version字段,用乐观锁配合重试作为兜底,但核心扣减仍然依赖条件更新。

余额场景:使用悲观锁或数据库约束+事务。
余额涉及资金,不能容忍丢失更新,且通常需要记录流水。可以先用悲观锁锁住账户行(SELECT ... FOR UPDATE),在事务内校验余额、扣减、插入流水。因为资金操作频率低于库存,但安全性要求更高,临界区可能包含写流水表,所以适合悲观锁。如果金额扣减逻辑简单,也可以用原子UPDATE配合余额不为负的条件,并记录流水。但要避免长事务。余额表一般行数少,锁竞争可控,悲观锁是稳妥选择。

用户资料场景:使用乐观锁@Version。
用户更新昵称、头像、个人简介等,冲突概率极低。不同用户修改自己的资料,几乎不会同时修改同一行。使用JPA的@Version可以防止多人同时编辑时覆盖。如果偶尔发生版本冲突,让用户重新加载或自动重试即可,失败重试成本很低。不要在用户资料表上用悲观锁,否则一个用户提交一个长表单(甚至包含图片处理)时会长期锁行,影响其他操作。

这三个场景的选择展示了“按冲突概率、临界区长度、失败重试成本”决策的方法。库存高频高冲突,原子更新;余额高频但需流水,悲观锁或原子扣减;资料低频低冲突,乐观锁。

08、小结

最后10件商品面对100个并发请求,把一个看似简单的扣库存变成了理解JPA与MySQL并发控制的完美实验。我们从丢失更新讲到死锁,从@Version讲到SELECT FOR UPDATE和原子UPDATE。实战压测告诉我们:没有万能锁,只有合适的锁。悲观锁不保证系统更安全,乐观锁也不保证性能更高,选型必须回到冲突概率、临界区长度和失败重试成本三个维度。库存适合原子条件更新,余额适合悲观锁加流水,用户资料适合乐观锁版本号。搞懂这些,下次再遇到并发抢购,你就能睡个安稳觉。

09、链接与概念说明

  • JPA乐观锁:通过@Version注解,更新时使用版本条件,冲突抛OptimisticLockException。参考JSR 338规范。
  • JPA悲观锁:通过LockModeType.PESSIMISTIC_WRITE映射到数据库行锁,如SELECT FOR UPDATE。
  • MySQL SELECT FOR UPDATE:在事务内对行加排他锁,直到事务结束。参考MySQL 8.0官方文档“Locking Reads”。
  • InnoDB行锁:通过索引记录加锁,事务使用主键更新时行锁,否则可能间隙锁或表锁。参考“InnoDB Locking”。
  • 死锁:InnoDB自动检测死锁并回滚一个事务,死锁日志可通过SHOW ENGINE INNODB STATUS查看。
  • 原子UPDATE:利用单条UPDATE的原子性完成检查和扣减,减少锁持有时间和竞态窗口。
  • 丢失更新:两个事务基于旧值更新,后提交覆盖先提交,解决方法是乐观锁、悲观锁或原子条件更新。
  • 扩展阅读:MySQL官方文档“InnoDB Locking and Transaction Model”、Hibernate User Guide“Locking”。
posted @ 2026-10-10 23:47  李福春  阅读(2)  评论(0)    收藏  举报