Redis实现分布式锁
分布式锁
控制分布式系统不同进程共同访问共享资源的一种锁实现的方式,如果不同的系统或者同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性
特征:
- 互斥性:任意时刻,只有一个客户端能持有锁
- 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
- 可重入性:可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。 说白了就是同一个线程再次进入同样代码时,可以再次拿到该锁。 (作用:防止在同一线程中多次获取锁导致死锁发生)
- 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效(高性能:查询快,高可用:节点故障时,服务仍然能正常运行或进行降级后提供部分服务)
- 安全性:锁只能被持有的用户删除,不能被其他客户端删除
实现方式(1)
SETNX + EXPIRE
SETNX KEY_NAME VALUE //设置成功,返回 1 。 设置失败,返回 0 。
Expire KEY_NAME TIME_IN_SECONDS //设置成功返回 1 。 当 key 不存在或者不能为 key 设置过期时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的过期时间)返回 0
使用这个方案要注意 setnx 与 expire 之间的原子性操作,如果在执行完 setnx 之后服务器 crash 或重启了导致加的这个锁没有设置过期时间,就会导致死锁的情况(别的线程就永远获取不到锁了)
实现方式(2)
SETNX + value 值(系统时间 + 过期时间)
示例代码:
1 | //系统时间+设置的过期时间 |
优点: 移除了 expire 单独设置过期时间的操作,把过期时间放到 setnx 的 value 值里面来,解决了所得不到释放的问题。
缺点:
- 过期时间是客户端自己生成的(System.currentTimeMillis() 是当前系统的时间),必须要求分布式环境下,每个客户端的时间必须同步。
- 如果所过期的时候,并发多个客户端同时请求过来,都执行 jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被其他的客户端覆盖。
- 该锁没有保存持有者的唯一标识,可能被别的客户端释放 / 解锁。
实现方式(3)
使用 Lua 脚本(包含 SETNX + EXPIPE 两条指令)
lua脚本
1 | if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then |
加锁代码
1 | String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" + |
实现方式(4)
SET 的扩展命令(SET EX PX NX)
set key value EX seconds PX milliseconds NX|XX
EX seconds:设置失效时长,单位秒
PX milliseconds:设置失效时长,单位毫秒
NX:key不存在时设置value,成功返回OK,失败返回(nil)
XX:key存在时设置value,成功返回OK,失败返回(nil)
示例代码
1 | if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁 |
隐患:
「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
实现方式(5)
SET EX PX NX + 校验唯一随机值,再删除
给value值设置一个标记当前线程唯一的随机数,在删除的时候,进行校验
示例代码
1 | if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁 |
判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
判断成功后,锁释放前锁超时,其他线程获取锁成功,此时将会释放其他线程的锁
lua脚本解决
1 | if redis.call('get',KEYS[1]) == ARGV[1] then |
实现方式(6)
Redisson框架
只要线程一加锁成功,就会启动一个
watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
实现方式(7)
多机实现的分布式锁Redlock+Redisson
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁,锁的安全性就没了。
为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例
RedLock的实现步骤如下:
1. 获取当前时间,以毫秒为单位。
2. 按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
3. 客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
4. 如果取到了锁,key的真正有效时间改变,需要减去获取锁所使用的时间。
5. 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)
简洁版
1. 按顺序向5个master节点请求加锁
2. 根据设置的超时时间来判断,是不是要跳过该master节点
3. 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功。
4. 如果获取锁失败,解锁
看门狗
概述
看门狗机制是Redission提供的一种自动延期机制,这个机制使得Redission提供的分布式锁是可以自动续期的。
1 private long lockWatchdogTimeout = 30 * 1000;看门狗机制提供的默认超时时间是30*1000毫秒,也就是30秒
如果一个线程获取锁后,运行程序到释放锁所花费的时间大于锁自动释放时间(也就是看门狗机制提供的超时时间30s),那么Redission会自动给
redis中的目标锁延长超时时间。在Redission中想要启动看门狗机制,那么我们就不用获取锁的时候自己定义
leaseTime(锁自动释放时间)。如果自己定义了锁自动释放时间的话,无论是通过
lock还是tryLock方法,都无法启用看门狗机制。
但是,如果传入的leaseTime为-1,也是会开启看门狗机制的。
分布式锁是不能设置永不过期的,这是为了避免在分布式的情况下,一个节点获取锁之后宕机从而出现死锁的情况,所以需要个分布式锁设置一个过期时间。但是这样会导致一个线程拿到锁后,在锁的过期时间到达的时候程序还没运行完,导致锁超时释放了,那么其他线程就能获取锁进来,从而出现问题。
所以,看门狗机制的自动续期,就很好地解决了这一个问题。
总结
watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
要使 watchLog机制生效 ,lock时 不要设置过期时间
watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
watchdog 会每 lockWatchdogTimeout/3时间,去延时。
watchdog 通过 类似netty的 Future功能来实现异步延时
watchdog 最终还是通过 lua脚本来进行延时
使用案例
原始代码
1 | String lockKey = "myLock"; |
问题:
上述代码抛出异常执行,锁无法释放,出现死锁
改进一:try/finally语句
1 | String lockKey = "myLock"; |
问题
finally 块执行前,程序部署机器死机,仍会出现上述问题
改进二:设置过期时间
1 | String lockKey = "myLock"; |
问题:
设置过期时间语句未执行,程序部署机器死机,发生死锁
改进:
同时设置value&时间
1 >result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "zhen",10,TimeUnit.SECONDS);
问题:
高并发情况下,线程1获取锁,锁有效期10s,业务代码执行15s,线程2同样获取该锁,有效期10s,业务代码执行15s
当前情况意味着,线程1业务完成后,锁已经被线程2持有,因此线程1的锁释放操作,释放的是线程2的锁,并且线程2尚未执行完成
改进三:value随机值
1 | String lockKey = "myLock"; |
问题:
功能完备,但是时间设置无法判断,如果锁失效时间设置为5s,可能太少锁不住,但如果设置为30s,也可能不够,无法确定业务执行时间
时间太长,会导致其他线程获取锁的等待时间拉长,影响程序运行效率
改进:
新建一个分支线程,设置一个定时任务,比如每10s判断一下线程还活着没,如果这个线程存在,就把expire再设置成30s,重置锁的失效时间
改进四:直接采用redisson
1 | String lockKey = "myLock"; |
注解实现分布式锁
1 |
|
1 | public class RedisLockAspect { |
使用
1 | //使用注解进行加锁 |



