文章目录
  1. 1. 分布式锁
  2. 2. 总结

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

比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。(Wiki解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)

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

分布式锁

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

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

1
2
3
4
5
6
7
8
127.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
5
127.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>

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

这种问题的根源就在于setnxexpire是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用Redis事务来解决。但是这里不行,因为expire是依赖于setnx的执行结果的,如果setnx的执行结果的,如果setnx没抢到锁,expire是不应该执行的。redis事务里没有if-else分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。

为了解决这个疑难,Redis2.8版本中作者加入了set指令的扩展参数,使得setnxexpire组合在一起的原子指令一起执行,它就是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
6
if 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)国际许可协议进行许可,转载请注明作者及出处, 否则保留追究法律责任的权利。

@一线攻城狮

关注微信公众号 @一线攻城狮

总访问:
总访客: