redis专题02 分布式锁
  热度 °
分布式应用进行逻辑处理时经常会遇到并发问题。
比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。(Wiki解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)
这个时候就可以使用分布式锁来限制程序的并发执行。Redis分布式锁使用非常广泛。
分布式锁
分布式锁本质上要实现的目标就是在Redis
里面占一个”茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。
占坑一般是使用setnx(set if not exists)
指令,只允许被一个客户端占坑。先来先占, 用完后再调用del
指令释放茅坑。1
2
3
4
5
6
7
8127.0.0.1:6379> setnx lock:codehole true
(integer) 1
127.0.0.1:6379> get lock:codehole
"true"
... do something critical ...
127.0.0.1:6379> del lock:codehole
(integer) 1
127.0.0.1:6379>
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致del
指令没有被调用,这样就会陷入死锁,锁永远得不到释放。可以在拿到锁之后,再给锁加上一个过期时间,比如5s
,这样即使中间出现异常也可以保证5秒
之后锁会自动释放。1
2
3
4
5127.0.0.1:6379> setnx lock:codehole true
(integer) 1
127.0.0.1:6379> expire lock:codehole 5
(integer) 1
127.0.0.1:6379>
但是以上逻辑还有问题。如果在setnx
和expire
之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致expire
得不到执行,也会造成死锁。
这种问题的根源就在于setnx
和expire
是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用Redis
事务来解决。但是这里不行,因为expire
是依赖于setnx
的执行结果的,如果setnx
的执行结果的,如果setnx
没抢到锁,expire
是不应该执行的。redis事务
里没有if-else
分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了解决这个疑难,Redis2.8版本中作者加入了set
指令的扩展参数,使得setnx
和expire
组合在一起的原子指令一起执行,它就是redis分布式锁的原理;
set
完整命令
SET key value [EX seconds] [PX milliseconds] [NX|XX]
EX
second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。PX
millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。NX
:只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。XX
:只在键已经存在时,才对键进行设置操作。
从客户端执行命令: SET resource-name anystring NX EX max-lock-time
如果服务器返回 OK ,那么这个客户端获得锁。
如果服务器返回 NIL ,那么客户端获取锁失败,可以在稍后再重试。
设置的过期时间到达之后,锁将自动释放。
可以通过以下修改,让这个锁实现更健壮:
不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)。
不使用 DEL 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
这两个改动可以防止持有过期锁的客户端误删现有锁的情况出现。
以下是一个简单的解锁脚本示例:1
2
3
4
5
6if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
这个脚本可以通过 EVAL …script… 1 resource-name token-value 命令来调用。
Redis的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格的串行执行。为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。
上述通过set
配合lua
脚本实现的redis分布式锁,在集群情况下会存在如下问题,
比如在Sentinel
集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。
为了处理在集群模式下redis
分布式锁存在的上述问题,可以考虑引入redlock
分布式锁机制,当然为了使用Redlock
,需要提供多个Redis
实例,这些实例之前相互独立没有主从关系。同很多分布式算法一样,redlock
也使用「大多数机制」
。加锁时,它会向过半节点发送set(key, value, nx=True, ex=xxx)
指令,只要过半节点 set
成功,那就认为加锁成功。释放锁时,需要向所有节点发送del
指令。不过Redlock
算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为Redlock
需要向多个节点进行读写,意味着相比单实例Redis
性能会下降一些。
如果很在乎高可用性,希望挂了一台redis
完全不受影响,那就应该考虑redlock
, 不过代价也是有的,需要更多的 redis 实例,性能也会有折扣等;
总结
- 阐述了
redis分布式锁
的实现原理; - 进一步分析简单
redis分布式锁
可能存的超时操作,误删除操作等无法合理处理的问题,建议引入lua
脚本配合执行,lua
脚本作为redis
的内置脚本可以优雅的协助处理很多redis事务
无法处理的场景, 同时建议redis分布式锁
不要用于较长时间的任务; - 分析了在集群模式下
set
命令配合lua
实现的redis分布式锁
会引发数据不一致性问题,进一步引入分析了redlock
锁机制,对其实现原理及可能带来的影响进行了简单分析总结;
作者署名:朴实的一线攻城狮
本文标题:redis专题02 分布式锁
本文出处:http://researchlab.github.io/2018/01/18/redis-02-distributed-lock/
版权声明:本文由Lee Hong创作和发表,采用署名(BY)-非商业性使用(NC)-相同方式共享(SA)国际许可协议进行许可,转载请注明作者及出处, 否则保留追究法律责任的权利。