1.分布式锁的使用场景

在分布式系统中,各系统同步访问共同的资源是很常见的。因此我们常常需要协调他们的动作。 如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

一个好的分布式锁常常需要以下特性:

  • 可重入
  • 同一时间点,只有一个线程持有锁
  • 容错性, 当锁节点宕机时, 能及时释放锁
  • 高性能
  • 无单点问题

2.三种实现方式

2.1 基于数据库的分布式锁

基于数据库的分布式锁, 常用的一种方式是使用表的唯一约束特性。当往数据库中成功插入一条数据时, 代表只获取到锁。将这条数据从数据库中删除,则释放锁。

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `cust_id` varchar(1024) NOT NULL DEFAULT '客户端唯一编码',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
)
 ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

添加锁

insert into methodLock(method_name,cust_id) values (‘method_name’,‘cust_id’)

这里cust_id 可以是机器的mac地址+线程编号, 确保一个线程只有唯一的一个编号。通过这个编号, 可以有效的判断是否为锁的创建者,从而进行锁的释放以及重入锁判断

释放锁

delete from methodLock where method_name ='method_name' and cust_id = 'cust_id'

重入锁判断

select 1 from methodLock where method_name ='method_name' and cust_id = 'cust_id'

加锁以及释放锁的代码示例

**
* 获取锁
*/
public boolean lock(String methodName){
    boolean success = false;
    //获取客户唯一识别码,例如:mac+线程信息
    String custId = getCustId();
    try{
        //添加锁
       success = insertLock(methodName, custId);
    } catch(Exception e) {
        //如添加失败
    }
    return success;
}

/**
* 释放锁
*/
public boolean unlock(String methodName) {
    boolean success = false;
    //获取客户唯一识别码,例如:mac+线程信息
    String custId = getCustId();
    try{
       success = deleteLock(methodName, custId);
    } catch(Exception e) {
        //如添加失败
    }
    return success;
}

完整流程

public void test() {
    String methodName = "methodName";
    //判断是否重入锁
    if (!checkReentrantLock(methodName)) {
        //非重入锁
        while (!lock(methodName)) {
            //获取锁失败, 则阻塞至获取锁
            try{
                Thread.sleep(100)
            } catch(Exception e) {
            }
        }
    }else{
    //重入锁

    }
    //TODO 业务处理

    //释放锁
    unlock(methodName);
}

以上代码还存在一些问题:

  • 没有失效时间。 解决方案:设置一个定时处理, 定期清理过期锁
  • 单点问题。 解决方案: 弄几个备份数据库,数据库之前双向同步,一旦挂掉快速切换到备库上

2.2 基于zookeeper的分布式锁


基于zookeeper临时有序节点可以实现的分布式锁。
大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

完整流程

public void test() {
    //Curator提供的InterProcessMutex是分布式锁的实现。通过acquire获得锁,并提供超时机制,release方法用于释放锁。
    InterProcessMutex lock = new InterProcessMutex(client, ZK_LOCK_PATH);
    try {
        //获取锁
        if (lock.acquire(10 * 1000, TimeUnit.SECONDS)) {
            //TODO 业务处理
        }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        try {
            //释放锁
            lock.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

2.3 redis实现分布式锁

大家在网上搜索分布式锁,恐怕最多的实现就是Redis了,Redis因为其性能好,实现起来简单所以让很多人都对其十分青睐。

2.3.1 Redis分布式锁简单实现

熟悉Redis的同学那么肯定对setNx(set if not exist)方法不陌生,如果不存在则更新,其可以很好的用来实现我们的分布式锁。对于某个资源加锁我们只需要

setNx resourceName value

这里有个问题,加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加入过期时间需要和setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是redis2.8之后redis支持nx和ex操作是同一原子操作。

set resourceName value ex 5 nx

使用redis 的set(String key, String value, String nxxx, String expx, int time)命令

  • 第一个为key,我们使用key来当锁,因为key是唯一的。
  • 第二个为value,我们传的是custId,这里cust_id 可以是机器的mac地址+线程编号, 确保一个线程只有唯一的一个编号。通过这个编号, 可以有效的判断是否为锁的创建者,从而进行锁的释放以及重入锁判断
  • 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作
  • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
  • 第五个为time,与第四个参数相呼应,代表key的过期时间。
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";
private static final Long RELEASE_SUCCESS = 1L;

// Redis客户端
private Jedis jedis;

/**
 * 尝试获取分布式锁
 * @param lockKey 锁
 * @param expireTime 超期时间
 * @return 是否获取成功
 */
public boolean lock(String lockKey, int expireTime) {
    //获取客户唯一识别码,例如:mac+线程信息
    String custId = getCustId();
    String result = jedis.set(lockKey, custId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

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

    return false;
}

/**
 * 释放分布式锁
 * @param lockKey 锁
 * @param requestId 请求标识
 * @return 是否释放成功
 */
public boolean unlock(String lockKey) {
    //获取客户唯一识别码,例如:mac+线程信息
    String custId = getCustId();
    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(custId));

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

/**
 * 获取锁信息
 * @param lockKey 锁
 * @return 是否重入锁
 */
public boolean checkReentrantLock(String lockKey){
    //获取客户唯一识别码,例如:mac+线程信息
    String custId = getCustId();

    //获取当前锁的客户唯一表示码
    String currentCustId = redis.get(lockKey);
    if (custId.equals(currentCustId)) {
        return true;
    }
    return false;
}

完整代码

public void test() {
    String lockKey = "lockKey";
    //判断是否重入锁
    if (!checkReentrantLock(lockKey)) {
        //非重入锁
        while (!lock(lockKey)) {
            //获取锁失败, 则阻塞至获取锁
            try{
                Thread.sleep(100)
            } catch(Exception e) {
            }
        }
    }
    //TODO 业务处理

    //释放锁
    unlock(lockKey);
}
2.3.2 RedLock

我们想象一个这样的场景当机器A申请到一把锁之后,如果Redis主宕机了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。

通过上面的代码,我们需要实现多个Redis集群,然后进行红锁的加锁,解锁。具体的步骤如下:

  1. 首先生成多个Redis集群的Rlock,并将其构造成RedLock。
  2. 依次循环对三个集群进行加锁,加锁的过程和上面一致。
  3. 如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。
  4. 加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用5ms,第一个集群加锁已经消耗了5ms了。那么也算加锁失败。
  5. 3,4步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

可以看见RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功,减少Redis某个集群出故障,造成分布式锁出现问题的概率。

3.分布式锁的安全问题

上面我们介绍过红锁,但是Martin Kleppmann认为其依然不安全。有关于Martin反驳的几点,我认为其实不仅仅局限于RedLock,前面说的算法基本都有这个问题,下面我们来讨论一下这些问题:

  • 长时间的GC pause:熟悉Java的同学肯定对GC不陌生,在GC的时候会发生STW(stop-the-world),例如CMS垃圾回收器,他会有两个阶段进行STW防止引用继续进行变化。那么有可能会出现下面图(引用至Martin反驳Redlock的文章)中这个情况:

    client1获取了锁并且设置了锁的超时时间,但是client1之后出现了STW,这个STW时间比较长,导致分布式锁进行了释放,client2获取到了锁,这个时候client1恢复了锁,那么就会出现client1,2同时获取到锁,这个时候分布式锁不安全问题就出现了。这个其实不仅仅局限于RedLock,对于我们的ZK,Mysql一样的有同样的问题。

  • 时钟发生跳跃:对于Redis服务器如果其时间发生了向跳跃,那么肯定会影响我们锁的过期时间,那么我们的锁过期时间就不是我们预期的了,也会出现client1和client2获取到同一把锁,那么也会出现不安全,这个对于Mysql也会出现。但是ZK由于没有设置过期时间,那么发生跳跃也不会受影响。

  • 长时间的网络I/O:这个问题和我们的GC的STW很像,也就是我们这个获取了锁之后我们进行网络调用,其调用时间由可能比我们锁的过期时间都还长,那么也会出现不安全的问题,这个Mysql也会有,ZK也不会出现这个问题。

文章来源掘金网 有部分修改 作者 终身幼稚园

文档更新时间: 2021-05-26 11:51   作者:fuluola