老生常谈分布式锁

网络上不少关于分布式锁的文章,有些标题起得夸张得要死, 内容却非常一般, 当然也有一些文章总结得相当不错, 让人受益匪浅.

本文比较务实,并没有很多高大上的理论, 单纯地想从分布式锁的实现的推演过程中,探讨一下分布式锁实现,和使用中应该注意哪些问题

分布式锁的实现方式

数据库主键实现

这个主要是利用到了数据库的主键的唯一性, 例如唯一性来实现分布式锁的排他性.

具体案例的话, 据我所知的, 就是quartz的集群模式中就利用到了innodb来做分布式锁,来避免同一个任务被多个节点重复执行.

例如数据库主键做分布式锁的主要问题是不够灵活,例如可重入等等特性实现起来比较麻烦, 适合比较简单的场景下使用

zookeeper实现

基于zk的分布式锁一般是用到了其临时顺序节点的特性, id最小的临时节点视为获取到锁, 会话结束时临时节点会被自动删掉,下一个最小id的临时节点获取到锁

zk分布式锁存在的问题是,zk的写性能其实不好,毕竟都是写在硬盘上的文件中的, 所以不大适合在高并发环境中

redis实现

这个主要是利用到了redis是每一个命令单个命令都是原子性的特性来实现分布式锁.

简单的来说就是,需要加锁的时候就调用set, 需要释放锁的时候就调用del, 当然实际上没有那么简单.

redis实现分布式锁的最大优点就是性能好.

小结

其实每一种分布式锁的实现都有它的优势, 例如说数据库的理解简单, zk的实现可靠性高, redis的实现性能高. 主要还是要根据具体的业务场景选择合适的实现方式.

由于实际应用中, 还是redis实现的比较多(印象流), 因此本文选择redis实现来进行分析

基于redis实现的分布式锁

首先定义一个最简单的分布式锁的接口,它只有两个方法:

  1. 加锁, 指定锁的名称, 和锁的超时时间, 获取不到直接返回false
  2. 释放锁
package com.north.lat.dislocklat;

/**
 * @author lhh
 */
public interface DisLock {
    /**
     * 加锁
     * @param lockName 锁的名称
     * @param lockValue 锁的redis值
     * @param expire 锁的超时时间
     * @return 加锁成功则返回true, 否则返回false
     */
    boolean lock(String lockName,String lockValue,int expire);

    /**
     *  释放锁
     * @param lockName
     * @param lockValue
     * @return 释放成功则返回true
     */
    boolean  unlock(String lockName,String lockValue);
}

复制代码
1. set NX PX 加锁, DELETE释放锁
redis官方已经为我们提供了一个命令
复制代码
    SET key value [EX seconds] [PX milliseconds] [NX|XX]
复制代码

这个命令可以在一个key不存在的时候,设置这个KEY的值, 并指定这个key的过期时间, 并且这个命令是原子性的, 所以可以完美地被我们用来作为加锁的操作

利用这个命令, 我们可以先实现第一个版本的分布式锁:

package com.north.lat.dislocklat.redisimpl;

import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;

/**
 * @author lhh
 */
public class DisLockV1 implements DisLock {
    public static final String OK = "OK";
    private Jedis jedis = new Jedis ("localhost",6379);
    @Override
    public boolean lock(String lockName, String lockValue, int expire) {
        String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
        return OK.equalsIgnoreCase(ret);
    }

    @Override
    public boolean unlock(String lockName,String lockValue) {
        Long c = jedis.del(lockName);
        return c > 0;
    }
}

复制代码

测试代码如下:

package com.north.lat.dislocklat;

import com.north.lat.dislocklat.redisimpl.DisLockV1;

public class DisLockTest {


    public static void main(String[] args) {
        String lockName = "test_lock";
        String lockValue = "test_value";
        DisLock disLock = new DisLockV1();
        boolean success = disLock.lock(lockName, lockValue, 10);
        if(success){
            try {
                doSomeThingImportant();
            }finally {
                disLock.unlock(lockName, lockValue);
            }
        }
    }

    public static void doSomeThingImportant(){

    }
}


复制代码

这是一个最简单版本的分布式锁

  1. 加锁成功,肯定会释放锁
  2. 锁的超时时间设为10秒,避免锁长时间不释放

