分布式锁总结

712 阅读27分钟

1. 什么是分布式锁

分布式锁是控制分布式系统之间同步访问共享资源的一种方式。

我们用锁在多线程环境中控制对资源的并发访问,但是随着分布式的快速发展,本地的加锁往往不能满足我们的需要,在我们的分布式环境中上面加锁的方法就会失去作用。

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

2. 为什么要用分布式锁

  • 提升效率 使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。
  • 保证正确性 加分布式锁同样可以保证正确性,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

3. 分布式锁的特性

以下的特性不是所有锁都需要支持的,根据具体的业务场景,可以只使用其中的某一种或者几种特性。考虑到单点问题,这个分布式锁最好是集群模式,支持高可用,同时获取锁和释放锁的性能要好。

  • 互斥性: 同一时刻只能有一个线程持有锁
  • 可重入性: 同一节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 锁超时:和JUC中的锁一样支持锁超时,防止死锁
  • 高性能和高可用: 加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒
  • 支持公平锁和非公平锁:公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。

4. 分布式锁的实现方式

一般实现分布式锁有以下几个方式:

  • MySql
  • Zk
  • Redis 下面分开介绍一下这些分布式锁的实现原理。

5. 基于Mysql实现分布式锁

基于MySql实现分布式锁有三种方式,分别是基于唯一索引,悲观锁与乐观锁

基于MySql实现分布式锁主要是利用MySql自身的行锁机制,保证对于某一行,同时只能由一个线程对其查询或者更新(这就实现了分布式锁的最基本的特性:互斥性),而能够访问该行的线程即可以认为是获取到分布式锁的线程。

(1)基于唯一索引实现

创建一个锁表,在表中添加一列名为lock_name,为这一列添加唯一索引unique key,对于某一个方法,如果有多个节点的多个线程同时访问,那么让这些线程去执行insert,由于唯一索引的存在,只会有一个线程插入成功,那么这个插入成功的线程就可以认为是获取到分布式锁的线程。

