Skip to content

Redis 分布式锁 ​

S4-010必学高级约 90 分钟JDK 21企业开发进阶 · Redis

1. 本节定位 ​

《缓存一致性与更新策略》解决缓存一致性;本节解决**"多实例下的互斥"**:

text
本地 synchronized 只在单机有效 ✗
跨实例互斥 → 需要一把「所有实例都能看见的锁」→ 分布式锁  ✓

★ 本节把分布式锁的六代演进逐级实现并实测 ✓✓ —— 因为面试与实战都要求能说清"为什么不能停在第三、四代" ✓

2. 学习目标 ​

  • 能说出六代演进各自的问题与解法 ★★★;
  • 知道"SETNX + EXPIRE"为什么必须合并成一条命令 ★★★;
  • 知道"误删别人的锁"这个事故的完整时序 ★★★;
  • 知道看门狗解决什么问题、两个必须注意的细节 ★★★;
  • 知道优先考虑"不用分布式锁"的方案**(乐观锁 / 原子命令 / MQ)** ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看本节的实测:无锁下的超卖 ✓:

text
===== 第 1 代:无锁(基线)=====
  库存初始 100,100 个并发各扣 1 → 最终库存 97
  → 超卖 3 件 ✗✗(期望 0)

★ 只超卖了 3 件,看起来"不严重" ✗ ——这正是并发 bug 的可怕之处 ✓✓:

它不会每次必现,线上只是"偶尔少几件" ✓

如果不是专门压测,很可能一直发现不了 ✗✗

5. 核心概念 ​

5.1 六代演进与每代的漏洞 ★★★ ​

代方案漏洞
①无锁check-then-act 超卖(实测超卖 3 件)✗
②本地 synchronized多机失效(3 个实例 = 3 把锁)✗✗
③SETNX(无过期)持有者崩溃 → 永久死锁 ✗✗
④SET NX EX误删别人的锁 / 锁提前过期 ✗
⑤唯一标识 + Lua 原子删除业务超时导致锁提前释放 ✗
⑥看门狗自动续期生产可用 ✓✓

★ 实测数据(关键的三代) ✓:

text
① 无锁:           超卖 3 件 ✗✗
④ SET NX EX(3 秒):超卖 2 件 ✗   ← 锁提前过期导致
⑤ 唯一标识 + Lua:  100 个并发拿到锁 4 次,**临界区最大并发数 = 1** ✓✓
⑥ 看门狗:         业务跑 5 秒(锁原本 2 秒),**续期 4 次,结束时锁仍属于自己** ✓✓

5.2 第 ③→④ 代:为什么必须是一条命令 ★★★ ​

✗ 危险的写法:

java
if (redis.setnx(key, "1")) {        // ① 拿到锁
    redis.expire(key, 30);          // ② 设置过期
    // ...
}

★ 如果 ① 与 ② 之间进程崩溃 ✗✗:

锁被创建了,但没设过期 → 永久死锁 ✗✗

整个业务永久阻塞 ✓

✓ 正确写法(一条命令,原子):

java
redis.set(key, value, "NX", "EX", 30);     // ★ 一条命令同时完成"不存在才设" + "设过期"

★ 这就是为什么 Redis 提供了 SET 的多参数形式 ✓✓ —— 它把"两条命令"合并成了一条,消除了中间窗口 ✓

5.3 第 ④→⑤ 代:误删别人的锁 ★★★ ​

★ 这是最经典的分布式锁事故 ✓✓:

text
A 拿到锁(3 秒过期)
→ A 的业务卡了 4 秒(GC 停顿、下游慢)
→ 锁自动过期
→ B 拿到锁
→ A 业务完成,执行 DEL lock     ← ✗✗ 删掉的是 B 的锁!
→ C 也拿到锁
→ **A、B、C 三个线程同时执行临界区** ✗✗✗

★ 解法:给锁加"唯一标识" + 原子校验删除 ✓✓:

java
String clientId = UUID.randomUUID().toString();
redis.set(key, clientId, "NX", "EX", 30);        // ★ value 是自己的唯一 ID

// 释放时:只有当 value 还是自己时才删
// 真 Redis 用 Lua 保证"校验 + 删除"的原子性:
String lua = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;
redis.eval(lua, List.of(key), List.of(clientId));

★★ 为什么必须用 Lua ✓✓:

"先 GET 判断、再 DEL"是两条命令 ✗

在两条之间锁可能刚好过期并被别人拿走 → 还是会误删 ✗✗

Lua 脚本在服务端一次执行,天然原子 ✓✓

★ 实测验证 ✓:

text
  B 是否拿到锁:是(A 的锁已超时)✓
  A 尝试释放锁:**被拒绝 ✓✓(不是自己的锁)**
  B 的锁现在还在吗:**还在 ✓✓**

5.4 第 ⑤→⑥ 代:看门狗 ★★★ ​

★ 第 ⑤ 代还剩的问题 ✗:

业务执行时间超过锁的过期时间 → 锁自动释放 → 别的线程进来 ✗✗

