Redis 分布式锁踩了三个坑:超时、误删、主从切换丢锁实录

库存扣减用 Redis 分布式锁防超卖,某次大促对账发现 37 个商品超卖。复盘出三个经典坑:加锁非原子、误删别人的锁、主从切换丢锁。投一周重写:SET NX PX 原子加锁 + Lua 校验 token 解锁 + 看门狗续期 + Redisson + DB WHERE num>=qty 兜底 + 热点商品分段锁,大促零超卖。

2024 年我们的库存扣减用 Redis 分布式锁防超卖。某次大促,对账发现 37 个商品出现了超卖 — 明明加了锁,库存还是被扣成了负数。复盘下来,我们的"分布式锁"踩了三个经典的坑:误删别人的锁、锁过期了业务还没跑完、Redis 主从切换导致锁凭空消失。投了一周重写分布式锁,引入 Redisson,之后大促再没出现超卖。本文复盘 Redis 分布式锁的三大坑与正确实现,覆盖原子加解锁、看门狗续期、主从切换、可重入、降级。

问题背景

业务:商品库存扣减,Redis 分布式锁防并发超卖
Redis:1 主 2 从,哨兵模式

最初的"分布式锁"实现(有三个致命 bug):
// 加锁
Boolean ok = redis.opsForValue().setIfAbsent("lock:item:" + id, "1");
if (ok) redis.expire("lock:item:" + id, 10, SECONDS);   // bug 1
// 业务:扣库存
// 解锁
redis.delete("lock:item:" + id);                        // bug 2,3

大促超卖 37 单,复盘出三个坑:

坑 1:加锁非原子
  setIfAbsent 和 expire 是两步。如果 setIfAbsent 成功后进程崩了,
  expire 没执行 → 锁永不过期 → 死锁

坑 2:误删别人的锁
  线程 A 加锁,业务执行 12s,但锁 10s 就过期了
  线程 B 趁机加锁成功
  线程 A 执行完,直接 delete → 删掉的是 B 的锁!
  → B 的临界区失去保护 → 并发扣库存 → 超卖

坑 3:主从切换丢锁
  线程 A 在 master 加锁成功
  master 还没把锁同步给 slave 就宕机
  哨兵把 slave 提为新 master,新 master 上没有这把锁
  线程 B 在新 master 加锁成功 → 两个线程同时持锁 → 超卖

修复 1:原子加锁 + 防误删

// === 坑 1 修复:加锁原子化 ===
// SET key value NX PX 一条命令搞定"不存在才设"+"带过期时间"
public boolean tryLock(String key, String token, long ttlMs) {
    // token = 唯一标识(UUID + 线程ID),用于解锁时校验"是不是自己的锁"
    String result = redis.execute((RedisCallback<String>) conn ->
        conn.set(key.getBytes(), token.getBytes(),
            Expiration.milliseconds(ttlMs),
            RedisStringCommands.SetOption.SET_IF_ABSENT));
    return "OK".equals(result);
}
// 对应原生命令:SET lock:item:123 <token> NX PX 10000
// NX = 不存在才设,PX = 毫秒级过期 —— 一个命令原子完成,不再有 bug 1

// === 坑 2 修复:解锁要校验 token,且"校验 + 删除"必须原子 ===
// 错误:先 get 比对,再 delete —— get 和 delete 之间锁可能过期被别人抢走
// String v = redis.get(key);
// if (token.equals(v)) redis.delete(key);   ← 这中间有缝隙

// 正确:用 Lua 脚本,让"比对 token + 删除"在 Redis 端原子执行
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 token) {
    Long result = redis.execute(
        new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
        Collections.singletonList(key),
        token);
    return Long.valueOf(1).equals(result);
}
// Lua 在 Redis 中单线程原子执行:要么"是我的锁并删掉",要么"不是,啥也不做"
// 彻底杜绝"删掉别人的锁"

// === 使用 ===
String token = UUID.randomUUID() + ":" + Thread.currentThread().getId();
String key = "lock:item:" + itemId;
if (tryLock(key, token, 10_000)) {
    try {
        deductStock(itemId);            // 临界区
    } finally {
        unlock(key, token);             // 只会删自己的锁
    }
}

修复 2:看门狗自动续期

// 坑 2 的根因之一:锁的 TTL 是"猜"的。猜短了,业务没跑完锁就没了
// 解法:看门狗(watchdog)—— 后台线程定期给锁续期,只要业务还在跑

