Redis 分布式锁大多数使用 SET
|SETNX
指令 就能实现了,但是就高枕无忧了嘛?其实CAP
这个东西还是一直存在的。
1、What is 分布式锁
比如一个医生在同一时刻来了一堆的患者,但是这个时候只能给一个患者就诊。
不然可能治疗感冒的药给了肾亏的人,肾亏的药给了感冒的人;造成了医疗事故。
当并发去读写一个【共享资源】的时候,为了保证数据的正确性,需要控制同一时刻只有一个线程访问。
分布式锁就是用来控制同一时刻,只有一个 JVM
进程中的一个线程可以访问被保护的资源。
2、Which is 分布式锁特性
-
互斥:在任何给定时刻,只有一个客户端可以持有锁;
-
无死锁:任何时刻都有可能获得锁,即使获取锁的客户端崩溃;
-
容错:只要大多数
Redis
的节点都已经启动,客户端就可以获取和释放锁。
这个流程有一个缺点,可能会造成锁不能正常的释放
1、客户端崩溃了,无法释放锁
2、业务处理有误,无法执行DEL
指令
基于DEL
问题,所以我们需要一个超时设置处理
给这个上锁的 KEY
加上一个超时时间,到点了就自动释放,其他业务就可以那到这个锁进行业务处理
SET resource_name random_value NX PX 30000
-
NX:表示只有
resource_name
不存在的时候才能SET
成功,从而保证只有一个客户端可以获得锁; -
PX 30000:表示这个锁有一个 30 秒自动过期时间。
3、释放了别人的锁,怎么办
有哪些场景会释放别人的锁:
-
客户 1 获取锁成功并设置设置 30 秒超时;
-
客户 1 因为一些原因导致执行很慢(网络问题、发生 FullGC……),过了 30 秒依然没执行完,但是锁过期「自动释放了」;
-
客户 2 申请加锁成功;
-
客户 1 执行完成,执行
DEL
释放锁指令,这个时候就把客户 2 的锁给释放了。
这也是很经典的一个Redis 业务面试,那这种要怎么办呢
1、在执行 DEL 指令的时候,要先检查下这个锁是不是自己的锁在执行删除指令。
2、在加锁的时候设置一个「唯一标识」作为 value
代表加锁的客户端。SET resource_name random_value NX PX 30000
在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。
if (redis.get("lock:168").equals(random_value)){ redis.del("lock:168"); //比对成功则删除 }
通过 GET
和 DEL
组合而成的业务,这会涉及到原子性问题。所以可以考虑下用 Lua
脚本来实现,这样判断和删除就是原子性的了。
// 获取锁的 value 与 ARGV[1] 是否匹配,匹配则执行 del if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
这样通过唯一值设置成 value 标识加锁的客户端很重要,仅使用 DEL 是不安全的,因为一个客户端可能会删除另一个客户端的锁。
使用上面的脚本,每个锁都用一个随机字符串“签名”,只有当删除锁的客户端的“签名”与锁的 value 匹配的时候,才会删除它。
3、设置正确的超时时间。
一般根据的是在测试环境中测试多次和压测多伦后,计算的平均QPS 在放大 3~5 倍。放大的理由是因为在锁的操作逻辑中涉及到网络 IO 操作、JVM 等,留点缓冲时间。
但是也不能太大,要是服务中途宕机的话,这个 KEY
的锁就会一直占着了,也可能会影响后面需要用到这个分布式锁的业务。
要是设置过短也不行,多长也不行,那怎么办才好?
我们可以考虑让获得锁的线程开启一个守护线程,在这个锁快要过期的时候进行【续航】。
如果这个锁的过期时间快到了,但是业务逻辑还没执行完成,自动对这个锁进行加钟,重新设置过期时间
这个时候我们就引入了一个新东西 Redisson ,而这个守护线程的东西,我们也称为【看门狗】线程。
-
通过
SET lock_resource_name random_value NX PX expire_time
,同时启动守护线程为快要过期但还没执行完的客户端的锁续命; -
客户端执行业务逻辑操作共享资源;
-
通过
Lua
脚本释放锁,先 get 判断锁是否是自己加的,再执行DEL
。
4、加解锁代码的位置
先看代码
public void doSomething() { redisLock.lock(); // 上锁 try { // 处理业务 ..... redisLock.unlock(); // 释放锁 } catch (Exception e) { e.printStackTrace(); } }
在业务处理完后就释放锁,看着好像没什么毛病;但是万一在处理业务中抛异常了这怎么办呢,锁不就没得释放了,只能等超时处理了?
所以释放锁的代码一定要放在 finally{}
块中。
加锁的位置也有问题,放在 try 外面的话,如果执行 redisLock.lock()
加锁异常,但是实际指令已经发送到服务端并执行,只是客户端读取响应超时,就会导致没有机会执行解锁的代码。
所以 redisLock.lock()
应该写在 try 代码块,这样保证一定会执行解锁逻辑
所以修改后的代码:
public void doSomething() { try { // 上锁 redisLock.lock(); // 处理业务 ... } catch (Exception e) { e.printStackTrace(); } finally { // 释放锁 redisLock.unlock(); } }
5、可重入锁
当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。
用一段代码解释可重入:
public synchronized void a() { b(); } public synchronized void b() { // pass }
1、Redis Hash 可重入锁
当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。
退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。
可以看到可重入锁最大特性就是计数,计算加锁的次数。
所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
我们可以使用 Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁的次数。
通过 Lua 脚本实现原子性,假设 KEYS1 = 「lock」, ARGV「1000,uuid」:
---- 1 代表 true ---- 0 代表 false if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end ; 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 1; end ; return 0;
加锁代码首先使用 Redis exists
命令判断当前 lock 这个锁是否存在。
如果锁不存在的话,直接使用 hincrby
创建一个键为 lock
hash 表,并且为 Hash 表中键为 uuid
初始化为 0,然后再次加 1,最后再设置过期时间。
如果当前锁存在,则使用 hexists
判断当前 lock
对应的 hash 表中是否存在 uuid
这个键,如果存在,再次使用 hincrby
加 1,最后再次设置过期时间。
最后如果上述两个逻辑都不符合,直接返回。
加锁逻辑是这样进行判断,同理,解锁的逻辑也大致
-- 判断 hash set 可重入 key 的值是否等于 0 -- 如果为 0 代表 该可重入 key 不存在 if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then return nil; end ; -- 计算当前可重入次数 local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1); -- 小于等于 0 代表可以解锁 if (counter > 0) then return 0; else redis.call('del', KEYS[1]); return 1; end ; return nil;
首先使用 hexists
判断 Redis Hash 表是否存给定的域。
如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil
。
若存在的情况下,代表当前锁被其持有,首先使用 hincrby
使可重入次数减 1 ,然后判断计算之后可重入次数,若小于等于 0,则使用 del
删除这把锁。
解锁代码执行方式与加锁类似,只不过解锁的执行结果返回类型使用 Long
。这里之所以没有跟加锁一样使用 Boolean
,这是因为解锁 lua 脚本中,三个返回值含义如下:
-
1 代表解锁成功,锁被释放
-
0 代表可重入次数被减 1
-
null
代表其他线程尝试解锁,解锁失败.
6、集群架构下的分布式锁问题
上面说 的都是 在单机的Redis 中可能存在的问题,在大多数的互联网架构中,Redis 都是集群模式进行保证其高可用。【Cluster集群】或者【哨兵集群】这两种集群模式是最长使用方式。
假设:
- 客户端 A 在master 节点获取锁成功。 - 还没有把获取锁的信息同步到 salve 的时候,master 宕机 - salve 被选举为 新的 master ,这个时候客户端 A 的获取锁的数据是不存在 salve 中的。 - 其他的客户端就可以获取 客户端 A 持有的 锁。
像这种情况,Redis 作者可能也考虑到了,所以出了一个解决方案,叫 RedLock。
1、what is RedLock
Redlock
红锁是为了解决主从架构中当出现主从切换导致多个客户端持有同一个锁而提出的一种算法。
大家可以看官方文档(Distributed locks with Redis – Redis),以下来自官方文档的翻译。
想用使用 Redlock,官方建议在不同机器上部署 5 个 Redis 主节点,节点都是完全独立,也不使用主从复制,使用多个节点是为容错。
2、 当客户端要获取锁的步骤
-
客户端获取当前时间
T1
(毫秒级别); -
使用相同的
key
和value
顺序尝试从N
个Redis
实例上获取锁。
-
-
每个请求都设置一个超时时间(毫秒级别),该超时时间要远小于锁的有效时间,这样便于快速尝试与下一个实例发送请求。
-
比如锁的自动释放时间
10s
,则请求的超时时间可以设置5~50
毫秒内,这样可以防止客户端长时间阻塞
-
-
客户端获取当前时间
T2
并减去步骤 1 的T1
来计算出获取锁所用的时间(T3 = T2 -T1
)。当且仅当客户端在大多数实例(N/2 + 1
)获取成功,且获取锁所用的总时间 T3 小于锁的有效时间,才认为加锁成功,否则加锁失败。 -
如果第 3 步加锁成功,则执行业务逻辑操作共享资源,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
-
如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。
另外部署实例的数量要求是奇数,为了能很好的满足过半原则,如果是 6 台则需要 4 台获取锁成功才能认为成功,所以奇数更合理。
3、关于RedLock的讨论
Martin Kleppmann 认为锁定的目的是为了保护对共享资源的读写,而分布式锁应该「高效」和「正确」。
-
高效性:分布式锁应该要满足高效的性能,Redlock 算法向 5 个节点执行获取锁的逻辑性能不高,成本增加,复杂度也高;
-
正确性:分布式锁应该防止并发进程在同一时刻只能有一个线程能对共享数据读写。
出于这两点,我们没必要承担 Redlock 的成本和复杂,运行 5 个 Redis 实例并判断加锁是否满足大多数才算成功。
主从架构崩溃恢复极小可能发生,这没什么大不了的。使用单机版就够了,Redlock 太重了,没必要。
Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!
-
Redlock 不伦不类:对于偏好效率来讲,Redlock 比较重,没必要这么做,而对于偏好正确性来说,Redlock 是不够安全的。
-
时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
-
无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。
Redis作者的回复
-
时钟问题:Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」,只要误差不要超过锁的租期即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。
-
网络延迟、进程暂停问题:
-
-
客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来
-
客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力
-
-
质疑 fencing token 机制。
7、Redisson分布式锁
在springboot中,Redisson和springboot版本是相对配合使用的。
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.16.4</version> </dependency> <dependency> <groupId>org.redisson</groupId> <!-- for Spring Data Redis v.2.5.x --> <artifactId>redisson-spring-data-25</artifactId> <version>3.16.4</version> </dependency>
通过Redisson的lock 中,获取锁失败会不停的重试,同时具有Watch Dog自动延期机制,默认是 续 30 s ,每隔 30 / 3 = 10 s 检测续到 30 s 。
RLock lock = redisson.getLock("MAO"); try { // 1.最常用的第一种写法 lock.lock(); // 执行业务逻辑 ..... } finally { lock.unlock(); } // 尝试拿锁10s后停止重试,获取失败返回false,具有Watch Dog 自动延期机制, 默认续30s boolean flag = lock.tryLock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁,没有 Watch dog boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
看门狗的续期时间是 30 s,可以通过 Config.lockWatchdogTimeout
配置进行修改。
Redisson
中还提供了可以指定 leaseTime
参数的加锁方法来指定加锁的时间。通过这个设置跟 设置超时释放不同的是,这个参数不会因为 看门狗 而去延长锁的有效期。
-
watchDog 只有在未显示指定加锁超时时间(leaseTime)时才会生效。
-
lockWatchdogTimeout 设定的时间不要太小 ,比如设置的是 100 毫秒,由于网络直接导致加锁完后,watchdog 去延期时,这个 key 在 redis 中已经被删除了。
8、关于源码的个人理解
在调用 lock 方法时,会最终调用到 tryAcquireAsync
。
调用链为:lock()->tryAcquire->tryAcquireAsync
。
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { RFuture<Long> ttlRemainingFuture; //如果指定了加锁时间,会直接去加锁 if (leaseTime != -1) { ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } else { //没有指定加锁时间 会先进行加锁,并且默认时间就是 LockWatchdogTimeout的时间 //这个是异步操作 返回RFuture 类似netty中的future ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); } //这里也是类似netty Future 的addListener,在future内容执行完成后执行 ttlRemainingFuture.onComplete((ttlRemaining, e) -> { if (e != null) { return; } // lock acquired if (ttlRemaining == null) { // leaseTime不为-1时,不会自动延期 if (leaseTime != -1) { internalLockLeaseTime = unit.toMillis(leaseTime); } else { //这里是定时执行 当前锁自动延期的动作,leaseTime为-1时,才会自动延期 scheduleExpirationRenewal(threadId); } } }); return ttlRemainingFuture; }
scheduleExpirationRenewal
中会调用 renewExpiration
启用了一个 timeout
定时,去执行延期动作。
private void renewExpiration() { 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 { // 省略部分代码 .... RFuture<Boolean> future = renewExpirationAsync(threadId); future.onComplete((res, e) -> { .... if (res) { //如果 没有报错,就再次定时延期 // reschedule itself renewExpiration(); } else { cancelExpirationRenewal(null); } }); } // 这里我们可以看到定时任务 是 lockWatchdogTimeout 的1/3时间去执行 renewExpirationAsync }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); ee.setTimeout(task); }
scheduleExpirationRenewal
会调用到 renewExpirationAsync
,执行下面这段 lua 脚本。
他主要判断就是 这个锁是否在 redis 中存在,如果存在就进行 pexpire 延期。
protected RFuture<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)); }
-
watch dog 在当前节点还存活且任务未完成则每 10 s 给锁续期 30s。
-
程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
-
要使 watchLog 机制生效 ,lock 时 不要设置 过期时间。
-
watchlog 的延时时间 可以由 lockWatchdogTimeout 指定默认延时时间,但是不要设置太小。
-
watchdog 会每 lockWatchdogTimeout/3 时间,去延时。
-
通过 lua 脚本实现延迟。