Redis实现分布式锁的分析与实践
技术分享
Jun 10, 2023
type
Post
status
Published
date
Jun 10, 2023
slug
redis-lock-in-practice
summary
本文来剖析一下到底应该怎么实现基于 Redis 的分布式锁,包含一个我自己的实现,还有 Redisson 的源码分析
tags
分布式
Java
Redis
category
技术分享
icon
password
Property
Jul 26, 2023 09:01 AM

分布式锁

持有锁的竞争者才能访问共享资源
持有锁的竞争者才能访问共享资源
分布式锁,顾名思义就是应用于分布式系统的锁。
所谓的锁(Lock 或者 Mutex)是一种在多个线程并发执行的场景中对资源的访问施加同步互斥限制的同步原语,确保了同一时间只有一个访问者能够访问到资源,避免了彼此之间的干扰。
跟传统单机多线程环境下的锁类似,分布式锁适用于分布式系统中多个进程或者多个节点同时访问共享资源的场景下所需的同步互斥控制,它是一种协作机制,确保在分布式环境中同一时间只有一个节点能够获取对共享资源访问的资格。分布式系统需要引入分布式锁,从根本上说有以下两个目的
  • 保证系统的效能:分布式锁能够避免同个任务被多次处理造成的不必要的消耗和成本,比如发送短信通知的时候,我们只需要给同一个号码发送一封,如果没有必要的锁来控制,同一用户可能会收到多次通知,会感到不适,我们也要支付更多的短信费用。
  • 保证系统的正确性:分布式锁能够防止并发进程们互相干扰并打乱系统状态,倘若没有并发控制机制的保护,多个进程同时对共享数据进行修改可能会造成分布式系统数据丢失、数据不一致等问题,严重损害系统的正确性。
使用目的关系到我们设计和实现分布式锁的时候所做的权衡和考量,如果为了保证执行效能,需要考虑的是实现方案的效率,如果为了保证正确性,实现方案的可靠性就应该放到第一位置来考虑。

实现方案

系统设计总是跟系统的业务目标和业务过程息息相关,分布式锁系统的业务目标(功能性需求)可以用系统中两个关键过程锁的获取和锁的释放来表示,当然还有一些非功能需求需要满足,下面是分布式锁系统常见的需求列表
  • 锁的获取和释放:分布式锁系统应该提供能够获取和释放锁的机制,并且确保同一时间只有一个节点能够获取到锁,其他节点需要等待锁的释放后再次竞争锁
  • 锁超时和自动释放:分布式锁系统可以支持锁的超时机制,即在一定时间内如果未能成功获取到锁,系统会自动释放锁资源,避免锁的持有时间过长,这有助于防止死锁和资源占用过长的情况
  • 锁的可重入性:分布式锁系统可以支持锁的可重入特性,即同一个节点在持有锁的情况下可以再次获取相同的锁,而不会被自身持有的锁所阻塞,这样可以避免同一节点由于重入获取锁而导致的死锁问题
  • 高性能:分布式锁系统需要具备高性能,能够处理大量的并发请求,并在短时间内完成锁的获取和释放操作,快速响应和低延迟对于确保系统的高吞吐量和良好的用户体验至关重要
  • 可靠性和高可用性:分布式锁系统应该是可靠的,能够在节点故障或网络分区等异常情况下继续正常工作,也就是说它应该具备高可用性,以确保在任何时候都能够提供对共享资源的互斥访问
  • 可扩展性:分布式锁系统应该具备良好的可扩展性,能够适应不断增长的负载和节点数量。它应该能够有效地处理更多的并发请求,并保持稳定的性能和可靠性
  • 管理和监控:分布式锁系统应该提供管理和监控功能,方便管理员进行系统的配置、监测和故障排查,它应该能够记录关键指标和日志,以便进行性能优化和故障排查
有了合理且清晰的需求之后,分析和设计才能有据可循。我们通过分析上面的需求,可以建立对分布式锁系统的基础模型画像,如下图所示:
notion image
  1. 分布式锁系统本身也具备一个分布式系统的能力,具有可扩展性、高可用性与数据一致性保证,严格来说它应该是一个分布式存储系统,下面会解释为什么需要存储能力
  1. 客户端请求锁的过程,就是竞争向此分布式系统中写入数据的过程,比如上图中,客户端对气球这个“模拟资源”写入 locked,就代表拥有了能够访问气球的锁,其他客户端再去获取气球的锁时,先检查是不是已经被写入 locked,如果是就会被拒绝,直到锁被释放,也就是气球被写入了 idle,这也表明此系统要具备基本的数据存储和检索能力
  1. 同一时间,请求同一个模拟资源的客户端只能有一个被允许写入,其他客户端要么阻塞等待,要么被立即返回拒绝信息,读取则不会限制
  1. 当模拟资源空闲的时候,如果有阻塞等待的客户端需要被唤醒重新开始竞争,唤醒的过程如果是按照先来后到的顺序,那么就是公平锁,如果是随机唤醒,就是非公平锁
  1. 客户端必须要能够被唯一识别,这样才能实现锁的可重入特性,这也能大大减少死锁的概率
  1. 因为是分布式系统,就必然存在分布式系统固有的问题,比如通信网络延迟、节点网络分区、客户端突然断开等等问题,这些都需要审慎对待,以免破坏了本系统的可靠性
