• redis实现分布式锁


    背景
    在很多互联网产品应用中,有些场景需要加锁处理,比如:秒杀,全局递增ID,楼层生成等等。大部分的解决方案是基于DB实现的,Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对redis的连接并不存在竞争关系。其次Redis提供一些命令SETNX,GETSET,可以方便实现分布式锁机制。

    Redis命令介绍
    使用Redis实现分布式锁,有两个重要函数需要介绍

    SETNX命令(SET if Not eXists)
    语法:
    SETNX key value
    功能:
    当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。

    GETSET命令
    语法:
    GETSET key value
    功能:
    将给定 key 的值设为 value ,并返回 key 的旧值 (old value),当 key 存在但不是字符串类型时,返回一个错误,当key不存在时,返回nil。

    GET命令
    语法:
    GET key
    功能:
    返回 key 所关联的字符串值,如果 key 不存在那么返回特殊值 nil 。

    DEL命令
    语法:
    DEL key [KEY …]
    功能:
    删除给定的一个或多个 key ,不存在的 key 会被忽略。

    兵贵精,不在多。分布式锁,我们就依靠这四个命令。但在具体实现,还有很多细节,需要仔细斟酌,因为在分布式并发多进程中,任何一点出现差错,都会导致死锁,hold住所有进程。

    加锁实现

    SETNX 可以直接加锁操作,比如说对某个关键词foo加锁,客户端可以尝试
    SETNX foo.lock <current unix time>

    如果返回1,表示客户端已经获取锁,可以往下操作,操作完成后,通过
    DEL foo.lock

    命令来释放锁。
    如果返回0,说明foo已经被其他客户端上锁,如果锁是非堵塞的,可以选择返回调用。如果是堵塞调用调用,就需要进入以下个重试循环,直至成功获得锁或者重试超时。理想是美好的,现实是残酷的。仅仅使用SETNX加锁带有竞争条件的,在某些特定的情况会造成死锁错误。

    处理死锁

    在上面的处理方式中,如果获取锁的客户端端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时效性检测。因此,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时效,防止锁无限期的锁下去,但是,在大并发情况,如果同时检测锁失效,并简单粗暴的删除死锁,再通过SETNX上锁,可能会导致竞争条件的产生,即多个客户端同时获取锁。

    C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。
    C2 向foo.lock发送DEL命令。
    C2 向foo.lock发送SETNX获取锁。
    C3 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。
    C3 向foo.lock发送SETNX获取锁。

    此时C2和C3都获取了锁,产生竞争条件,如果在更高并发的情况,可能会有更多客户端获取锁。所以,DEL锁的操作,不能直接使用在锁超时的情况下,幸好我们有GETSET方法,假设我们现在有另外一个客户端C4,看看如何使用GETSET方式,避免这种情况产生。

    C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,调用GET命令获得foo.lock的时间戳T1,通过比对时间戳,发现锁超时。
    C4 向foo.lock发送GESET命令,
    GETSET foo.lock <current unix time>
    并得到foo.lock中老的时间戳T2

    如果T1=T2,说明C4获得时间戳。
    如果T1!=T2,说明C4之前有另外一个客户端C5通过调用GETSET方式获取了时间戳,C4未获得锁。只能sleep下,进入下次循环中。

    现在唯一的问题是,C4设置foo.lock的新时间戳,是否会对锁产生影响。其实我们可以看到C4和C5执行的时间差值极小,并且写入foo.lock中的都是有效时间错,所以对锁并没有影响。
    为了让这个锁更加强壮,获取锁的客户端,应该在调用关键业务时,再次调用GET方法获取T1,和写入的T0时间戳进行对比,以免锁因其他情况被执行DEL意外解开而不知。以上步骤和情况,很容易从其他参考资料中看到。客户端处理和失败的情况非常复杂,不仅仅是崩溃这么简单,还可能是客户端因为某些操作被阻塞了相当长时间,紧接着 DEL 命令被尝试执行(但这时锁却在另外的客户端手上)。也可能因为处理不当,导致死锁。还有可能因为sleep设置不合理,导致Redis在大并发下被压垮。最为常见的问题还有

    GET返回nil时应该走那种逻辑?

    第一种走超时逻辑
    C1客户端获取锁,并且处理完后,DEL掉锁,在DEL锁之前。C2通过SETNX向foo.lock设置时间戳T0 发现有客户端获取锁,进入GET操作。
    C2 向foo.lock发送GET命令,获取返回值T1(nil)。
    C2 通过T0>T1+expire对比,进入GETSET流程。
    C2 调用GETSET向foo.lock发送T0时间戳,返回foo.lock的原值T2
    C2 如果T2=T1相等,获得锁,如果T2!=T1,未获得锁。

    第二种情况走循环走setnx逻辑
    C1客户端获取锁,并且处理完后,DEL掉锁,在DEL锁之前。C2通过SETNX向foo.lock设置时间戳T0 发现有客户端获取锁,进入GET操作。
    C2 向foo.lock发送GET命令,获取返回值T1(nil)。
    C2 循环,进入下一次SETNX逻辑

    两种逻辑貌似都是OK,但是从逻辑处理上来说,第一种情况存在问题。当GET返回nil表示,锁是被删除的,而不是超时,应该走SETNX逻辑加锁。走第一种情况的问题是,正常的加锁逻辑应该走SETNX,而现在当锁被解除后,走的是GETST,如果判断条件不当,就会引起死锁,很悲催,我在做的时候就碰到了,具体怎么碰到的看下面的问题

    GETSET返回nil时应该怎么处理?

    C1和C2客户端调用GET接口,C1返回T1,此时C3网络情况更好,快速进入获取锁,并执行DEL删除锁,C2返回T2(nil),C1和C2都进入超时处理逻辑。
    C1 向foo.lock发送GETSET命令,获取返回值T11(nil)。
    C1 比对C1和C11发现两者不同,处理逻辑认为未获取锁。
    C2 向foo.lock发送GETSET命令,获取返回值T22(C1写入的时间戳)。
    C2 比对C2和C22发现两者不同,处理逻辑认为未获取锁。

    此时C1和C2都认为未获取锁,其实C1是已经获取锁了,但是他的处理逻辑没有考虑GETSET返回nil的情况,只是单纯的用GET和GETSET值就行对比,至于为什么会出现这种情况?一种是多客户端时,每个客户端连接Redis的后,发出的命令并不是连续的,导致从单客户端看到的好像连续的命令,到Redis server后,这两条命令之间可能已经插入大量的其他客户端发出的命令,比如DEL,SETNX等。第二种情况,多客户端之间时间不同步,或者不是严格意义的同步。

    时间戳的问题

    我们看到foo.lock的value值为时间戳,所以要在多客户端情况下,保证锁有效,一定要同步各服务器的时间,如果各服务器间,时间有差异。时间不一致的客户端,在判断锁超时,就会出现偏差,从而产生竞争条件。
    锁的超时与否,严格依赖时间戳,时间戳本身也是有精度限制,假如我们的时间精度为秒,从加锁到执行操作再到解锁,一般操作肯定都能在一秒内完成。这样的话,我们上面的CASE,就很容易出现。所以,最好把时间精度提升到毫秒级。这样的话,可以保证毫秒级别的锁是安全的。

    分布式锁的问题

    1:必要的超时机制:获取锁的客户端一旦崩溃,一定要有过期机制,否则其他客户端都降无法获取锁,造成死锁问题。
    2:分布式锁,多客户端的时间戳不能保证严格意义的一致性,所以在某些特定因素下,有可能存在锁串的情况。要适度的机制,可以承受小概率的事件产生。
    3:只对关键处理节点加锁,良好的习惯是,把相关的资源准备好,比如连接数据库后,调用加锁机制获取锁,直接进行操作,然后释放,尽量减少持有锁的时间。
    4:在持有锁期间要不要CHECK锁,如果需要严格依赖锁的状态,最好在关键步骤中做锁的CHECK检查机制,但是根据我们的测试发现,在大并发时,每一次CHECK锁操作,都要消耗掉几个毫秒,而我们的整个持锁处理逻辑才不到10毫秒,玩客没有选择做锁的检查。
    5:sleep学问,为了减少对Redis的压力,获取锁尝试时,循环之间一定要做sleep操作。但是sleep时间是多少是门学问。需要根据自己的Redis的QPS,加上持锁处理时间等进行合理计算。
    6:至于为什么不使用Redis的muti,expire,watch等机制,可以查一参考资料,找下原因。

    import redis.clients.jedis.Jedis;
    import redis.clients.jedis.JedisPool;
    import redis.clients.jedis.Transaction;

    /**
    *
    * @title
    * @project
    * @note redis分布式锁
    * @author
    * @date 2017年8月17日
    */
    public class RedisDCSLock {

    /**
    *
    */
    private JedisPool jedisPool;
    /**
    * Lock key path.
    */
    private String lockKey;

    /**
    * 锁超时时间,防止线程在入锁以后,无限的执行等待
    */
    private int expireMsecs = 10 * 1000;
    /*
    * 锁状态
    */
    private boolean locked = false;


    /**
    *
    * @param jedisPool
    * @param lockKey
    */
    public RedisDCSLock(JedisPool jedisPool, String lockKey) {
    this.jedisPool = jedisPool;
    this.lockKey = lockKey + "_lock";
    }

    /**
    *
    * @param jedisPool
    * @param lockKey
    * @param expireMsecs
    */
    public RedisDCSLock(JedisPool jedisPool, String lockKey, int expireMsecs) {
    this(jedisPool, lockKey);
    this.expireMsecs = expireMsecs;
    }

    /**
    * 获得 lock.
    * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
    * reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
    * 执行过程:
    * 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
    * 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
    *
    * @return true if lock is acquired, false acquire timeouted
    * @throws InterruptedException in case of thread interruption
    */
    public boolean lock(){
    long expires = System.currentTimeMillis() + expireMsecs + 1;
    String expiresStr = String.valueOf(expires); //锁到期时间
    Jedis jedis = jedisPool.getResource();
    Long setnx = jedis.setnx(lockKey, expiresStr);
    if (setnx!=null && setnx==1) {
    // lock acquired
    locked = true;
    jedis.close();
    return true;
    }

    //解决redis锁的超时时间
    String currentValueStr = jedis.get(lockKey);
    //判断redis中的锁的时间是否超时
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
    //获取上一个锁到期时间,并设置现在的锁到期时间(
    //这里不能直接删除,比如:
    //C1线程 获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。
    //C2线程 向foo.lock发送DEL命令。
    //C2线程 向foo.lock发送SETNX获取锁。
    //C3线程 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。
    //C3线程 向foo.lock发送SETNX获取锁。
    //这时如果高并发的话,很可能获取两把锁
    //)
    //而如果使用getset的话,两个c2,c3同时并发,都认为锁时间过期了,通过getset来设置新的时间返回旧时间,比较是否和使用get时得到的超时时间一致
    //如果一致就认为该线程获取到了锁,如果此时c3也通过getset设置新时间反回旧时间,虽然它给锁设置了新时间,但c2和c3并发时的时间差是极小的,所以,不影响。
    //而c3通过与get到的超时时间进行比较,就发现不一样,返回false,不能获取锁,等待一段时间后,进行下一轮的循环获取锁。
    //只有一个线程才能获取上一个线上的设置时间,因为jedis.getSet是同步的
    String oldValueStr = jedis.getSet(lockKey, expiresStr);

    //[分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
    // lock acquired
    locked = true;
    jedis.close();
    return true;
    }
    }
    jedis.close();
    return false;
    }

    /**
    * 释放锁
    */
    public void unlock() {
    Jedis jedis = jedisPool.getResource();
    if (locked) {
    /**
    * WATCH命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行。
    * 监控一直持续到EXEC命令(事务中的命令是在EXEC之后才执行的,所以在MULTI命令后可以修改WATCH监控的键值)
    * watch避免极限情况下del时锁被其他线程获取
    */
    jedis.watch(this.lockKey);
    String currentValueStr = jedis.get(lockKey); //redis里的时间
    //没有超时,则删除,超时的话就不管了,因为如果别的线程获取锁的时候,对超时的锁使用getset来解决
    if (currentValueStr != null && Long.parseLong(currentValueStr) > System.currentTimeMillis()) {
    /**
    * 为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作,
    * 因为可能客户端因为某个耗时的操作而挂起,操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。
    */
    Transaction tx = jedis.multi();
    tx.del(lockKey);
    tx.exec();
    }
    jedis.unwatch();
    locked = false;
    }
    jedis.close();
    }

    }

    Redis的事务中,WATCH命令可用于提供CAS(check-and-set)功能。假设我们通过WATCH命令在事务执行之前监控了多个Keys,倘若在WATCH之后有任何Key的值发生了变化,EXEC命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。例如,我们再次假设redis中并未提供incr命令来完成键值的原子性递增,如果要实现该功能,我们只能自行编写相应的代码。其伪码如下:
          val = GET mykey
          val = val + 1
          SET mykey $val
     

         以上代码只有在单连接的情况下才可以保证执行结果是正确的,因为如果在同一时刻有多个客户端在同时执行该段代码,那么就会出现多线程程序中经常出现的一种 错误场景--竞态争用(race condition)。比如,客户端A和B都在同一时刻读取了mykey的原有值,假设该值为10,此后两个客户端又均将该值加一后set回Redis服 务器,这样就会导致mykey的结果为11,而不是我们认为的12。为了解决类似的问题,我们需要借助WATCH命令的帮助,见如下代码:
          WATCH mykey
          val = GET mykey
          val = val + 1
          MULTI
          SET mykey $val
          EXEC
          和此前代码不同的是,新代码在获取mykey的值之前先通过WATCH命令监控了该键,此后又将set命令包围在事务中,这样就可以有效的保证每个连接在 执行EXEC之前,如果当前连接获取的mykey的值被其它连接的客户端修改,那么当前连接的EXEC命令将执行失败。这样调用者在判断返回值后就可以获 悉val是否被重新设置成功。
  • 相关阅读:
    使用magic iperf测试UDP上传填写的命令行
    自学30个常用python实现
    IIS发布的网站,内网和外网不能访问的解决办法
    腾讯课堂不能连麦
    SQL Server双机热备之后项目的FailOver自动连接
    SQL 知道字段名 全表搜索此字段属于哪个表
    sql server 2008 r2 双机热备操作流程Microsoft SQL Server 双机热备份,实时同步
    记一次 Linux scp> 报错:not a regular file
    java整合ftp 一些操作
    解析json文件,json接口,将内容入数据库(Python脚本)
  • 原文地址:https://www.cnblogs.com/wangjing666/p/7494470.html
Copyright © 2020-2023  润新知