这个分布锁理论上在简单的场景下是没有问题的,然而在doSomeThingImportant()业务比较复杂, 处理时间过长的情况下, 就会出现问题了. 我们来模拟一下

时刻线程1线程2线程3线程4
第1秒加锁加锁未开始执行未开始执行
第2秒获取到锁没获取到锁未开始执行未开始执行
第10秒执行业务逻辑已返回未开始执行未开始执行
第11秒执行业务逻辑(锁已超时失效)-加锁未开始执行
第12秒释放锁, 这时把线程2的锁也释放了-执行业务逻辑未开始执行
第13秒返回-执行业务逻辑加锁(获取锁成功)
第14秒--执行业务逻辑执行业务逻辑
第n秒--......

对照上面的时刻表, 前面的10秒都没有问题, 如果10秒内线程能处理完业务逻辑的话,也不会有问题.

然而, 第11秒的时候线程1还没有处理完它自己的业务逻辑, 刚好线程2又过来加锁, 这时候问题就出现了: 线程1还没有释放锁的时候, 线程2加锁成功了.

问题并不止一个, 到了第12秒的时候,线程1终于处理完自己的业务逻辑,然后就屁颠屁颠地去把锁给释放了.这一释放不单把自己的锁给释放了, 还把线程3的锁也给释放了.

到了第13秒的时候, 线程4过来加锁,有线程1和线程3的锁都被释放了, 因此线程4加锁成功

整个过程中, 线程1和线程3同时执行过临界区代码, 线程3和线程4也同时执行过临界区代码.分布锁跟本没起一点作用

综上所述, 这个绝对不是一个可用的分布式锁代码. 那么它的问题是什么呢, 主要是下面两点:

    1. 超时时间设置不合理, 因为redis key过期导致锁失效
    2. 释放锁的问题, 释放锁的时候把其他线程加的锁也给释放了
复制代码

怎么解决呢? 我们来看第二个版本的分布式锁实现

2. set Nx px + lua 脚本delete + 定时器
锁的超时时间

对于分布式锁的过期时间的值,其实是一个比较难确定的东西. 因为我们永远不知道临界区的业务逻辑到底要执行多长时间, 如果设置太短, 就会出现上面的那种情况, 如果说设置得长点, 那多长算是长呢?

一个简单的办法就是在锁快要失效的时候,如果代码没有执行完,那么就给这个锁的过期时间延长一些.

这个算法思想大概如下:

1. 加锁, 过期时间为N秒
2. 如果加锁成功, 则开启一个定时器
3. 定时器一直在执行, 每过了X(X < N, 一般可配置)秒, 就给这个锁延长Y (Y > X, 一般可配置)秒
4. 释放锁的时候, 把定时器删掉
复制代码

在上面算法中, 只要临界区的代码没有执行完, 定时器会一直给分布式锁"续命", 直到这个分布式锁被应用程序释放掉.

乍一看,如果业务代码一直没有处理完, 那这里岂不是跟没有设置超时时间一样一样的?

但其实还是有区别:

   1. 没有设置超时时间, redis的key是不会失效的.
   2. "续命"的这种方式, 只有在应用程序(的临界代码)一直在运行的情况下, redis的key的过期时间会不断地被延长
   
   区别就在于, 锁的失效与否还是在锁的使用方手上, 而不是在于锁本身
复制代码

另外定时器(具体实现中可能是一个守护线程)都是在临界区内生成和销毁的, 也就是每个时刻最多只会有一个定时器存在, 所以也不必担心性能问题

只是要保证加锁释放锁和定时器的生成销毁的事务性, 即加锁成功必须要生成定时器, 释放锁必须要销毁定时器

锁释放的问题

锁释放的时候,误把其他线程加的锁也释放了. 这个问题其实很容易解决, 就是释放锁的时候, 判断一下这个锁是否是自己加的,是的话才释放锁. 伪代码实现如下:

    public boolean unlock(String lockName,String lockValue) {
          String val = jedis.get(lockName); // (1)
          if(lockValue.equalsIgnoreCase(val)){
              jedis.del(lockName); // (2)
          }
          return true;
    }
复制代码

但是上面这段代码明显(1)和(2)不是原子性的, 很可能会带来一些未知的问题.所以真正的实现并不是这样的,而是使用lua脚本,把两个命令放在一起,原子性地执行, 代码如下:

  public boolean unlock(String lockName,String lockValue) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        // public Object eval(String script, List<String> keys, List<String> args)
        // 第一个参数是脚本, 第二个参数是脚本中涉及到的key的列表, 这里只涉及到lockName一个key, 第三个参数是涉及到的参数的列表, 这里只有一个lockValue参数
        // 所以这里实际执行的脚本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
        Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
        return "1".equalsIgnoreCase(o.toString());
    }