基于上面的分析,我们可以选择自己去实现一个这样的分布式(存储)系统,也可以选择现有的分布式(存储)系统,基于它提供的能力去构建分布式锁,实际上很多时候,我们都会选择直接使用现有的分布式存储系统来实现,因为自己再重复造一个这样的轮子 ROI(投资回报率)太低。
现在流行的分布式锁的载体主要有以下两类
  • 基于异步复制实现的分布式系统,比如 Redis、MySQL 等
  • 基于共识算法实现的分布式系统,比如 Zookeeper、Etcd 等
就上述分布式载体方案中,我比较推荐 Zookeeper 和 Etcd 来实现分布式锁,因为个人认为 CP 特性对分布式锁更为重要,因为倘若系统中各个节点上数据不一致,此时的可用性反而会增加锁被多个客户端同时获取的风险。
本文主要分析基于 Redis 的实现,其他暂不展开

Redis 实现分布式锁的方案

基于 Redis 实现分布式锁的方案有两种,一种是基于单节点 Redis 实现,一种基于 Redis 集群来实现,我们先从单节点来分析,并逐步引入多节点 Redlock 算法。

Redis 单节点实现方案

正如前文所述,锁的两个功能 API 必须要实现,正确的实现如下
  • 获取锁
    • SET {resource_name} {client_uid} NX PX {expire_time}
      resource_name: 在 Redis 中来模拟共享资源的 KEY client_uid: 用来唯一标识每个请求的 VALUE,这个值需保证全局唯一性 NX: 只有当 resource_name 这个 KEY 不存在的时候,才会设置这个 KEY 的 VALUE 为client_uid,这个指令和 SET 操作具有原子性,保证了数据的准确性
      PX:设置该 VALUE 的有效时间,单位是豪秒,也就是锁的超时时间,这个指令保证了锁的活性,确保不会出现锁一直被不当持有,无法释放的情况
  • 释放锁
    • if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
      释放锁采用 Lua 脚本的方式,这样能够保证整个操作的原子性,执行过程为:先检查模拟资源 KEY 的值,确定是不是自己的锁,也就是 VALUE 是不是等于自己的 client_uid,如果不是就不需要继续操作,避免把别人的锁给释放掉,造成数据不一致的情况,如果是自己的锁,则使用 del 命令释放锁
单机实现简单易懂,效率不错,可以在一定程度上满足我们的需求,但是很明显,它也有一些缺点,在某些情况下会使得这个锁系统不能保证正确性和可靠性
  • 节点宕机会造成系统不可用,如果 Redis 没有设置持久化,或者持久化设置不正确,从失败中恢复的时候可能会导致丢失部分锁的状态,让整个系统的正确性无法保证
  • 假如客户端持有锁之后进入休眠或者阻塞状态(比如长时间 GC、IO 阻塞等),Redis 自动将其锁失效,随即锁被其他进程获得,然后当客户端恢复的时候,依然觉得自己持有锁,对共享资源进行操作,这个时候会出现锁失效的情况,违背了同步互斥访问的基础功能性保证
第一个问题我们可以使用主从结构的 Redis 来保证可用性并设置好持久化来解决,第二个问题可以使用 Fencing token 的方案来解决,具体的就不展开了

Redlock 算法

我们把单机的模型扩展到分布式环境,就能得到 Redlock 算法。
本算法假设我们有 N 个 Redis 的 Master 节点,节点完全独立,无需复制或者协调机制来组成集群,然后按照下面的步骤来实现锁的获取和释放
  1. 客户端获取当前的毫秒时间戳
  1. 客户端尝试使用上面单机的方法依次从 个节点获取锁,每次尝试都使用同样的 resource_nameclient_uid 。此时,需要注意的是客户端应该在获取锁的时候设置一个超时时间且这个时间应该小于锁的有效期 expire_time ,目的是为了不在已经宕机的节点浪费时间,影响获取锁的效率。
  1. 当完成对所有节点的尝试,客户端使用当前毫秒时间戳 计算获取锁所花费的时间,即 。当且仅当客户端成功在大多数节点(即 个节点)获取到锁且花费的时间 小于锁的有效期expire_time,才认为客户端成功获取锁。
  1. 当客户端成功获取锁时,锁的有效期应该为原本设定的过期时间expire_time 减去获取锁花费的时间 ,考虑到客户端们本地时间的些许偏移,锁最小的有效期应该是 MIN_VALIDITY = TTL - (T2-T1) - CLOCK_DRIFT
  1. 当客户端没有成功获取锁,应当对所有节点执行释放锁的操作,即单节点释放锁的 Lua 脚本,此步必不可少,为了不阻碍别的客户端获取锁。如果当失败之后需要重试,客户端仍需执行第五步,且可以间隔随机时间之后再尝试,避免同一时间获取锁的客户端过多,导致大家都无法获取到大多数节点的锁,最终均无法成功获取锁。
