• 为什么需要分布式锁?

在编写多线程程序时,为了避免同时操作进程中的共享变量,一般会使用一把“互斥锁”,确保该进程中共享变量的正确性,这把“互斥锁”就是与“分布式锁”相对应的“单机锁”。又或者是在同一台机器上的不同进程,想要同时操作一个共享资源(比如修改同一个文件),可以使用操作系统提供的“文件锁”或“信号量”来做互斥。

以上介绍的两种互斥操作,都仅限于线程、进程处于同一台机器上。如果是不同机器上的不同进程,要同时操作一个共享资源(比如修改数据库中的数据),如何做到互斥呢?

至此,“分布式锁”应运而生。

想要实现分布式锁,需要借住一个外部系统来管理锁。这个外部系统必须具备互斥的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败或等待。

外部系统可以是 MySQL、Redis、Zookeeper、Etcd。本片文章介绍的是 Redis。高并发业务场景下,Redis 做分布式锁可以有更高的性能。

  • 如何基于 Redis 实现分布式锁?

在 Redis 中,SETNX 命令可以实现互斥。SETNX 命令表示 SET if Not eXists,如果 key 不存在,才会设置 key 的值,如果 key 存在,则 SETNX 什么也不做。

开启两个客户端进程先后执行这个命令,互斥效果如下:

客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lockKey uniqueValue
(integer) 1    // 客户端 1,加锁成功

客户端 2 申请加锁,加锁失败:

127.0.0.1:6379> SETNX lockKey uniqueValue
(integer) 0    // 客户端 2,加锁失败

其中,lockKey 表示加锁的锁名,uniqueValue 表示能够唯一标识锁的随机字符串。至于为什么需要 uniqueValue 这样的唯一字符串后面在介绍 Redis 分布式锁存在的第三个问题——释放被人的锁时会介绍到。这样一来,加锁成功的客户端 1 就可以去操作共享资源。例如:修改 MySQL 某一行数据,或调用某个 API 执行业务逻辑。操作完成后,需要及时释放锁,让后面的客户端进程也可以操作共享资源。

直接通过 DEL 命令删除对应的 key 就可以释放锁:

127.0.0.1:6379> DEL lockKey
(integer) 1    // 释放锁成功

上述加锁过程逻辑非常简单。但是,存在一个很大的问题,有【死锁】的可能:

  1. 加锁成功后,程序处理业务逻辑发生异常,没及时释放锁;
  2. 加锁成功后,进程挂了,没机会释放锁。

以上述加锁为例,此时的客户端 1 就会一直占用这把锁,客户端 2 就一直无法拿到这把锁,出现“锁饥饿”现象。

  • Redis 分布式锁存在的第一个问题:死锁

对于第 1 种情况,程序在处理业务逻辑时发生异常,没及时释放锁。解决方案是对这段业务代码加上异常处理,保证无论业务逻辑是否异常,都可以把锁释放掉。在 GoLang 的 defer 语句和 Java 的 finally 语句种手动释放锁:

Go:defer redis.del(key)

Java:try … catch … finally:redis.del(key)

对于第 2 种情况,进程挂了,没机会释放锁。解决方案是对锁设置一个过期时间。语句如下:

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK

上述语句的 EX 3 参数表示过期时间设置为 3 秒(EX 以秒为单位),3 秒后该锁自动释放。PX 30000 参数表示过期时间设置为 30 秒(PX 以毫秒为单位)。

加锁、设置过期时间本来是两条命令,不能保证原子操作,就仍然会存在【死锁】问题。所以在 Redis 2.6.12 之后,Redis 官方扩展了 SET 命令参数,把 NX/EX 集成到 SET 语句中,执行一条命令即可。

解决了【死锁】问题后,其实还存在其它的问题。

试想这样一种场景:

  1. 客户端 1 加锁成功,开始操作共享资源;
  2. 客户端 1 操作共享资源的时间,超过了锁的过期时间,锁被提前释放;
  3. 客户端 2 加锁成功,开始操作共享资源;
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)。

存在两个问题:一个是锁过期,客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有;另一个是释放别人的锁,客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁。

  • Redis 分布式锁存在的第二个问题:锁过期

锁过期问题的本质就是客户端拿到锁之后,执行的业务逻辑所需的预估时间不准确导致的。如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效;如果锁的超时时间设置过长,又会影响到性能。

解决方案:加锁时,先设置一个过期时间,然后额外开启一个线程异步定时的检测锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就会自动对锁进行续期,重新设置过期时间。

  • 如何实现锁的续期?

