库存超卖问题几乎是从单机应用走向分布式架构时必然要面对的一道坎。在单体应用里,一个synchronized关键字就能保证扣减库存的线程安全,可一旦服务部署了多个实例,JVM级别的锁就彻底失效了,两个节点同时读到库存为1,各自扣减一次,超卖就这样发生了。本文以一个典型的秒杀下单接口为例,讲解如何用Redisson分布式锁在Spring Boot项目中解决这个问题。

一、超卖是怎么产生的:从一段有问题的代码说起
先看一段最常见的下单扣库存代码,很多项目的初版长这样:
public void createOrder(Long productId) {
// 第一步:查询库存
Integer stock = stockMapper.getStock(productId);
if (stock <= 0) {
throw new BusinessException("库存不足");
}
// 第二步:扣减库存
stockMapper.decreaseStock(productId);
// 第三步:创建订单
orderMapper.insert(buildOrder(productId));
}
这段代码在单机低并发下没有任何问题,但它的致命之处在于"查"和"减"是两步操作,不具备原子性。假设库存只剩1件,线程A和线程B同时执行到查询这一行,都拿到了stock等于1,都通过了校验,接着各自执行扣减,库存就变成了-1,这就是超卖的直接成因。
把服务部署多个实例后,即使你在方法上加@Synchronized或者JUC的锁,也只能锁住当前JVM内的线程。负载均衡把两个请求分发到两台机器上,两把"本地锁"互不可见,形同虚设。这就是必须引入分布式锁的原因:把锁放在所有实例都能访问的第三方介质上,这里自然首选Redis。
二、为什么不用setnx手写锁,而要用Redisson
不少同学的第一反应是直接用Redis的SET key value NX EX seconds命令自己实现,这在原理上没问题,但生产环境会踩三个坑。第一个坑是锁过期时间不好估:设置太短,业务还没执行完锁就自动释放了,第二个请求趁虚而入;设置太长,持有锁的实例宕机后其他节点要干等很久。
第二个坑是误删别人的锁。线程A的锁过期后被线程B拿到,A执行完后执行DEL把B的锁删掉了,C又进来了,锁等于没加。虽然可以用Lua脚本先比较value再删除来规避,但代码复杂度明显上升。第三个坑是不可重入:同一个线程内嵌套调用加锁方法时会把自己堵死。
Redisson把这些坑全部处理掉了。它提供可重入锁RLock、看门狗自动续期机制、加锁解锁的原子性操作(底层是Lua脚本),并且和Spring容器无缝集成。对于绝大多数业务系统来说,用Redisson比手写锁可靠得多,也省心得多。
三、Spring Boot整合Redisson完整步骤
第一步引入依赖,以Maven为例,注意版本与你的Spring Boot版本和Redis部署模式相关,单机模式引入基础包即可:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
第二步编写配置类。虽然starter支持在yml中配置,但用Java Config方式更灵活,方便后续切换集群模式:
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
// 单机模式,三个参数依次是地址、数据库、连接池大小
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(0)
.setConnectionPoolSize(32);
return Redisson.create(config);
}
}
第三步在业务代码中使用RLock。核心是tryLock方法:带等待时间的重载表示最多等待多少秒去抢锁,抢不到就放弃;leaseTime表示锁的持有时间。改造后的下单逻辑如下:
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(Long productId) {
// 锁的粒度要细到具体商品,避免不同商品互相阻塞
RLock lock = redissonClient.getLock("stock:lock:" + productId);
boolean locked = false;
try {
// 最多等待5秒,锁30秒后自动释放
locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("当前抢购人数过多,请稍后重试");
}
Integer stock = stockMapper.getStock(productId);
if (stock <= 0) {
throw new BusinessException("库存不足");
}
stockMapper.decreaseStock(productId);
orderMapper.insert(buildOrder(productId));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 只有持有锁的线程才能解锁,这里必须放在finally里
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
这里有几个细节值得展开。锁的key粒度要控制在商品维度,用商品ID拼接,这样不同商品的抢购互不影响,吞吐量比全局一把锁高出几个量级。解锁务必写在finally块中,并且加上isHeldByCurrentThread判断,防止在锁已经过期的情况下解锁抛出异常。
四、看门狗机制:不传leaseTime才有续期
上面代码里显式传了30秒的leaseTime,此时看门狗是不会生效的。如果你调用的是lock.tryLock(5, TimeUnit.SECONDS)这种不指定锁时长的重载,Redisson会启用看门狗:默认锁时长30秒,后台定时任务每10秒(即锁时长的三分之一)检查一次,只要持锁线程还活着就自动把锁续期到30秒,直到业务执行完主动解锁或进程崩溃。
这个机制解决了"锁过期但业务没执行完"的经典矛盾。看门狗的本质是在Redisson客户端维护了一个定时任务,依赖Netty的时间轮实现,进程一旦挂掉,续期任务也随之消失,锁会在30秒后自然过期,不会造成死锁。
实践中如何选择?如果业务执行时间相对稳定且可预估,显式指定leaseTime更简单直接;如果业务耗时波动大,比如内部还调用了第三方接口,建议交给看门狗管理。但要警惕一点:业务代码里如果有while死循环,看门狗会一直续期,锁就永远不释放,这属于业务bug而非框架问题。
五、实战避坑清单与方案延伸
分布式锁能防住并发读改写,但它不是银弹。以下几点经验供参考:
- 锁内逻辑要轻:只把"查库存加扣库存"这段临界区放锁内,创建订单、发消息等操作尽量挪到锁外或异步执行,缩小锁粒度能显著提升并发能力。
- 兜底方案是数据库:即使加了分布式锁,扣库存的SQL也应该写成
UPDATE stock SET num = num - 1 WHERE product_id = ? AND num > 0,根据影响行数判断是否成功,双保险防极端情况。 - 热点商品考虑锁替换:秒杀爆款商品的锁竞争依然激烈,可叠加Redis预扣库存加Lua脚本原子扣减,或用分段库存把一把锁拆成多把。
- 主从切换风险:Redis主从架构下锁信息还没同步到从库时主节点宕机,可能出现两个客户端同时持锁。对正确性要求极高的场景可开启Redisson的RedLock或改用Zookeeper实现,需自行权衡复杂度。
总结一下,Redisson通过可重入锁、Lua脚本原子操作和看门狗续期,把手写分布式锁的各种隐患都封装妥当,配合Spring Boot的依赖注入用起来非常顺手。防超卖的正确姿势是:分布式锁控制并发进入临界区,数据库条件更新做最后兜底,两者结合才能扛住真实秒杀场景的考验。
Redisson分布式锁Spring Boot防超卖修改时间:2026-09-13 00:24:40