分布式锁
-
分布式锁的基本要求
- **安全性:**互斥,在给定的任何时刻,仅有一个线程可以获取到锁
- 无死锁:无死锁,如果当前线程奔溃或者当前客户端奔溃,不会导致死锁
- 容错性:超过半数的节点存活,客户端就可以获取和释放锁
-
使用场景
- 对共享资源的操作是非幂等操作
- 业务操作的时间要远远小于锁的持有时间
单机版实现方案
-
需要考虑的问题
- 考虑一:设置值需要原子性,避免死锁,需要设置一个超时时间
- 考虑二:引入超时时间会导致分布式锁失效。A线程业务处理的时间超过了锁的过期时间,此时锁已经释放了,导致B线程抢到了锁。此时导致分布式锁失效。
- 考虑三:同一个线程加锁必须由同一个线程解锁。A线程业务处理的时间超过了锁的过期时间,此时A持有的锁已经释放了,导致B线程抢到了锁,A假设执行完成,此时A执行释放锁,将B的锁删除了(A持有的锁已经释放了)。同理,B线程业务执行时间快,可能导致B把A持有的锁删除了
- 考虑四:是否要支持重入锁
- **考虑五:**Redis是否需要实时持久化,保证宕机后重启锁数据不丢失
- **考虑六:**系统时钟不能被人为修改,即时钟不能发生跳跃。需要在运维层面保证
-
考虑一解决方案:使用SETNX实现:SETNX是一个原子操作,如果key不存在就设置,返回1(代表获取锁成功)。如果key存在,直接返回0。为了防止死锁,需要给key设置超时时间
#方式一:使用LUA脚本实现原子操作 SETNX key value EXPIRE key seconds #方式二:使用原子命令 SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] EX :设置超时时间,单位是秒; PX :设置超时时间,单位是毫秒; NX :当且仅当对应的 Key 不存在时才进行设置; XX:当且仅当对应的 Key 存在时才进行设置。
-
**考虑二解决方案:**使用watchdog机制解决锁续期问题。假设锁超时时间是 30 秒,此时程序需要每隔一段时间去扫描一下该锁是否还存在,扫描时间需要小于超时时间,通常可以设置为超时时间的 1/3(这里就是10s)。如果锁还存在,则重置其超时时间恢复到 30 秒。通过这种方案,只要业务还没有处理完成,锁就会一直有效;而当业务一旦处理完成,程序也会马上删除该锁
- watchdog机制的问题:由于在进程内,所以一旦当前宕机或者无法连接Redis进行续期(或者续期异常),watchdog可能无法执行,锁到期自动释放,其他进程抢到锁
-
**考虑三解决方案:**在创建锁时为其指定一个唯一的标识作为 value
1. 可以采用【UUID+线程ID】作为唯一标识 UUID.randomUUID() + ":" + Thread.currentThread().getId(); 2. 在删除锁时,先将该唯一标识与锁的 Value 值进行比较,如果不相等,证明该锁不属于当前线程,此时不执行删除操作 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
-
考虑四解决方案:同一个线程,在锁资源释放之前,可以重复加锁。为了满足这样的业务场景,SET和SETNX就无法满足需求,一般情况使用hash类型。
-- 重入加锁LUA脚本 if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return 0; end ; -- hincrby为hash key中field的值加上增量increment -- 重入就是增加数量 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 0; end ; return redis.call('pttl', KEYS[1]); ------------------------------------------------------- -- 重入解锁LUA脚本 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;
-
**考虑五解决方案:**假设不开启持久化,假设进程 A 获得锁后正在处理业务逻辑,此时Redis节点宕机重启,因为锁数据丢失了,其他进程便可以再次创建该锁,因此 Redis 节点需要开启 AOF 的持久化方式。AOF 默认的同步机制每秒进程一次持久化,此时能够兼顾性能与数据安全,发生意外宕机的时,最多会丢失一秒的数据。但如果碰巧就是在这一秒的时间内进程 A 创建了锁,并由于宕机而导致数据丢失。此时其他进程还可以创建该锁,锁的互斥性也就失效了。解决这个问题有两种方式:
- 方式一实时持久化:修改 Redis.conf 中
appendfsync
的值为always
,即每次命令后都进行持久化,此时会降低 Redis 性能,进而也会降低分布式锁的性能,但锁的互斥性得到了绝对的保证 - 方式二延迟重启:一旦节点宕机了,需要等到锁的超时时间过了之后才进行重启,此时相当于原有锁自然失效(但你首先需要保证业务能在设定的超时时间内完成)
- 方式一实时持久化:修改 Redis.conf 中
哨兵主从模式
-
哨兵模式能够在故障发生时自动进行故障切换,选举出新的主节点。但由于 Redis 的复制机制是异步的,因此在哨兵主从模式下实现的分布式锁是不可靠的
- 主从之间的复制操作是异步的,当主节点上创建好锁后,此时从节点上的锁可能尚未创建。而如果此时主节点发生了宕机,从节点上将不会创建该分布式锁;
- 从节点晋升为主节点后,其他进程(或线程)仍然可以在该新主节点创建分布式锁,此时就存在多个进程(或线程)同时进入了临界区,分布式锁就失效了。
-
Redis的主从模式是一个AP模型。大部分情况只用单台节点的 Redis 实现分布式锁。这台节点什么也不做,只服务用于实现分布式锁的 Redis,这样的话 Redis 服务因为内存不足、CPU 负载过高等原因挂掉的几率可以说非常非常低。网络、断电问题,给该节点配置多块网卡、多块电源,只要有一块能够工作,那么该节点就能正常工作,除非所有的网卡和电源同时宕掉,但很明显这概率也是非常低
RedLock
-
单机的Redis毕竟存在宕机的风险,这是牺牲了可用性,一旦宕机会导致应用不可用
-
RedLock为了解决单机问题,需要一些前提条件和假设
- 需要N个(大于2,建议是5个)完全相互独立的Redis Master(不存在集群、主从复制、无协调机制)
- 多个进程实例的本地时钟都是以相同的速率流动,相比于锁的自动释放时间误差要小很多
-
RedLock算法步骤
- 客户端获取当前的时间戳T1(毫秒)
- 按顺序在N个实例中依次尝试获取锁,使用的都是同样的key和随机值,请求超时时间要远小于锁的过期时间。
- 每一个实例设置锁,客户端会设定一个小于**【锁的自动释放时长】的【请求超时时间】,比如【锁的自动释放时间】是10s,那么【客户端请求超时时间设置为5~50ms】**,这样可以避免当一个Redis节点挂掉,客户端还在等待响应结果。如果Redis服务器没有在规定时间内响应,客户端应该尽快尝试另一个Redis实例
- 每次获取锁的时候的过期时间都不同,需要减去之前获取锁的操作的耗时。比如**【锁的过期时间是100ms】,第一个节点的【加锁请求时间】花了1ms,那么第一个节点锁的过期时间就是99ms。第二个节点【加锁请求时间】**花了5ms,那么第二个节点锁的过期时间是95ms。如果锁的过期时间小于等于 0 了,说明整个获取锁的操作超时了,整个操作失败
- 如果获取到锁,key的真正有效时间等于**【锁的自动释放时长】减去【加锁请求时间】**
- 判断是否获取锁成功:当且仅当从大多数Redis节点读取到锁,并且使用时间小于锁过期时间,锁才算获取成功。如果失败则释放锁
- 如果因为某些原因获取锁失败(没有获取大多数实例的锁或者有效时间变为负数),客户端应该在所有的Redis实例上进行解锁(即使某些Redis实例没有加锁成功)
- 释放锁为什么要对所有节点操作? 因为分布式场景下从一个节点获取锁失败不代表在那个节点上加锁失败,可能实际上加锁已经成功了,但是返回时因为网络抖动超时了
Martin对于Redlock的质疑
-
分布式专家 Martin 对于 Relock 的质疑
-
分布式锁的目的是什么
- 效率。使用分布式锁的互斥能力,是避免不必要地做同样的两次工作。如果锁失效,并不会带来严重的后果,例如发了 2 次邮件、短信等。为了效率而使用分布式锁,单节点Redis方案就足够了,使用Redlock反而得不偿失
- 正确性。使用锁用来防止并发进程互相干扰。如果锁失效,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题
-
锁在分布式系统中会遇到的问题,分布式系统会遇到的三座大山:NPC
N:Network Delay,网络延迟
P:Process Pause,进程暂停(GC)
C:Clock Drift,时钟漂移
-
Martin 认为GC可能发生在程序的任意时刻,而且执行时间是不可控的。即使是使用没有 GC 的编程语言,在发生网络延迟、时钟漂移时,也都有可能导致 Redlock 出现问题。GC问题导致RedLock安全性问题的案例
- 客户端 1 请求锁定节点 A、B、C、D、E
- 客户端 1 的拿到锁后,进入 GC(时间非常长)
- 所有 Redis 节点上的锁都过期了
- 客户端 2 获取到了 A、B、C、D、E 上的锁
- 客户端 1 的 GC 结束,认为成功获取锁,此时开始执行业务逻辑
- 客户端 2 也认为获取到了锁,此时也开始执行业务逻辑。此时有两个线程都在执行业务操作,导致分布式锁失效
-
假设时钟设置的是不合理的或者当多个 Redis 节点「时钟」发生问题时,也会导致 Redlock 锁失效
1. 客户端 1 获取节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E
2. 节点 C 上的时钟「向前跳跃」,导致锁到期
3. 客户端 2 获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B
4. 客户端 1 和 2 现在都相信它们持有了锁(冲突)
-
Martin认为RedLock必须「强依赖」多个节点的时钟是保持同步的。一旦有节点时钟发生错误,那这个算法模型就失效了。即使 C 不是时钟跳跃,而是「崩溃后立即重启」,也会发生类似的问题。
-
Martin提出fecing token 的方案,保证正确性
客户端在获取锁时,锁服务可以提供一个「递增」的 token
客户端拿着这个token 去操作共享资源
共享资源可以根据 token 拒绝「后来者」的请求
- Martin的结论
- Redlock 不伦不类:它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的
- 时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效
- 无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper
Redis 作者 Antirez 的反驳
- 时钟问题
- Redlock 并不需要完全一致的时钟,可以有误差,只要误差范围在很小范围
- 时钟跳跃,可以通过恰当的运维手段来保证,实际上是可以保证的
- 网络延迟、GC 问题
- 客户端 1 的拿到锁后,进入 GC(时间非常长)。此时所有 Redis 节点上的锁都过期了。导致客户端2获取到锁。对于这种问题其他的分布式锁同样存在问题,不仅仅是RedLock的问题
- 即如果客户端在获取到锁之前,无论经历了GC、网络延迟导致的耗时问题,都是可以自动检测出来的。但是如果客户端在获取锁之后,发省NPC问题,那RedLock和Zookeeper都无能为力
- 质疑fencing token 机制
- 必须要求要操作的「共享资源」有拒绝「旧 token」的能力,对于Mysql可以通过乐观锁和事务机制保证。但是对于磁盘或者HTTP请求没有这种能力
- 可以根据RedLock的UUID来达到同样的效果(CAS方式,操作共享资源前设置,操作时判断)。分布式锁的本质是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就好了,不需要关心「顺序性」
RedLock的问题
-
RedLock缺点
- 加锁和解锁的延迟较大(即使是异步并行)
- 难以在集群版或者标准版(主从架构)的Redis实例中实现。
- 占用的资源过多,需要创建多个互不相关的Redis实例。
-
性能问题
- 获取锁的时间上:N个Master节点,一个个请求,耗时比较长(一般使用异步并行操作,取决于耗时最长的节点)。如果M个节点(M>N/2)都操作成功,但是redis响应时由于网络抖动,导致超时。此时当前客户端失败,其他客户端也无法获取到锁
- 重试问题:可能会出现多个 Client 几乎在同一时刻获取同一个锁,然后每个 Client 都锁住了部分节点,但是没有一个 Client 获取大多数节点的情况。解决方案就是,在重试的时候让多个节点错开(重试时间加随机时间)
- 执行的业务步骤太多:可以通过分段加锁或者将资源拆分成多个步骤
-
持久化问题
- 对于单 Master 节点且没有做持久化的场景,宕机就挂了,这个就必须在实现上支持重复操作,自己做好幂等
- 对于多 Master 的场景:
- 假设有 5 个 Redis 的节点:A、B、C、D、E,没有做持久化。
- Client1 从 A、B、C 这3 个节点获取锁成功,那么 client1 获取锁成功。
- 节点 C 挂了。
- Client2 从 C、D、E 获取锁成功,client2 也获取锁成功,那么在同一时刻 Client1 和 Client2 同时获取锁
-
解决不持久化带来的问题
- 打开持久化:持久化可以做到持久化每一条 Redis 命令,但这对性能影响会很大,一般不会采用,如果不采用这种方式,在节点挂的时候肯定会损失小部分的数据,可能我们的锁就在其中。
- 延迟启动:一个节点挂了修复后,不立即加入,而是等待一段时间再加入,等待时间要大于宕机那一刻所有锁的最大 TTL。但是这样依然有问题,如果步骤3中B和C都挂了,只剩下A、D、E三个节点,从 D 和 E 获取锁成功就可以了,还是会出问题。这种只能增加Master节点的总量(虽然可以提高稳定性,但是也增加了成本)
-
在加锁的时候,一般都会给一个锁的 TTL,这是为了防止加锁后 Client 宕机,锁无法被释放的问题。但是这样会面临另一个问题,就是无法保证业务的执行时间一定小于锁的过期时间
-
ClientA获取到锁
-
ClientA开始执行业务逻辑,此时发生GC停顿(或者网络延迟),时间超过了锁的过期时间
-
ClientB获取到锁
-
ClientB开始执行业务逻辑
-
ClientA的GC恢复(或者网络延迟恢复),继续执行任务,ClientA和ClientB都认为自己获取到了锁,都会处理业务,导致不一致的问题(分布式锁失效)
-
-
解决以上问题的方式:不设置锁的过期时间,给锁一个watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期
- 这种也无法保证同一个时刻只有一个Client获取到锁。比如如果续期失败,就会导致同一时刻有多个 Client 获得锁了
-
解决以上问题的一个策略:让加锁的资源自己维护一套保证不会因加锁失败而导致多个 Client 在同一时刻访问同一个资源的情况。
- 在客户端获取锁的同时,也获取到一个资源的 Token,这个 Token 是单调递增的,每次在写资源时,都检查当前的 Token 是否是较老的 Token,如果是就不让写。Client1 获取锁的同时分配一个 33 的 Token,Client2 获取锁的时候分配一个 34 的 Token。在 Client1 GC 期间,Client2 已经写了资源,这时最大的 Token 就是 34 了,Client1 从 GC 中回来,再带着 33 的 Token 写资源时,会因为 Token 过期被拒绝。
-
以上解决方案也存在一些问题
- 无法保证事务:如果 Client1 带着 33 的 Token 在 GC 前访问过一次 Storage,然后发生了 GC。Client2 获取到锁,带着 34 的 Token 也访问了 Storage,这时两个 Client 写入的数据是否还能保证数据正确?如果不能,那么这种方案就有缺陷,除非 Storage 自己有其他机制可以保证,比如事务机制(我们的业务逻辑是操作数据库,把token当做版本号,进行乐观锁判断。也就是说这种方式需要业务耦合token,并保证事务一致性)
- 高并发场景:每次只有最大的 Token 能写,这样 Storage 的访问就是线性的,在高并发场景下,这种方式会极大的限制吞吐量
-
系统时钟漂移
- 系统的时钟和 NTP 服务器不同步。只能在运维层面保证
- 时钟被人为修改。导致时钟跳跃
-
Chubby使用上面自增序列的方案用来解决分布式不安全的问题,但是它提供了多种校验方法:
- checkSequencer():调用 Chubby 的 API 检查此时这个序列号是否有效。
- 访问资源服务器检查,判断当前资源服务器最新的序列号和我们的序列号的大小。
- lock-delay:为了防止我们校验的逻辑入侵我们的资源服务器,其提供了一种方法当客户端被动失联的时候,并不会立即释放锁,而是在一定的时间内(默认 1min)阻止其他客户端拿去这个锁。
Redisson实现案例
-
环境准备
$ docker run --name redis1 -p 8000:6379 -d --restart=always redis:3.2.0 redis-server --appendonly yes $ docker run --name redis2 -p 8001:6379 -d --restart=always redis:3.2.0 redis-server --appendonly yes $ docker run --name redis3 -p 8002:6379 -d --restart=always redis:3.2.0 redis-server --appendonly yes
-
代码实现
@Test public void testRedLock() throws Exception { RedissonClient redissonClient1 = createClient("redis://127.0.0.1:8000"); RedissonClient redissonClient2 = createClient("redis://127.0.0.1:8001"); RedissonClient redissonClient3 = createClient("redis://127.0.0.1:8002"); // 构造N个普通的分布式锁 String resourceKey = "lock0"; RLock lock1 = redissonClient1.getLock(resourceKey); RLock lock2 = redissonClient2.getLock(resourceKey); RLock lock3 = redissonClient3.getLock(resourceKey); // 合并为一个红锁 RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3); ConcurrentExecute.start(10, () -> { try { //在大部分节点上加锁成功才算成功,有watchdog lock.lock(); log.info("{}加锁成功", Thread.currentThread().getName()); long startTime = System.currentTimeMillis(); //模拟业务执行时间 Thread.sleep(50000); long endTime = System.currentTimeMillis(); log.info("{}耗时:{}s", Thread.currentThread().getName(), (endTime - startTime) / 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { log.info("{}释放锁", Thread.currentThread().getName()); lock.unlock(); } }); }
总结
- 分布式锁无法完全保证正确性,只能保证效率。大多数情况下使用单机版Redis即可满足需求,同时降低复杂度。