Redis应用-分布式锁

分布式应用进行逻辑处理时经常会遇到并发问题。

比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存的两个操作不是原子的。

这个时候就要使用到分布式锁来限制程序的并发执行。Redis分布式锁使用非常广泛

分布式锁

分布式锁本质上是要实现的目标就是在Redis里面占一个茅坑,当别的进程也要来占坑时,发现已经有人在那里了,就只好放弃或者稍后再试。

占坑一般是使用setnx (set if not exists)指令,只允许一个客户端占坑。先来先占,用完了,在调用del指令释放茅坑。

1
2
3
4
5
>setnx lock:codehole  true
OK
....do something
>del lock:codehole' //用来删除锁
(integer) 1

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致del指令没有调用,这样就会陷入死锁,锁永远得不到释放。

于是我们在拿到锁之后,再给锁加上一个过期时间,比如5秒,这样即使中间出现异常也可以保证5秒之后锁会的到释放。

1
2
3
4
5
6
>setnx lock:codehole true 
OK
>expire lock:codehole 5
... do something
>del lockLc:codehole
(integer )1

但是以上逻辑还是有问题。如果setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被认为杀掉的,就可能导致expire得不到执行,也会造成死锁。

这种问题的根源就在于setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。

为了解决这个问题,Redis2.8版本中作者加入了set指令的扩展参数,使得setnx和expire指令可以一起执行了

1
2
3
4
5
>set lock:codehole true ex 5 nx 
OK
... dosomething
>del lock:codehole

上面的指令就是setnx和expire 组合在一起的原子指令,它就是分布式锁的奥义所在。

超时问题

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。

为了避免这个问题,Redis分布式锁不要用较长时间的任务。如果真的偶尔出现了,数据的小波错乱可能需人工介入解决

1
2
3
4
tag = random.nextint()  # 随机数 
if redis.set(key, tag, nx=True, ex=5):
do_something()
redis.delifequals(key, tag) # 假象的 delifequals 指令

有一个更加安全的方案是为set指令的value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后在删除key。但是匹配value 和 删除key不是一个原子操作,redis也没有提供类似于delifequals这样的指令,这就需要使用Lua脚本来处理了,因为Lua脚本可以保证连续多个指令的原子性操作。

1
2
3
4
5
# delifequals 
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0 end

可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果这一个锁支持同一个线程多次加锁,那么这个锁就是可重入的。比如Java语言里有个ReentrantLock就是可重入锁。Redis分布式锁如果要支持可重入,需要对客户端的set方法进行包装,使用线程的ThreadLocal变量存储当前持有锁的计数。

扩展

在集群环境下,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来的及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这把锁,所以但另一个客户端进来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全由此产生。

不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

Redlock算法

为了解决这个问题,Antirez发明了Redlock算法,他的流程比较复杂,不过有了很多开源的library做了良好的封装,用户拿来用即可

为了使用 Redlock,需要提供多个 Redis 实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,redlock 也使用「大多数机制」。 加锁时,它会向过半节点发送 set(key, value, nx=True, ex=xxx) 指令,只要过半节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些

Redlock使用场景

如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock。不过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的 library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