Redis实战之分布式锁

接下来的几篇文章,会学习一下Redis除了用做缓存之外的一些实际使用方法。这篇文章主要探讨了分布式锁的几种实现机制和原理。

本文涉及到的Redis指令及语法

SET key value [EX seconds] [PX milliseconds] [NX|XX]

  • EX seconds : 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value
  • PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value
  • NX : 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value
  • XX : 只在键已经存在时, 才对键进行设置操作。

什么是分布式锁?

所谓分布式锁,指的是当多个进程不在同一个系统中,当访问共享资源时,需要使用分布式锁来控制这些不同的进程对资源的访问。

分布式锁的设计原则——安全性和有效性

Redis官网上对分布式锁提出至少需要满足以下三个要求:

  1. 互斥(属于安全性)—— 在任何给定时刻,只有一个客户端可以持有锁。
  2. 无死锁(属于有效性)—— 即使锁定资源的客户端奔溃或者被分区,也总是可以获得锁(通常通过超时机制实现)。
  3. 容错性(属于有效性)—— 只要大多数Redis节点都启动,客户端就可以获取和释放锁。

此外,分布式锁的设计中还需要考虑:

  1. 加锁解锁的同源性:A加的锁,不能被B解锁
  2. 获取锁是非阻塞的:如果获取不到锁,不能无限期等待
  3. 高性能:加锁和解锁是高性能的

分布式锁的实现方案

分布式锁有很多种实现方案,基于不同的体系,比如基于数据库乐观锁和悲观锁实现、基于Redis实现、基于zookeeper实现等等,本文主要聊聊基于Redis实现的分布式锁。

方案一:单实例Redis

所谓的分布式锁,其本质就是在分布式的系统中,当访问指定的资源时在Redis中占一个坑,当其他进程要来访问的时候,发现已经有其他进程访问了,只好放弃或者稍后重试。

一般使用 setnx 命令来”占坑“,用完之后使用 del 删除。

但是这种逻辑假如出现了异常,导致 del 没有被执行,那么就会陷入死锁不是么?所以为了解决这个问题,我们一般还会使用 expire 给锁加上一个过期时间,这样中间即使出现异常也可以自动释放锁。

这时候又出现问题了,假如在 setnxexpire 命令之间断电了导致进程挂掉了,那不也会造成死锁吗?

是的,这个问题的根源不在于设置过期时间,而是 setnxexpire 这两个命令不是原子性的,当然我们可以使用一些三方库来解决这个问题,但是这样未免有点大题小作。为了彻底解决这个问题,在Redis 2.8版本中,作者加入了 set 指令的扩展参数,即命令 SET key value [EX seconds] [PX milliseconds] [NX|XX] ,彻底解决了单实例下Redis分布式锁的乱象。

我们通过一个例子来看看这种方案具体是怎么实现分布式锁的。

单实例Redis分布式锁的解决方案

1. 加锁

首先,当我们需要分布式锁的时候,向Redis发起如下命令:

SET scanId:lock a947a56f9b40402b9db6e6be657cb9af NX PX 10000

其中,scanId:lock 是自己定义的,再细分的话,scanId可以代指某次扫描任务的ID,而 :lock 中的:只是一种写法,代表这个key是用来做分布式锁的。a947a56f9b40402b9db6e6be657cb9af是一个随机生成的字符,必须保证全局唯一。NXsetnx,只有当scanId:lock 不存在时才执行成功。PX 10000 指的是过期时间10秒。执行此命令后成功则表明服务成功的获得了锁。

2. 解锁

在加锁的时候,我们注意到value是一个随机的字符串,这么设计的原因是我们需要保证加锁和解锁的一定是同一个进程。那么我们在解锁的时候需要对这一点进行校验,也就是判断进程持有的value和Redis内存储的value是不是一致的。由于这个校验需要两个命令,所以我们使用lua脚本来解决(lua脚本可以保证连续多个指令的原子性执行):

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

但这也不是一个完美的方案,只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其他线程也会乘虚而入。当然我们可以考虑引入可重入锁来解决,但是大部分情况下没有必要,这增加了客户端的复杂性。

方案二:Redis集群模式

假设有两个服务A、B都希望获得锁,有一个包含了5个redis master的Redis Cluster,执行过程大致如下:

  1. 客户端获取当前时间戳,单位: 毫秒
  2. 服务A轮寻每个master节点,尝试创建锁。(这里锁的过期时间比较短,一般就几十毫秒) RedLock算法会尝试在大多数节点上分别创建锁,假如节点总数为n,那么大多数节点指的是n/2+1。
  3. 客户端计算成功建立完锁的时间,如果建锁时间小于超时时间,就可以判定锁创建成功。如果锁创建失败,则依次(遍历master节点)删除锁。
  4. 只要有其它服务创建过分布式锁,那么当前服务就必须轮寻尝试获取锁。

进一步理解

  1. 借助Redis实现分布式锁时,有一个共同的缺陷: 当获取锁被拒绝后,需要不断的循环,重新发送获取锁(创建key)的请求,直到请求成功。这就造成空转,浪费宝贵的CPU资源。
  2. RedLock算法本身有争议,具体看这篇文章How to do distributed locking 以及作者的回复Is Redlock safe?

有人可能要进一步问了,那该怎么做才能保证锁的绝对安全呢?

对此我只能说,鱼和熊掌不可兼得,我们之所以用Redis作为分布式锁的工具,很大程度上是因为Redis本身效率高且单进程的特点,即使在高并发的情况下也能很好的保证性能,但很多时候,性能和安全不能完全兼顾,如果你一定要保证锁的安全性的话,可以用其他的中间件如db、zookeeper来做控制,这些工具能很好的保证锁的安全,但性能方面只能说是差强人意,否则大家早就用上了。

一般来说,用Redis控制共享资源并且还要求数据安全要求较高的话,最终的保底方案是对业务数据做幂等控制,这样一来,即使出现多个客户端获得锁的情况也不会影响数据的一致性。当然,也不是所有的场景都适合这么做,具体怎么取舍就需要各位看官自己处理啦,毕竟,没有完美的技术,只有适合的才是最好的。

基于客户端的实现方式

待续..

updatedupdated2023-06-032023-06-03
加载评论