在 Redis 官网文档中可以找到各种变成语言的解决方案,比如 Java 中的 Redisson,Go 中的 Redsync,Redis 官方文档地址:Distributed Locks with Redis | Docs

本片文章着重介绍一下 Redisson 的实现原理,解读一下源码。

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它提供了一个专门用来监控和续期锁的 Watch Dog(看门狗)机制,不断延长锁的过期时间,直到操作共享资源的线程执行完成。流程图如下:

下面介绍的源码版本是 redission-3.17.6

redisson/src/main/java/org/redisson/config/Config.java 文件中有个名为 getLockWatchdogTimeout() 的方法,看门狗名字由此而来。这个方法返回的是看门狗给锁续期的时间,默认值为 30 秒,源码如下:

// Redisson 配置类
public class Config {
    // ...... 其它配置参数在此省略
    
    // 默认续期 30 秒
    private long lockWatchdogTimeout = 30 * 1000;

    public Config() {}
    
    public Config(Config oldConf) {
        // ...... 其它初始化操作在此省略
        
        // 初始化看门狗续约时间
        setLockWatchdogTimeout(oldConfig.getLockWatchdogTimeout());
    }

    public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
        this.lockWatchdogTimeout = lockWatchdogTimeout;
        return this;
    }
    
    public long getLockWatchdogTimeout() {
        return lockWatchdogTimeout;
    }
}

redisson/src/main/java/org/redisson/RedissonBaseLock.java 文件中有个名为 renewExpiration() 方法,包含了看门狗的主要逻辑。源码如下:

private void renewExpiration() {
    // EXPIRATION_RENEWAL_MAP 是一个静态的 ConcurrentHashMap,全局存储所有锁的续期信息。
    // key 是 String 类型,保存锁的唯一标识;value 是 ExpirationEntry 对象。
    // getEntryName() 方法可以获取当前锁的唯一标识。
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask()) {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 根据锁名获取对应锁的信息
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            // 获取锁的线程 Id
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            // 异步实现锁的续约
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                // e 返回的是续约异常信息,可能是网络异常或者 Redis 服务器不可用导致的。
                // 从 EXPIRATION_RENEWAL_MAP 中移除该锁的相关信息,不在续约,看门狗停止工作。
                if (e != null) {
                    log.error("Can't update lock " + getRawName() + " expiration", e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                
                // res 返回的是正常续约对应的续约结果。
                // true 表示续约成功;false 表示续约失败(锁已被删除或者锁不属于当前线程)
                if (res) {
                    // 续约成功,递归调用,继续下一次续约
                    renewExpiration();
                } else {
                    // 续约失败,取消续约
                    cancelExpirationRenewal(null);
                }
             });
        }
       // 延迟 internalLockLeaseTime / 3 (30 / 3,默认 10 s)再调用续约逻辑
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
}

默认情况下,每过 10 秒看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前会判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。

异步续期 renewExpirationAsync() 方法源码如下:

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

在 renewExpirationAsync 方法中是调用 Lua 脚本实现的续约,可以保证续约的原子性。

接下来对 Lua 脚本进行完整解析:

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then  -- 检查锁是否存在且属于当前线程
    redis.call('pexpire', KEYS[1], ARGV[1]);            -- 续期锁的过期时间
    return 1;                                           -- 返回续期成功
end;
return 0;                                               -- 返回续期失败

命令KEYS[1] 对应锁名,ARGV[1] 标识锁的过期时间,ARGV[2] 表示锁的唯一标识。

通过 hexists 命令判断该锁是否为当前线程所持有;

通过 pexpire 命令将锁的过期时间重置为 ARGV[1] ,默认为 30 秒。

Java 中通过 Redisson 实现分布式可重入锁 RLock :

// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lockKey");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁

参考阅读:分布式锁常见实现方案总结 | JavaGuide

  • 如何实现可重入锁?

可重入锁是指在一个线程中可以多次获取同一把锁,比如一个线程在执行在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,无需重新获得锁。Java 中的 synchronized 和 ReentrantLock 都属于可重入锁。

在 RedissonBaseLock 类中的 ExpirationEntry 类中有个 addThreadId 方法,源码如下:

private final Map<Long, Integer> threadIds = new LinkedHashMap<>();

public synchronized void addThreadId(long threadId) {
    threadIds.compute(threadId, (t, counter) -> {
        counter = Optional.ofNullable(counter).orElse(0);
        counter++;
        return counter;
    });
}

其中 threadIds 表示为每个锁关联一个可重入计数器。当可重入计数器大于 0 时,锁被认为被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。

addThreadId 方法通过 synchronized 保证线程安全,防止多个线程同时修改 threadIdsMap,确保重入

后面继续介绍下 Go 中的 Redsync,解读下源码:

待补充~~~

到此,回顾一下,上述内容介绍了 Redis 做分布式锁存在的第二个问题——锁过期。除此之外,Redis 还有第三个问题——释放别人的锁问题

  • Redis 分布式锁存在的第三个问题:锁被别人释放

重新审视一下问题现象:客户端 1 释放了客户端 2 持有的锁。问题本质是客户端释放锁时是无判断的,它并没有检查这把锁是否还归自己持有,所以会发生释放别人锁的风险。

前面在分析 renewExpiration() 方法时有提到对 res 续约结果做判断,当 res == false,说明该锁可能并不属于当前线程。除此之外,前面在介绍 SETNX 命令时,提到为什么设置的 lockKey 要配置一个唯一标识的 uniqueValue 随机字段串。目的就是让客户端加锁时,设置一个只有自己知道的唯一标识,后续用来判断锁是否还属于自己。

一般这个唯一标识 uniqueValue 采用 id +”:” + lockKey 组成,id 为自己的线程 Id,当然也可以一个 UUID

  • Redis 分布式锁小结

至此,对前面所写进行小结,基于 Redis 实现的分布式锁,完整的过程如下:

  1. 加锁:SET lockKey uniqueValue EX expireTime NX
  2. 操作共享资源:异步开启定时器,对锁进行续约,直到业务逻辑操作共享资源结束
  3. 释放锁:基于 Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

每个步骤存在的问题以及相应的解决措施:

  1. 死锁:原子命令给锁设置过期时间;
  2. 过期时间不好评估,导致锁提前释放:看门狗机制,异步定时续约;
  3. 锁被别人释放:锁写入唯一标识 uniqueValue,释放锁前先检查标识,再释放。
  • Redis 如何解决集群情况下分布式锁的可靠性?

上面提到的 Redis 做分布式锁存在的三个问题是在单个 Redis 实例中可能出现的问题,在实际工程中一般会采用主从复制+哨兵模式部署。当主节点宕机后,哨兵节点可以实现故障转移,通过选举把从节点升级为主节点,保证高可用。

但是在 Redis 的集群模式下分布式锁会出现问题,试想这样的场景:

  1. 客户端 1 在 Redis 主节点上执行 SET 命令,加锁成功;
  2. 但是,Redis 主节点异常宕机,SET 命令还未同步到 Redis 从节点上(主从复制是异步的);
  3. Redis 从节点晋升为主节点时并不知道原主节点的锁,所以这把锁就丢失了,新 Redis 主节点还可以重复拿到该把锁。

Redis 的作者 antirez 提出了 Redlock 方案,Redlock 方案给出的配置如下:

  1. 不再需要部署从库哨兵实例,只部署主库
  2. 主库要部署多个,官方推荐至少 5 个实例。

Redlock 是直接部署至少 5 个彼此孤立的 Redis 实例,直接操作 Redis 节点,而不是部署 Redis 集群,非常的简单粗暴,但是最直观的缺点就是太重了,资源消耗极大。

Redlock 整体的流程如下:

  1. 客户端先获取【当前时间戳 T1】;
  2. 客户端依次向这 5 个 Redis 实例使用相同的 lockKey 和 uniqueValue 获取锁,即 SET 命令。值得注意的是,当向 Redis 申请锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该远小于锁的失效时间。假如锁的默认过期时间为 10 秒,那么超时时间应该在 5-50 毫秒之间。以保证某个 Redis 实例加锁失败时就立即向下一个 Redis 实例申请加锁;
  3. 如果客户端从 >= 3 个(半数)以上 Redis 实例加锁成功,则再次获取【当前时间戳 T2】。如果 T2 – T1 < 锁的过期时间,就认为客户端加锁成功,否则认为加锁失败;
  4. 加锁成功,可以操作共享资源,执行自己的业务逻辑(修改数据库等);
  5. 加锁失败,客户端应该在所有的 Redis 实例上进行解锁

Redlock 的思想是只要大多数的节点加锁成功,就可以保证集群模式下的 Redlock 正常工作。

但是 Redlock 性能太差,服务器内部发生时钟变迁时会有很多安全性隐患。《数据密集型应用系统设计》作者 martin 曾公开质疑 antirez 的 Redlock 方案,两位大佬的争论,可以看一下红锁之争这篇文章,写的很清楚。

参考阅读:

分布式锁常见实现方案总结 | JavaGuide

【求锤得锤的故事】Redis锁从面试连环炮聊到神仙打架。

Categories:

Tags:

No responses yet

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注