public class WatchdogLock {
    private final ScheduledExecutorService scheduler =
        Executors.newScheduledThreadPool(2);

    // 续期 Lua:是自己的锁才续(防止续了别人的锁)
    private static final String RENEW_LUA =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('pexpire', KEYS[1], ARGV[2]) " +
        "else return 0 end";

    public ScheduledFuture<?> startWatchdog(String key, String token, long ttlMs) {
        // 每 ttl/3 续一次期,把 TTL 重置回 ttlMs
        return scheduler.scheduleAtFixedRate(() -> {
            try {
                redis.execute(new DefaultRedisScript<>(RENEW_LUA, Long.class),
                    Collections.singletonList(key), token, String.valueOf(ttlMs));
            } catch (Exception e) {
                log.warn("watchdog renew failed: {}", key, e);
            }
        }, ttlMs / 3, ttlMs / 3, TimeUnit.MILLISECONDS);
    }

    public void doWithLock(String key, Runnable task) {
        String token = UUID.randomUUID().toString();
        if (!tryLock(key, token, 30_000)) throw new LockFailException();
        ScheduledFuture<?> watchdog = startWatchdog(key, token, 30_000);
        try {
            task.run();                          // 业务执行多久,锁续多久
        } finally {
            watchdog.cancel(true);               // 业务结束,停止续期
            unlock(key, token);                  // 释放锁
        }
    }
}
// 关键:看门狗解决了"锁过期早于业务完成"。但也要设上限,
// 比如续期累计超过 5min 还没结束,就放弃续期 + 告警,防止业务死循环把锁焊死

// === 生产建议:别自己造,用 Redisson(看门狗是它的内置能力)===
RLock lock = redisson.getLock("lock:item:" + itemId);
lock.lock();                                     // 默认 30s TTL + 自动续期看门狗
try {
    deductStock(itemId);
} finally {
    lock.unlock();
}
// lock.lock() 不传时间 → 看门狗自动续期(每 10s 续到 30s)
// lock.lock(10, SECONDS) → 传了时间则不续期,10s 后强制释放

修复 3:主从切换丢锁

# 坑 3:这是 Redis 分布式锁最难的问题,先认清它的本质

# 主从异步复制 + 故障转移 = 锁可能丢
# 这是 CAP 权衡的结果,不是 bug:Redis 选了 AP,牺牲强一致换可用性

# === 方案对比 ===

方案 A:单点 Redis(不开主从)
  - 优:不存在主从切换丢锁
  - 缺:Redis 挂了锁服务整个不可用
  - 适合:能容忍锁服务短暂不可用的场景

方案 B:RedLock(红锁)
  - 思路:部署 5 个独立 master(非主从),向多数(3 个)加锁成功才算锁定
  - 优:单个节点挂了不影响
  - 缺:实现复杂、有时钟漂移争议(Martin Kleppmann vs antirez 著名论战)
        成本高(5 个独立实例),性能下降
  - 适合:对正确性要求极高、愿意付出成本的场景

方案 C:接受 Redis 锁的"弱保证" + 业务兜底(我们的选择)
  - 承认 Redis 锁在主从切换的瞬间可能失效(极小概率)
  - 用"业务层最终一致"兜底:即使锁偶尔失效,数据也不会错

方案 D:用强一致的锁服务
  - ZooKeeper / etcd:基于 Raft/ZAB,天生强一致,不丢锁
  - 缺:性能比 Redis 低,运维另一套组件
  - 适合:已有 ZK/etcd、且锁正确性 > 性能
// 我们的最终方案:Redisson 锁(挡住 99.99% 并发)+ 数据库层兜底(防万一)
// 即使分布式锁在主从切换瞬间失效,这条 SQL 也不会让库存变负

public void deductStock(long itemId, int qty) {
    RLock lock = redisson.getLock("lock:item:" + itemId);
    boolean locked = false;
    try {
        locked = lock.tryLock(2, 10, TimeUnit.SECONDS);  // 等锁 2s,持锁最多 10s
        if (!locked) throw new BizException("系统繁忙,请重试");

        // 兜底:UPDATE 带条件,数据库行锁 + 乐观条件,锁失效也不会超卖
        int rows = stockDao.deduct(itemId, qty);
        // SQL: UPDATE stock SET num = num - #{qty}
        //      WHERE item_id = #{itemId} AND num >= #{qty}
        if (rows == 0) {
            throw new BizException("库存不足");           // num < qty,扣减失败
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BizException("获取锁中断");
    } finally {
        if (locked && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
// 设计哲学:分布式锁负责"减少并发冲突、提升性能"(不用每次都打 DB 行锁竞争)
//          数据库的 WHERE num >= qty 负责"绝对正确性"(最后一道防线)
// 单靠任何一层都不够稳,两层叠加才是生产级方案

修复 4:可重入与公平锁

// === 可重入:同一线程能重复获取同一把锁(否则自己调自己会死锁)===
// 自己实现可重入要用 hash 记录重入次数:
//   HSET lock:key <token> <count>,加锁 +1,解锁 -1,减到 0 才真正删
// Redisson 的 RLock 默认就是可重入的(内部用 hash 实现):
RLock lock = redisson.getLock("lock:order:" + orderId);
lock.lock();
try {
    methodA();          // methodA 内部又 lock.lock() 同一把锁 → 重入 +1,不死锁
} finally {
    lock.unlock();      // 重入计数减到 0 才释放
}

// === 公平锁:按申请顺序排队拿锁,防止线程饥饿 ===
RLock fairLock = redisson.getFairLock("lock:fair:" + key);
fairLock.lock();        // Redisson 内部用 list 维护等待队列,先到先得
// 代价:公平锁性能比非公平锁低,只在"防饥饿"确实重要时才用

// === tryLock 一定要设等待上限,别无限等 ===
// 错误:lock.lock() 在高竞争下可能等很久,拖垮线程
// 正确:tryLock(waitTime, leaseTime, unit),拿不到就快速失败 → 降级
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* ... */ } finally { lock.unlock(); }
} else {
    // 拿不到锁 → 走降级:返回"系统繁忙"或进队列异步处理
    throw new BizException("当前抢购火爆,请重试");
}

修复 5:锁粒度与降级

// === 锁粒度:能细就别粗 ===
// 错误:一把全局锁,所有商品扣库存都抢它 → 并发度 = 1,性能灾难
// redisson.getLock("lock:stock:global");

// 正确:按商品维度,不同商品的锁互不影响
// redisson.getLock("lock:stock:item:" + itemId);

// 进一步:热点商品(秒杀爆款)单 key 锁仍是瓶颈 → 分段锁
// 把一个商品的库存拆成 N 段,分别加锁,扣减时随机选一段
public boolean deductSegmented(long itemId, int qty) {
    int seg = ThreadLocalRandom.current().nextInt(SEGMENT_COUNT);
    RLock lock = redisson.getLock("lock:stock:" + itemId + ":seg:" + seg);
    if (!lock.tryLock()) return false;
    try {
        return segmentStockDao.deduct(itemId, seg, qty);   // 扣这一段
    } finally {
        lock.unlock();
    }
}
// 10 段锁 → 并发度提升 10 倍。代价:某段扣空要尝试其他段,逻辑变复杂

// === 锁服务不可用时的降级 ===
public void deductWithDegrade(long itemId, int qty) {
    try {
        deductWithLock(itemId, qty);
    } catch (RedisConnectionException e) {
        // Redis 整个挂了:不能因为"锁拿不到"就停止卖货
        log.error("lock service down, degrade to db-only", e);
        // 降级:纯靠数据库行锁 + WHERE num>=qty 兜底,性能差但正确性在
        int rows = stockDao.deduct(itemId, qty);
        if (rows == 0) throw new BizException("库存不足");
    }
}
// 原则:分布式锁是"性能优化组件",不该成为"单点故障源"
// 它挂了,业务要能降级到 DB 兜底继续跑,而不是整个停摆

修复 6:监控告警

# 分布式锁埋点 → Micrometer → Prometheus
# 埋点:加锁成功/失败/等待耗时、持锁时长、续期次数、解锁失败

groups:
- name: distributed-lock
  rules:
  # 1. 加锁失败率(竞争激烈或锁服务异常)
  - alert: LockAcquireFailHigh
    expr: |
      rate(lock_acquire_total{result="fail"}[5m])
      / rate(lock_acquire_total[5m]) > 0.3
    for: 5m
    annotations:
      summary: "{{ $labels.lock }} 加锁失败率 > 30%,检查竞争或锁服务"

  # 2. 持锁时长过长(业务慢 / 死循环 / 看门狗一直续)
  - alert: LockHoldTooLong
    expr: |
      histogram_quantile(0.99, rate(lock_hold_seconds_bucket[5m])) > 30
    for: 5m
    annotations:
      summary: "{{ $labels.lock }} 持锁 P99 > 30s,排查临界区慢逻辑"

  # 3. 解锁失败(误删/锁已过期/网络问题)
  - alert: LockReleaseFail
    expr: increase(lock_release_total{result="fail"}[5m]) > 5
    annotations:
      summary: "{{ $labels.lock }} 解锁失败,可能锁已过期或被误删"

  # 4. 看门狗续期失败(续期失败 = 锁可能提前过期)
  - alert: LockWatchdogRenewFail
    expr: increase(lock_watchdog_renew_total{result="fail"}[5m]) > 0
    annotations:
      summary: "{{ $labels.lock }} 看门狗续期失败,锁有提前释放风险"

  # 5. 等锁耗时(影响接口 RT)
  - alert: LockWaitSlow
    expr: |
      histogram_quantile(0.99, rate(lock_wait_seconds_bucket[5m])) > 1
    for: 5m
    annotations:
      summary: "{{ $labels.lock }} 等锁 P99 > 1s,考虑细化锁粒度"

优化效果

指标                    治理前          治理后
=============================================================
加锁原子性              两步(有缝隙)   SET NX PX 单命令
解锁安全性              直接 del        Lua 校验 token 原子删
误删别人的锁            会发生          0(token 校验)
锁过期早于业务          会发生          看门狗自动续期
主从切换丢锁            会导致超卖      DB WHERE num>=q 兜底
大促超卖单数            37 单           0
锁实现                  手写(三个 bug) Redisson + DB 双层
锁粒度                  商品级          热点商品分段锁
锁服务挂掉影响          业务停摆        降级 DB 兜底继续跑

压测(单热点商品 5w QPS 抢购):
- 治理前:超卖 + 死锁,库存扣成负数
- 治理后:零超卖,分段锁支撑 5w QPS,P99 45ms

排查与改造:
- 复盘定位三个坑:1 天
- 引入 Redisson + 重写锁逻辑:3 天
- 数据库兜底 SQL 改造 + 分段锁:2 天
- 大促全链路压测:1 天

避坑清单

  1. 加锁用 SET key value NX PX 单命令,绝不拆成 setnx + expire 两步
  2. 锁的 value 必须是唯一 token,解锁前校验"是不是自己的锁"
  3. 解锁用 Lua 脚本让"校验 token + del"原子执行,杜绝误删
  4. 锁 TTL 不要靠猜,用看门狗按业务实际时长自动续期
  5. 看门狗要设续期上限,防业务死循环把锁永久焊死
  6. Redis 主从切换可能丢锁,这是 AP 权衡,必须有业务层兜底
  7. 关键正确性靠 DB 的 WHERE num>=qty,分布式锁只做性能优化
  8. tryLock 必须设等待上限,拿不到就快速失败走降级
  9. 锁粒度尽量细,热点商品用分段锁提升并发度
  10. 分布式锁是优化组件不是单点,锁服务挂了要能降级到 DB

总结

这次超卖事故让我对分布式锁有了三个层次的重新认识。第一层是实现细节:很多人写的"分布式锁"其实漏洞百出 —— setnx 加 expire 不是原子的、解锁直接 del 会删掉别人的锁、TTL 靠猜会导致锁提前失效,这三个坑我们一次踩全了,而正确实现就是 SET NX PX 加锁、Lua 脚本校验 token 解锁、看门狗续期,或者直接用经过千锤百炼的 Redisson,不要自己造轮子。第二层是分布式系统的本质:Redis 分布式锁在主从切换的瞬间一定可能丢锁,这不是 bug 而是 CAP 权衡的必然结果,Redis 选择了 AP,用强一致性换来了高可用和高性能,RedLock 想解决这个问题但代价高昂且有争议,真要强一致就该用 ZooKeeper/etcd。第三层、也是最重要的认知改变:不要把正确性押注在分布式锁上。我们最终的方案是 Redisson 锁加数据库 WHERE num>=qty 的双层设计 —— 锁负责挡住绝大部分并发、提升性能,数据库的行锁和条件更新负责兜住绝对正确性,即使锁在某个极端时刻失效了,那条 SQL 也绝不会让库存变成负数。分布式锁是一个性能优化组件,而不是数据正确性的唯一保障,想明白这一点,才算真正会用分布式锁。

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

生产服务器 Too many open files:文件描述符与连接泄漏排查实录

2026-5-20 12:14:34

技术教程

MySQL 慢查询拖垮数据库:从一条 31 秒 SQL 说起的索引优化实录

2026-5-20 12:21:03

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