2017 字
10 分钟
Redis08-分布式锁
2023-07-05
2023-11-25
无标签

分布式锁要协调的是不同进程对同一资源的访问,而不是只锁住单个应用实例内的线程。竞争者需要使用一致的资源标识,通过共享的协调服务决定谁可进入临界区;获取失败时不得继续执行受保护操作。互斥、释放策略和故障边界需要分别设计,不能因为使用了 Redis 就默认全部具备。

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:

MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用好好好
高性能一般好一般
安全性断开连接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

注意redis的超时释放

锁租期结束后旧持有者误删新锁的时序

过期时间限制的是 Redis 锁键的存活,并不会中断持锁线程。旧线程停顿期间锁可能到期,另一个线程随后获得同名锁;旧线程恢复后若直接删除该键,就删除了别人的锁。解锁需要核对本次加锁标识,并且将核对与删除合并执行,避免新的竞争窗口。

租期结束后,同名锁可能已经属于新的请求。旧持有者恢复执行时,如果不检查当前锁值便直接删除,就会把新持有者的锁一并释放,使后续请求也进入临界区。超时机制有助于故障后的自动释放,却不会停止仍在执行的旧业务,因此不能把键过期理解为业务已经结束。

解决方法:释放锁的时候判断一下锁标识

锁值应保存本次加锁生成的唯一标识,释放前只接受与该标识一致的所有者。仅记录一个容易被不同进程重复使用的线程编号并不充分;不同持有者必须能够区分。检查归属只能确定应该删除谁的锁,若检查与删除分成两次命令,仍存在中间过期、换主人的竞争窗口。

非原子检查删除的竞争窗口与 Lua 原子解锁

先 GET 再 DEL 即使检查过所有者,也可能在两条命令之间发生过期和重新加锁。把比较与条件删除放进同一个 Lua 脚本,只有锁值等于预期标识时才删除同一把锁,其他客户端无法在这两个步骤之间插入命令。这个保证限于脚本内的 Redis 操作,不覆盖数据库事务。

==使用Redis事务(Redis只保证了原子性,不能保证一致性,要不然再加上乐观锁进行判断,复杂==

==使用简单的Lua脚本,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。==

样例:

调用解锁脚本时,要把实际锁键作为键参数传入,再把期望的所有者标识作为普通参数传入。脚本中的 KEYS[1] 与 ARGV[1] 分别读取这两类输入,不应把用户输入直接拼进脚本文本。调用方还要解释返回值,区分本次确实删除了锁与锁已不存在、已不属于自己等未释放情况。

这段 Lua 的核心是先读取目标锁的当前值,再与调用者提供的标识比较,匹配时删除的必须仍是同一个键。这里键参数应统一使用 KEYS[1],不能换成未定义的 key 变量。返回值也应与删除结果对应,否则调用者无法仅凭固定返回值知道是否真正完成了释放。

-- 比较标识的一致性
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁
redis.call('del', key)
end
return 0

完整的锁生命周期需要两处不可分割的操作:获取时用一条带 NX 与过期参数的 SET 同时建立互斥和租期,避免先加锁后设置过期之间中断;释放时用脚本原子比较标识并条件删除。租期仍应覆盖业务风险,自动过期本身不能保证旧执行者已停止访问受保护资源。

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示
  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁

特性:

  • 利用set nx满足互斥性
  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性·利用Redis集群保证高可用和高并发特性

Redsson可重入锁#

==基于setnx存在以下问题==

  • 不可重入:同一个线程无法多次获取同一把锁
  • 不可重试:获取锁只尝试一次就返回false,没有重试机制
  • 锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
  • 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现

使用可重入锁时,先按业务资源取得同名锁对象,再尝试获取;只有获取成功才执行受保护逻辑,并在退出时确保释放。若获取过程被中断或未成功,不能当作已经持锁继续处理,也不能无条件解锁。等待时长与租期属于不同配置概念,需要根据所选调用方式确认续期行为。

可重入原理#

Redisson 所有者、重入计数与释放通知的生命周期

可重入允许同一所有者重复进入同一把锁,而不是允许其他线程共享持有。锁状态记录可区分客户端与线程的标识及重入计数,每次正确解锁减少计数,归零才释放并通知等待者。等待者仍需重新获取锁,通知不等于授权;续期是否启用也要结合固定租期或 watchdog 模式判断。

加锁时需要区分锁不存在、锁属于当前线程、锁属于其他线程三种状态。不存在时建立所有者与计数,属于当前所有者时增加重入次数,属于别人时不能直接覆盖,而应依据等待策略重试或返回。被通知唤醒只是获得再次竞争的机会,并不等于已经自动取得锁。

解锁先确认当前执行者有权操作这把锁,再减少自己的重入计数。仍有未退出的重入层级时锁应继续保留,只有最后一层退出才删除锁并发布释放通知。其他线程收到通知后还要重新竞争,不能将一次通知当作锁已转交,也不能由非持有者随意清空计数。

Redisson 将重入计数、等待唤醒和适用模式下的租期续约组合起来:同一所有者重入增加计数,其他线程等待通知后再尝试竞争,最终释放才唤醒等待者。watchdog 不是所有调用方式下都启用的无限续期,也不能消除进程长时间停顿、网络中断或主从切换的全部互斥风险。

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间( releaseTime / 3),重置超时时间