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