分布式锁
1. 基本实现
分布式锁本质上要实现的目标就是在Redis里面占一个"坑",当别的进程也要来占坑时,如果发现已经有一个坑了,就要放弃或者稍后再试。
分布式锁的实现主要依赖setnx
(set if not exists)这个指令实现,只允许一个客户端占坑。先来占坑,用完了,再调用del
指令释放坑。
相关指令
# 为了方便理解, 这里的Key就是“lock:codehole"
> setnx lock:codehole true
OK
# do something,执行业务逻辑
> del lock:codehole
(integer) 1
但是这里有一个问题,如果逻辑执行中出现异常,可能会导致del lock:codehole
这个指令没有被调用,那么就会陷入死锁,锁永远无法释放。
为了解决这个问题,需要在拿到锁以后,在给锁加上一个过期时间,比如5s,这样即使执行过程中出现了问题,锁过了过期时间也会自动释放
> setnx lock:codehole true
OK
> expire lock:codehole 5
# do something
> del lock:codehole
(integer) 1
但是以上逻辑还有问题,如果在setnx
和 设置expire
指令之间,服务器突然发生异常,就可能导致expire
得不到执行,也会造成死锁。
这种问题的根源就在于setnx
和expire
不是原子指令。但是这里不能使用redis的事务来解决,因为expire
是依赖setnx
的执行结果的,如果setnx
没有抢到锁,expire
是不应该执行的。(如果没有锁,怎么设置锁的过期时间呢?)事务里面没有if-else
分支,要么全部执行,要么一个都不执行。
为了解决这个难题,Redis开源社区出现了很多分布式锁的工具包。这意味着不仅仅要学会使用Jedis,同时还需要引入分布式锁的library,随后,Redis2.8版本中,作者加入了set指令的扩展参数,使得setnx
可以和expire
一起执行,解决了分布式锁的乱象。
> set lock:codehole true ex 5 nx # set lock:codehole = true expire is 5s if key is not sxists
OK
# do something
> del lock:codehole
其中,set <key> <value> ex <time> nx
就是分布式锁的奥义所在。
2. 超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的业务逻辑执行时间太长,长到超过锁的过期时间,那么就会出现问题。
因为在执行业务逻辑的时候,锁过期了,那么就会有其他线程重新获取到这把锁,导致临界区同时有两个线程在执行,那么就会导致执行结果出现错误。
因此,Redis分布式锁不适合较长时间的任务,如果真的出现了错误,造成较小的数据错乱就可能需要人工介入。
tag = random.nextint()
if redis.set(key, tag, nx=True, ex=5):
do_something()
redis.delifequals(key, tag) # 假象的del if equals
有一个稍微安全的方案是,将set的指令的value参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再(删除Key)释放锁,这是为了确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期被服务器释放的。
但是匹配随机数和删除key不是一个原子操作,Redis没有提供类似于delifequals
的指令,这时就需要Lua
脚本来处理了,因为Lua
脚本可以保证连续多个指令的原子性执行。
3. 可重入性
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程多次加锁,那么这个锁就是可重入的。比如Java中的ReentrantLock。但是Redis分布锁如果要支持可重入,就需要对客户端的set方法进行包装,使用线程的Threadlocal变量存储当前持有锁的计数。
在此,作者不推荐使用可重入锁,因为它加重了客户端的复杂度,在编写业务方法时注意在逻辑结构上进行调整,避免使用可重入锁。
4. 集群环境下的分布式锁
上述的分布式锁的实现是使用一条指令就可以完成,不过在集群环境下,这种方式是有缺陷的,它不是绝对安全的。
比如:在Sentinel集群环境中,当主节点挂掉时,从节点会取而代之,但是客户端上并没有明显感知。比如,原先第一个客户端在主节点上申请成功了一把锁,但是这把锁还没来得及同步到从节点,主节点突然挂掉了,然后从节点变成了主节点,这个新的主节点没有这把锁,所以当另一个客户端就可以立即获取锁,导致了一定的不安全性。
不过这种安全尽在主从发生failover的情况下才会产生,而且持续时间很短,业务系统多数情况下可以容忍。
Redlock算法
为了解决这个问题,Antirez发明了Redlock算法,已经有很多开源的library做了良好的封装,用户可以直接使用redlock-py
import redlock
addrs = [{
"host":"localhost",
"port": 6379,
"db":0
},{
"host":"localhost",
"port": 6479,
"db":0
},{
"host":"localhost",
"port": 6579,
"db":0
}]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lck-leesure", 5000)
if success:
print("lock success")
dlm.unlock("user-lck-leesure")
else:
print("lock failed")
为了使用Redlock,需要提供多个Redis实列,这些实列之间相互独立,没有主从关系,同很多分布式算法一样,Redlock也是用大多数机制。
加锁时,他会向过半结点发送set(key,value, nx=True, ex=xxx)
指令,只要过半的结点set成功,就认为加锁成功。
释放锁时,需要向所有结点发送del指令,不过Redlock算法还需要考虑出错重试,时钟漂移等很多细节问题,同时,因为Redlock
需要向多个结点进行读写,也就是相比单个实例Redis的性能会下降一些。
Redlock使用场景
如果你很在乎高可用性,希望即时挂了一台Redis后也完全不受影响,就应该考虑Redlock
,不过代价也是有的,需要更多的Redis实列,性能也下降了。代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌。