分布式锁简析

180 阅读18分钟

分布式锁简析

1. 背景

    为了保证一个方法在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用 Java 并发处理相关的 API(如 ReentrantLcok 或 synchronized)进行互斥控制。但是,随着业务发展的需要,原单体部署的系统被演化成分布式系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,为了解决这个问题就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。

2.简介

1. 使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。

  ​       Martin Kleppmann是英国剑桥大学的分布式系统的研究员,之前和Redis之父Antirez进行过关于RedLock(红锁,后续有讲到)是否安全的激烈讨论。

(1) 效率:允许多个客户端操作共享资源

​ 这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。

(2) 正确性:只允许一个客户端操作共享资源

​ 这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。

2. 分布式锁的一些特点

1. 互斥性

分布式锁需要保证在不同节点的不同线程的互斥

2. 可重入性

同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁

3. 锁超时

和本地锁一样支持锁超时,防止死锁

4. 高效,高可用

加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效,可以增加降级

5. 支持阻塞和非阻塞

和ReentrantLock一样支持lock和tryLock以及tryLock(Long timeOut)

6. 支持公平锁和非公平锁

公平锁的意思是按照请求加锁的顺序获得锁,非公平锁相反是无序的。这个一般来说实现的比较少。

7. 支持读写锁

一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁。

8. 容错

当部分节点(redis节点等)宕机时,客户端仍然能获取锁和释放锁。

3. 常见实现