唯一标识只保证"不误删",不保证"锁不被提前释放" ✓

★ 解法:看门狗(自动续期) ✓✓:

java
// 后台线程:每 1 秒检查一次,只要业务还在跑就续期
while (!Thread.currentThread().isInterrupted()) {
    Thread.sleep(1000);
    if (clientId.equals(redis.get(key))) {
        redis.expire(key, lockSeconds);      // ★ 续期
    } else {
        break;                                // 锁已不属于我,停止续期
    }
}

★ 实测 ✓:

text
  业务实际跑了 5000 ms,锁原本 2 秒过期
  看门狗续期 4 次
  业务结束时锁仍属于自己:**是 ✓✓**

★★ 两个必须注意的细节 ✓✓:

#细节原因
①续期前必须校验锁还是自己的 ★★★否则会给别人的锁续期 ✗✗(那就等于永久不释放)
②业务结束要停止续期 ★★★否则锁永远不过期(等于没有过期时间) ✗

5.5 生产环境:直接用 Redisson ★★★ ​

★ 不要自己写 ✗ ——Redisson 已经把六代的问题都处理了 ✓✓:

Redisson 的能力对应哪一代的问题
可重入(同一线程可重复获取)⑤ 的延伸
看门狗自动续期⑥
原子释放(Lua)⑤
红锁(RedLock)多节点容错
公平锁 / 读写锁 / 信号量更丰富的语义

★ 标准写法 ✓:

java
RLock lock = redissonClient.getLock("lock:stock");
try {
    if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {      // ★ 等待 3 秒,持有 30 秒(自动续期)
        // 临界区
    }
} finally {
    if (lock.isHeldByCurrentThread()) { lock.unlock(); }
}

5.6 最重要的一节:先想清楚"要不要用锁" ★★★ ​

★ 分布式锁是最重的方案 ✗(网络往返 + 阻塞 + 各种边界)

★ 能替代它的四种方案 ✓✓:

方案适用例子
数据库乐观锁 ★★★库存扣减、状态流转update stock set num = num - 1 where id = ? and num > 0
唯一约束 ★★防重复插入insert ... 唯一索引冲突则忽略
Redis 原子命令 ★★★计数、限流、去重DECR / INCR / SETNX
消息队列串行化 ★★异步任务、顺序敏感把并发请求丢进队列,单消费者处理

★★ 最关键的一条认知 ✓✓:

分布式锁解决的是"互斥",但很多业务其实要的是"原子扣减" ✓

后者用 DECR 一行就够了,比锁更简单也更快 ✓✓

只有当"临界区里有多个步骤需要一起保护"时,才真的需要锁 ✓

★ 一句话选择顺序 ✓✓:

text
数据库原子操作 → Redis 原子命令 → 消息队列串行化 → 分布式锁(最后手段)✓

6. 递进示例 ​

6.1 最小示例:正确的加锁与释放 ​

java
String clientId = UUID.randomUUID().toString();
boolean locked = redis.set(key, clientId, "NX", "EX", 30);
try {
    if (!locked) { return; }             // 没抢到,直接返回或稍后重试
    // 临界区
} finally {
    releaseViaLua(redis, key, clientId); // ★ 原子校验删除
}

6.2 业务示例:库存扣减的三种写法 ​

java
// ✗ 无锁 check-then-act(本节实测:超卖 3 件)
int stock = queryStock();
if (stock > 0) { updateStock(stock - 1); }

// ✓ 数据库原子操作(推荐)
update stock set num = num - 1 where id = ? and num > 0;   // 受影响行数 > 0 才算成功

// ✓ Redis 原子命令
long remain = redis.decr("stock:" + id);
if (remain < 0) { redis.incr("stock:" + id); return FAIL; }  // 回补并拒绝

6.3 反例:用 tryLock 却不设超时 ​

java
lock.tryLock();      // ✗ 无限等待 → 可能永久阻塞

7. 完整可运行代码与实测 ​

代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/DistributedLockLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.DistributedLockLesson

实测输出(关键部分):

text
===== 第 1 代:无锁(基线)=====
  库存初始 100,100 个并发各扣 1 → 最终库存 97
  → 超卖 3 件 ✗✗(期望 0)

===== 第 4 代:SET NX EX(加过期时间)=====
  → 超卖 2 件 ✗   ← 锁提前过期导致(3 秒锁不够 100 个并发跑完)

===== 第 5 代:唯一标识 + 原子校验删除 =====
  B 是否拿到锁:是(A 的锁已超时)✓
  A 尝试释放锁:**被拒绝 ✓✓(不是自己的锁)**
  100 个并发:拿到锁 4 次,**临界区最大并发数 = 1** ✓✓

===== 第 6 代:看门狗(自动续期)=====
  业务实际跑了 5000 ms,锁原本 2 秒过期
  看门狗续期 4 次
  业务结束时锁仍属于自己:**是 ✓✓**

★ 三条可落地的结论 ✓:

#结论依据
①无锁一定超卖,且"只超一点"最危险超卖 3 件 ✗
②唯一标识 + Lua 是"不误删"的唯一正确做法释放被拒、别人的锁还在 ✓
③看门狗让"长业务"不再被锁过期打断续期 4 次,锁始终属于自己 ✓

★ 一个诚实的说明 ✓:第 2 代(单机锁)的实测显示"扣减了 100 件" —— 那是因为单机内它确实有效 ✓;但它的失效条件是"部署多个实例",本实验无法模拟多机 ✗ —— 这一点在代码注释里写明了 ✓

8. Debug 观察点 ​

  1. 把第 4 代的锁时间从 3 秒改成 30 秒:观察超卖是否消失(说明 3 秒不够)★★;
  2. 把 Lua 校验删掉(直接 DEL):重现"误删别人的锁" ✗✗;
  3. 把看门狗的"校验锁是否属于自己"去掉:思考会给谁续期 ✗;
  4. 把看门狗的循环加个"业务结束也继续跑":观察锁永不过期 ✗。

9. 常见错误 ​

#错误说明
1SETNX 与 EXPIRE 分两条命令 ✗✗中间崩溃就死锁(5.2)✓
2释放时直接 DEL ✗✗会误删别人的锁 ✓✓
3用"先 GET 判断再 DEL"代替 Lua ✗✗两条命令之间仍可能被抢 ✓
4锁时间固定且业务可能超时 ✗需要看门狗或合理估值 ✓
5续期时不校验锁的归属 ✗✗会给别人的锁续期 ✓
6tryLock 不设超时 ✗✗可能永久阻塞 ✓
7该用原子命令/乐观锁时上分布式锁 ✗✗过度设计 + 性能更差 ✓✓

10. 动手实验 ​

目标:亲手走完六代,看清每一步的漏洞。

步骤:

  1. 跑 DistributedLockLesson,确认无锁超卖、第 5 代临界区并发数为 1 ✓;
  2. 把第 5 代的 Lua 校验删掉,重现误删 ★★★;
  3. 把锁时间缩到 1 秒,观察超卖加剧 ✓;
  4. 给看门狗加一个"业务结束后不停止"的 bug,观察锁永不过期 ⚠️;
  5. 用 DECR 重写库存扣减,对比"用锁"的代码量与耗时 ★★。

验收方式:能说出"六代里哪一步是为了解决哪个漏洞"。

扩展任务:给"秒杀"设计并发控制方案:

  • 用 DECR 做库存预扣(原子)✓;
  • 用唯一约束防同一用户重复下单 ✓;
  • 用 MQ 异步落库 ✓;
  • 说明:为什么这个方案比"全用分布式锁"更好(提示:锁的粒度和持有时间)★★

11. 练习题 ​

  1. 判断:用 SETNX 加锁后立刻 EXPIRE 是安全的。(对 / 错)
  2. 填空:分布式锁六代演进中,第 ______ 代解决"崩溃不释放",第 ______ 代解决"误删别人的锁",第 ______ 代解决"业务超时"。
  3. 简答:为什么释放锁必须用 Lua 脚本?
  4. (代码阅读)
java
public void seckill(long productId) {
    RLock lock = redisson.getLock("stock:" + productId);
    lock.lock();                       // 注意:没有参数
    try {
        // 扣库存
    } finally {
        lock.unlock();
    }
}

这段代码有什么问题? 5. (设计)给"防止同一用户重复提交订单"设计三种方案,并说明各自代价。

参考答案(要点)
  1. 错。两条命令之间存在"崩溃窗口",会导致永久死锁 ✗✗ ——要用 SET key val NX EX 30 一条命令 ✓★
  2. ③;⑤;⑥ ✓
  3. 因为"先 GET 判断、再 DEL"是两条命令,中间锁可能过期并被别人拿走 → 仍会误删 ✗ —— Lua 在服务端一次执行,天然原子 ✓✓
  4. lock.lock() 没有超时,可能永久阻塞 ✗✗ —— 应该用 tryLock(等待时间, 持有时间, 单位);另外 unlock 前应判断 isHeldByCurrentThread() ✓
  5. 要点:① 唯一索引(user_id + 业务ID,插入失败即重复提交)✓; ② Redis SETNX(带 TTL 的提交令牌)✓; ③ 前端/网关层幂等 token ✓ —— 代价:唯一索引最可靠但占存储;SETNX 快但要处理 TTL;前端方案最轻但可绕过 ✓

12. 掌握标准 ​

  • [ ] 能说出六代演进与每代的漏洞 ★★★;
  • [ ] 知道"SETNX + EXPIRE"必须合并为一条命令 ★★★;
  • [ ] 知道"误删别人的锁"的完整时序与 Lua 解法 ★★★;
  • [ ] 知道看门狗的两个必须注意的细节 ★★★;
  • [ ] 知道生产用 Redisson,不自研 ★★;
  • [ ] 知道"能用乐观锁/原子命令就不用分布式锁" ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《Redis 持久化与高可用》:RDB、AOF 与主从哨兵。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引