分布式锁

分享下一个基于redis来实现的分布式锁。

加锁过程分析

加锁过程分析

我第一次读代码的时候,有这么几个疑惑:

Q1:为什么不使用 SET key value [expiration EX seconds|PX milliseconds] [NX|XX] 这个指令来实现key的自动过期呢,反而放到应用代码判断key是否过期?

A1:我们的分布式锁开发的时候SET命令还不支持NX、PX,所以才想出这种办法来实现key过期,NX、PX在2.6.12以后开始支持;

Q2:已经判断了当前key对应的时间戳已经过期了,为什么还要使用getset再获取一次呢,直接使用set指令覆盖不可以吗?

A2:这里其实牵扯到并发的一些事情,如果直接使用set,那有可能多个客户端会同时获取到锁,如果使用getset然后判断旧值是否过期就不会有这个问题,设想一下如下场景:

C1加锁成功,不巧的是,这时C1意外的奔溃了,自然就不会释放锁;
C2,C3尝试加锁,这时key已存在,所以C2,C3去判断key是否已过期,这里假设key已经过期了,所以C2,C3使用set指令去设置值,那两个都会加锁成功,这就闯大祸了;如果使用getset指令,然后判断下返回值是否过期就可以避免这种问题,假如C2跑的快,那C3判断返回的时间戳已经过期,自然就加锁失败;

Q1:为什么释放锁时还需要判断key是否过期呢,直接del不是性能更高吗?

A1:考虑这样一种场景:

C1获取锁成功,开始执行自己的操作,不幸的是C1这时被阻塞了;
C2这时来获取锁,由于C1被阻塞了很长时间,所以key对应的value已经过期了,这时C2通过getset加锁成功;
C1尘封了太久终于被再次唤醒,对于释放锁这件事它可是认真的,伴随着一波del操作,悲剧即将发生;
C3来获取锁,好家伙,居然一下就成功了,接着就是一波操作猛如虎,接着就是一堆的客诉过来了;
为什么会这样呢?回想C1被唤醒以后的事情,居然敢直接del,C2活都没干完呢,锁就被C1给释放了,这时C3来直接就加锁成功,所以为了安全起见C3释放锁时得分成两步:1.判断value是否已经过期 2.如果已过期直接忽略,如果没过期就执行del。这样就真的安全了吗?安全了吗?安全了吗?假如第一步和第二步之间相隔了很久是不是也会出现锁被其他人释放的问题呢?是吧?是的!有没有别的解决办法呢?听说借助lua就可以解决这个问题了,感兴趣的直接给你传送过去可好。

释放锁过程分析

释放锁过程分析

Q1:Redis锁的过期时间小于业务的执行时间该如何续期?

A1:这个暂时没有实现,据说有一个叫Redisson的家伙解决了这个问题,我们也有部分业务在使用,未来有可能会切换到Redisson。

Q2:怎么实现的高可用?

A2:我们采用Failover机制,初始化redis锁的时候会维护一个redis连接池,加锁或者释放锁的时候采用多写的方式来保障一致性,如果某个节点不可用的时候会自动切换到其他节点,但是这种机制可能会导致多个客户端同时获取到锁的情况,考虑这种情况:

C1去redis1加锁,加锁成功后会写到redis2,redis3;
C2也去redis1加锁,但是此时C2到redis1的网络出现问题,这时C2切换到redis2去加锁,由于第一步中的redis多写并不是原子的,所有就有可能导致C2也获取锁成功;
针对这种情况,目前有些业务方是通过数据库唯一索引的方式来规避的,未来会修复这个bug,具体方案目前还没有。

java实现

package cn.wkq.java.utils;

import cn.wkq.util.JedisPoolUtil;
import org.apache.commons.lang3.StringUtils;
import redis.clients.jedis.Jedis;

/**
 * 简单的基于Redis分布式锁
 *
 * <pre>
 *     参考 https://www.cnblogs.com/chopper-poet/p/10802242.html
 *          https://github.com/wyzssw/DistributedLock/blob/master/src/main/java/com/wyzssw/distributedLock/DistributedLock.java
 * </pre>
 *
 * @author: weikeqin.cn@gmail.com
 * @date: 2019-12-29 21:27
 **/
public class RedisLock {

    /**
     * redis 无用户名密码 默认配置 测试使用
     */
    public Jedis jedis = JedisPoolUtil.getJedis();


