初识Redis · 分布式锁
目录
前言:
分布式锁
setnx
lua脚本和看门狗
redlock算法
Redlock 的加锁流程(5 步)
前言:
到了分布式锁这一章之后,我们首先能联想到的问题就是线程安全的问题,线程安全指的是多个线程在并发执行的时候先手顺序是不确定的,那么就会出现同时对一个变量操作的情况,对于这种情况我们要保证的就是顺序问题,保证程序在任意执行顺序下的逻辑都是没问题的。
那么线程安全问题我们可以简单认为是n个线程对同一个变量操作引发的问题,它的解决方案是使用一把锁,将操作变量的过程锁住,谁持有锁谁就可以进行操作。那么问题来了,在实际应用场景中,我们的情况是多个客户端,多个服务器,也就是如下:
这种情况,多个客户端买票的时候,比如客户端A执行买票的过程,查询服务器A,发现票数为1,那么就执行买票的操作,可是这个过程中,客户端B也执行了买票的操作,他查询的是服务器B,发现票数也为1(此时A还没有完全买好),那么A和B同时对数据库操作,执行了买票的操作,此时就会导致超卖的情况。
导致出现超卖的原因是因为多个线程同时对多个服务器操作,而我们一般的锁能应对的情况是多个线程操作一个服务器,在一个服务器上加锁就能让多个线程按照一定次序执行操作,那么面对多服务器的时候就不行了。
此时就应该引入分布式锁了。
分布式锁
分布式锁可以很好的解决上述的多对多的问题,如果我们使用Redis作为分布式锁的话,那么涉及到了我们应该如何加锁呢?
其实加锁的这个过程和上述线程安全的互斥锁也是差不了太多的,我们简单回忆一下线程安全中的锁的竞争,多个线程竞争在寄存器中的某个值,如果谁通过寄存器获取到了这个值,那么就可以执行后续的操作,回到Redis,既然我们使用了Redis作为分布式锁,那么我们加锁的过程也是类比上面的过程。
具体的操作是多个服务器连接的Redis服务器,在进行买票操作的时候先设置一个特殊的key,设置的时候发现没有这个key,那么设置成功,也就代表了加锁成功,如果设置的时候发现已经存在了该key,那么加锁失败,只能乖乖等待key过期或被删除了。
那么这里肯定会有心细的同学发现了,好像MySQL的事务能够解决这个问题,将对应的操作打包为一个事务然后进行,这个看起来确实是可以的,但是实际上分布式系统的存储介质不一定只有MySQL,其他的存储介质不一定有事务,所以还是分布式锁使用起来更权威一点。
setnx
上文提到具体首先分布式系统是通过在redis里面设置特殊的key来实现的,那么怎么才能知道设置成功了呢?
此时我们最开始学习Redis的时候,涉及到的一个命令就狠狠派上用场了,即setnx,nx代表的是不存在才设置,如果key存在的话是设置不了的,所以setnx首先用于设置分布式锁的。
那么,针对解锁的操作是有两种的,一种是设置过期时间,一种是操作完之后进行del,此时就会引发不同的问题了。
对于del来说:
服务器执行完对应的操作之后,正常来说是del即可,但是不免有特殊情况,比如服务器突然掉电了,导致进程直接异常终止,此时就没有服务器来del这个锁了,当然实际情况来说肯定不会考虑的这么片面,但是这种情况导致的结果确实就是无法del锁,从而导致阻塞了。那么就引入了过期时间的做法。
对于过期时间来说:
它也算是服务器为自己留的一条后路,如果自己真的突然挂了,那么对应的锁也会通过过期时间自动释放掉。而对于过期时间来说,设置的时候还是有说法的,比如setnx ex 这样来设置过期时间就是对的,如果是先setnx,然后再通过expire设置过期时间就不行了。
因为对于Redis来说是单线程的,收到请求的时候按照顺序执行,一次setnx ex是原子性的,所以多个服务器同时使用redis加锁的时候只有一个能成功,但是先set然后再expire,就不是原子性的了,会引发加锁逻辑漏洞,以下是官方文档的解释:
"Using SETNX and then setting an EXPIRE with a separate command is not safe. If the client crashes after the SETNX but before the EXPIRE, the lock will be held forever."
翻译:
“使用
SETNX
然后再单独执行EXPIRE
是不安全的。如果客户端在执行SETNX
成功之后、但在执行EXPIRE
之前崩溃,那么这个锁就会永远被占用。”
我们现在就清楚了,所谓的分布式加锁解锁的过程不过是set key和del key的过程,那么你说会不会有服务器恶意del别的服务器已经设置好的key呢?
正常来说是不会的,但是代码总会有bug,说不定一不小心就绕过了set的逻辑直接就执行对应的del了。面对上面的这种情况,就需要引入校验机制了,总不能说谁来都可以删吧?
一般来说的处理方式是,每个服务器都有一个自己的身份标识,那么set的时候就会使用服务器的身份标识充当value,key对应的是哪个资源。
这个时候删除的时候,先查询一下当前服务器的身份标识,再和这个value对比一下,校验成功才能执行下一步操作,这种操作就可以避免误删了。
lua脚本和看门狗
什么,居然用到了咱们大名鼎鼎的lua脚本了?是的是的,因为在上文我们提到了删除操作之前有一个操作是查询对应的标识符,那么也就是分为了两步,一步是get 一步是del。
既然是两步操作,那么就代表不是原子的,我们举个例子:
-
客户端 A 加锁成功,值为
uuid-A
; -
锁过期,Redis 自动删除
lock_key
; -
客户端 B 加锁成功,值为
uuid-B
; -
客户端 A 执行
GET lock_key
,发现是uuid-B
;-
假如这里网络延迟或客户端数据错误,仍错误判断为
uuid-A
-
-
A 执行
DEL lock_key
; -
B 的锁被误删,严重并发逻辑错误!
所以我们查询删除这个操作我们就可以使用Lua脚本:
-- 解锁脚本:只有当 key 的值等于给定值时才删除
if redis.call("get", KEYS[1]) == ARGV[1] thenreturn redis.call("del", KEYS[1])
elsereturn 0
end
使用lua脚本的时候就是原子的了。
那么为什么不使用事务来代替这个操作呢?因为事务的操作也不是原子性的,它只是按照顺序打包顺序执行,并且Redis的事务不具备MySQL的回滚的特性,事务中的命令依次执行,期间可以被其他客户端插入操作影响的。
所以推荐的是使用lua脚本。
我们既然引入了过期时间的方案,那么我们要担心一个点就是,过期时间我们应该设置多久?如果设置短了,那么会导致类似于线程安全的问题,如果设置长了,Redis就阻塞住了,无论是哪种方案都是不好的。
所以,我们应该动态调整对应的过期时间,此时我们就会单独引入一个线程,用来动态调整过期时间。那么这个线程,我们就叫做看门狗。
redlock算法
以上的讨论都是基于Redis本身运行是成功的情况下的,那么你说有没有一种可能,Redis本身就挂了呢?
这太有可能了!!
那么针对Redis本身挂的情况,解决的方法也是非常粗暴:冗余
即我们引入了多个Redis节点,加锁的时候都加锁,解锁的时候都解锁,
这个算法是Redis作者给出来的一种解决方案,多加几个Redis就行了。
这个算法的核心思想是:使用多个 Redis 实例(建议 5 个)来共同决定是否加锁成功。只有多数节点(如 3/5)成功加锁,客户端才认为锁加成功。
Redlock 的加锁流程(5 步)
假设我们有 5 个 Redis 实例(R1
~ R5
),并设置锁过期时间 TTL
。
-
获取当前系统时间(毫秒),记为
start_time
。 -
依次向
R1
~R5
执行SET lock_key unique_id NX PX TTL
操作:-
unique_id
是唯一标识(如 UUID); -
NX
保证 key 不存在时才能加锁; -
PX
设置过期时间,避免死锁。
-
-
统计成功加锁的 Redis 实例数量
n
。 -
如果
n >= 3
(超过半数),且总耗时 < TTL:-
加锁成功;
-
保证锁有效性和新鲜度。
-
-
如果加锁失败(如不足多数、耗时过长):
-
向已加锁的节点发送
DEL lock_key
解锁; -
防止部分节点锁遗留。
-
以上就是分布式锁的基本介绍。
我们主要要注意的就是set nx 和 get del的原子性问题。
感谢阅读!