CREATE TABLE lock(
id int(11) NOT NULL AUTO_INCREMENT COMMENT ‘主键’,
lock_name varchar(64) NOT NULL DEFAULT ‘’ COMMENT ‘锁名’,
desc varchar(1024) NOT NULL DEFAULT ‘备注信息’,
update_time timestamp NOT NULL DEFAULT 
CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ‘保存数据时间,自动生成’,
PRIMARY KEY (id),
UNIQUE KEY uidx_lock_name (lock_name) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=‘锁定中的方法’;

--获取锁执行insert语句,如果插入成功则说明可以成功获取到锁
insert into lock(lock_name,desc) values (‘lock_name’,‘desc’)

--释放锁则执行delete语句
delete from lock where lock_name='lock_name'

insert为非阻塞的,一旦插入失败就返回结果了,如果想要实现阻塞可以使用while循环;

要实现公平锁,可以引入一张表,记录因为插入失败而阻塞的线程,一旦锁被释放,被阻塞的线程可以根据插入的先后顺序来决定自己是否可以获取锁;

要实现可重入性,需要在锁表中增加一列,记录获取锁的服务节点信息与线程信息,获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了;

要实现锁超时,需要在锁表增加一列,记录锁失效的时间,同时增加一个定时任务系统,定时扫描锁表中超时的记录,删除该条记录从而释放锁。

(2)基于悲观锁(数据库排他锁)实现

for update

for update是一种行级锁,又叫排它锁,一旦用户对某个行施加了行级加锁,则该用户可以查询也可以更新被加锁的数据行,其它用户只能查询但不能更新被加锁的数据行.如果其它用户想更新该表中的数据行,则也必须对该表施加行级锁.即使多个用户对一个表均使用了共享更新,但也不允许两个事务同时对一个表进行更新,真正对表进行更新时,是以独占方式锁表,一直到提交或复原该事务为止。行锁永远是独占方式锁。另外for update仅适用于InnoDB,且必须在事务处理模块(BEGIN/COMMIT)中才能生效。

只有当出现如下之一的条件,才会释放共享更新锁:

  • 执行提交(COMMIT)语句
  • 退出数据库(LOG OFF)
  • 程序停止运行

通常情况下,select语句是不会对数据加锁,妨碍影响其他的DML和DDL操作。同时,在多版本一致读机制的支持下,select语句也不会被其他类型语句所阻碍。

而select … for update 语句是我们经常使用手工加锁语句。在数据库中执行select … for update ,会对数据库中的表或某些行数据进行锁表,在mysql中,如果查询条件带有主键,会锁行数据,如果没有,会锁表。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它解锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。就像for update,再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

分布式锁实现

基于数据库排他锁for update实现,使用select … for update成功的线程可以获取到锁,失败的线程会被阻塞。而事务执行完毕,commit事务的时候就相当于释放了锁资源。

@Transaction
public void lock(String lockName) {
  ResourceLock rlock = 
  exeSql("select * from resource_lock where resource_name = lockName for update");
    if (rlock == null) {
          exeSql("insert into resource_lock(reosurce_name,owner) 
          values (lockName, 'ip')");
    }
   /**
     业务逻辑
   **/
   // 最后事务执行完毕,提交的时候就表示锁可以释放了
}

查询和插入的操作放在一个事务之中,事务开始执行select … for update查询的时候,代表有请求进来想要获取锁,如果查询成功或者发现锁表中没有该条记录,那么说明当前的请求可以获取该分布式锁资源。获取到锁资源之后,执行业务逻辑,业务逻辑执行完毕,事务提交,那么锁资源就被释放了。

MySql Innodb引擎中可以通过设置innodb_lock_wait_timeout控制全局的锁超时时间,默认是50s。

排他锁需要事务资源,会占用数据库连接,因为每个请求过来都会去for update,并且只有一个线程请求可以真正执行,其他的请求会持有连接资源阻塞住等待已经获取锁的线程释放。这样会造成大量的连接被占用,产生连接爆满的问题。

显然,使用排他锁的话很难实现锁重入。并且由于innodb_lock_wait_timeout是针对全局的,所以对锁超时的支持也不太好。

(3)基于乐观锁实现

乐观锁

顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

分布式锁实现

我们知道,mysql乐观锁机制是在表中增加了version列,记录版本号。之后先查询某一行数据将版本号取出。更新的时候对版本号进行比较,一致的话,说明没有其他请求修改该条记录,那么就可以执行更新操作,否则的话无法更新。

select * from resource where resource_name = xxx;

update resource set version= 'newVersion' ... 
where resource_name = xxx and version = 'oldVersion;

很显然,同一时刻只能有一个请求成功更新该条记录,那么我们就可以把成功更新该条记录的线程作为获取到分布式锁的线程。

@Transaction
public void lock(String lockName) {
  ResourceLock rlock = exeSql("select * from resource_lock 
  where resource_name = lockName");
    if (rlock == null) {
          exeSql("insert into resource_lock(reosurce_name,owner,version) 
          values (lockName, 'ip',1)");
    }
   /**
     业务逻辑
   **/
   Integer count = exeSql("update resource_lock set version=version+1 
   where  resource_name = lockName and version=#{version}");
   if(count == 1) {
       // 成功获取到分布式锁,可以提交本次事务
   } else {
       // 使用while重复执行select,update来获取分布式锁资源或者直接回滚本地事务
   }
   
}

乐观锁的实现要先执行select再执行update,如果冲突比较多的话,大量的线程请求会执行CAS耗费CPU时间。

(4)总结

基于数据库实现分布式锁,主要就是利用数据库自身的锁机制,保证插入,查询或者更新的排他性,但是由于数据库本身锁的开销以及性能,这种使用场景并不太多。

  • 优点:理解起来简单,不需要维护额外的第三方中间件(比如Redis,Zk)。
  • 缺点:虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。

6. 基于Zookeeper实现分布式锁

(1)Zookeeper简介

Zookeeper 是一种提供「分布式服务协调」的中心化服务,类似于Unix文件系统结构,可以看作一棵树,每个节点叫做ZNode。ZNode中可以存储数据。ZNode节点分为两种类型:

  • 临时节点 当客户端和服务端断开连接后,所创建的Znode(节点)会自动删除。根据节点是否有序分为普通临时节点与有序临时节点

  • 持久化节点 当客户端和服务端断开连接后,所创建的Znode(节点)不会删除。根据节点是否有序分为普通持久化节点与有序持久化节点

Zookeeper还提供了Watcher机制,Zookeeper允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候(比如节点存储信息改变/节点被删除/节点新增(删除)子节点等),ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是Zookeeper实现分布式协调服务的重要特性。

(2)Zookeeper分布式锁的实现

image.png

首先创建持久化节点/lock_name1,每当有客户端来访问/lock_name1节点,就在该节点下创建一个临时有序子节点,由于临时有序节点是递增的,所以总有一个临时有序节点的序号是最小的,那么这个序号最小的节点就可以获得分布式锁。而其他未获取到锁请求则通过Watcher机制监听上一个比自己序号小的节点。

  • 客户端C1拿到/lock_name1下面所有的子节点,比较序号,发现自己序号最小,所以得到锁。

  • 客户端C2拿到/lock_name1下面所有的子节点,比较序号,发现自己的序号不是最小的,所以客户端C2并不会获取到锁,而是监听比自己序号小的上一个节点C1的状态。

  • 客户端C3拿到/lock_name1下面所有的子节点,比较序号,发现自己的序号不是最小的,所以客户端C3并不会获取到锁,而是监听比自己序号小的上一个节点C2的状态。

  • 客户端C1执行完毕,释放锁资源,同时C1节点被删除,而C2监控到C1节点状态发生变化,比较之后发现自己的序号最小,所以可以获取锁。

(3)Zookeeper分布式锁存在的问题

  • Zk性能上可能并没有缓存服务那么高 因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上。
  • 有可能带来并发问题 由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。

就可能产生并发问题了,这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。

多次重试之后还不行的话才会删除临时节点。所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。

Curator

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

  • 监听机制的问题 监听,是所有服务都去监听一个节点的,节点的释放也会通知所有的服务器,如果服务器数量很多,对服务器将是很大的一个挑战。一个释放的消息,就好像一个牧羊犬进入了羊群,大家都四散而开,随时可能干掉机器,会占用服务资源,网络带宽等等,也就是羊群效应

可以用临时顺序节点解决这个问题。每个节点只监听前一个节点,因为是顺序的,很容易找到自己的前后。和之前监听一个永久节点的区别就在于,这里每个节点只监听了自己的前一个节点,释放当然也是一个个释放下去,就不会出现羊群效应了。

  • 为什么创建的是临时节点 避免死锁。如果第一个客户端加锁成功了,在执行代码的时候,机器宕机了,那节点就不能删除了,其他节点一直等待锁释放,就会出现死锁问题。而创建临时节点,客户端连接一断开,别的客户端就可以监听到节点的变化了,此时其他客户端就可以自动获取锁。

(4)总结

zookeeper实现分布式锁主要也是使用了zookeeper的自身的特性,即使同一时刻多个请求过来,创建的节点的序号也是递增的且不会重复。而利用监听机制可以保证锁被释放之后其他节点可以获取到该信息从而有机会去获取锁。

zk通过临时节点,解决掉了死锁的问题,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉,其他客户端自动获取锁。ZK要实现锁的可重入,可以带上线程信息或者机器信息这样的唯一标识,获取的时候判断一下。zk通过节点排队监听的机制,也实现了阻塞的原理,其实就是个递归在那无限等待最小节点释放的过程。zk的集群也是高可用的,只要半数以上的或者,就可以对外提供服务了。

  • 优点 ZK可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用ZK集群进行保证。
  • 缺点 ZK需要额外维护,增加维护成本,性能和Mysql相差不大,依然比较差。

7. 基于Redis实现分布式锁

Redis除了用作缓存之外,也可以用来作为分布式锁。利用redis天然的支持分布式系统的特性以及某些命令的使用,可以较好的来实现分布式锁。redis的读写性能比较数据库来说也有极大的提升,目前也是一种较为流行的分布式锁解决方案。

(1)SetNX命令实现分布式锁

SETNX key value 将key的值设置为value,当且仅当key不存在的时候。SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

redis 2.6.12之前的版本是使用setnx+expire命令来实现分布式锁,expire命令是给锁设置过期时间避免发生死锁。

public static boolean lock(Jedis jedis, String lockKey, String requestId, int expireTime) {
   	// 使用requestId作为value是防止出现线程B把线程A的锁给释放了
       Long result = jedis.setnx(lockKey, requestId);
       //设置锁
       if (result == 1) {
           //获取锁成功
           //若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
           //通过过期时间删除锁
           jedis.expire(lockKey, expireTime);
           return true;
       }
       return false;
}

由于上面setnx命令与expire命令非原子性,那么在获取锁成功准备去设置锁超时时间的时候,极端情况下系统崩溃了,那么该锁就没有设置超时时间,无法释放锁资源。我们可以使用lua脚本来将这两个命令设置为原子性的。

另外,这种方式还有个问题:客户端A获取到分布式锁执行自己的程序,正常情况下,程序执行完毕需要释放锁资源,但是在某次程序执行的时候,由于调用第三方服务超时了,在没有执行到程序结束的时候,锁就已经过有效期了,这个时候客户端B就可以获取锁资源去执行自己的程序,在客户端B执行的过程中,客户端A执行的程序结束了,这个时候客户端A仍然执行释放锁的操作,结果就会把客户端B的锁给释放了。

(2)SET key value [EX seconds] [PX milliseconds] [NX|XX] 命令实现分布式锁

从 Redis 2.6.12 版本开始, SET 命令的行为可以通过一系列参数来修改:

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX - key second value 。
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
  • XX :只在键已经存在时,才对键进行设置操作。 通过设置EX PX NX 这三个参数,可以实现setnx与expire命令的结合。这个是Redis 2.6.12 版本之后提供的原子性的命令。
// 获取锁
public static boolean tryGetDistributedLock(String lockKey,
       String requestId, int expireTime) {
   Jedis jedis = RedisUtil.getJedis();
   String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
   if ("OK".equals(result)) {
       return true;
   }
   return false;
}

// 释放锁,使用lua脚本
public static boolean tryGetDistributedLock(String lockKey,
       String requestId, int expireTime) {
   Jedis jedis = RedisUtil.getJedis();
   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)); // 执行lua脚本
   if (1L.equals(result)) {
       return true;
   }
   return false;
}

(3)Redisson实现分布式锁

Redission是基于redis实现的分布式锁,主要使用的是redis的hash结构。

  • HSET key field value

将哈希表 key 中的域 field 的值设为 value 。如果 key 不存在,一个新的哈希表被创建并进行 HSET 操作。如果域 field 已经存在于哈希表中,旧值将被覆盖。

  • HINCRBY key field increment

为哈希表 key 中的域 field 的值加上增量 increment 。增量也可以为负数,相当于对给定域进行减法操作。

  • HEXISTS key field

查看哈希表 key 中,给定域 field 是否存在。

Redisson的使用

使用起来很简单,跟正常使用Lock基本没啥区别。另外,Redisson提供了watch dog机制,可以在锁将要过期的时候自动延期,防止程序尚未执行完毕锁即被释放。

public void redissonDemo(){
        //分布式锁
        RLock redissonLock = redisson.getLock("xxx");
        //加锁
        redissonLock.lock();
        try {
            //业务逻辑
        }catch (Exception e){
            //异常捕获
            e.printStackTrace();
        }finally {
            //还在持有锁的状态,并且是当前线程持有的锁再解锁
            if (redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
                redissonLock.unlock();
            }
        }
    }
Redisson的底层加锁逻辑

主要还是使用LUA保证原子性。加锁时判断锁是否存在,存在就设置过期时间,如果锁已经存在了,那对比一下线程,线程是一个那就证明可以重入,锁在了,但是不是当前线程,证明别人还没释放,那就把剩余时间返回,加锁失败。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,     
                            long threadId, RedisStrictCommand<T> command) {

        //过期时间
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  //如果锁不存在,则通过hset设置它的值,并设置过期时间
                  "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; " +
                  //如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
                  "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; " +
                  //如果锁已存在,但并非本线程,则返回过期时间ttl
                  "return redis.call('pttl', KEYS[1]);",
        Collections.<Object>singletonList(getName()), 
                internalLockLeaseTime, getLockName(threadId));
    }
Redisson的底层解锁逻辑

锁的释放主要是publish释放锁的信息,然后做校验,一样会判断是否当前线程,成功就释放锁,还有个hincrby递减的操作,锁的值大于0说明是可重入锁,那就刷新过期时间。如果值小于0了,那删掉Key释放锁。

与AQS的思想很相似,AQS就是通过一个volatile修饰status去看锁的状态,也会看数值判断是否是可重入的。

public RFuture<Void> unlockAsync(final long threadId) {
    final RPromise<Void> result = new RedissonPromise<Void>();
    
    //解锁方法
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.addListener(new FutureListener<Boolean>() {
        @Override
        public void operationComplete(Future<Boolean> future) throws Exception {
            if (!future.isSuccess()) {
                cancelExpirationRenewal(threadId);
                result.tryFailure(future.cause());
                return;
            }
            //获取返回值
            Boolean opStatus = future.getNow();
            //如果返回空,则证明解锁的线程和当前锁不是同一个线程,抛出异常
            if (opStatus == null) {
                IllegalMonitorStateException cause = 
                    new IllegalMonitorStateException("
                        attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                result.tryFailure(cause);
                return;
            }
            //解锁成功,取消刷新过期时间的那个定时任务
            if (opStatus) {
                cancelExpirationRenewal(null);
            }
            result.trySuccess(null);
        }
    });

    return result;
}


protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
    
            //如果锁已经不存在, 发布锁释放的消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            //如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            //通过hincrby递减1的方式,释放一次锁
            //若剩余次数大于0 ,则刷新过期时间
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            //否则证明锁已经释放,删除key并发布锁释放的消息
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
    Arrays.<Object>asList(getName(), getChannelName()), 
        LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
Redisson的问题

在Redis哨兵模式下,客户端1 对某个master节点写入了redisson锁,此时会异步复制给对应的 slave节点。但是这个过程中一旦发生 master节点宕机,主备切换,slave节点从变为了 master节点。这时客户端2 来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。

(4)RedLock简介

Redis 官方提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  • 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  • 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
  • 容错性:只要大部分 Redis 节点存活就可以正常提供服务
怎么在单节点上实现分布式锁?

例如:SET resource_name my_random_value NX PX 30000   主要依靠上述命令,该命令仅当 Key 不存在时(NX保证)set 值,并且设置过期时间 3000ms (PX保证),值 my_random_value 必须是所有 client 和所有锁请求发生期间唯一的,释放锁的逻辑是:

if redis.call("get",KEYS[1]) == ARGV[1] then
     return redis.call("del",KEYS[1])
 else
     return 0
 end

上述实现可以避免释放另一个client创建的锁,如果只有 del 命令的话,那么如果 client1 拿到 lock1 之后因为某些操作阻塞了很长时间,此时 Redis 端 lock1 已经过期了并且已经被重新分配给了 client2,那么 client1 此时再去释放这把锁就会造成 client2 原本获取到的锁被 client1 无故释放了,但现在为每个 client 分配一个 unique 的 string 值可以避免这个问题。至于如何去生成这个 unique string,方法很多随意选择一种就行了。

算法

起 5 个 master 节点,分布在不同的机房尽量保证可用性。为了获得锁,client 会进行如下操作:

  • 得到当前的时间,微秒单位
  • 尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间
  • 当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
  • 如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间
  • 如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态
失败重试

如果一个 client 申请锁失败了,那么它需要稍等一会在重试避免多个 client 同时申请锁的情况,最好的情况是一个 client 需要几乎同时向 5 个 master 发起锁申请。另外就是如果 client 申请锁失败了它需要尽快在它曾经申请到锁的 master 上执行 unlock 操作,便于其他 client 获得这把锁,避免这些锁过期造成的时间浪费,当然如果这时候网络分区使得 client 无法联系上这些 master,那么这种浪费就是不得不付出的代价了。

锁的释放

放锁操作很简单,就是依次释放所有节点上的锁就行了

(5)总结

  • 优点:对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自己就可以利用setNx进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。
  • 缺点:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

8. 分布式锁的安全问题

  • 长时间的GC pause 熟悉Java的同学肯定对GC不陌生,在GC的时候会发生STW(stop-the-world),例如CMS垃圾回收器,他会有两个阶段进行STW防止引用继续进行变化。那么有可能会出现下面图(引用至Martin反驳Redlock的文章)中这个情况: image.png client1获取了锁并且设置了锁的超时时间,但是client1之后出现了STW,这个STW时间比较长,导致分布式锁进行了释放,client2获取到了锁,这个时候client1恢复了锁,那么就会出现client1,2同时获取到锁,这个时候分布式锁不安全问题就出现了。这个其实不仅仅局限于RedLock,对于我们的ZK,Mysql一样的有同样的问题。

解决方案:对于ZK这种他会生成一个自增的序列,那么我们真正进行对资源操作的时候,需要判断当前序列是否是最新,有点类似于我们乐观锁。不过,既然都能生成一个自增的序列了那么你完全不需要加锁了,也就是可以按照类似于Mysql乐观锁的解法去做。这种解法增加了复杂性,当我们对资源操作的时候需要增加判断序列号是否是最新,无论用什么判断方法都会增加复杂度。

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

解决方案:RedLock不安全很大的原因也是因为时钟的跳跃,因为锁过期强依赖于时间,但是ZK不需要依赖时间,依赖每个节点的Session。Redis作者也给出了解答:对于时间跳跃分为人为调整和NTP自动调整。

人为调整:人为调整影响的那么完全可以人为不调整,这个是处于可控的。

NTP自动调整:这个可以通过一定的优化,把跳跃时间控制的可控范围内,虽然会跳跃,但是是完全可以接受的。

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

解决方案:可以控制网络调用的超时时间,把所有网络调用的超时时间相加,那么我们锁过期时间其实应该大于这个时间,当然也可以通过优化网络调用比如串行改成并行,异步化等。

参考文章:
www.cnblogs.com/baichunyu/p… blog.csdn.net/wgwaaaa/art… juejin.cn/post/684490…
juejin.cn/post/684490…