复制代码
DisLockV2

在实现我们的第二个版本的redis分布锁之前, 我们先来总结一些,针对第一版,有哪些优化

1. 每个线程加锁的时候, redis key的值必须不一样,而且唯一.释放锁的时候要传上这个唯一值
2. 加锁的时候,要新建一个定时器, 不断地延长这key的过期时间,直到锁释放
3. 释放锁的时候, 要判断当前锁的redis value是否是当前线程set进入的, 如果不是则不能释放
4. 释放锁的时候要把定时器销毁
复制代码

代码简单实现如下:

package com.north.lat.dislocklat.redisimpl;

import com.north.lat.dislocklat.DisLock;
import redis.clients.jedis.Jedis;

import java.util.Collections;

/**
* @author lhh
*/
public class DisLockV2 implements DisLock {
  public static final String OK = "OK";
  private Jedis jedis = new Jedis ("localhost",6379);
  @Override
  public boolean lock(String lockName, String lockValue, int expire) {
      String ret = jedis.set(lockName, lockValue, "NX", "EX", expire);
      createTimer(lockName, jedis, expire);
      return OK.equalsIgnoreCase(ret);
  }

  @Override
  public boolean unlock(String lockName,String lockValue) {
      deleteTimer();
      String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
      // public Object eval(String script, List<String> keys, List<String> args)
      // 第一个参数是脚本, 第二个参数是脚本中涉及到的key的列表, 这里只涉及到lockName一个key, 第三个参数是涉及到的参数的列表, 这里只有一个lockValue参数
      // 所以这里实际执行的脚本是: if redis.call('get', lockName) == lockValue then return redis.call('del', lockName) else return 0 end
      Object o = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(lockValue));
      return "1".equalsIgnoreCase(o.toString());
  }
  /**
   * 创建定时器, 这里暂时省略实现
   * @param lockName
   * @param jedis
   * @param expire
   */
  void createTimer(String lockName,Jedis jedis, int expire){
      //每过了X(X < expire, 一般可配置)秒,jedis.expire  就给lockName这个锁延长Y (Y > X, 一般可配置)秒
  }

  /**
   *销毁定时器, 这里暂时省略实现
   */
  void deleteTimer(){
  }
}

复制代码

测试main方法, 唯一变动的是lockValue:

package com.north.lat.dislocklat;

import com.north.lat.dislocklat.redisimpl.DisLockV1;

import java.util.UUID;

public class DisLockTest {


  public static void main(String[] args) {
      String lockName = "test_lock";
      // 用uuid 保持唯一
      String lockValue = UUID.randomUUID().toString();
      DisLock disLock = new DisLockV1();
      boolean success = disLock.lock(lockName, lockValue, 10);
      if(success){
          try {
              doSomeThingImportant();
          }finally {
              disLock.unlock(lockName, lockValue);
          }
      }
  }

  public static void doSomeThingImportant(){

  }
}

复制代码

上面这个定时器的思路,其实是 redission 分布式锁里面的实现细想. 当然redission还实现了可重入,异步等等特性,我们的跟它的是无法比的这里只是体现一下思想而已.

那么这样实现的分布式锁是否还有问题? 答案是肯定的. 让我们再来推演一下两种异常情况.

redis主从切换

大家都知道, 为了提高可用性, 生产环境中的redis一般都不会是单点.解决单点有很多种方案, 可用是客户端分片, 哨兵模式,集群模式等等, 不管是哪种方式 redis一般都会有一主一从. 正常情况是master提供服务, slave节点保持数据同步, 当master挂了的话, slave节点变成新的master, 来继续提供服务.

在redis只作为缓存服务的时候, 这个模式是比较可靠的. 但是在作为分布锁的情况下, 有时就不可用了.考虑以下的一种场景:

时刻线程1线程2redis1redis2备注
第1秒获取锁-作为master作为slaveredis1有lock的key, redis2还没有
第2秒获取到锁,执行业务逻辑获取锁挂了成为master假设由于网络延迟,redis1的lock的key还没有同步到redis2
第3秒执行业务逻辑获取到锁,执行业务逻辑挂了作为master同时有两个线程在执行临界区代码,分布式锁不起作用
第4秒执行业务逻辑执行业务逻辑挂了作为master
第n秒...........

