库存卖超了 7 件:一次 Redis 分布式锁踩坑的复盘

秒杀某 SKU 库存 100 却卖出 107,根子是手写的 Redis 分布式锁:加锁与设过期非原子、解锁直接 delete 误删别人的锁、固定过期时间业务超时即失效、主从切换锁直接丢。几天重写分布式锁:SET NX PX、Lua 安全解锁、看门狗续期、Redisson、数据库兜底、锁监控。

2024 年我们的库存扣减出过一次让人后背发凉的事故:同一笔秒杀活动,某个 SKU 明明只有 100 件库存,结果卖出去了 107 件。复盘下来,问题出在我们自己手写的那把"分布式锁"上——用的是 Redis 的 SETNX,看起来很标准,实际上锁会在持有者还没干完活时就提前过期,于是两个线程同时进了临界区。后来又发现这把锁还有一堆别的毛病:误删别人的锁、不可重入、Redis 主从切换时锁直接丢失。投了几天把分布式锁彻底重写,换成了 Redisson,本文复盘这次实战。

问题背景

业务:秒杀库存扣减,多实例部署,用 Redis 分布式锁保证扣减串行
事故现象:
- 某 SKU 库存 100,实际卖出 107,超卖 7 件
- 偶发"扣减失败"日志,但库存又确实少了
- 一次 Redis 主从切换后,大面积超卖

现场排查:
# 1. 看我们手写的加锁代码
public boolean lock(String key, String value) {
    // SETNX + 单独设过期时间
    Boolean ok = redis.setIfAbsent(key, value);
    if (Boolean.TRUE.equals(ok)) {
        redis.expire(key, 10, TimeUnit.SECONDS);  // 单独一步设过期
    }
    return Boolean.TRUE.equals(ok);
}

public void unlock(String key) {
    redis.delete(key);   // 直接删,不管是不是自己的锁
}

# 2. 发现的问题:
# - setIfAbsent 和 expire 是两步,中间宕机 -> 锁永不过期(死锁)
# - 过期时间写死 10s,业务跑了 12s -> 锁提前过期,别人能进来
# - unlock 直接 delete -> 删的可能是别人刚加的锁
# - 没有重入:同一线程二次加锁直接失败

根因:
1. 加锁与设过期非原子 -> 中间崩溃会留下永不过期的死锁
2. 过期时间固定 -> 业务超时后锁失效,临界区被多个线程同时进入
3. 解锁不校验持有者 -> 误删别人的锁,引发连锁超卖
4. 锁不可重入 -> 同一调用链二次加锁自己把自己锁死

修复 1:加锁必须原子 —— SET 的正确姿势

// === 错误:setIfAbsent 与 expire 分两步 ===
// Boolean ok = redis.setIfAbsent(key, value);
// redis.expire(key, 10, SECONDS);   // 两步之间宕机 -> 死锁

// === 正确:用一条 SET 命令同时完成"加锁 + 设过期" ===
// Redis 的 SET 支持 NX(不存在才设)和 PX(过期毫秒)一次搞定:
//   SET lock_key unique_value NX PX 30000
// 一条命令是原子的,不会出现"加了锁却没过期时间"的中间态。

public boolean tryLock(String key, String value, long expireMs) {
    // Spring Data Redis 的写法:set 带 NX + 过期
    Boolean ok = redisTemplate.opsForValue()
        .setIfAbsent(key, value, Duration.ofMillis(expireMs));
    return Boolean.TRUE.equals(ok);
}

// === value 必须是"能唯一标识当前持有者"的值 ===
// 不能用固定值,要用每次加锁都不同的唯一串,
// 解锁时才能判断"这把锁到底是不是我加的"。
String lockValue = UUID.randomUUID().toString();

// === 关键认知 ===
// 1. 加锁 = SET key value NX PX,必须是【一条命令】
// 2. 必须带过期时间,否则持有者宕机后锁永远不释放(死锁)
// 3. value 必须唯一,这是后面"安全解锁"的前提

修复 2:解锁必须校验持有者 —— Lua 脚本

// === 坑:直接 delete 会误删别人的锁 ===
// 设想这样的时序:
//   T1 加锁,过期 10s
//   T1 业务跑了 12s(超过锁过期时间)-> 锁已自动过期
//   T2 此时加锁成功,拿到了这把锁
//   T1 终于干完,调 unlock -> delete(key)
//   -> 删掉的是 T2 的锁!T3 又能进来 -> 临界区被打穿

// === 错误写法 ===
public void unlock(String key) {
    redis.delete(key);   // 不管这锁是不是自己的,直接删
}

// === 正确写法:先比对 value,是自己的才删 ===
// "比对 + 删除"两个动作也必须原子,否则比对完、删之前锁又过期了。
// 用 Lua 脚本把这两步打包成一个原子操作:
private static final String UNLOCK_LUA =
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "  return redis.call('del', KEYS[1]) " +
    "else " +
    "  return 0 " +
    "end";

public boolean unlock(String key, String value) {
    DefaultRedisScript script =
        new DefaultRedisScript<>(UNLOCK_LUA, Long.class);
    Long result = redisTemplate.execute(
        script,
        Collections.singletonList(key),   // KEYS[1]
        value);                           // ARGV[1] = 我加锁时的唯一值
    return Long.valueOf(1).equals(result);
}

// === 为什么必须用 Lua ===
// Redis 单线程执行 Lua 脚本,脚本内部不会被其他命令打断。
// "GET 比对 -> DEL" 在脚本里是一个不可分割的整体,
// 杜绝了"比对通过后、删除前锁被别人抢走"的窗口。

修复 3:锁过期了业务还没干完 —— 看门狗续期

=== 核心矛盾 ===
锁的过期时间(比如 30s)是为了防死锁:持有者宕机后,
锁能自动释放,不至于卡死所有人。
但它带来一个新问题:如果业务真的需要跑 40s,
锁在 30s 就过期了,临界区就被打穿。

=== 错误的应对:把过期时间设得很大 ===
把过期时间设成 5 分钟?
- 业务正常时,5 分钟够用了,没问题
- 但持有者一旦真的宕机,这把锁要卡死所有人 5 分钟
过期时间设小了会提前失效,设大了宕机后卡死太久,
固定值怎么设都是错。

=== 正确的应对:自动续期(看门狗机制)===
思路:过期时间还是设一个不大的值(如 30s),
但持有者在干活期间,起一个后台任务定期去"续命":
每隔 10s 检查一下锁还在不在自己手里,在的话
就把过期时间重新刷回 30s。
- 业务没干完 -> 一直续期,锁不会提前过期
- 持有者宕机 -> 续期任务也随之停止,锁在 30s 内自然过期
这就是 Redisson 的"看门狗(watchdog)"机制。

=== 续期也要用 Lua 保证原子 ===
续期的动作同样是"先确认锁是自己的,再刷新过期时间",
和解锁一样,必须用 Lua 脚本把这两步打包成原子操作,
否则会出现"刷新了别人的锁过期时间"的问题。

修复 4:别再手写 —— 用 Redisson

// === 结论:分布式锁的坑太多,不要自己手写,用 Redisson ===
// Redisson 把上面所有的坑(原子加锁、安全解锁、看门狗续期、
// 可重入)都处理好了,而且经过大量生产验证。

// === 1. 配置 RedissonClient ===
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://10.0.0.10:6379")
        .setPassword("***")           // 密码从配置中心读,别硬编码
        .setConnectionPoolSize(64)
        .setConnectionMinimumIdleSize(10);
    return Redisson.create(config);
}

// === 2. 最常用:tryLock + 看门狗自动续期 ===
public void deductStock(String skuId, int count) {
    RLock lock = redissonClient.getLock("stock_lock:" + skuId);
    boolean locked = false;
    try {
        // waitTime=5s:最多等 5 秒去抢锁,抢不到就放弃
        // leaseTime=-1(不传):启用看门狗,自动续期,不会提前过期
        locked = lock.tryLock(5, TimeUnit.SECONDS);
        if (!locked) {
            throw new BizException("系统繁忙,请重试");
        }
        // ---- 临界区:扣减库存 ----
        doDeduct(skuId, count);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BizException("加锁被中断");
    } finally {
        // 只有确实持有锁,才解锁;且只解自己的锁
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

// === 3. 如果显式传了 leaseTime,看门狗就【不生效】===
// lock.tryLock(5, 30, TimeUnit.SECONDS);
// -> 锁 30s 后强制过期,不再自动续期。
// 想要自动续期,leaseTime 就不要传(用默认值)。

// === Redisson 的锁默认就是可重入的 ===
// 同一线程对同一把锁多次 lock,内部用计数器累加,
// 要 unlock 同样的次数才真正释放,不会自己锁死自己。

修复 5:Redis 主从切换导致锁丢失

=== 又一次事故:主从切换后大面积超卖 ===
我们的 Redis 是一主一从。某次主节点故障,哨兵把从节点
提升为新主。问题来了:
- 客户端 A 在【旧主】上加锁成功
- 旧主还没来得及把这条加锁数据同步给从节点,就宕机了
- 从节点被提升为新主,但它身上【没有】这把锁
- 客户端 B 到新主上加锁 -> 成功
-> A 和 B 同时持有"同一把锁" -> 临界区被打穿

根因:Redis 主从复制是【异步】的,
加锁成功不代表这条数据已经安全落到从节点。
主从切换的瞬间,未同步的锁就丢了。

=== 应对思路 ===
1. 大多数业务:接受这个理论上的小概率风险
   主从切换 + 恰好未同步 + 恰好临界区敏感,三者同时
   发生的概率不高。给临界区内的操作【再加一道
   数据库层面的兜底】(如唯一约束、乐观锁版本号、
   库存行 UPDATE ... WHERE stock >= count),
   就算锁偶尔失效,也不会真的超卖。

2. 强一致要求:用 RedLock 或换底座
   Redisson 提供 RedLock(红锁):向多个【独立】的
   Redis 节点加锁,多数成功才算加锁成功。
   但 RedLock 部署复杂、也有争议,多数场景不需要。
   要更强的一致性,可以直接用 ZooKeeper / etcd
   这类基于一致性协议的组件做分布式锁。

=== 我们的选择 ===
保留 Redisson 锁(性能好、够用),同时在库存扣减的
SQL 上加 "WHERE stock >= count" 的兜底 ——
锁负责把绝大多数并发挡在外面,数据库约束负责
"万一锁失效也绝不超卖"。两层防护,各司其职。

修复 6:分布式锁的使用规范与监控

// === 1. 锁的粒度要尽量细 ===
// 错:整个"下单"加一把全局锁 -> 所有用户的下单全串行
// RLock lock = redisson.getLock("order_lock");
// 对:按 SKU / 用户维度加锁,不同 SKU 互不阻塞
RLock lock = redissonClient.getLock("stock_lock:" + skuId);

// === 2. 临界区里只放"必须互斥"的代码,越短越好 ===
// 锁持有时间越长,并发度越低。
// 查询、参数校验、发 MQ 这些不需要互斥的,挪到锁外面。
public void deduct(String skuId, int count) {
    validate(skuId, count);                 // 锁外:校验
    RLock lock = redissonClient.getLock("stock_lock:" + skuId);
    lock.lock();
    try {
        doDeduct(skuId, count);              // 锁内:只放扣减
    } finally {
        lock.unlock();
    }
    sendMq(skuId, count);                    // 锁外:发消息
}

// === 3. 一定要设 waitTime,不要无限等锁 ===
// lock.lock() 会一直阻塞直到拿到锁,高并发下线程全堆在这。
// 用 tryLock(waitTime, ...),等不到就快速失败、返回"请重试"。

// === 4. unlock 务必放 finally,且判断 isHeldByCurrentThread ===
// 不放 finally:业务异常时锁不释放,等过期才放,白白阻塞。
// 不判断持有者:锁已超时释放再 unlock 会抛 IllegalMonitorStateException。
# 分布式锁监控:重点盯"抢锁失败率"和"持锁时长"
groups:
- name: distributed-lock
  rules:
  # 1. 抢锁失败率过高(锁竞争激烈或临界区太慢)
  - alert: LockAcquireFailHigh
    expr: |
      rate(lock_acquire_fail_total[5m])
      / rate(lock_acquire_total[5m]) > 0.2
    for: 5m
    annotations:
      summary: "分布式锁抢锁失败率 > 20%,排查临界区耗时或锁粒度"

  # 2. 持锁时间过长(临界区里塞了慢操作)
  - alert: LockHoldTimeHigh
    expr: lock_hold_seconds{quantile="0.99"} > 5
    for: 5m
    annotations:
      summary: "锁持有 P99 > 5s,检查临界区是否有慢 SQL/远程调用"

  # 3. 看门狗续期失败(Redis 抖动,锁可能提前失效)
  - alert: LockRenewFail
    expr: increase(lock_watchdog_renew_fail_total[5m]) > 0
    annotations:
      summary: "看门狗续期失败,Redis 连接异常,锁有提前过期风险"

优化效果

指标                      治理前              治理后
=============================================================
加锁                      SETNX + expire 两步  SET NX PX 一条命令
加锁中途宕机              留下永不过期死锁     带过期,自然释放
解锁                      直接 delete          Lua 校验 value 再删
误删别人锁                偶发,引发连锁超卖   杜绝
锁提前过期                固定 10s,业务超时即失效  看门狗自动续期
可重入                    不支持,自己锁死自己 Redisson 原生可重入
主从切换                  锁丢失 -> 大面积超卖 数据库 WHERE 兜底
超卖                      100 件卖出 107       0 超卖
锁可观测                  无                   抢锁失败率/持锁时长监控

治理过程:
- 定位超卖根因(锁提前过期 + 误删):0.5 天
- 手写锁改 SET NX PX + Lua 解锁:1 天
- 接入 Redisson + 看门狗:1 天
- 库存 SQL 加 WHERE 兜底 + 主从场景验证:1 天
- 监控接入 + 压测复现验证:1 天

避坑清单

  1. 加锁必须用 SET key value NX PX 一条命令,加锁和设过期分两步会留死锁
  2. 锁的 value 必须是唯一值(如 UUID),这是安全解锁能校验持有者的前提
  3. 解锁不能直接 delete,要先比对 value 确认是自己的锁,否则会误删别人的
  4. "比对 + 删除"必须用 Lua 脚本保证原子,中间不能被其他命令打断
  5. 锁过期时间固定值怎么设都错:设小了业务超时即失效,设大了宕机后卡死太久
  6. 用看门狗自动续期:业务没干完就续命,持有者宕机则续期停止、锁自然过期
  7. 分布式锁的坑太多,不要手写,直接用 Redisson,它已处理好原子/续期/可重入
  8. Redisson 显式传 leaseTime 会关闭看门狗,想自动续期就别传 leaseTime
  9. Redis 主从复制是异步的,主从切换瞬间未同步的锁会丢,需数据库层面兜底
  10. 锁粒度要细(按 SKU/用户),临界区只放必须互斥的代码,unlock 放 finally

总结

这次分布式锁的事故复盘,最该被记住的一句话是:分布式锁看起来简单,真正写对极难,所以不要自己手写。回头看我们最初那段"标准"的加锁代码,它的每一行单独看都没错——用 SETNX 抢占、用 expire 设过期、用 delete 释放,这不就是教科书写法吗?可正是这种"看起来对"的代码最危险,因为它的错误不在语法层面,而在并发时序的缝隙里,只有在高并发、在宕机、在主从切换这些极端时刻才会暴露,而那时往往已经造成了超卖这种实打实的业务损失。我们踩的坑可以串成一条完整的链。第一个坑,加锁和设过期是分开的两步,这两步之间只要进程崩溃,就会留下一把永远不会过期的锁,把所有后来者死死卡住——解法是用 Redis 原生的 SET 命令,把 NX 和 PX 揉进一条命令里,让"加锁"和"带上过期时间"成为一个原子动作。第二个坑更隐蔽,解锁时直接 delete,你以为删的是自己的锁,但如果你的业务执行时间超过了锁的过期时间,锁早已自动释放并被别人抢走,你这一删,删的是别人的锁,于是临界区被彻底打穿——解法是给每把锁一个唯一的 value,解锁前先比对"这锁到底是不是我的",而且这个"比对加删除"必须用 Lua 脚本打包成原子操作。第三个坑是最根本的两难:锁的过期时间到底设多久?设短了,业务还没干完锁就没了;设长了,持有者一旦宕机,这把锁要卡死所有人很久。这个矛盾用任何固定值都无法化解,真正的答案是看门狗——过期时间设一个不大的值,但持有者在干活期间有个后台任务不断地给锁续命,业务没结束就一直续,而持有者一旦宕机,续命也随之停止,锁便会在不久后自然过期。这三个坑,加上锁的可重入性,Redisson 已经全部替我们处理好了,而且经过了海量生产环境的锤炼,所以结论非常明确:用 Redisson,别手写。但故事还有最后一层,也是最容易被忽略的一层——Redis 的主从复制是异步的,这意味着你在主节点上加锁成功的那一刻,这条数据可能还没同步到从节点,如果此时主节点宕机、从节点被提升,这把锁就凭空消失了。这个风险无法靠"把锁写得更好"来消除,因为它源于 Redis 架构本身。我们的选择是务实的:不追求理论上的绝对完美,而是承认 Redis 锁有这个小概率的薄弱点,然后在它身后再加一道数据库层面的硬约束——库存扣减的 SQL 里永远带着 WHERE stock >= count。Redis 锁负责把百分之九十九点九的并发高效地挡在门外,而数据库约束负责那最后的万一:就算锁真的失效了,数据库也绝不会让库存被扣成负数。这种"性能层 + 兜底层"的双层防护,比执着于寻找一把"绝对可靠的锁"要现实得多,也可靠得多。经过这次复盘我形成了一个判断:涉及钱、涉及库存这种绝对不能错的业务,永远不要把正确性百分之百押在分布式锁这一个点上,锁是用来提升并发性能、减少冲突的,而最终的正确性,应该由数据库的唯一约束、乐观锁这些更扎实的机制来兜底。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

TIME_WAIT 堆到四万:一次 HTTP 客户端连接池踩坑的复盘

2026-5-20 13:33:43

技术教程

付一次钱记了三笔账:一次接口幂等设计的复盘

2026-5-20 13:38:47

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索