Redis 分布式锁的正确实现方式( Java 版 )

前言

分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。本篇博客将介绍第二种方式,基于Redis实现分布式锁。虽然网上已经有各种介绍Redis分布式锁实现的博客,然而他们的实现却有着各种各样的问题,为了避免误人子弟,本篇博客将详细介绍如何正确地实现Redis分布式锁。

可靠性

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

代码实现

组件依赖

首先我们要通过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

加锁代码

正确姿势

Talk is cheap, show me the code。先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。

总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

错误示例1

比较常见的错误示例就是使用jedis.setnx()和jedis.expire()组合实现加锁,代码如下:

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }

}

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

错误示例2

这一种错误示例就比较难以发现问题,而且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。代码如下:

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {

    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);

    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }

    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }

    // 其他情况,一律返回加锁失败
    return false;

}

那么这段代码问题在哪里?1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。

解锁代码

正确姿势

还是先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例1

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

错误示例2

这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {

    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }

}

如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

总结

本文主要介绍了如何使用Java代码正确实现Redis分布式锁,对于加锁和解锁也分别给出了两个比较经典的错误示例。其实想要通过Redis实现分布式锁并不难,只要保证能满足可靠性里的四个条件。互联网虽然给我们带来了方便,只要有问题就可以google,然而网上的答案一定是对的吗?其实不然,所以我们更应该时刻保持着质疑精神,多想多验证。

如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件,链接在参考阅读章节已经给出。

参考阅读

  1. Distributed locks with Redis
  2. EVAL command
  3. Redisson


相关文章

发表评论

Comment form

(*) 表示必填项

