分布式锁是为了解决在分布式环境里,当多个节点同时竞争访问同一个资源带来数据不一致的问题,分布式锁除了要满足互斥、无死锁、可重入等特性外,还需要考虑高可用、高性能等问题。分布式锁比较流行的实现方式有三种:

  1. 数据库
  2. Redis
  3. Zookeeper

本文主要介绍使用Redis时如何实现分布式锁,使用Redis实现分布式锁的最低要求需要满足以下三点:

  1. 互斥,在任何给定时刻,只有一个客户端可以持有锁。
  2. 无死锁,即使锁定资源的客户端崩溃或分区,也始终可以获取锁定。
  3. 容错能力,只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。

互斥和无死锁可以可以使用Redis的setnx指令保证只能有一个节点设置锁,同时设置一个超时时间保证在极端情况下能自动释放锁,容错能力可部署Redis集群。

基于故障转移Redis分布式锁的实施

使用Redis实施分布式锁最简单的方法是在实例在创建一个key,使用Redis的超时功能,在有限的时间内保持key的可用性,以便最终将其释放。最后当客户端处理完成后删除key。

一看使用Redis实施分布式锁还蛮容易,不需要过多的步骤,但是在单实例里面如果实例发生宕机或网络不可用时会发怎么办呢?这个也蛮好解决我们可以加个奴隶使用一主一从,如果主服务器不可用还可以使用它。这样会带来另外一个问题,你们发现这样实施后锁没法保证互斥性了,比如发生以下的情况:

  1. 客户端A获取Master节点中的锁
  2. Slave节点同步key之前,Master宕机
  3. Slave节点升级为Master节点
  4. 客户端再次去获取同样的锁,获取锁成功。安全违规!

画个图比较清楚一点:

Redis分布式锁实施方案

单个实例实施分布式锁

在解决单实例Redis分布式锁的限制之前,先看一下如果正确的使用Redis在单实例中实施分布式锁,要获取分布式锁,必遵循循以下方法:

SET resource_name my_random_value NX PX 30000

该命令只有在key不存在时才设置key(NX选项),并且会设置到期时间为30000ms(PX选项),key的值为一个随机值,唯一的限制是必需保持该值在所有的锁定请求中必需唯一。

使用随机值是为了更安全的释放锁,只有只有key中存放的值是期望的值时才释放锁了,为了保证判断值是否一致与删除key的原子性,使用Lua脚本来完成:

if redis.call("get",KEYS[1]) == ARGV[1] then
 return redis.call("del",KEYS[1])
else
 return 0
end

ReidsLock算法

在分布式环境中,如何保证Redis锁的高可用呢?假设有N个Master节点,所有节点相互独立,不采用任务复制或其他协调系统。比如我们有5个Redis主服务器,为了获取锁客户端相当做如下操作:

  1. 可以以毫秒为单位获取当前时间
  2. 生成key和随机值和超时时间
  3. 发送Redis命令的超时时间尽可能的小,这样当节点故障时能快速的访问下一个节点
  4. 顺序发送获取锁命令
  5. 客户使用当前时间减去第一步中获取的时间得到锁的有效时间,当有至少3个节点获取锁功能,并且锁的有效时间小于锁的超时时间才认为成功获取到锁
  6. 如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负),则认为获取锁失败,它将尝试解锁所有实例。
Redis分布式锁实施方案

上图简单画出5台Redis获取锁的过程,客户端A成功在Redis 1、Redis 2、Redis 4中获取到锁。伪代码大概是这样子:

long timestamp = System.currentTimeMillis();
String lockKey = xxx
String value = xxx
long timeout = 5000
int n = 5
int successCount = 0;
for (i = 0; i < n; i++){
 if(
 redis[i].setnx(lockKey, value, timeout) 
 ){
 //设置成功
 successCount++;
 }
 
 if(successCount == (N / 2 + 1) ) 
 break;
}
//获取锁总花费时间
long spent = System.currentTimeMillis() - timestamp 
//获取成功的数量等于 (N / 2 + 1)并且,有交时间小时超时时间
if(successCount == (N / 2 + 1) && 
 (timeout - spent) > 0
){
 //成功获取到锁
}else{
 //获取锁失败,需要释放锁
}

使用此算法面临的问题

  1. 因为有多台Redis服务器,发送命令时间和过期时间多少会有一些延迟
  2. 某台机器崩溃锁丢失,重启后其他客户端可以再次锁定
  3. 使用持久化解决锁的丢失,如果停电key未写入磁盘
  4. ...

如何解决超时时间延迟的问题?

只要保证使用锁的时间小时有效时间,在锁过期之前释放锁,就可以忽略这些延迟,实事上这个很难保证。

如何解决机器崩溃问题?

使用持久化方案,将锁信息保存到磁盘,因为Redis过期是从语义上实现的,故障期间Redis服务器时间仍然流逝,所以当Redis服务器重启后,过期时间还是可以保持一致的。

锁数据未持久化到磁盘如何处理?

如果锁未持久化入磁盘,那会跟问题2一样,重启后其他客户端可再次获取锁,解决这个问题可以将持久性设置中始终启用fsync = always,反过来这将完全破坏性能。

为了保证这一点,我们只需要使一个实例在崩溃后至少不可用超过我们使用的最大TTL(即实例崩溃时存在的所有锁的所有键)所需的时间即可。无效并自动释放。

使用延迟重新启动,即使没有任何种类的Redis持久性,也基本上可以实现安全性,但是请注意,这可能会转化为可用性损失。例如,如果大多数实例崩溃,则系统将无法在全局范围内使用TTL(此处,全局范围是指在此期间根本没有资源可锁定)。