【Java系列004】别小瞧了Redis分布式锁

北城以北 提交于 2020-04-05 20:04:16

你好,我是miniluo,对于要求分布式经验的岗位,面试官总喜欢问分布式锁的疑问。近期有幸参加公司的面试,我也常问关于分布式锁的知识,大部分应聘者的回答更多仅限于用过,并未深入思考。今天我们就以Redis分布式锁为例一起学习我踩过的坑。

Jedis为我们提供了便捷的分布式锁方法有setex和setnx,两者的区别在于setex可以设置超时时间(注意单位秒)。这里我们不用setex,而是用set自行指定nxxx和expx。释放锁,为了保证原子性操作,我们使用LUA命令。具体看下面2部分代码。

/**
 * NX-Only set the key if it does not already exist.
 * XX -- Only set the key if it already exist.
 */
private static final String SET_IF_NOT_EXIST = new String("NX");
/**
 * EX|PX, expire time units: EX = seconds; PX = milliseconds
 */
private static final String SET_WITH_EXPIRE_TIME = new String("PX");
private static final String LOCK_OK = new String("OK");
private static final Long RELEASE_SUCCESS = new Long(1);/**
 * 尝试获得锁
 *
 * @param jedis
 * @param lockKey
 * @param value
 * @param milliseconds(毫秒)
 * @return
 */
private static Boolean tryGetLock(Jedis jedis, String lockKey, String value, int milliseconds) {
    String result = jedis.set(lockKey, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, milliseconds);
    if (LOCK_OK.equals(result)) {
        log.info("获得锁,线程名称==" + Thread.currentThread().getName());
        return true;
    }
    log.info("未获得锁,线程名称==" + Thread.currentThread().getName());
    return false;
}

/**
 * 释放锁
 *
 * @param jedis
 * @param lockKey
 * @param value
 * @return
 */
private static Boolean releaseLock(Jedis jedis, String lockKey, String value) {
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Long result = (Long) jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(value));
    if (RELEASE_SUCCESS.equals(result)) {
        log.info("释放锁,线程名称==" + Thread.currentThread().getName());
        return true;
    }
    log.info("未释放锁,线程名称==" + Thread.currentThread().getName());
    return false;
}

下面我们模拟多线程并发获取锁资源。

public static void main(String[] args) {
    String lockKey = "LF-TEST:DISTRIBUTION:LOCK";
    String value = "123";
    int maxThread = 1000;
    ExecutorService fixedCacheThreadPool = Executors.newFixedThreadPool(maxThread);
    CountDownLatch cdLatch = new CountDownLatch(maxThread);
    for (int i = 0; i < maxThread; i++) {
        fixedCacheThreadPool.execute(() -> {
            RedisUtil redisUtil = RedisUtil.getInstance();
            Jedis jedis = redisUtil.getJedis();
            try {
                if(tryGetLock(jedis, lockKey, value, 200)){
                    TimeUnit.MILLISECONDS.sleep(100);
                    releaseLock(jedis, lockKey, value);
                }
            } catch (Exception ex) {
                log.error("获取或释放锁有误:", ex);
            } finally {
                if (null != jedis) {
                  jedis.close();//归还连接
                }
            }
            cdLatch.countDown();
        });
    }
    try {
        cdLatch.await(30, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
       log.error("cdLatch 异常:", e);
    }

}

写完用例,我们执行程序,把日志拿出来统计获得锁的是3个线程,释放锁只有2个线程。

难道还有一个线程没有释放锁?其实,并不是,这是个并发问题,由于每个线程的key和value是一样的,刚好线程A在准备释放锁的时候,刚好也超时(锁释放),这个时候线程B获得锁,就这样线程A把原本属于线程B的锁给释放了。所以就算你写了LUA脚本也不要以为妥妥的(没经过单元测试的程序就是耍流氓)。

那我们要怎么改进呢?目的是要达到线程间隔离,而且value也要不一致。目的明确了,我们自然想到可以用ThreadLocal和UUID来达到目的。我们改进代码后试试。

 


private static final ThreadLocal<String> thdLocalLockValue = new ThreadLocal<>();
public static void main(String[] args) {
    String lockKey = "LF-TEST:DISTRIBUTION:LOCK";
    int maxThread = 1000;
    ExecutorService fixedCacheThreadPool = Executors.newFixedThreadPool(maxThread);
    CountDownLatch cdLatch = new CountDownLatch(maxThread);
    for (int i = 0; i < maxThread; i++) {
        fixedCacheThreadPool.execute(() -> {
            if(Objects.isNull(thdLocalLockValue.get())){
                thdLocalLockValue.set(StringUtils.replace(UUID.randomUUID().toString(), "-", ""));
            }
            RedisUtil redisUtil = RedisUtil.getInstance();
            Jedis jedis = redisUtil.getJedis();
            try {
                if(tryGetLock(jedis, lockKey, thdLocalLockValue.get(), 200)){
                    TimeUnit.MILLISECONDS.sleep(100);
                    releaseLock(jedis, lockKey, thdLocalLockValue.get());
                }
            } catch (Exception ex) {
                log.error("获取或释放锁有误:", ex);
            } finally {
              if (null != jedis) {
                 jedis.close();//归还连接
                }
                thdLocalLockValue.remove();
            }
            cdLatch.countDown();
        });
    }
    try {
        cdLatch.await(30, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
       log.error("cdLatch 异常:", e);
    }
}

把输出日志统计后,我们发现获得锁的线程数和释放锁的线程数是一致,多尝试几次后也一样(由于篇幅问题就不截图了,有兴趣的朋友可以撸完代码试试)。

总结

今天我们一起学习了Redis分布式锁的实现,以及遇到的坑,这个坑就是刚好锁过期和释放锁两个线程并发导致。因此我们采用ThreadLocal来解决线程间隔离和每次锁的资源不一样。当然我们都知道除了Redis能实现分布式锁之外,zookeeper可以使用“临时顺序节点”实现分布式锁,其实对于单库而言也可以通过乐观锁和悲观锁实现。

 

思考和讨论

1、上面提到Zookeeper和数据库乐观锁、悲观锁都能实现分布式锁,你了解过它们间的区别吗?

2、代码中为何jedis.close(),注释写着是归还,而不是关闭连接呢?

3、我们用到了ThreadLocal,为何最后要显式remove()?不remove会带来什么问题?

欢迎留言与我分享和指正!也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

感谢您的阅读,我们下节再见!

扫码关注我们,与君共进

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!