从上面第2秒可以看到,由于主从切换的时候, slave节点上面是不一定有master节点的所有的数据的, 这个时候如果有另外一个线程来获取锁, 就会出现多个线程同时获取到锁的情况

3. REDLOCK

如果redis是单实例的话, 上面的分布式锁已经是可用的了, 只是又必须要面临单redis实例挂掉的风险.

为了解决redis主从切换带来的问题,reddsion的设计者实现一个新的分布式锁, 就是大名鼎鼎的REDLOCK

REDLOCK的设计思想还是很符合我们实事求是,具体问题具体分析的方法论的:

 1. 主从切换会导致分布式锁失效? ok, 那就用单实例的redis
 2. 单实例存在单点故障? ok, 那我们用多个相互独立的单实例redis
复制代码

总的来说, REDLOCK的实现思路就是放弃redis的主从结构, 使用N(一般是5)个redis实例来保证可用性

N个redis实例互相独立,分布式锁只有在大多数的实例上成功获取到锁, 才到算获取到锁成功. 为了避免多个实例同时挂掉, 一般来说每个实例都在不同的机器上面.

当客户端尝试去获取分布式锁的时候, 需要经过以下几个步骤

  1. 计算当前时间戳CUR_T
  2. 客户端逐一向N个redis获取锁.也就是把同一个KEY和VALUE分布写到每个redis实例中,过期时间为EX_T. 获取锁的时候还需要指一个时间:
  这次set命令的响应超时时间RESP_T. 其中RESP_T < EX_T. RESP_T的存在是为了避免某个redis实例已经挂了的时候,还在苦等它响应返回.
  3. 对于第2步中的任何一个redis实例, 如果RESP_T时间内没有返回, 或者set命令返回false, 则代表获取锁失败, 否则就是获取锁成功. 不管在当前实例获取锁成功还是失败, 都立马向下一个实例获取锁.
  4. N个redis都请求完后,计算总耗时(用加锁完成时间戳-CUR_T) ,满足至少有(N/2+1)个实例能获取到锁,而且总耗时小于锁的失效时间才算获取锁成功.
  5. 如果获取锁失败,要算所有的实例unlock释放锁.
复制代码

上面的这个思路, 在这篇译文中描述得非常清楚, 文中REDLOCK的作者大概的论证了这个算法的正确性,并非常自信地认为该分布锁算法是无懈可击的

但是另外一位大神Martin Kleppmann在他的文章内举了不少的例子, 来证明REDLOCK是脆弱的,不可靠的. 其中这里是一篇简单的译文

我试着理解了一下他的其中一个举证

jvm发生FULL GC

在java应用里面, 当full gc发生的时候, 整个jvm会发生stop the world的停顿, 当停顿发生时, 分布锁的正确性就可能会被打破

来考虑一下下面的一种场景:

时刻进程1进程2进程3
第1秒加锁加锁未开始执行
第2秒获取到锁没获取到锁未开始执行
第3秒执行业务逻辑,发生FULL GC已返回未开始执行
第4秒执行业务逻辑,FULL GC, STOP THE WORLD中已返回未开始执行
第11秒FULL GC结束,执行业务逻辑(锁已超时失效)-加锁
第12秒执行业务逻辑-执行业务逻辑
第n秒........

当JVM在stop the world时, 不管是业务逻辑代码, 还是上面的"续命"定时器代码, 都会停止运行.

当FULL GC的停顿时间过长时, redis中分布式锁的key有可能已经过期了. 假若FULL GC结束的瞬间有另外一个进程过来获取锁的话, 就会发生同时两个进程获取到锁,同时执行临界区代码的情况.

Martin Kleppmann也给出这个情况的解决方案(详细见这篇译文), 并指出redlock处理不了这种情况, 所以redlock是不可靠的.

有趣的是, redlock的作者在另外一篇文章回应了Martin Kleppmann的质疑. 内容就没有仔细看了, 质疑的论文和反质疑的论文都是两三年前的了, 在技术日新月异的这个时代, 文中的一些观点可能早就过时或者是解决掉了.

转载于:https://juejin.im/post/5d08cfe96fb9a07efc498af5

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值