防超卖:分布式锁和原子 SQL,为什么两个都要
一句话结论
锁保证效率,SQL 保证正确性。 分布式锁让 1000 个请求里只有几个真的去扣库存,原子 SQL 保证即使锁失效了库存也不会变成负数。
问题场景
秒杀开始,100 件库存涌入 1000 个请求。约束是硬的:卖出去的不能超过 100 件。
如果只用 synchronized 或单机锁,根本没用——服务是多实例部署,请求落在不同 JVM 上,锁根本互不相识。
第一层:原子 SQL(真正的不变量)
这是整个方案的地基,在 mall-product 的 SkuMapper 里:
@Update("UPDATE sku SET stock = stock - #{num} " +
"WHERE id = #{id} AND stock >= #{num} AND is_deleted = 0")
int deductStock(@Param("id") Long id, @Param("num") Integer num);
三个要点:
stock = stock - #{num}而不是stock = #{newValue}—— 前者不依赖「读到的旧值」,从数据库层面就没有 lost update。AND stock >= #{num}是判断与扣减合并成一条语句 —— 由数据库保证原子性,不存在「判断通过了但扣减时被别人改了」的窗口。- 返回影响行数 ——
rows == 0说明库存不足或 SKU 不存在,这是唯一的判断依据。
回补同理:
@Update("UPDATE sku SET stock = stock + #{num} WHERE id = #{id} AND is_deleted = 0")
int restoreStock(@Param("id") Long id, @Param("num") Integer num);
第二层:Redisson 分布式锁(减少无效竞争)
光有 SQL 也够正确,但 1000 个请求会全部打到数据库。所以在外面加一层锁:
RLock lock = redissonClient.getLock("stock:lock:" + skuId);
boolean locked = false;
try {
// 最多等 3 秒;不传租期 → Redisson 看门狗自动续期
locked = lock.tryLock(3, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
int rows = skuMapper.deductStock(skuId, dto.getNum());
if (rows == 0) {
// 再查一次,用来区分「商品不存在」和「库存不足」
Sku sku = skuMapper.selectById(skuId);
if (sku == null) {
throw new BusinessException("SKU 不存在");
}
throw new BusinessException("商品库存不足");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 记得恢复中断标记
throw new BusinessException("获取锁被中断");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
三个容易被追问的细节
① 为什么 tryLock(3, TimeUnit.SECONDS) 不传租期?
Redisson 的 tryLock(waitTime, unit) 用的是看门狗续期机制:拿到锁后默认每 10 秒续一次 30 秒的租期,业务执行多久都不会自动释放。
如果我自己指定租期(比如 5 秒),而业务执行了 8 秒,锁会在第 5 秒过期被别的线程拿走,我的代码还在改数据 → 锁形同虚设。不传租期反而更安全。
② 为什么 unlock 前要判断 isHeldByCurrentThread()?
只有持有者才能解锁。如果 tryLock 没拿到锁却执行了 unlock,会抛 IllegalMonitorStateException;而如果锁已经超时被释放,unlock 同样会抛。判断一下就都不会。
③ 锁的粒度为什么是 stock:lock:{skuId}?
按 SKU 加锁,不同 SKU 之间完全并行。粒度太大(比如锁整个商品表)会把所有请求串行化,等于没做并发。
两层分别在什么情况下失效
| 方案 | 失效场景 | 后果 |
|---|---|---|
| 只有锁 | 有绕过锁的写库存路径(定时任务回补、管理后台改库存、脚本) | 超卖 |
| 只有 SQL | 1000 个请求全部打到数据库 | 数据库被打满、请求超时 |
所以结论是:SQL 保正确性,锁保性能。这不是二选一,是两层各管一件事。
复盘
这个方案里我最满意的两个决定:
- 判断和扣减合并成一条 SQL。我一开始写的是「先
select查库存判断,再update扣减」,看着没问题,但只要有人后来加一条路径绕过锁,就会超卖。合并之后,不变量由数据库守着,谁都得遵守。 rows == 0之后又查了一次 SKU。因为「库存不足」和「商品不存在」要给用户不同的提示——前者是正常售罄,后者是脏请求。
评论
0加载中…