分布式锁-基于Redis实现
分布式锁-基于Redis实现
1. 高可用分布式锁特性
互斥性:作为锁,需要保证任何时刻只能有一个客户端(用户)持有锁
可重入: 同一个客户端在获得锁后,可以再次进行加锁
高可用:获取锁和释放锁的效率较高,不会出现单点故障
自动重试机制:当客户端加锁失败时,能够提供一种机制让客户端自动重试
2. 实现原理
2.1 常用命令解析
setnx 是『SET if Not eXists』(如果不存在,则 SET)的简写。 命令格式:SETNX key value;使用:只在键 key 不存在的情况下,将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。返回值:命令在设置成功时返回 1 ,设置失败时返回 0 。
getset 命令格式:GETSET key value,将键 key 的值设为 value ,并返回键 key 在被设置之前的旧的value。返回值:如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时,命令返回一个错误。
expire 命令格式:EXPIRE key seconds,使用:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。返回值:设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。
del 命令格式:DEL key [key …],使用:删除给定的一个或多个 key ,不存在的 key 会被忽略。返回值:被删除 key 的数量。
2.2 原理解析
实现原理一:
过程分析:
- 1.客户端获取锁,通过setnx(lockkey,currenttime+timeout)命令,将key为lockkey的value设置为当前时间+锁超时时间
- 2.如果setnx(lockkey,currenttime+timeout)设置后返回值为1时,获取锁成功,说明redis中不存在lockkey,也不存在别的客户端拥有这个锁
- 3.获取锁后首先使用expire(lockkey)命令设置lockkey的过期时间,目的是为了防止死锁的发生,因为不设置lockKey的过期时间,lockkey就会一直存在于redis中,当别的客户端使用setnx(lockkey,currenttime+timeout)命令时返回的结果一直未0,造成死锁
- 4.执行相关业务逻辑
- 5.释放锁,执行业务逻辑完成后,使用del(lockkey)命令删除lockKey,为了别的客户端可以及时获取到锁,减少等待时间
缺陷:
如果客户端A,在获取锁以后,也就是在执行setnx(lockkey,currenttime+timeout)命令成功以后,redis宕机或者程序异常终止,未执行expire(lockkey)命令,那么锁就一直存在,别的客户端就一直获取不到锁,造成阻塞。
解决方法:
关闭Tomcat有两种方式,一种通过温柔的执行shutdown关闭,一种通过kill杀死进程关闭
1 |
|
为解决以上设计存在的弊端,优化设计,采用双重防死锁解决死锁问题
实现原理二:
过程分析:
- 1.当A通过setnx(lockkey,currenttime+timeout)命令能成功设置lockkey时,即返回值为1,过程与原理1一致;
- 2.当A通过setnx(lockkey,currenttime+timeout)命令不能成功设置lockkey时,这是不能直接断定获取锁失败;因为我们在设置锁时,设置了锁的超时时间timeout,当当前时间大于redis中存储键值为lockkey的value值时,可以认为上一任的拥有者对锁的使用权已经失效了,A就可以强行拥有该锁;具体判定过程如下;
- 3.A通过get(lockkey),获取redis中的存储键值为lockkey的value值,即获取锁的相对时间lockvalueA
- 4.lockvalueA!=null && currenttime>lockvalue,A通过当前的时间与锁设置的时间做比较,如果当前时间已经大于锁设置的时间临界,即可以进一步判断是否可以获取锁,否则说明该锁还在被占用,A就还不能获取该锁,结束,获取锁失败;
- 5.步骤4返回结果为true后,通过getSet设置新的超时时间,并返回旧值lockvalueB,以作判断,因为在分布式环境,在进入这里时可能另外的进程获取到锁并对值进行了修改,只有旧值与返回的值一致才能说明中间未被其他进程获取到这个锁;
- 6.lockvalueB == null || lockvalueA==lockvalueB,判断:若果lockvalueB为null,说明该锁已经被释放了,此时该进程可以获取锁;旧值与返回的lockvalueB一致说明中间未被其他进程获取该锁,可以获取锁;否则不能获取锁,结束,获取锁失败。
优化点:
加入了超时时间判断锁是否超时了,及时A在成功设置了锁之后,服务器就立即出现宕机或是重启,也不会出现死锁问题;因为B在尝试获取锁的时候,如果不能setnx成功,会去获取redis中锁的超时时间与当前的系统时间做比较,如果当前的系统时间已经大于锁超时时间,说明A已经对锁的使用权失效,B能继续判断能否获取锁,解决了redis分布式锁的死锁问题。
2.3 问题总结
- 问题一:时间戳的问题
我们看到lockkey的value值为时间戳,所以要在多客户端情况下,保证锁有效,一定要同步各服务器的时间,如果各服务器间,时间有差异。时间不一致的客户端,在判断锁超时,就会出现偏差,从而产生竞争条件。
锁的超时与否,严格依赖时间戳,时间戳本身也是有精度限制,假如我们的时间精度为秒,从加锁到执行操作再到解锁,一般操作肯定都能在一秒内完成。这样的话,我们上面的CASE,就很容易出现。所以,最好把时间精度提升到毫秒级。这样的话,可以保证毫秒级别的锁是安全的。分布式锁,多客户端的时间戳不能保证严格意义的一致性,所以在某些特定因素下,有可能存在锁串的情况。要适度的机制,可以承受小概率的事件产生。
- 问题二:死锁
必要的超时机制:获取锁的客户端一旦崩溃,一定要有过期机制,否则其他客户端都降无法获取锁,造成死锁问题。
- 问题三:阻塞
只对关键处理节点加锁,良好的习惯是,把相关的资源准备好,比如连接数据库后,调用加锁机制获取锁,直接进行操作,然后释放,尽量减少持有锁的时间。
在持有锁期间要不要CHECK锁,如果需要严格依赖锁的状态,最好在关键步骤中做锁的CHECK检查机制,但是根据我们的测试发现,在大并发时,每一次CHECK锁操作,都要消耗掉几个毫秒,而我们的整个持锁处理逻辑才不到10毫秒,玩客没有选择做锁的检查。
为了减少对Redis的压力,获取锁尝试时,循环之间一定要做sleep操作。但是sleep时间是多少是门学问。需要根据自己的Redis的QPS,加上持锁处理时间等进行合理计算。
3. 具体实现
3.1 引入依赖
pom.xml
1 |
|
3.2 编辑配置文件
application.yml
1 |
|
lock.lua 获得分布式锁lua脚本
1 |
|
unlock.lua 释放分布式锁lua脚本
1 |
|
3.3 初始化lua脚本
LuaScript
使用redis实现分布式锁时,加锁操作必须是原子操作,否则多客户端并发操作时会导致各种各样的问题
由于我们实现的是可重入锁,加锁过程中需要判断客户端ID的正确与否。而redis原生的简单接口没法保证一系列逻辑的原子性执行,因此采用了lua脚本来实现加锁操作。lua脚本可以让redis在执行时将一连串的操作以原子化的方式执行。
1 |
|
3.4 定义分布式锁接口
DistributeLock
1 |
|
RedisDistributeLock
调用lockAndRetry方法进行加锁时,如果加锁失败,则当前客户端线程会短暂的休眠一段时间,并进行重试。在重试了一定的次数后,会终止重试加锁操作,从而加锁失败。
需要注意的是,加锁失败之后的线程休眠时长是”固定值 + 随机值”,引入随机值的主要目的是防止高并发时大量的客户端在几乎同一时间被唤醒并进行加锁重试,给redis服务器带来周期性的、不必要的瞬时压力。
1 |
|
3.5 redisclient工具类
1 |
|
1 |
|
3.6 测试分布式锁
1 |
|
3.7 基于注解切面简化实现分布式锁
RedisLock
1 |
|
RedisLockAspect
1 |
|
3.8 源码参考地址
https://github.com/FocusProgram/person-improve/tree/main/springcloud-redis-lock
3.9 总结
主从同步可能导致锁的互斥性失效
在redis主从结构下,出于性能的考虑,redis采用的是主从异步复制的策略,这会导致短时间内主库和从库数据短暂的不一致。
试想,当某一客户端刚刚加锁完毕,redis主库还没有来得及和从库同步就挂了,之后从库中新选拔出的主库是没有对应锁记录的,这就可能导致多个客户端加锁成功,破坏了锁的互斥性。
休眠并反复尝试加锁效率较低
lockAndRetry方法在客户端线程加锁失败后,会休眠一段时间之后再进行重试。当锁的持有者持有锁的时间很长时,其它客户端会有大量无效的重试操作,造成系统资源的浪费。
进一步优化时,可以使用发布订阅的方式。这时加锁失败的客户端会监听锁被释放的信号,在锁真正被释放时才会进行新的加锁操作,从而避免不必要的轮询操作,以提高效率。
不是一个公平的锁
当前实现版本中,多个客户端同时对锁进行抢占时,是完全随机的,既不遵循先来后到的顺序,客户端之间也没有加锁的优先级区别。
后续优化时可以提供一个创建公平锁的接口,能指定加锁的优先级,内部使用一个优先级队列维护加锁客户端的顺序。公平锁虽然效率稍低,但在一些场景能更好的控制并发行为。