至于关于 Redlock 正确性的讨论,可以翻看 Redis 官网介绍以及网络上的公开资料,此处不再展开。

粗鄙的实现 - 出自本人

本人多年前的项目实践,其中包含了对 Redlock 和 Zookeeper 的实现,实现的比较简陋,感兴趣的去瞄一眼即可

精巧的实现 - 出自 Redisson

我们来研究一下 Redisson 的实现代码,从中汲取点营养。
在学习的过程中,顺便给 Redisson 提交 PR,修改两个跟锁相关的缺陷 😄
Redisson 仓库地址 — https://github.com/redisson/redisson

RedissonLock

notion image
上图是 RedissonLock 部分源码的结构,其中 RedissonFencedLock 继承了 RedissonLock 实现了 Fencing Token 的功能。
Redisson 的源码显示其实现了众多类型的锁和同步机制,包括可重入锁(Reentrant Lock)、公平锁(Fair Lock)、联锁(MultiLock)、读写锁(ReadWriteLock)、信号量(Semaphore)、可过期性信号量(PermitExpirableSemaphore)、闭锁(CountDownLatch)还有上文介绍的红锁(RedLock)。
如果负责使用这个分布式锁的客户端节点宕机以后,而且这个锁正好处于锁住的状态(且没有主动设置过期时间)时,这个锁会出现锁死的状态,还有一种情况客户端设置了过期时间 20s 但是执行时间超过了 20s,这个时候锁在 redis 上会被自动释放,导致客户端后面的操作失败或者出现跟其他客户端同时持有锁的情况。为了避免这些情况的发生,Redisson 内部提供了一个监控锁的看门狗(WatchDog),它的作用是在 Redisson 实例被关闭前,不断的延长锁的有效期。
我们这里选取基本的 RedissonLock 查看其具体实现。

获取锁

翻看代码即可得知,Redisson 获取锁是执行了如下一段 Lua 代码
--[[ KEYS[1] 就是上文的 resouce_name ARGV[1] 就是上文的 expire_time ARGV[2] 就是上文的 client_uid --]] if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then -- 如果 KEY 不存在,或者 KEY 和 FIELD 都在 HSET 里面了, -- 说明没有人获取锁,或者自己已经获取锁 redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 如果不存在就加入 HSET,VALUE 为 1,如果存在给他的值 +1 redis.call('pexpire', KEYS[1], ARGV[1]); -- 更新 KEY 的过期时间 return nil; end; return redis.call('pttl', KEYS[1]); -- 获取失败,查询剩余时间
这段代码最大的亮点是把上文中直接使用 SET 命令用 HSET 结构来代替,好处是使用 HSET 可以快速的检查出 resouce_nameclient_uid这一对是不是同时存在,提高了判断锁是不是已经被自己持有的效率,同时能够记录锁被同一个客户端获取(重入)的次数

锁的释放

--[[ KEYS[resouce_name, channel_for_resouce_name] ARGV[unlock_message(0), expire_time, client_uid, PUBLISH(or SPUBLISH)] --]] if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;-- 如果锁不是自己持有的,万万不可以轻举妄动,返回空 end; -- 判断是不是被多次重入,因为每次获取锁的时候,都会给这个 field 加上 1, -- 释放的时候 -1 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(ARGV[4], KEYS[2], ARGV[1]); -- 通知订阅这个 channel 的各位客户端,可以竞争了 return 1; -- 返回操作成功 end; return nil;
支持了对锁的重入次数的支持,同时在释放锁的时候主动通知等待的客户端们,当前锁已经被释放了(发送了消息 0 )
 

总结

温故知新,每次学习都能有新发现,这种感觉很好。Redisson 的代码没有做过多的解释,比如里面的 WatchDog 的实现也没有涉及,因为本文重点放在了锁的获取和释放过程,以后有时间会把 Redisson 的代码好好看看。
  • 分布式
  • Java
  • Redis
  • Kafka 并发消费的设计和实现
    手把手教会你快速部署 MySQL 8.0 InnoDB Cluster