分布式锁的由来

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

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

所谓原子操作是指不会被线程调度机制打断的操 作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。)

这个时候就要使用到分布式锁来限制程序的并发执行,保证同一时刻只能有一个进程在修改用户的状态。

分布式锁在很多场景中是非常有用的原语,比如上面的例子,可以总结为 不同的进程必须以独占资源的方式实现资源共享。

有很多分布式锁的库和描述怎么实现分布式锁管理器(DLM)的博客,但是每个库的实现方式都不太一样,很多库的实现方式为了简单降低了可靠性,而有的使用了稍微复杂的设计。

一些可供参考的实现库 http://redis.cn/topics/distlock.html

分布式锁的Redis实现

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

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

整体思想是这样,不过实际运用中会遇到如下问题,在如下问题中慢慢的演进了,最后得到一个相对好一些的解决方案。

死锁问题

del来不及执行

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

如何解决

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

expire来不及执行

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

如何解决

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。

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

为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问题。实现方法极为复杂,小白用户一般要费很大的精力才可以搞懂。如果你需要使用分布式锁, 意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。

为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,解决了分布式锁的乱象。

> set lock:code true nx ex 60 ... 
> do something critical ... 
> del lock:code

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

超时问题

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。

锁误解除导致并发

如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;

但此时线程A并没有马上执行完成,调用 DEL 命令来释放锁,线程 A 和线程 B 并发执行。

如何解决
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成(但这也会造成一些问题)
  • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间
锁误解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;

随后 A 执行完成,线程 A 调用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

如何解决

通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有。可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
    do smoething...
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

Lua 脚本可以保证连续多个指令的原子性执行

上述代码块是现在主流的Redis分布式锁的解决方案

我们使用 SET key value NX EX second命令加锁, 解决了死锁问题;使用lua脚本判断唯一锁之后再释放,保证不发生锁误解除的问题,一定程度上解决了超时问题。

问题

原因

解决方法

死锁问题

无法保证原子性

使用 SET key value NX EX second命令

超时问题

执行时间过长、锁误解除

合理设置过期时间、确保value的唯一性

这种用法在简单的单Redis节点下是没有问题的,但在实际生产中,还是会面临如下问题。。。

主从failover问题

为了保证Redis的高可用,发展出了 主从架构、哨兵架构、还有集群架构。

不管是主从架构、哨兵架构、还是集群架构,其原理都是从节点异步复制主节点的数据。当主节点挂掉时,从节点会取而代之。

原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。

Redlock算法

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

不过这个实现也很有争议:

  • 在实际生产中也不可取,因为要使用到几个完全独立的master节点(这几个master节点不是集群中master节点的概念,相当于是单点实例),有点浪费资源
  • 这个设计本身就会存在一些问题


总结

Redis可以一定程度上实现分布式锁,有缺陷和争议。

根据你锁的用途来看:

  • 为了效率(efficiency),协调各个客户端避免做重复的工作。即使锁偶尔失效了,只是可能把某些操作多做一遍而已,不会产生其它的不良后果。比如重复发送了一封同样的email。
  • 为了正确性(correctness)。在任何情况下都不允许锁失效的情况发生,因为一旦发生,就可能意味着数据不一致(inconsistency),数据丢失,文件损坏,或者其它严重的问题。

选择:

  • 如果是为了效率(efficiency)而使用分布式锁,允许锁的偶尔失效,那么使用单Redis节点的锁方案就足够了,简单而且效率高。Redlock则是个过重的实现(heavyweight)。
  • 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用Redlock。它不是建立在异步模型上的一个足够强的算法,它对于系统模型的假设中包含很多危险的成分(对于timing)。而且,它没有一个机制能够提供fencing token。那应该使用什么技术呢?Martin认为,应该考虑类似Zookeeper的方案,或者支持事务的数据库。

Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。

Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要使用数据库等其他的防并发手段。