当前位置:首页 > 未命名 > 正文内容

Redis 分布式锁

淙嶙5年前 (2020-07-20)未命名607

一、什么是分布式锁?

在分布式环境下,系统被拆分,代码可能会被不同的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)  // setnx 和expire 分开会存在原子性问题
        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脚本实现
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
// lua脚本,用来释放分布式锁  命令解析
if redis.call('get', KEYS[1]) == ARGV[1]
then
     return redis.call('del',KEYS[1])
else
    return 0
end
  1. 守护线程 上面虽然在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 脚本
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("当前线程获取锁失败,请稍后重试");
}
  1. 释放锁:
//lua简单脚本实例
String delScript = "local currentValue = redis.call('get', KEYS[1])" + "if currentValue == ARGV[1] " + "then " + "redis.call('del',KEYS[1]) " +" else "+ " return true " + "end" ;
//和上面value值相同,防止误删锁
public void releaseLock(String key, String value) {
    //三个参数,key值会员编号,value返回值:可以放当前线程id,第三个参数是lua脚本
    execDelLuaScript(key, value, delScript);
}

相关文章

...

使用void方法交换两个Integer整数

使用void方法交换两个Integer整数

前提条件:1.参数的传递方式:值传递和引用传递,其中值传递为基础数据类型,引用传递为 对象,数组,集合等2.注意,这里要特殊考虑String,以及Integer、Double等几个基本类型包装类,它们...

移除 K 位得到最小值

移除 K 位得到最小值

描述 有一行由正数组成的数字字符串,移除其中的 K 个数,使剩下的数字是所有可能中最小的。假设: 字符串的长度一定大于等于 K 字符串不会以 0 开头 输入 一行由正整数组成的数字字...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。