分布式锁的实现方式(Redis setNX、过期时间、续租机制)聊聊?
1 个回答
时间不在乎你拥有多少,而在于你怎样使用。小王子-B644**获取(SET NX EX)→ 续租(watchdog)→ 释放(Lua 校验)→ 容错与坑**。 # 1. 基础原语:SET NX + 过期时间(租约,lease) * **指令**:`SET key value NX EX ttl`(或 `PX ttl_ms`) * **value**:放**随机唯一标识**(如 UUID/节点ID+线程ID),用于判断**锁的归属** * **ttl(租约)**:必须设置,防止持有者崩溃导致**死锁** * **成功**:返回 OK,拿到锁;**失败**:说明有人持有锁 为什么用 `SET` 而不是 `SETNX`+`EXPIRE`:**原子性**(一次指令完成写入与过期)。 # 2. 释放锁:必须“比对 value,再删除”(Lua 原子脚本) 避免 A 释放了 B 的锁(典型竞态:持有者阻塞 → 过期 → 被他人获得 → 旧持有者执行 `DEL`)。 ```lua -- unlock.lua if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end ``` 客户端释放时:`EVALSHA unlock.lua <1> lock_key <value>`。 # 3. 续租机制(watchdog / 自动续期) 当业务执行可能超过初始 TTL,需**自动续租**: * 仅**当前持有者**才能续租(先比对 value) * 续租频率:建议在 TTL 的 **1/2 \~ 2/3** 时刷新 * Lua 脚本(原子校验+续期): ```lua -- renew.lua if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) -- ARGV[2]=新的 ttl 毫秒 else return 0 end ``` 实现上可以后台守护线程(watchdog)按周期 `EVALSHA renew.lua`;Redisson 就是这么干的(默认看门狗 30s)。 # 4. 重试与退避 * 获取失败 → **指数退避 + 抖动**重试(避免惊群) * 设定**总体获取超时**(如 200\~500ms),超时则放弃,避免长时间阻塞 # 5. 选 TTL 多大? * > 规则:`TTL >= 预估临界区时长的上界 + 安全裕度` * 不确定时长的任务 → 必须配合**续租**;否则宁短不长(崩溃可自愈) # 6. 正确性进阶:Fencing Token(围栏令牌) 即使拿到锁,也可能出现**GC 停顿/网络抖动**导致“锁过期 → 他人获得 → 旧持有者晚到”覆盖新写。 做法:为每次获得锁**发号递增 token**(存在 DB/Redis `INCR`)。下游资源(DB/队列/存储)**拒绝旧 token**: ```sql -- 资源侧记录最新 token,只有更大的 token 才被接受 UPDATE resource SET token=? , ... WHERE ? > token; ``` 这能从根上避免“旧持有者回魂”造成的数据破坏。 # 7. 单机、主从、集群的“安全级别” * **单实例 Redis**:最简单、**一致性最好**(只要 AOF/RDB 崩溃恢复可接受)。 * **主从复制(异步)**:主写成功但未复制就主挂了 → 从提升导致**锁丢**(脑裂风险)。 规避: * 给锁 key 设置**较短 TTL + 续租**,降低错误窗口; * 或在 `SET` 后 `WAIT 1 <ms>`(等待至少 1 个副本确认,增加开销但提升安全); * 关键业务**尽量用单机内存主**(哨兵切换也有窗口),或考虑 **etcd/ZooKeeper**。 * **Redlock(多主多数派)**:跨 N 个独立实例拿到多数才算加锁。社区对其在现实时钟/网络模型下的安全性有争议;若需要强一致,**优先 etcd/zk**(基于共识)。 # 8. 与任务模型的搭配 * **一次性短临界区**:`SET NX EX` + 释放 Lua,通常不需要续租 * **长任务**:必须 watchdog 续租 + fencing token * **幂等落库**:临界区里仍应设计**幂等/去重**,避免边界条件下重复执行造成副作用 # 9. 可直接用的 Java 伪代码 ```java class RedisLock implements AutoCloseable { final String key, val = UUID.randomUUID().toString(); final int ttlMs; volatile boolean locked = false; ScheduledFuture<?> renewTask; boolean tryLock(int waitMs) { long end = System.currentTimeMillis() + waitMs; while (System.currentTimeMillis() < end) { // SET key val NX PX ttl if (jedis.set(key, val, SetParams.setParams().nx().px(ttlMs)) != null) { locked = true; startWatchdog(); // 可选:长任务续租 return true; } Thread.sleep(ThreadLocalRandom.current().nextInt(5, 30)); // 抖动退避 } return false; } void startWatchdog() { renewTask = scheduler.scheduleAtFixedRate(() -> { // renew.lua: 仅持有者续租 jedis.evalsha(renewSha, Collections.singletonList(key), Arrays.asList(val, String.valueOf(ttlMs))); }, ttlMs/2, ttlMs/2, TimeUnit.MILLISECONDS); } @Override public void close() { if (!locked) return; if (renewTask != null) renewTask.cancel(true); // unlock.lua: 对比 value 再 DEL jedis.evalsha(unlockSha, Collections.singletonList(key), Collections.singletonList(val)); locked = false; } } ``` # 10. 典型坑与对策(必看) 1. **忘记 TTL** → 死锁;务必 `SET NX EX` 一次完成 2. **直接 `DEL`** → 可能删掉别人的锁;必须 Lua 比对 value 3. **锁续租不鉴权** → 任意客户端可续租;续租脚本要校验 value 4. **长 GC/Stop-The-World** → 锁过期被他人获取;用 **fencing token** + 幂等 5. **主从切换锁丢** → 单机/WAIT/共识系统替代 6. **惊群**(大量等待同时苏醒)→ 加随机退避 & 排队(本地 singleflight/队列) # 11. 什么时候别用 Redis 锁 * 需要\*\*强一致(线性一致)\*\*语义(如金融强约束、全局单主)→ 用 **etcd/ZooKeeper**(`lease + keepAlive + compare-and-swap`) * 需要锁与数据同事务 → **DB 乐观锁/悲观锁**往往更简单可靠 # 12. 最小可用脚本汇总 **unlock.lua** ```lua if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end ``` **renew\.lua** ```lua if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end ``` **一句话总括** > Redis 分布式锁 = `SET NX EX` 拿 **租约** + **Lua 校验释放** + **watchdog 续租**,并用 **fencing token** 防“旧持有者回魂”。主从/网络抖动下若要更强一致,考虑 etcd/zk 或配合 WAIT。发布于 2025-08-10 15:37 •IP属地 黑龙江举报评论收藏
公众号进入网站体验更佳