27 条评论

  1. 张玉祥 说道:

    在错误示例1里面,我如果给判断加个异常捕获,在finally中解锁,不可以解决死锁的问题么

    Hot debate. What do you think? Thumb up 5 Thumb down 8

    • 菜鸟 说道:

      不可以,程序崩溃退出呢?怎么捕获?

      Well-loved. Like or Dislike: Thumb up 4 Thumb down 0

      • yuzhao.xu 说道:

        设置超时,超过n分钟自动解锁.程序崩溃再启动的时间一般都在n分钟,锁里面的逻辑执行一般在n分钟之内

        Thumb up 0 Thumb down 0

      • 说道:

        如果简单实现的话,我感觉倒也可以。程序崩溃的几率多大? 一周一次? 过于频繁的话,还是应先解决程序的崩溃问题。
        真遇到了小概率的崩溃事件,没捕获也没关系,到了过期时间就自动释放掉了,不会导致后面的其他需要锁的线程被锁死。
        倒是这种finally中解锁的方式,需要调用者小心点,必须要先获取锁,成功获取后再try catch finally。

        Thumb up 0 Thumb down 0

  2. 杨光 说道:

    分布锁, 你这个地方主要是用在什么场景?

    Thumb up 3 Thumb down 0

  3. 泰山小黄鱼 说道:

    有一个疑问,请楼主指点:你这里解锁,从代码逻辑上来看没啥问题,但是实际业务上来使用的时候,核心就是过期时间长短的设置,比如有一些比较耗时的逻辑处理过程,而且每次耗时可能都不同,你这个过期时间设置就很关键了,设置长了,锁的竞争会很激烈,设置短了,又达不到锁的目的。所以时间业务上使用起来,感觉风险还是挺高的啊?楼主怎么避归这样的问题?

    Well-loved. Like or Dislike: Thumb up 17 Thumb down 0

    • 泰山小黄鱼 说道:

      补充一下:前面说的加锁,不是解锁,打错了

      Well-loved. Like or Dislike: Thumb up 6 Thumb down 0

      • 杜仲 说道:

        这个问题可以用redis的通知来处理

        Thumb up 0 Thumb down 0

      • 说道:

        “这个过期时间设置就很关键了,设置长了,锁的竞争会很激烈” –这个不至于,因为用好了就会马上解锁的,不会等到过期时间到了才解锁。设置过期时间只是为了应对异常情况。正常代码崩掉后,锁释放不了了,就利用过期时间来“兜底”释放。激不激烈取决于锁里面的业务执行效率。

        Thumb up 0 Thumb down 0

    • 唐尤华 说道:

      Hi,请到伯乐在线的小组发帖提问,支持微信登录。链接是: http://group.jobbole.com/
      在小组发帖,其他网友也能回复你哦。

      Thumb up 1 Thumb down 0

  4. James 说道:

    经过实际测试,下面代码好像不能够保证同步问题。
    jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

    请求134d5eec-b60d-4bd3-b858-353c6b924d61获取锁成功
    请求134d5eec-b60d-4bd3-b858-353c6b924d61释放锁成功
    请求e7ee3bd7-5551-4393-ba18-e9961d059bf4获取锁成功
    请求e7ee3bd7-5551-4393-ba18-e9961d059bf4释放锁成功
    请求d658f9ed-7c6f-477b-bf13-c87bc6b1f2f0获取锁成功
    请求286ef972-1d75-4c40-8c28-36539fd09292获取锁成功
    请求d658f9ed-7c6f-477b-bf13-c87bc6b1f2f0释放锁成功
    请求286ef972-1d75-4c40-8c28-36539fd09292释放锁成功

    Well-loved. Like or Dislike: Thumb up 10 Thumb down 5

    • bauerchen 说道:

      啥情况,楼主分析头头是道,被现实无情击碎,搞这么一套复杂的加锁方式根本起不到分布式锁的效果?

      Thumb up 0 Thumb down 0

      • bauerchen 说道:

        测试一下,没有出现这种情况,分布式锁有效
        Request dd442a50-1f38-4efa-b4bb-e1641ab53bd3 acquire mutex lock for pageId 102410241024000 true.
        Request f9224b15-5476-45d3-bf65-2437ae3770d6 acquire mutex lock for pageId 102410241024000 false.
        Request bf46a5f8-e024-4d5c-9575-00f5b7e2bb8f acquire mutex lock for pageId 102410241024000 false.
        Request 1e4b1fbf-9d5b-4e71-822f-1422c1dd96bf acquire mutex lock for pageId 102410241024000 false.
        Request ece90d32-bf76-4ac0-984b-c4efc1cf85d9 acquire mutex lock for pageId 102410241024000 false.

        Thumb up 0 Thumb down 0

  5. 逐兔郎 说道:

    如果A加锁之后处理业务逻辑耗时太久,这时候已经到了过期时间而自动解锁了,B又过来拿到了锁,那又该怎么办?

    Well-loved. Like or Dislike: Thumb up 11 Thumb down 1

    • 说道:

      所以一般设置的过期时间远大于业务的执行时间,如果业务执行需要1~2秒,那么设置成10~20秒也没问题(具体根据实际情况而定,只是举个例子)。如果业务本身就有可能超过10秒的,反而是业务本身就有问题了,超过10秒而且并发这么高的业务,本身就不合理。

      Thumb up 0 Thumb down 0

  6. 会编程的哈士奇 说道:

    大山楼下有人质疑你。

    Thumb up 0 Thumb down 0

  7. luozi 说道:

    可以只要保证redis key是唯一的就可以了,比如uuid, 这样是不是可以用普通的解锁方法不需要考虑解错锁的问题

    Thumb up 0 Thumb down 3

  8. 码农 说道:

    哥们你说的redis方式的分布式锁也有问题的,获取锁的这个方法本身就不是原子的,如何保证它是线程安全的呢?如果线程A在执行完第一行代码之后就挂起,线程B在线程A设置的过期时间之后直接获取到锁,这时线程A被换线它确是以正确获得锁返回(这时该线程获得锁早已过期),这就导致了多个线程同时获取锁的情况发生了。

    Hot debate. What do you think? Thumb up 3 Thumb down 8

  9. dieslrae 说道:

    这个代码在获取锁的时候回立即返回结果,如何做到阻塞直到获取锁超时呢

    Thumb up 0 Thumb down 0

  10. mrguo 说道:

    感觉写的第一种并不能有效解决分布式锁问题,假如锁超时了,新的线程过来执行的时候,虽然不能解锁,但是可以加锁成功,除非保证这个某个业务方法的requestId一定永远是相同的才可以

    Thumb up 1 Thumb down 0

  11. binglang2 说道:

    4.解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

    关于最后一句:
    客户端为什么会把别人加的锁给解掉?解锁的前提是你要先获得锁,既然别人加锁了,你如何能获得锁,既然获取不到锁,你为什么会去解别人的锁?这不是自相矛盾吗?想得太多

    Thumb up 1 Thumb down 3

    • 串一串 说道:

      redis的加锁只是在redis指定的工作空间里创建一个指定的key,你可以理解为节点,只要使用这同一个工作空间的用户都可以对这个key进行删除操作,如果直接使用del方法,会不校验这个key的拥有者身份,直接删除,释放锁并不需要用有锁,这个和ReentrantLock这些原理不一样

      Thumb up 1 Thumb down 0

      • lujinke 说道:

        我同意@binglang2的观点,你放锁的前提是你已经获得了锁,如果你没有获得锁,谈何放锁?至于你说的redis中的key其他任何人都可以del,这属于另外一个问题(为啥会有其他人del你的key?这属于你应用或者操作的BUG了),按照你的说法,我也可以直接连上redis去修改你加锁的者的标志requestID。

        Thumb up 0 Thumb down 0

  12. 杜仲 说道:

    uuid不能保证下次随机还是同一个UUID啊

    Thumb up 1 Thumb down 0

  13. cellargalaxy 说道:

    关于lua脚本我有个疑问。如果锁是因为过期而释放的,但对于调用这个方法的线程来说,锁确实是释放成功了,毕竟他已经不持有锁了。但是redis.call(‘get’, KEYS[1]) == ARGV[1]却为false了,返回的是释放失败。不矛盾吗?

    Thumb up 0 Thumb down 0

  14. shawn 说道:

    “String script = “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end”;
    Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));”

    这里script 为什么是 lockkey==requestId呢?
    自己只能释放自己的锁,随意我觉得应该是
    “` get(lockkey)==requestId吧,
    所以我不明白KEYS[1]) == ARGV[1]/ jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));是什么意思。

    Thumb up 0 Thumb down 0

  15. 叶嘉豪 说道:

    jedis3已经没有jedis.set(k,v,nx,ex,time)方法了,对应改为了
    jedis.set(k, v, setParams);

    Thumb up 0 Thumb down 0

跳到底部
返回顶部