Redis:SETNX解决分布式锁误删问题
一.概述
前提:解决“一人一单”问题时,使用Redis互斥锁 锁住联合查询、扣库存、创建订单的步骤,防止同一个userID的多个线程去创建订单,最后在finally{ } 释放锁;
问题:出现锁的误删现象;
分析:
当线程1获取锁,而此时线程1执行的业务因为某些情况被 阻塞,阻塞的时间太长,TTL
到期导致锁被释放;
这时线程2就能获取锁;线程2执行自己的业务;
假设此时线程1 阻塞完了,业务完成,执行DEL 释放锁的逻辑!则线程2的锁被释放了!(误删)
由于锁被删除,则线程3也能获取锁;此时就有两个线程并行执行!
原因:
1.业务阻塞导致线程1的锁提前释放;
2.线程1释放了线程2的锁;
解决:
上锁时 添加 线程标识;
解锁时 判断 线程标识;
二. 分布式锁(初级)
需求:
1.获取锁时的value使用 UUID + 线程ID
作为线程标识;
2.释放锁时先获取锁中的线程标识,判断 于当前线程标识是否一致!
一致则释放锁;
不一致则不释放;
(1)锁接口
(2)锁实现类+上锁
使用 SETNX
/ setIfAbsent
作为互斥锁;
key
是传入的name;
value
=UUID+线程ID
(保证不易冲突),UUID使用hutool
中的UUID.randomUUID.toString,会去掉默认的横线 “-”;
(3)释放锁
先通过key获取当前线程的线程标识(value);
然后判断当前锁中的线程标识是否等于锁的线程标识即value;
注意:这里的UUID
是静态static修饰的,类加载的时候就会产生,同一个项目(JVM)内UUID相同,不同的项目(JVM)UUID则不同,这样就能防止线程ID作为标识存在不同JVM之间重复冲突的问题;
总结:通过上锁时添加线程标识、解锁时判断线程标识,解决了锁误删,提高健壮性!
(4)存在的问题
依然可能产生误删!
线程1判断成功准备释放锁,此时线程1 阻塞,如GC中的stw;(判断锁和释放锁是两个动作)
当阻塞时间够长超过TTL,则锁超时释放;
此时线程2可以获取锁,并执行业务;
此时线程1阻塞结束,而之前已经判断过锁了,线程1认为锁是自己的,所以直接释放锁,再次造成误删!
原因:判断锁和释放锁是两个动作!应该有原子性!
三. 改进释放锁
由于判断锁和释放锁是两个动作,【当判断锁和释放锁之间】产生了阻塞,则会导致误删;
解决:
使判断锁和释放锁具有 原子性
!
需求:使用基于Lua脚本实现分布式锁的释放锁逻辑;
使用RedisTemplate中的Excute()
方法来执行Lua脚本,参数分别是:脚本、KEYS参数集合、ARGS参数集合;(不需要指定key的个数)
(1)准备unlock.lua脚本
KEY参数传入锁的key;
AVG参数传入当前线程标识;
并将unlock.lua 脚本放到项目的 resources
中下;
(2)提前读取脚本
为了避免在使用脚本的时候才去读脚本,造成IO流影响性能,使用DefaultRedisScript
读取脚本,使用静态代码块提前读取;
(3)实现释放锁
执行脚本实现释放锁,此时只有一行代码(在Lua脚本中),可以保证判断线程标识和释放锁的 原子性
!
使用RedisTemplate的 execute()
方法执行Lua脚本;
KEYS参数必须是集合,使用Collections.singletonList快速生成;
总结:
利用SETNX
实现互斥性;
利用EXPIRE
保证超时释放;
使用Redis集群保证高可用,高并发;
运行Lua
脚本保证原子性;
key
:将用户ID作为锁的key,保证同一个用户ID获取同一把锁,而不同用户之间不阻塞!
value
:为了防止锁误删,使用线程标识作为value 以区别不同的线程,释放锁的时候先判断再释放;
四. 基于SETNX实现的分布式锁的问题
-
不可重入(同一个线程多次获取同一把锁,如A方法调用方法B,A中获取了锁,而B又要获取同一把锁,就需要可重入锁)
-
没有重试机制(自旋),没有获取到锁就立即失败;
-
超时释放 虽然可以防止死锁,但是会导致业务还没执行完锁就被释放/误删;
-
主从一致性(读写分离模式):Redis有一个主节点和多个从节点,当执行写操作访问主节点,读操作访问从节点;(主从会同步但是有延迟)
当主节点正在将数据同步到从节点时突然宕机,此时会选择一个从节点作为主节点,客户端再向新的主节点发送请求,但由于数据未同步完成,此时锁失效了;
解决:使用 Redission分布式锁;
-
可重入:基于Hash结构,使用Hash的
value
来记录锁重入的次数(类似Reentrantlock的state
) -
可重试:借助发布订阅模式,第一次尝试获取锁失败以后不会立即失败,而是会等待释放锁的消息,而获取锁成功的线程在释放的时候会发送消息,则等待的线程捕获到消息会再次尝试获取锁;如果再失败,则继续等待释放锁的信号,捕获信号后再次尝试获取锁;超过设置的
持续时间
就不再重试; -
超时续约:利用
watchDog
看门狗,【获取锁成功后】会开启一个定时任务,这个任务每隔一段时间就去重置锁的超时时间! -
主从一致性:使用
MultiLock 联锁
不要主从模式,节点都是独立的读写;
之前客户端获取锁只需要找到Master节点,而现在给每个Redis节点都配置一个Redsission
锁,然后使用getMultiLock()
将其连起来,形成联锁
;
假设有节点宕机,因为锁依然存在,所以锁依然有效;
还可以再使用主从同步,进一步提高可靠性:
此时假设一个Master宕机,则其他线程需要获取每一个节点的锁才能成功! 所以是安全的;
之前客户端获取锁只需要找到Master节点,而现在需要依次向多个Redis节点都去获取锁,都要保存锁的标识,全部获取才能成功;
假设有节点宕机,因为锁依然存在,所以锁依然有效;
此时实现了高可用,同时避免了主从一致导致的锁时效问题;