前言
在我们没有了解分布式锁前,使用最多的就是线程锁和进程锁,但他们仅能满足在单机jvm或者同一个操作系统下,才能有效。跨jvm系统,无法满足。因此就产生了分布式锁,完成锁的工作。
分布式锁是一种用于在分布式系统中实现同步和互斥访问的机制。在分布式系统中,多个节点同时访问共享资源可能会导致数据不一致或竞争条件的发生。分布式锁提供了一种保护共享资源的方式,以确保在任意时刻只有一个节点可以访问该资源。
本文将会带你梳理基于redis分布式锁的设计演化,让你对分布式锁不再恐惧,简简单单拿捏它,让你跟别人聊的时候,做到侃侃而谈,有条不紊。
基本介绍
一个好的分布式锁应该满足以下条件
- 互斥性:任意时刻,只能有一个客户端才能获取锁
- 防止死锁: 分布式锁应该设计成在锁的持有者异常退出或崩溃时能够自动释放,以防止死锁的发生。一般通过设置合适的锁超时时间来避免死锁。
- 高可用性: 在节点故障时也能正常工作,确保锁的可靠性。
- 可重入性:允许同一个线程或客户端在持有锁的情况下多次获取同一个锁,而不会出现死锁或阻塞的情况。这对于递归函数调用等场景尤其重要。
- 唯一标识: 分布式锁应该具备唯一的标识,以便客户端可以识别和管理不同的锁。
我们实现分布式锁借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。如果同时有多个客户端发 送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。最基础的实现流程:
- 多个客户端同时获取锁(setnx)
- 获取成功,执行业务逻辑,执行完成释放锁(del)
- 其他客户端等待重试
演化过程
防死锁
原因:我们试想一个场景,假如客户端拿到了锁,但在执行业务流程的过程中,发生了宕机,这个时候业务没有执行完成,拿到的锁也是无法释放的,导致其他客户端线程一直在阻塞,无法获取到锁。
解决:给锁添加过期时间,如果发生了宕机,让它主动释放锁。
给锁设置过期时间,自动释放锁。 设置过期时间两种方式:
1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)
防误删
原因:给锁上了过期时间以后,如果设置这个过期时间过短了,就可能会出现误删的情况,比如,一个业务需要执行5s,但是锁的过期时间为3s,首先线程1获得了锁,3s后锁过期自动释放,3s后被另外一个线程拿到了锁,在第5s时,线程1执行业务完成,进行锁的释放,这个时候就会把线程2的锁的释放掉了。(当然,这个设置的过期时间本身就不合理的,按照道理来说设置的过期时间应大于业务的执行时间,如果不确定,后面会提到自动续期解决这个问题)。
解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁,删除的时候需要满足原子性,即判断跟删除是原子的,可以通过lua脚本实现。
如果不是原子的话,就可能出现以下问题:
- index1执行删除时,查询到的lock值确实和uuid相等
- index1执行删除前,lock刚好过期时间已到,被redis自动释放
- index2获取了lock
- index1执行删除,此时会把index2的lock删除
脚本如下:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
自动续期
原因:在使用 redis 分布式锁时,为避免持有锁的使用方因为异常状况导致无法正常解锁,进而引发死锁问题,我们可以使用到 redis 的数据过期时间 expire 机制,这种 expire 机制的使用会引入一个新的问题——过期时间不精准,因为此处设置的过期时间只能是一个经验值(通常情况下偏于保守),既然是经验值,那就做不到百分之百的严谨性。
试想假如占有锁的使用方在业务处理流程中因为一些异常的耗时(如 IO、GC等),导致业务逻辑处理时间超过了预设的过期时间,就会导致锁被提前释放. 此时在原使用方的视角中,锁仍然持有在自己手中,但在实际情况下,锁数据已经被删除,其他取锁方可能取锁成功,于是就可能引起一把锁同时被多个使用方占用的问题,锁的基本性质——独占性遭到破坏。
解决:原生的redis可以Timer定时器 + lua脚本实现锁的自动续期(也就是另起一个线程开启一个定时任务,不断的判断锁的过期时间,如果快到了进行自动续期即可,同时对redis)当然也可以采用redission框架中的看门狗:
- 在执行 redis 分布式锁的上锁操作时,通过 setNEX 指令完成锁数据的设置,携带了一个默认的锁数据过期时间
- 确认上锁成功后,异步启动一个 watchDog 守护协程,按照锁默认过期时间 1/4 ~ 1/3 的节奏(可自由设置),持续地对锁数据进行 expire 续期操作
- 在解锁成功后,会负责关闭 watchDog,回收协程资源.(由于看门狗续期操作会先检查锁的所有权再延期数据,因此实际上使用方只要删除了锁数据,续期操作就不会生效了. 回收看门狗协程是为了规避协程泄漏问题)
需要锁续期的情况:
- 长时间任务: 如果获取分布式锁的业务逻辑较为复杂或耗时,那么可能需要设置锁续期,以防止持有锁的客户端在执行业务逻辑时由于各种原因无法及时释放锁。
- 业务处理时间不确定: 如果业务处理时间不确定,无法预测锁会持有多长时间,那么设置锁续期可以确保在业务逻辑执行期间锁不会过早地被释放。
不需要锁续期的情况:
- 短时间任务: 如果获取分布式锁的业务逻辑非常简单且耗时很短,可以在执行完业务逻辑后立即释放锁,不需要设置锁续期。
- 业务逻辑可控: 如果业务逻辑可以控制在一个较短的时间内完成,且不会出现无法释放锁的情况,也可能不需要设置锁续期。
可重入
原因:加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加 锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,需要的这把锁就是我们现在拥有的这把锁,锁明明是被我们拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己。
解决:当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。可以使用redis中的Hash数据类型完成。
利用 lua 脚本判断逻辑:
加锁:
if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('hincrby', KEYS[1], ARGV[1], 1); redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end
假设值为:KEYS:[lock], ARGV[uuid, expire]如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次数加1。
解锁:
if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then return nil; elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then return 0; else redis.call('del', KEYS[1]); return 1; end;
判断 hash set 可重入 key 的值是否等于 0
- 如果为 nil 代表 自己的锁已不存在,在尝试解其他线程的锁,解锁失败
- 如果为 0 代表 可重入次数被减 1
- 如果为 1 代表 该可重入 key 解锁成功
主从一致
原因:当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。
解决: 可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。
如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁
总结
独占排他:setnx
防死锁:
- redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
- 不可重入:可重入
防误删:先判断是否自己的锁才能删除
原子性:
- 加锁和过期时间之间
- 判断和释放锁之间
可重入性:hash + lua脚本
自动续期:Timer定时器 + lua脚本