背景

竞争条件

多个线程依赖某个共享资源,并根据共享资源的值修改该共享资源。由于线程调度的不确定性,MVVC等原因可能会导致出现预期之外的结果。

举个例子,有一家医院要求每个科室每天晚上至少有一个医生值夜班,这一天内科同时有两个医生A和B在值夜班。

A突然有紧急的事情,决定要回家,于是A查看医院的管理系统,发现此时B还在值班,所以A放心的走了。

与此同时B也有急事,也要回家,B打开医院的管理系统,一看A还在值班,B也放心的走了。

这就是一个竞争条件,解决这个问题只需要对读和写加一个互斥锁,保证整个操作是一个原子操作,并且同时只有一个线程可以访问即可。

分布式一致性

构建容错系统的最好方法,是找到一些带有实用保证的通用抽象,实现一次,然后让应用依赖这些保证。这与事务处理方法相同:通过使用事务,应用可以假装没有崩溃(原子性),没有其他人同时访问数据库(隔离),存储设备是完全可靠的(持久性)。即使发生崩溃,竞态条件和磁盘故障,事务抽象隐藏了这些问题,因此应用不必担心它们。

现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概念。例如,分布式系统最重要的抽象之一就是共识(consensus):就是让所有的节点对某件事达成一致。

分布式锁

为什么选用Redis实现分布式锁

微服务多个实例之间无法使用Java内置的锁进行互斥,所以需要使用共享的内存,为什么使用RDB实现分布式锁,有以下几个原因:

1.串行化

中间件有好多,为什么要选用Redis呢?其中很大一部分原因是因为Redis的工作线程是单线程的,可以提供串行化的保障。在可串行化的隔离级别下,编写互斥锁是非常容易的。

2.原子命令

只有串行化是不够的,如果命令是分散的,仍然会因为顺序的不确定性导致出现预期外的结果。Redis提供了setNX,lua Script等功能帮助大家实现原子操作

3.持久化

Redis提供多种序列化方式,虽然仍然不能保证数据完全不会丢失,对于锁来说足够了

4.QPS高

很难想想一个串行化的系统的QPS还能这么高,Redis通过以下几点实现高QPS:

  • 基于内存
  • 耗时操作少
  • 多路复用

实现加锁

用Redis来实现分布式锁最简单的方式就是在实例里创建一个键值(对于已经存在的键值再次声明返回失败),创建出来的键值一般都是有一个超时时间的(这个是Redis自带的超时特性),所以每个锁最终都会释放。

最简单的加锁实现:

  1. redisConnection.set(lockKey, NX , PX, expireMsecs)

加入获取锁的重试逻辑(在有限的等待时间内重试几次):

  1. public boolean tryLock() {
  2. long waitMillis = timeoutMsecs;
  3. value = UUID.randomUUID().toString();
  4. while (waitMillis >= 0) {
  5. long startNanoTime = System.nanoTime();
  6. String lockResult = setNx();
  7. locked = OK.equals(lockResult);
  8. if (locked || waitMillis == 0) return locked;
  9. int sleepMillis = new Random().nextInt(100);
  10. sleep(sleepMillis);
  11. long escapedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanoTime);
  12. waitMillis = waitMillis - escapedMillis;
  13. }
  14. return false;
  15. }

实现解锁

解锁是一个看似简单的操作,但是如果考虑不周也会存在很多问题

最简单的方案:直接del对应的key

这个方案有什么问题呢?
假如线程A持有了锁,设置了超时时间为1秒,由于GC等各种原因当代码执行到unlock的时候,A的锁已经超时,并且此时该锁已经被B拿到,如果此时A直接delete对应的key,那么就会导致B的锁失效。此时假如有新的线程访问同一个锁,就无法保证互斥。

加锁方记录时间戳

最简单的方式是在加锁方记录一个时间,解锁时判断是否超时,这样其实还是存在一个问题,就是如果还有1ms就要超时了,此时去delete还是有可能把别人的锁删掉,根本原因是由于判断和删除不是一个原子操作。

使用lua script

我们可以在Redis的键值中记录锁的所有者,只有锁的所有者才能删除这个key,并且保证判断和删除是一个原子操作。
Redis提供了执行lua script的功能,帮助我们实现这个原子操作,我们只需要在加锁时指定一个唯一值,并在解锁时使用如下lua script即可实现解锁操作。

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

可重入

线程可重入

线程级别的重入可以将重试次数存放在ThreadLocal,进行计次即可

调用链级别的重入

如果期望调用链级别的可重入,重入次数需要记录在Redis中,加锁和解锁都需要编写lua script

有分歧的一点就是超时时间如何设置,一般有以下两种方式:

  • 第一次加锁时指定一个总超时时间
  • 每次重入都会增加单次重入的超时时间

我个人更倾向于第一种,超时时间应该是一个静态可控的值,如果随时都有可能增加,会给排查问题带来很多困难

一些细节

时钟

细心的同学可能会发现在实现重试的时候使用的时间API为System.nanoTime(),它和我们常用的System.currentTimeMillis()有什么区别呢?

单调钟与时钟

现代计算机至少有两种不同的时钟:时钟和单调钟。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的目的。

时钟

时钟是您直观地了解时钟的依据:它根据某个日历(也称为挂钟时间(wall-clock time))返回当前日期和时间。例如,Linux上的clock_gettime(CLOCK_REALTIME)和Java中的System.currentTimeMillis()返回自epoch(1970年1月1日 午夜 UTC,格里高利历)以来的秒数(或毫秒),根据公历日历,不包括闰秒。有些系统使用其他日期作为参考点。

