主题
界面
主题
《缓存一致性与更新策略》解决缓存一致性;本节解决**"多实例下的互斥"**:
本地 synchronized 只在单机有效 ✗
跨实例互斥 → 需要一把「所有实例都能看见的锁」→ 分布式锁 ✓★ 本节把分布式锁的六代演进逐级实现并实测 ✓✓ —— 因为面试与实战都要求能说清"为什么不能停在第三、四代" ✓
SET NX EX 吗?(《Redis 数据结构与常用命令》)✓先看本节的实测:无锁下的超卖 ✓:
===== 第 1 代:无锁(基线)=====
库存初始 100,100 个并发各扣 1 → 最终库存 97
→ 超卖 3 件 ✗✗(期望 0)★ 只超卖了 3 件,看起来"不严重" ✗ ——这正是并发 bug 的可怕之处 ✓✓:
它不会每次必现,线上只是"偶尔少几件" ✓
如果不是专门压测,很可能一直发现不了 ✗✗
| 代 | 方案 | 漏洞 |
|---|---|---|
| ① | 无锁 | check-then-act 超卖(实测超卖 3 件)✗ |
| ② | 本地 synchronized | 多机失效(3 个实例 = 3 把锁)✗✗ |
| ③ | SETNX(无过期) | 持有者崩溃 → 永久死锁 ✗✗ |
| ④ | SET NX EX | 误删别人的锁 / 锁提前过期 ✗ |
| ⑤ | 唯一标识 + Lua 原子删除 | 业务超时导致锁提前释放 ✗ |
| ⑥ | 看门狗自动续期 | 生产可用 ✓✓ |
★ 实测数据(关键的三代) ✓:
① 无锁: 超卖 3 件 ✗✗
④ SET NX EX(3 秒):超卖 2 件 ✗ ← 锁提前过期导致
⑤ 唯一标识 + Lua: 100 个并发拿到锁 4 次,**临界区最大并发数 = 1** ✓✓
⑥ 看门狗: 业务跑 5 秒(锁原本 2 秒),**续期 4 次,结束时锁仍属于自己** ✓✓✗ 危险的写法:
if (redis.setnx(key, "1")) { // ① 拿到锁
redis.expire(key, 30); // ② 设置过期
// ...
}★ 如果 ① 与 ② 之间进程崩溃 ✗✗:
锁被创建了,但没设过期 → 永久死锁 ✗✗
整个业务永久阻塞 ✓
✓ 正确写法(一条命令,原子):
redis.set(key, value, "NX", "EX", 30); // ★ 一条命令同时完成"不存在才设" + "设过期"★ 这就是为什么 Redis 提供了 SET 的多参数形式 ✓✓ —— 它把"两条命令"合并成了一条,消除了中间窗口 ✓
★ 这是最经典的分布式锁事故 ✓✓:
A 拿到锁(3 秒过期)
→ A 的业务卡了 4 秒(GC 停顿、下游慢)
→ 锁自动过期
→ B 拿到锁
→ A 业务完成,执行 DEL lock ← ✗✗ 删掉的是 B 的锁!
→ C 也拿到锁
→ **A、B、C 三个线程同时执行临界区** ✗✗✗★ 解法:给锁加"唯一标识" + 原子校验删除 ✓✓:
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 脚本在服务端一次执行,天然原子 ✓✓
★ 实测验证 ✓:
B 是否拿到锁:是(A 的锁已超时)✓
A 尝试释放锁:**被拒绝 ✓✓(不是自己的锁)**
B 的锁现在还在吗:**还在 ✓✓**★ 第 ⑤ 代还剩的问题 ✗:
业务执行时间超过锁的过期时间 → 锁自动释放 → 别的线程进来 ✗✗
唯一标识只保证"不误删",不保证"锁不被提前释放" ✓
★ 解法:看门狗(自动续期) ✓✓:
// 后台线程:每 1 秒检查一次,只要业务还在跑就续期
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(1000);
if (clientId.equals(redis.get(key))) {
redis.expire(key, lockSeconds); // ★ 续期
} else {
break; // 锁已不属于我,停止续期
}
}★ 实测 ✓:
业务实际跑了 5000 ms,锁原本 2 秒过期
看门狗续期 4 次
业务结束时锁仍属于自己:**是 ✓✓**★★ 两个必须注意的细节 ✓✓:
| # | 细节 | 原因 |
|---|---|---|
| ① | 续期前必须校验锁还是自己的 ★★★ | 否则会给别人的锁续期 ✗✗(那就等于永久不释放) |
| ② | 业务结束要停止续期 ★★★ | 否则锁永远不过期(等于没有过期时间) ✗ |
★ 不要自己写 ✗ ——Redisson 已经把六代的问题都处理了 ✓✓:
| Redisson 的能力 | 对应哪一代的问题 |
|---|---|
| 可重入(同一线程可重复获取) | ⑤ 的延伸 |
| 看门狗自动续期 | ⑥ |
| 原子释放(Lua) | ⑤ |
| 红锁(RedLock) | 多节点容错 |
| 公平锁 / 读写锁 / 信号量 | 更丰富的语义 |
★ 标准写法 ✓:
RLock lock = redissonClient.getLock("lock:stock");
try {
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // ★ 等待 3 秒,持有 30 秒(自动续期)
// 临界区
}
} finally {
if (lock.isHeldByCurrentThread()) { lock.unlock(); }
}★ 分布式锁是最重的方案 ✗(网络往返 + 阻塞 + 各种边界)
★ 能替代它的四种方案 ✓✓:
| 方案 | 适用 | 例子 |
|---|---|---|
| 数据库乐观锁 ★★★ | 库存扣减、状态流转 | update stock set num = num - 1 where id = ? and num > 0 |
| 唯一约束 ★★ | 防重复插入 | insert ... 唯一索引冲突则忽略 |
| Redis 原子命令 ★★★ | 计数、限流、去重 | DECR / INCR / SETNX |
| 消息队列串行化 ★★ | 异步任务、顺序敏感 | 把并发请求丢进队列,单消费者处理 |
★★ 最关键的一条认知 ✓✓:
分布式锁解决的是"互斥",但很多业务其实要的是"原子扣减" ✓
后者用
DECR一行就够了,比锁更简单也更快 ✓✓只有当"临界区里有多个步骤需要一起保护"时,才真的需要锁 ✓
★ 一句话选择顺序 ✓✓:
数据库原子操作 → Redis 原子命令 → 消息队列串行化 → 分布式锁(最后手段)✓String clientId = UUID.randomUUID().toString();
boolean locked = redis.set(key, clientId, "NX", "EX", 30);
try {
if (!locked) { return; } // 没抢到,直接返回或稍后重试
// 临界区
} finally {
releaseViaLua(redis, key, clientId); // ★ 原子校验删除
}// ✗ 无锁 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; } // 回补并拒绝tryLock 却不设超时 lock.tryLock(); // ✗ 无限等待 → 可能永久阻塞代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/DistributedLockLesson.java
运行:
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.DistributedLockLesson实测输出(关键部分):
===== 第 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 件" —— 那是因为单机内它确实有效 ✓;但它的失效条件是"部署多个实例",本实验无法模拟多机 ✗ —— 这一点在代码注释里写明了 ✓
| # | 错误 | 说明 |
|---|---|---|
| 1 | SETNX 与 EXPIRE 分两条命令 ✗✗ | 中间崩溃就死锁(5.2)✓ |
| 2 | 释放时直接 DEL ✗✗ | 会误删别人的锁 ✓✓ |
| 3 | 用"先 GET 判断再 DEL"代替 Lua ✗✗ | 两条命令之间仍可能被抢 ✓ |
| 4 | 锁时间固定且业务可能超时 ✗ | 需要看门狗或合理估值 ✓ |
| 5 | 续期时不校验锁的归属 ✗✗ | 会给别人的锁续期 ✓ |
| 6 | tryLock 不设超时 ✗✗ | 可能永久阻塞 ✓ |
| 7 | 该用原子命令/乐观锁时上分布式锁 ✗✗ | 过度设计 + 性能更差 ✓✓ |
目标:亲手走完六代,看清每一步的漏洞。
步骤:
DistributedLockLesson,确认无锁超卖、第 5 代临界区并发数为 1 ✓;DECR 重写库存扣减,对比"用锁"的代码量与耗时 ★★。验收方式:能说出"六代里哪一步是为了解决哪个漏洞"。
扩展任务:给"秒杀"设计并发控制方案:
DECR 做库存预扣(原子)✓;SETNX 加锁后立刻 EXPIRE 是安全的。(对 / 错)public void seckill(long productId) {
RLock lock = redisson.getLock("stock:" + productId);
lock.lock(); // 注意:没有参数
try {
// 扣库存
} finally {
lock.unlock();
}
}这段代码有什么问题? 5. (设计)给"防止同一用户重复提交订单"设计三种方案,并说明各自代价。
SET key val NX EX 30 一条命令 ✓★lock.lock() 没有超时,可能永久阻塞 ✗✗ —— 应该用 tryLock(等待时间, 持有时间, 单位);另外 unlock 前应判断 isHeldByCurrentThread() ✓user_id + 业务ID,插入失败即重复提交)✓; ② Redis SETNX(带 TTL 的提交令牌)✓; ③ 前端/网关层幂等 token ✓ —— 代价:唯一索引最可靠但占存储;SETNX 快但要处理 TTL;前端方案最轻但可绕过 ✓《Redis 持久化与高可用》:RDB、AOF 与主从哨兵。
暂无