Redis 典型应⽤-分布式锁
一、什么是分布式锁?
在⼀个分布式的系统中,也会涉及到多个节点访问同⼀个公共资源的情况.此时就需要通过锁来做互斥控制,避免出现类似于"线程安全"的问题.
⽽java的synchronized或者C++的std::mutex,这样的锁都是只能在当前进程中⽣效,在分布式的这 种多个进程多个主机的场景下就⽆能为⼒了.
此时就需要使⽤到分布式锁
本质上就是使⽤⼀个公共的服务器,来记录加锁状态.
这个公共的服务器可以是Redis,也可以是其他组件(⽐如MySQL或者ZooKeeper等),还可以 是我们⾃⼰写的⼀个服务.
二、 分布式锁的基础实现
思路⾮常简单.本质上就是通过⼀个键值对来标识锁的状态.
举个例⼦:考虑买票的场景,现在⻋站提供了若⼲个⻋次,每个⻋次的票数都是固定的.
现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定⻋次的余票,如果余票>0,则设 置余票值-=1.
显然上述的场景是存在"线程安全"问题的,需要使⽤锁来控制.
否则就可能出现"超卖"的情况.
此时如何进⾏加锁呢?我们可以在上述架构中引⼊⼀个Redis,作为分布式锁的管理器.
此时,如果买票服务器1尝试买票,就需要先访问Redis,在Redis上设置⼀个键值对.⽐如key就是⻋ 次,value随便设置个值(⽐如1).
如果这个操作设置成功,就视为当前没有节点对该001⻋次加锁,就可以进⾏数据库的读写操作.操作完 成之后,再把Redis上刚才的这个键值对给删除掉.
如果在买票服务器1操作数据库的过程中,买票服务器2也想买票,也会尝试给Redis上写⼀个键值对, key 同样是⻋次.但是此时设置的时候发现该⻋次的key已经存在了,则认为已经有其他服务器正在持 有锁,此时服务器2就需要等待或者暂时放弃.
Redis中提供了setnx操作,正好适合这个场景.即:key不存在就设置,存在则直接失败.
但是上述⽅案并不完整.
三、引⼊过期时间
当服务器1加锁之后,开始处理买票的过程中,如果服务器1意外宕机了,就会导致解锁操作(删除该 key) 不能执⾏.就可能引起其他服务器始终⽆法获取到锁的情况.
为了解决这个问题, 可以在设置key的同时引⼊过期时间.即这个锁最多持有多久,就应该被释放.
可以使⽤ set ex nx 的⽅式,在设置锁的同时把过期时间设置进去.
注意! 此处的过期时间只能使⽤⼀个命令的⽅式设置.
如果分开多个操作,⽐如setnx之后,再来⼀个单独的expire,由于Redis的多个指令之间不存在关 联,并且即使使⽤了事务也不能保证这两个操作都⼀定成功,因此就可能出现setnx成功,但是expire 失败的情况.
此时仍然会出现⽆法正确释放锁的问题.
四、 引⼊校验id
对于Redis中写⼊的加锁键值对,其他的节点也是可以删除的.
⽐如服务器1写⼊⼀个"001":1这样的键值对,服务器2是完全可以把"001"给删除掉的.
当然,服务器2不会进⾏这样的"恶意删除"操作,不过不能保证因为⼀些bug导致服务器2把锁误删 除.
为了解决上述问题,我们可以引⼊⼀个校验id.
⽐如可以把设置的键值对的值,不再是简单的设为⼀个1,⽽是设成服务器的编号.形如"001":"服务器 1".
这样就可以在删除key(解锁)的时候,先校验当前删除key的服务器是否是当初加锁的服务器,如果是, 才能真正删除;不是,则不能删除.
逻辑⽤伪代码描述如下:
String key = [要加锁的资源 id];String serverId = [服务器的编号]; // 加锁, 设置过期时间为 10sredis.set(key, serverId, "NX", "EX", "10s"); // 执⾏各种业务逻辑, ⽐如修改数据库数据. doSomeThing(); // 解锁, 删除 key. 但是删除前要检验下 serverId 是否匹配. if (redis.get(key) == serverId) {redis.del(key);}
但是很明显,解锁逻辑是两步操作"get"和"del",这样做并⾮是原⼦的.
五、引⼊lua
为了使解锁操作原⼦,可以使⽤Redis的Lua脚本功能.
Lua也是⼀个编程语⾔.读作"撸啊".是葡萄⽛语中的"⽉亮"的意思.(出⾃于Lua官⽅⽂档 https://www.lua.org/about.html)
Lua的语法类似于JS,是⼀个动态弱类型的语⾔.Lua的解释器⼀般使⽤C语⾔实现.Lua语法 简单精炼,执⾏速度快,解释器也⽐较轻量(Lua解释器的可执⾏程序体积只有200KB左右).
因此Lua经常作为其他程序内部嵌⼊的脚本语⾔.Redis本⾝就⽀持Lua作为内嵌脚本.
很多程序都⽀持内嵌脚本,⽐如MySQL8⽀持JS作为内嵌脚本,⽐如Vim⽀持VimScript 和Python作为内嵌脚本....通过内嵌脚本来实现更复杂的功能,提供更强的扩展性.
Lua除了和Redis搭伙之外,在很多场景也会作为内嵌脚本.⽐如在游戏开发领域常常作为 编写逻辑的语⾔.
使⽤Lua脚本完成上述解锁功能
if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1])
else return 0
end;
述代码可以编写成⼀个.lua后缀的⽂件,由 redis-cli 或者 redis-plus-plus 或者 jedis 等客⼾端加载,并发送给Redis服务器,由Redis服务器来执⾏这段逻辑.
⼀个lua脚本会被Redis服务器以原⼦的⽅式来执⾏
redis-plus-plus 和 jedis 如何调⽤lua,咱们此处不做过多介绍.具体api的写法⼤家可以 ⾃⾏研究.
六、引⼊watchdog(看⻔狗)
上述⽅案仍然存在⼀个重要问题.当我们设置了key过期时间之后(⽐如10s),仍然存在⼀定的可能性, 当任务还没执⾏完,key就先过期了.这就导致锁提前失效.
把这个过期时间设置的⾜够⻓,⽐如30s,是否能解决这个问题呢?很明显,设置多⻓时间合适,是⽆⽌ 境的.即使设置再⻓,也不能完全保证就没有提前失效的情况.
⽽且如果设置的太⻓了,万⼀对应的服务器挂了,此时其他服务器也不能及时的获取到锁.
因此相⽐于设置⼀个固定的⻓时间,不如动态的调整时间更合适.
所谓watchdog,本质上是加锁的服务器上的⼀个单独的线程,通过这个线程来对锁过期时间进⾏"续约"
注意 ,这个线程是业务服务器上的,不是Redis服务器的.
举个具体的例⼦:
初始情况下设置过期时间为10s.同时设定看⻔狗线程每隔3s检测⼀次.
那么当3s时间到的时候,看⻔狗就会判定当前任务是否完成.
- 如果任务已经完成,则直接通过lua脚本的⽅式,释放锁(删除key).
- 如果任务未完成,则把过期时间重写设置为10s.(即"续约")
这样就不担⼼锁提前失效的问题了.⽽且另⼀⽅⾯,如果该服务器挂了,看⻔狗线程也就随之挂了,此时 ⽆⼈续约,这个key⾃然就可以迅速过期,让其他服务器能够获取到锁了.
七、引⼊Redlock算法
实践中的Redis⼀般是以集群的⽅式部署的(⾄少是主从的形式,⽽不是单机).那么就可能出现以下⽐较极端的情况:
服务器1向master节点进⾏加锁操作.这个写⼊key的过程刚刚完成,master挂了;slave节 点升级成了新的master节点.但是由于刚才写⼊的这个key尚未来得及同步给slave呢,此时 就相当于服务器1的加锁操作形同虚设了,服务器2仍然可以进⾏加锁(即给新的master写 ⼊key.因为新的master不包含刚才的key).
为了解决这个问题,Redis的作者提出了Redlock算法.
我们引⼊⼀组Redis节点.其中每⼀组Redis节点都包含⼀个主节点和若⼲从节点.并且组和组之间存 储的数据都是⼀致的,相互之间是"备份"关系(⽽并⾮是数据集合的⼀部分,这点有别于Rediscluster).
加锁的时候,按照⼀定的顺序,写多个master节点.在写锁的时候需要设定操作的"超时时间".⽐如 50ms. 即如果setnx操作超过了50ms还没有成功,就视为加锁失败.
如果给某个节点加锁失败,就⽴即再尝试下⼀个节点.
当加锁成功的节点数超过总节点数的⼀半,才视为加锁成功.
如上图 , ⼀共五个节点,三个加锁成功,两个失败,此时视为加锁成功.
这样的话,即使有某些节点挂了,也不影响锁的正确性.
同理,释放锁的时候,也需要把所有节点都进⾏解锁操作.(即使是之前超时的节点,也要尝试解锁,尽量保 证逻辑严密).
简⽽⾔之,Redlock算法的核⼼就是,加锁操作不能只写给⼀个Redis节点,⽽要写个多个!!分布式系统 中任何⼀个节点都是不可靠的.最终的加锁成功结论是"少数服从多数的".
由于⼀个分布式系统不⾄于⼤部分节点都同时出现故障,因此这样的可靠性要⽐单个节点来说靠谱不少.
八、其他功能
上述描述中我们解释了基于Redis的分布式锁的基本实现原理.
上述锁只是⼀个简单的互斥锁.但是实际上我们在⼀些特定场景中,还有⼀些其他特殊的锁,⽐如:
- 可重⼊锁
- 公平锁
- 读写锁
- ......
基于Redis的分布式锁,也可以实现上述特性.(当然了对应的实现逻辑也会更复杂).
此处我们不做过多讨论了.
实际开发中,我们也并不会真的⾃⼰实现⼀个分布式锁.已经有很多现成的库帮我们封装好了,我们直接 使⽤即可.
⽐如Java中的Redisson,C++中的redis-plus-plus.当然,有些⼤⼚也会有⾃⼰版本的分布式锁的实现.