老生常谈分布式锁

网络上很多关于分布式锁的文章,有些标题起得夸张得要死, 内容却很是通常, 固然也有一些文章总结得至关不错, 让人受益不浅.html

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

分布式锁的实现方式

数据库主键实现

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

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

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

zookeeper实现

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

zk分布式锁存在的问题是,zk的写性能其实很差,毕竟都是写在硬盘上的文件中的, 因此不大适合在高并发环境中数据库

redis实现

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

简单的来讲就是,须要加锁的时候就调用set, 须要释放锁的时候就调用del, 固然实际上没有那么简单.bash

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 线程2 redis1 redis2 备注
第1秒 获取锁 - 做为master 做为slave redis1有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的质疑. 内容就没有仔细看了, 质疑的论文和反质疑的论文都是两三年前的了, 在技术突飞猛进的这个时代, 文中的一些观点可能早就过期或者是解决掉了.

相关文章
相关标签/搜索