一、什么是分布式锁?
在分布式环境下,系统被拆分,代码可能会被不同的jvm运行,在单进程的情况下,我们可以使用java语言和本身的类库提供的锁,完成高并发的需求。
二、常见的分布式锁:
- Memcached分布式锁
- redis分布式锁
- Zookeeper分布式锁
- Chubby 底层使用了Paxos一致性算法
三、redis如何实现分布式锁?
三要素:
1. 加锁
sentnx命令
setnx(key,1) ,key一般可以用商品ID
当一个线程返回1,说明key不存在;
返回0说明key已存在,抢锁失败
2. 解锁
解锁伪代码如下:
del(key)
3. 锁超时
锁超时就是说获得锁的线程在运行的时候挂掉了,来不及释放锁
所以设置锁的时候必须设置超时时间。
setnx不支持超时参数,所以有单独的指令:expire(key, 30)
分布式的伪代码如下:
if(setnx(key, 1) == 1) { //代码执行到这里,线程挂掉
expire(key, 30)
try {
do something...
} finally {
del(key)
}
}
存在问题
1. 上面代码在并发的时候会有大问题:
见注释
代替指令:set(key, 1, 30, NX)
2. del误删
假如A线程锁超时时间是30毫秒,A线程执行的慢,锁释放
B线程拿到锁,执行,上面释放的可能是B线程的锁,所以加锁的时候要用自己线程的ID作为value,再释放锁的时候验证key对应的value是否属于当前线程。
加锁:
String threadId = Thread.currentThread.getId();
set(key, threadId, 30, NX);
解锁:
if(threadId.equals(redClient.get(key))) {
del(key)
}
这里有一个问题,判断和释放锁不是一个原子性操作,需要使用lua脚本实现
"local currentValue = redis.call('get', KEYS[1]);\n" + "if currentValue == ARGV[1]\n" + "then\n"
+ "redis.call('del',KEYS[1]);\n" + "return true;\n" + "end\n" + "return nil;";
3. 守护线程
上面虽然在value中记录了版本号,并且根据版本号判断释放锁,但是,还是没有解决A执行慢,导致超时后锁释放,B线程获取锁执行造成的并发问题。
解决方法:
方案1:
对于上述问题,我们必须设置锁超时时间>线程执行时间,但是这个时间很难把握,网络波动等一系列因素,会让线程执行时间不确定。可以在项目中强行指定超时时间,超过规定时间,抛出超时异常,这时锁超时时间比较容易确定。
方案2:
获得锁的线程开启守护线程,给快要过期的锁续航,这样A线程就可以完成任务再释放。
守护线程伪代码:
public void run() {
int waitTime = lockTime * 1000 * 2 / 3;
while(线程存活) {
Thread.sleep(waitTime);
重置超时时间
}
}
重置超时时间
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1],ARGV[2]) else return '0' end";
首先需要在合适的时间对执行的线程续航,这里选择了锁超时时间的三分之二,其次重置时间需要使用lua脚本,和锁释放很像,判断了当前持有锁的对象是否一致,避免随意续航的情况。
由于守护线程和A线程在同一个进程中,如果jvm挂掉,这把锁到了超时时间也没人续命,自动释放。
第二种方案,会让系统代码变得更加复杂,推荐使用第一种
4. 主从切换导致锁问题
Redis集群是高可用的,一般使用哨兵机制进行主备切换,但是,由于Redis的主从复制是异步的,可能存在数据丢失,Redis主从复制原理可以看我的这篇博客。
流程分析:
- 客户端1从Master获取了锁。
- Master宕机了,存储锁的key还没有来得及同步到Slave上。
- Slave升级为Master。
- 客户端2从新的Master获取到了对应同一个资源的锁
解决方案:
进行合理的参数配置,降低丢失数据的可能性
min-slaves-to-write 1
min-slaves-max-lag 10
至少有一个slave与master的同步复制不超过10s,一旦达到10s,master不会再接受任何请求
对于上述情况,业内还存在大名鼎鼎的RedLock算法,但是该算法争议较大,好像也存在问题,后续有时间会研究下
四、Redis分布式锁代码
这里再提出一个问题,如果线程过来拿锁失败怎么办?
我们可以借鉴jdk自旋锁的思想,如果拿锁失败,也就是有线程正在执行,那么让当前线程休眠一会儿,然后再次尝试拿锁,如果循环几次,最后拿不到锁抛出异常
至于休眠时间和重试此处可以做成可配置
代码实现:
1. 加锁 用会员编号作为key
value = redisUtil.tryLock(req.getCustNum());
//tryLock() 方法实现:
public String tryLock(String key) {
//重试次数小于指定最大自旋次数
while(retryCount < maxRetryCount) {
if (redisManager.setnx(key, value, REDIS_EXPIRE_TIME)) {
return value; //返回value,在释放锁时验证释放的是同一个锁
}
//休眠
Thread.sleep(maxWaitTime)
retryCount++;
}
throw new RuntimeExeception("当前线程获取锁失败,请稍后重试");
}
2. 释放锁:
//和上面value值相同,防止误删锁
public void releaseLock(String key, String value) {
//三个参数,key值会员编号,value返回值:可以放当前线程id,第三个参数是lua脚本
execDelLuaScript(key, value, delScript);
}
//lua简单脚本实例
"local currentValue = redis.call('get', KEYS[1]);\n" + "if currentValue == ARGV[1]\n" + "then\n"
+ "redis.call('del',KEYS[1]);\n" + "return true;\n" + "end\n" + "return nil;";