本文主要简述一下基于set命令的Redis分布式锁的原理。
一,a线程持有的锁不要被b线程同时持有→setnx
抢锁的时候,最核心的就是,a线程持有的锁不要被b线程同时持有,放在基于set命令的redis分布式锁中来看,就是“如果锁(key)存在我就不能抢,锁(key)不存在我才能抢”,
用原子的redis命令来说就是
setnx key value
key可以设置为你业务中要抢的锁的名字,和其他key区分开。
一开始,redis提供了set、setex(设置key value的同时设置过期时间)、setnx(如果不存在key就设置key value)。
二,如果锁被别人释放了怎么办→ThreadLocal和UUID
一个比较常见的担心是,如果a线程加的锁,被b线程释放了怎么办,毕竟只是一个set命令,用del就可以删除,也就是解锁,那怎么办呢。
在前面我们只设置了key的名字,对value没有做要求。其实我们可以给value加一个唯一ID,让别的线程识别到这个锁是否是自己持有。
线程id是否可以呢,我们直接用线程的id作为value,让线程看一下是否是自己的id,简单直接。答案是不可以,线程id在机器中是唯一的,但是这是分布式锁,多个机器之间,线程id不唯一。
我们倒也不用直接跳到分布式id这么远,其实uuid足矣。uuid中包括了机器的IEEE识别号(如果有网卡,从网卡的mac地址获得),优点是全球唯一且简单、代码方便,uuid不用于自增主键的原因是它不区域递增,以及可能造成网卡的mac地址泄露(曾用于寻找梅丽莎病毒制作者)。
此时我们就需要用到ThreadLocal,让每个线程私有uuid。就这样,我们加入了ThreadLocal和UUID,解决了锁被其他线程释放的问题。
三,锁无人释放问题→加过期时间
1,只靠setnx命令,死锁无法释放
那么紧接着问题来了, 如果加锁的线程挂掉了,导致锁无法正常释放怎么办?要知道,redis的数据如果没有特意设置过期时间,默认的是永不过期。所以我们要设置一个过期时间。
redis中设置过期时间的命令为expire命令,我们当然可以使用expire来给上面的setnx命令来加过期时间,如下:
setnx lock XXX
expire lock 10 //给lock键设置过期时间为10秒
2,setnx+expire命令不够原子
第二个问题来了,setnx+expire命令不够原子,即如果有线程只执行了加锁命令setnx,在第二个命令expire执行之前就挂掉了,又怎么办呢?因为这两个是分开执行的,意味着是存在这种可能的。因此我们就要想方法来将其一次执行。
(1),set key value ex second nx
正如一开始所述,redis在一开始只提供了set、setnx、setex几个命令,却没有其他参数,因此要执行必须分开执行。
在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,使我们可以直接在一条命令中完成所有操作,即如果不存在,再设置Key value(nx),同时设置过期时间(ex)。
注,set命令中,ex参数设置秒,px参数设置毫秒
(2),Lua脚本
redis整合了Lua语言,我们可以基于redis的eval命令,通过Lua脚本来完成原子性操作;
eval
命令如下:
eval 脚本内容 Ke个数 key列表 参数列表
如果觉得每次都要上传Lua脚本占带宽,还可以在Redis启动时将Lua脚本上传,由redis将脚本缓存,客户端拿到sha1,在使用lua脚本时只需要上传sha1值即可[1]。即通过script load命令上传脚本,得到sha1,再基于evalsha命令,通过sha1参数执行指定的lua脚本。
evalsha
命令使用示例如下:
script load "${cat lua_demo.lua}"
evalsha 脚本sha1值 key个数 key列表 参数列表
四,如何评定过期时间→看门狗线程续期
现在好了,一个基本的redis分布式锁的方案已经有了,那么还有什么问题呢。仔细想想就会发现,问题出在了业务执行时间上。我们应该如何衡量业务执行时间并由此设置过期时间呢?
一个项目里,有的业务执行时间长,有的执行时间短。就算是同一个业务,你通过链路追踪或者监控知道了业务执行大多数是多久,下面分为两种情况,
情况1,我们假设就按照大多数业务能完成的时间来设置过期时间,这肯定是不行的,因为有的业务没完成就释放了锁,极容易出现问题。
情况2,我们按照一个比较大,大到应该不可能出现问题的过期时间,总行了吧?也不行,第一,“应该不可能”,谁知道以后有没有可能有的业务就是时间长到超过了过期时间。第二,为了少数业务执行时间长的情况,而给所有业务,包括大多数只需要较短持有锁时间的业务,都施加很长的过期时间,很明显项目并发能力会严重下降。
怎么办呢,长了也不行,短了也不行。这时候,我们可以加入看门狗方案。什么意思呢,就是说我们可以给线程设置一个不算长对于大多数线程都比较合理的时间,哪怕短点也无所谓。我们专门设置一个守护线程
(thread.setDaemon(true))用于监控线程的看门狗线程,看门狗线程会在过期时间快到了且锁还没有被释放时,就去用expire命令给锁续期。
同时配合一个延迟队列DelayQueue
(本质是个堆Heap),在加锁时,将装有线程id和续期时间(注意是续期时间
,续期时间要比key的过期时间
早一点)的entity扔进去,延迟队列根据续期时间排序,这样堆中排在最上面的永远是续期时间最近的entity(堆的特性)。
看门狗线程的任务内容,就是while循环,对延迟队列用take方法阻塞的去拿最近一个需要判断是否要续期的entity,有的话就进行续期,这样我们就可以用一个线程完成对整个项目的所有锁的续期,这样的分布式锁,耗时短的业务用一次过期时间就可以完成,耗时长的业务也可以有看门狗线程来实现续期过期时间。
五,总结→建议不要自己开发,用现成的如Redisson
上面实现的分布式锁,解决了如下几个问题
- a线程持有的锁不要被b线程同时持有→setnx
- 如果锁被别人释放了怎么办→ThreadLocal和UUID
- 只依靠setnx,持有锁的线程挂掉,导致无人去释放锁→给锁添加过期时间,expire命令设置过期时间
- setnx和expire不够原子→set命令和参数ex、nx,或者lua脚本配置命令eval、evalsha
- 过期时间不好评定→加入看门狗守护线程,配合延迟队列,看门狗线程用死循环,使用take方法阻塞的等待将最近要续期的任务,将其续期。
实际使用上,我个人的看法是轮子不需要重复开发,我们只需要用Redisson实现的分布式锁,就具有看门狗线程[2]。简简单单两三行代码,就有更完善的分布式锁使用。除非一些特殊的原因,不然不要重复造轮子,以及如非必要,勿增实体(奥卡姆剃刀原则)。
RLock testLock = redissonClient.getLock("test_lock");boolean res = testLock.tryLock(10, TimeUnit.SECONDS);
参考文章:
[1],用jedis执行lua脚本
[2],redisson中的看门狗机制总结