时钟通常与NTP同步,这意味着来自一台机器的时间戳(理想情况下)意味着与另一台机器上的时间戳相同。但是如下节所述,时钟也具有各种各样的奇特之处。特别是,如果本地时钟在NTP服务器之前太远,则它可能会被强制重置,看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实,使时钟不能用于测量经过时间。

单调钟

单调钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux上的clock_gettime(CLOCK_MONOTONIC),和Java中的System.nanoTime()都是单调时钟。这个名字来源于他们保证总是前进的事实(而时钟可以及时跳回)。

你可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。

事务

假如存在下面一组操作:

  1. 加锁
  2. 查询数据是否存在
  3. 如果不存在插入到数据库
  4. 解锁

如果此时有两个线程同时访问一个接口,且请求参数都相同,那么会出现同时插入两条相同数据的情况吗?

答案是会,我们在上述步骤中加入线程A,线程B,事务A的描述

  1. 线程A:加锁
  2. 线程A:查询数据不存在
  3. 线程A:插入到数据库
  4. 线程A:解锁
  5. 线程B:拿到锁
  6. 线程B:查询数据是否存在
  7. 线程A:提交事务
  8. 线程B:插入到数据库
  9. 线程B:解锁

按照这个执行顺序,就会出现一些问题。所以在使用分布式锁的时候也需要考虑事务的问题,对于本地事务可以将解锁方法注册到事务提交的回掉里。对于分布式事务可能需要考虑2PC等解决方案。

简化使用

在Java的互斥锁中有着这样一个强制规范

在使用阻塞等待获取锁的方式中,必须在try代码块之外,并且在加锁方法与try代码块之间没有任何可能抛出异常的方法调用,避免加锁成功后,在finally中无法解锁。

  • 说明一:如果在lock方法与try代码块之间的方法调用抛出异常,那么无法解锁,造成其它线程无法成功获取锁。
  • 说明二:如果lock方法在try代码块之内,可能由于其它方法抛出异常,导致在finally代码块中,unlock对未加锁的对象解锁,它会调用AQS的tryRelease方法(取决于具体实现类),抛出IllegalMonitorStateException异常。
  • 说明三:在Lock对象的lock方法实现中可能抛出unchecked异常,产生的后果与说明二相同。

正例:
  1. Lock lock = new XxxLock();
  2. // ...
  3. lock.lock();
  4. try {
  5. doSomething();
  6. doOthers();
  7. } finally {
  8. lock.unlock();
  9. }

反例:
  1. Lock lock = new XxxLock();
  2. // ...
  3. try {
  4. // 如果此处抛出异常,则直接执行finally代码块
  5. doSomething();
  6. // 无论加锁是否成功,finally代码块都会执行
  7. lock.lock();
  8. doOthers();
  9. } finally {
  10. lock.unlock();
  11. }

在使用尝试机制来获取锁的方式中,进入业务代码块之前,必须先判断当前线程是否持有锁。锁的释放规则与锁的阻塞等待方式相同。

说明:Lock对象的unlock方法在执行时,它会调用AQS的tryRelease方法(取决于具体实现类),如果当前线程不持有锁,则抛出IllegalMonitorStateException异常。

同样的问题放在RedisLock这里也是存在的,比如在未获取锁的时候就去解锁,虽然RedisLock不会报错,但是也会增加一次Redis访问。尽管如此,通过一些改造,RedisLock能改进一些使用的体验。

加锁

RedisLock加锁不同于Java的内置锁,会出现很多种异常,如果我们期望在外部捕获,由于lock()不能写在try finnaly块中,那么我们需要在写一层try catch,实在过于冗余。

如果能lock能写在try块里或者try finally能够简洁优雅一些就好了

使用Java 7提供的try witch resource可以使得操作更加简洁

  1. try(Lock lock = new RedisLock()) {
  2. }

想要RedisLock支持该特性只需要实现AutoCloseable接口

看起来是不是很好用,但是我们好像疏忽了一点

使用try witch resource会导致不管有没有或者到锁都会调用unlock方法

解锁

所幸我们实现的RedisLock和Java中提供的锁有两个本质区别:

  1. Java中提供的锁无法修改代码
  2. Java中的锁在内存中是多线程访问的,而RedisLock是局部变量(RedisLock只是Client,真正的锁在Redis)
    因此我们可以在RedisLock中增加一个变量记录是否加锁成功,如果未加锁成功调用解锁方法就直接return了
  1. public void unlock() {
  2. if (!locked) return;
  3. RedisCommands<String, String> redisCommands = RedisConnections.getConnection(6).sync();
  4. Object result = redisCommands.eval(UNLOCK_SCRIPT, ScriptOutputType.INTEGER, new String[]{lockKey}, value);
  5. if (CastUtil.castInt(result) < 0) {
  6. }
  7. locked = false;
  8. }

RedLock

我们之前的实现都是基于单节点Redis实现的,但是基于单Redis节点的分布式锁无法解决failover的问题:

如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。考虑下面的执行序列:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

针对这个问题,Redis的作者antirez设计了Redlock算法。

Redlock的算法描述就放在Redis的官网上:https://redis.io/topics/distlock

关于这个算法的可靠性,也产生了一场争论,大家对RedLock感兴趣可以看下以下文章:

引用

开源版本

DisLock是我使用Lettuce实现的单节点RedisLock
DisLock:https://github.com/KeshawnVan/DisLock