目录
前言
基本介绍
演化过程
防死锁
防误删
自动续期
可重入
主从一致
总结
在我们没有了解分布式锁前,使用最多的就是线程锁和进程锁,但他们仅能满足在单机jvm或者同一个操作系统下,才能有效。跨jvm系统,无法满足。因此就产生了分布式锁,完成锁的工作。
分布式锁是一种用于在分布式系统中实现同步和互斥访问的机制。在分布式系统中,多个节点同时访问共享资源可能会导致数据不一致或竞争条件的发生。分布式锁提供了一种保护共享资源的方式,以确保在任意时刻只有一个节点可以访问该资源。
本文将会带你梳理基于redis分布式锁的设计演化,让你对分布式锁不再恐惧,简简单单拿捏它,让你跟别人聊的时候,做到侃侃而谈,有条不紊。
一个好的分布式锁应该满足以下条件
我们实现分布式锁借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。如果同时有多个客户端发 送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。最基础的实现流程:
原因:我们试想一个场景,假如客户端拿到了锁,但在执行业务流程的过程中,发生了宕机,这个时候业务没有执行完成,拿到的锁也是无法释放的,导致其他客户端线程一直在阻塞,无法获取到锁。
解决:给锁添加过期时间,如果发生了宕机,让它主动释放锁。
给锁设置过期时间,自动释放锁。 设置过期时间两种方式:
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框架中的看门狗:
需要锁续期的情况:
不需要锁续期的情况:
原因:加锁命令使用了 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
防死锁:
防误删:先判断是否自己的锁才能删除
原子性:
可重入性:hash + lua脚本
自动续期:Timer定时器 + lua脚本