    /**
     * 加锁
     *
     * <pre>
     *     1. 尝试加锁 setNx,key是自己设置,value设置成 当前时间+过期时间   System.currentTimeMillis() + lockTimeOut
     *     2. 判断key是否存在,不存在,获取锁成功,返回 过期时间 ( expireTime = System.currentTimeMillis() + lockTimeOut ),结束。
     *     3. key已存在,获取锁失败
     *     4. 获取key对应的过期时间。 此时获取到的值是另一个设置的时间戳。
     *     5. 拿当前时间戳 和 上一步取到的时间戳对比,判断key是否过期。
     *     6. 过期,使用 getset 再次获取锁。  redis.getset(key, System.currentTimeMillis() + lockTimeOut)
     *     7. 用当前时间戳 对比 判断 返回的value是否过期。
     *     8. 如果过期,获取锁成功。
     *     9. 如果不过期,获取锁失败。
     *     10. 拿当前时间戳判断key是否过期,不过期,获取锁失败。
     * </pre>
     *
     * @param k
     * @param lockTimeOut
     * @return
     */
    public long tryLock(String k, long lockTimeOut) {

        long expireTime = System.currentTimeMillis() + lockTimeOut;
        // SETNX KEY_NAME VALUE
        /** 1. 尝试加锁 setNx */
        Long resNum = jedis.setnx(k, String.valueOf(expireTime));
        /** 2. 判断key是否存在,不存在,获取锁成功 */
        if (resNum == 1) {
            // 获取锁成功
            return expireTime;
        }

        /** 3. key已存在,获取锁失败 */

        /** 4. 获取key对应的过期时间。 */
        String curLockTimeStr = jedis.get(k);
        /** 5. 拿当前时间戳 和 上一步取到的时间戳对比,判断key是否过期。 */
        if (StringUtils.isBlank(curLockTimeStr)
                || System.currentTimeMillis() > Long.valueOf(curLockTimeStr)) {
            expireTime = System.currentTimeMillis() + lockTimeOut;

            /** 6. 过期,使用 getset 再次获取锁。 */
            curLockTimeStr = jedis.getSet(k, String.valueOf(expireTime));
            /** 7. 用当前时间戳 对比 判断 返回的value是否过期 */
            if (StringUtils.isBlank(curLockTimeStr) || System.currentTimeMillis() > Long.valueOf(curLockTimeStr)) {
                /** 8. 如果过期,获取锁成功。 */
                return expireTime;
            } else {
                /** 9. 如果不过期,获取锁失败。 */
                return 0;
            }

        }

        /** 10. 拿当前时间戳判断key是否过期,不过期,获取锁失败。(其实是第5步判断的结果) */
        return 0;
    }


    /**
     * 得到锁返回设置的超时时间,得不到锁等待重试
     *
     * @param key
     * @return
     * @throws InterruptedException
     */
    public long lock(String key, int lockTimeOut, int perSleep) throws InterruptedException {

        long sleep = (perSleep == 0 ? lockTimeOut / 10 : perSleep);

        long starttime = System.currentTimeMillis();

        //得到锁后设置的过期时间,未得到锁返回0
        long expireTime = 0;
        for (; ; ) {
            long getexpireTime = tryLock(key, lockTimeOut);
            if (getexpireTime > System.currentTimeMillis()) {
                return getexpireTime;
            }

            // 获取锁失败,休眠一会
            Thread.sleep(sleep);

            if (lockTimeOut > 0 && ((System.currentTimeMillis() - starttime) >= lockTimeOut)) {
                expireTime = 0;
                return expireTime;
            }
        }

    }

    /**
     * 先判断自己运行时间是否超过了锁设置时间,是则不用解锁
     *
     * @param key
     * @param expireTime
     */
    public void unlock(String key, long expireTime) {
        if (System.currentTimeMillis() - expireTime > 0) {
            return;
        }
        String curLockTimeStr = jedis.get(key);
        if (StringUtils.isNotBlank(curLockTimeStr) && Long.valueOf(curLockTimeStr) > System.currentTimeMillis()) {
            jedis.del(key);
        }
    }

}

References

[1] 我司使用了六年的分布式锁
[2] redis分布式锁-github/wyzssw/DistributedLock
[3] Redis教程
[4] 再有人问你分布式锁,这篇文章扔给他
[5] 分布式锁看这篇就够了
[6] 分布式锁的实现与应用场景对比
[7] 分布式锁的场景与实现