《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个并发请求会排队。
三种方式的实现路径不同:
- 乐观锁:实体上使用
@Version,扣减逻辑为“读实体 -> 扣减 -> 保存”。冲突时捕获OptimisticLockException并重试。 - 悲观锁:使用
@Lock(PESSIMISTIC_WRITE)的查询,事务内“锁行 -> 读当前值 -> 扣减 -> 保存”。其他事务在查询时阻塞。 - 原子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”。

浙公网安备 33010602011771号