阿飞的博客

防超卖:分布式锁和原子 SQL,为什么两个都要

article.title

一句话结论

锁保证效率,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);

三个要点:

  1. stock = stock - #{num} 而不是 stock = #{newValue} —— 前者不依赖「读到的旧值」,从数据库层面就没有 lost update。
  2. AND stock >= #{num} 是判断与扣减合并成一条语句 —— 由数据库保证原子性,不存在「判断通过了但扣减时被别人改了」的窗口。
  3. 返回影响行数 —— 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 之间完全并行。粒度太大(比如锁整个商品表)会把所有请求串行化,等于没做并发。

两层分别在什么情况下失效

方案失效场景后果
只有锁有绕过锁的写库存路径(定时任务回补、管理后台改库存、脚本)超卖
只有 SQL1000 个请求全部打到数据库数据库被打满、请求超时

所以结论是:SQL 保正确性,锁保性能。这不是二选一,是两层各管一件事。

复盘

这个方案里我最满意的两个决定:

  1. 判断和扣减合并成一条 SQL。我一开始写的是「先 select 查库存判断,再 update 扣减」,看着没问题,但只要有人后来加一条路径绕过锁,就会超卖。合并之后,不变量由数据库守着,谁都得遵守。
  2. rows == 0 之后又查了一次 SKU。因为「库存不足」和「商品不存在」要给用户不同的提示——前者是正常售罄,后者是脏请求。
标签 JavaRedis踩坑记录

评论

0
加载中…
无需登录 · 同一 IP 每分钟限 3 条