1. mysql 的方式实现分布式锁

    首先来说一下 Mysql 分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。对于分布式锁我们可以创建一个锁表

 create table if not exists resource_lock ( 
     id bigint unsigned auto_increment comment '主键'       primary key,           resource_name varchar(128default '' not null comment '资源名称'
     node_info varchar(128default '' not null comment '节点信息'
     count tinyint(4unsigned default 0 not null comment '重入次数',             description varchar(128default '' not null comment '描述'
     gmt_modified timestamp default CURRENT_TIMESTAMP not null on update    CURRENT_TIMESTAMP comment '修改时间'
     gmt_create timestamp default CURRENT_TIMESTAMP not null comment '创建时间',      constraint uk_resource_name       unique (resource_name) 
 );

适用场景:

    Mysql 分布式锁一般适用于资源不存在数据库,如果数据库存在比如订单,那么可以直接对这条数据加行锁,不需要我们上面多的繁琐的步骤,比如一个订单,那么我们可以用 select * from order_table where id = 'xxx' for update 进行加行锁,那么其他的事务就不能对其进行修改。

Lock()

    Lock 一般是阻塞的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作

 public void lock() {
         while (true) {
             if (lock.getLock(资源)) {
                 return;
             }
             // 休眠
          LockSupport.parkNanos(时间);
         }
}

可重入

 @Transactional()
 public boolean getLock() {
  source =select ··· for update
         if (source == null) 
{
             return insert > 0;
         } else if (node1==node2) {
            return update> 0;
         } else {
                 return false;
          }
   }

    需要注意的是这一段代码需要加事务,必须要整整这一系列操作的原子性。

tryLock()

     tryLock()是非阻塞获取锁,如果获取不到那么久会马上返回,代码可以如下:

  public boolean tryLock() {
         return lock.getLock(资源);
  }

tryLock(Long timeout)

public boolean tryLock(Long time) {
        Long start = time();
        while (true) {
            if (lock.getLock(resource)) {
                return true;
            }
            Long end = time();
            if (time < end - start) {
                return false;
            }
       }
}

    但是要注意的是 select ... for update 这个是阻塞的获取行锁,如果同一个资源并发量较大还是有可能会退化成阻塞的获取锁。

unlock()

@Transactional()
public boolean unLock() {
    source =select ··· for update
    if (source == null) 
{
        return false;
    } else if (node1==node){
            if (count > 1) {
                return update > 0;
            } else {
                return delete > 0;
            }
   }else{
     return false;
   }
}

    count 为 1 那么可以删除,如果大于 1 那么需要减去 1。

锁超时

    我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的一般的时间,比如是 5ms,那么我们可以稍微扩大一点,当这个锁超过 20ms 没有被释放我们就可以认定是节点挂了然后将其直接释放。

2. redis 实现分布式锁

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

2. Redission

    Javaer 都知道 Jedis,Jedis 是 Redis 的 Java 实现的客户端,其 API 提供了比较全面的 Redis 命令的支持。Redission 也是 Redis 的客户端,相比于 Jedis 功能简单。Jedis 简单使用阻塞的 I/O 和 redis 交互,Redission 通过 Netty 支持非阻塞 I/O。Jedis 最新版本 2.9.0 是 2016 年的快 3 年了没有更新,而 Redission 最新版本是 2018.10 月更新。Redission 封装了锁的实现,其继承了 java.util.concurrent.locks.Lock 的接口,让我们像操作我们的本地 Lock 一样去操作 Redission 的 Lock,下面介绍一下其如何实现分布式锁。

RLock rLock = Redisson.create().getLock("resourceName");
 // 直接加锁
 rLock.lock();
 // 尝试加锁5s,锁过期时间10s
 rLock.tryLock(510, TimeUnit.SECONDS);
 // 支持非阻塞异步操作
 RFuture<Boolean> rFuture =rLock.tryLockAsync(510, TimeUnit.SECONDS);
 rFuture.whenCompleteAsync((result,throwable)->
         System.out.println("当前加锁的情况:" + result + throwable));
 rLock.unlock();

    Redission 不仅提供了 Java 自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。 由于内部源码较多,就不贴源码了,这里用文字叙述来分析他是如何加锁的,这里分析一下 tryLock 方法:

  1. 尝试加锁:首先会尝试进行加锁,由于保证操作是原子性,那么就只能使用 lua 脚本,相关的 lua 脚本如下:
if (redis.call('exists', KEYS[1]) == 0)
then redis.call('hset', KEYS[1], ARGV[2], 1);
     redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
  1. 如果尝试加锁失败,判断是否超时,如果超时则返回 false。

  2. 如果加锁失败之后,没有超时,那么需要在名字为 redisson_lock__channel+lockName 的 channel 上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。

  3. 重试步骤 1,2,3,直到最后获取到锁,或者某一步获取锁超时。

    对于我们的 unlock 方法比较简单也是通过 lua 脚本进行解锁,如果是可重入锁,只是减 1。如果是非加锁线程解锁,那么解锁失败。

 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
 then return nil;
 end;
 local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
 if (counter > 0)
 then redis.call('pexpire', KEYS[1], ARGV[2]);
 return 0;
 else
 redis.call('del', KEYS[1]);
 redis.call('publish', KEYS[2], ARGV[1]);
 return 1;
 end;
 return nil;

    Redission 还有公平锁的实现,对于公平锁其利用了 list 结构和 hashset 结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,这里就不展开介绍了,有兴趣可以参考源码。

3.RedLock

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

RLock rLock1 = redissonClient1.getLock("lock1");
RLock rLock2 = redissonClient2.getLock("lock2");
RLock rLock3 = redissonClient2.getLock("lock3");
RedissonRedLock redissonRedLock = new RedissonRedLock(rLock1, rLock2, rLock3);
redissonRedLock.lock();
redissonRedLock.unlock();

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

  1. 首先生成多个 Redis 集群的 Rlock,并将其构造成 RedLock。

  2. 依次循环对三个集群进行加锁

  3. 如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。

  4. 加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用 3ms,第一个集群加锁已经消耗了 3ms 了。那么也算加锁失败。

  5. 3,4 步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。

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

3. zookeeper 实现分布式锁

1.基础实现

    ZooKeeper 也是我们常见的实现分布式锁方法,相比于数据库如果没了解过 ZooKeeper 可能上手比较难一些。ZooKeeper 是以 Paxos 算法为基础分布式应用程序协调服务。Zk 的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。我们以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册 Watcher 到上一个客户端。 /lock 是我们用于加锁的目录,/resource_name 是我们锁定的资源,其下面的节点按照我们加锁的顺序排列。

2.Curator 实现

1.Curator

    Curator 封装了 Zookeeper 底层的 Api,使我们更加容易方便的对 Zookeeper 进行操作,并且它封装了分布式锁的功能,这样我们就不需要再自己实现了。Curator 实现了可重入锁(InterProcessMutex),也实现了不可重入锁(InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。

2. InterProcessMutex

    InterProcessMutex 是 Curator 实现的可重入锁,我们可以通过下面的一段代码实现我们的可重入锁:

String lockOn= "/test";
    InterProcessMutex mutex = new InterProcessMutex(curatorFramework,lockOn);
    try {
        boolean locked =mutex.acquire(0, TimeUnit.SECONDS);
        System.out.println(locked);
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        //finally部分
        try {
            mutex.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    我们利用 acuire 进行加锁,release 进行解锁。

    加锁的流程具体如下:

  1. 首先进行可重入的判定:这里的可重入锁记录在 ConcurrentMap<Thread, LockData> threadData 这个 Map 里面,如果 threadData.get(currentThread)是有值的那么就证明是可重入锁,然后记录就会加 1。我们之前的 Mysql 其实也可以通过这种方法去优化,可以不需要 count 字段的值,将这个维护在本地可以提高性能。
  2. 然后在我们的资源目录下创建一个节点:比如这里创建一个/0000000002 这个节点,这个节点需要设置为 EPHEMERAL_SEQUENTIAL 也就是临时节点并且有序。
  3. 获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个。
  4. 如果是第一个,则获取到锁,那么可以返回。
  5. 如果不是第一个,则证明前面已经有人获取到锁了,那么需要获取自己节点的前一个节点。/0000000002 的前一个节点是/0000000001,我们获取到这个节点之后,再上面注册 Watcher(这里的 watcher 其实调用的是 object.notifyAll(),用来解除阻塞)。
  6. object.wait(timeout)或 object.wait():进行阻塞等待这里和我们第 5 步的 watcher 相对应。
     解锁的具体流程: 1.首先进行可重入锁的判定:如果有可重入锁只需要次数减 1 即可,减 1 之后加锁次数为 0 的话继续下面步骤,不为 0 直接返回。 2.删除当前节点。 3.删除 threadDataMap 里面的可重入锁的数据。
3.读写锁

    Curator 提供了读写锁,其实现类是 InterProcessReadWriteLock,这里的每个节点都会加上前缀:

private static final String READ_LOCK_NAME = "**READ**";
private static final String WRITE_LOCK_NAME = "**WRIT**";

    根据不同的前缀区分是读锁还是写锁,对于读锁,如果发现前面有写锁,那么需要将 watcher 注册到和自己最近的写锁。写锁的逻辑和我们之前 4.2 分析的依然保持不变。

4.锁超时

    Zookeeper 不需要配置锁超时,由于我们设置节点是临时节点,我们的每个机器维护着一个 ZK 的 session,通过这个 session,ZK 可以判断机器是否宕机。如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时。

4.自研(chubby)实现分布式锁

    大家搜索 ZK 的时候,会发现他们都写了 ZK 是 Chubby 的开源实现,Chubby 内部工作原理和 ZK 类似。但是 Chubby 的定位是分布式锁和 ZK 有点不同。Chubby 也是使用上面自增序列的方案用来解决分布式不安全的问题,但是他提供了多种校验方法:

  • CheckSequencer():调用 Chubby 的 API 检查此时这个序列号是否有效。

• 访问资源服务器检查,判断当前资源服务器最新的序列号和我们的序列号的大小。

• lock-delay:为了防止我们校验的逻辑入侵我们的资源服务器,其提供了一种方法当客户端失联的时候,并不会立即释放锁,而是在一定的时间内(默认 1min)阻止其他客户端拿去这个锁,那么也就是给予了一定的 buffer 等待 STW 恢复,而我们的 GC 的 STW 时间如果比 1min 还长那么你应该检查你的程序,而不是怀疑你的分布式锁了。

4.存在问题

1. 长时间 GC pause

熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 STW(stop-the-world)

 

这个其实不仅仅局限于 RedLock,对于我们的 ZK,Mysql 一样的有同样的问题。

2.时钟跳跃

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

3.长时间网络 I/O

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

5.总结

优点 缺点 理解 复杂性 性能 可靠性
MySQL 理解起来简单,不需要维护额外的第三方中间件(比如 Redis,Zk)。 虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。
Redis 对于 Redis 实现简单,性能对比 ZK 和 Mysql 较好。如果不需要特别复杂的要求,那么自己就可以利用 setNx 进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴 Redission。对于一些要求比较严格的场景来说的话可以使用 RedLock。 需要维护 Redis 集群,如果要实现 RedLock 那么需要维护更多的集群。
Zookeeper ZK 可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK 获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用 ZK 集群进行保证。 ZK 需要额外维护,增加维护成本,性能和 Mysql 相差不大,依然比较差。并且需要开发人员了解 ZK 是什么。

    上面几种方式,哪种方式都无法做到完美。就像 CAP 一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

    最后,原谅博主经验有限,文采不足,文中如有错误,请帮忙指正!谢谢!

本文使用 mdnice 排版