接下来的几篇文章,会学习一下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官网上对分布式锁提出至少需要满足以下三个要求:
- 互斥(属于安全性)—— 在任何给定时刻,只有一个客户端可以持有锁。
- 无死锁(属于有效性)—— 即使锁定资源的客户端奔溃或者被分区,也总是可以获得锁(通常通过超时机制实现)。
- 容错性(属于有效性)—— 只要大多数Redis节点都启动,客户端就可以获取和释放锁。
此外,分布式锁的设计中还需要考虑:
- 加锁解锁的同源性:A加的锁,不能被B解锁
- 获取锁是非阻塞的:如果获取不到锁,不能无限期等待
- 高性能:加锁和解锁是高性能的
分布式锁的实现方案
分布式锁有很多种实现方案,基于不同的体系,比如基于数据库乐观锁和悲观锁实现、基于Redis实现、基于zookeeper实现等等,本文主要聊聊基于Redis实现的分布式锁。
方案一:单实例Redis
所谓的分布式锁,其本质就是在分布式的系统中,当访问指定的资源时在Redis中占一个坑,当其他进程要来访问的时候,发现已经有其他进程访问了,只好放弃或者稍后重试。
一般使用 setnx
命令来”占坑“,用完之后使用 del
删除。
但是这种逻辑假如出现了异常,导致 del
没有被执行,那么就会陷入死锁不是么?所以为了解决这个问题,我们一般还会使用 expire
给锁加上一个过期时间,这样中间即使出现异常也可以自动释放锁。
这时候又出现问题了,假如在 setnx
和 expire
命令之间断电了导致进程挂掉了,那不也会造成死锁吗?
是的,这个问题的根源不在于设置过期时间,而是 setnx
和 expire
这两个命令不是原子性的,当然我们可以使用一些三方库来解决这个问题,但是这样未免有点大题小作。为了彻底解决这个问题,在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
是一个随机生成的字符,必须保证全局唯一。NX
即setnx
,只有当scanId:lock
不存在时才执行成功。PX 10000
指的是过期时间10秒。执行此命令后成功则表明服务成功的获得了锁。
2. 解锁
在加锁的时候,我们注意到value是一个随机的字符串,这么设计的原因是我们需要保证加锁和解锁的一定是同一个进程。那么我们在解锁的时候需要对这一点进行校验,也就是判断进程持有的value和Redis内存储的value是不是一致的。由于这个校验需要两个命令,所以我们使用lua脚本来解决(lua脚本可以保证连续多个指令的原子性执行):
|
|
但这也不是一个完美的方案,只是相对安全一点,因为如果真的超时了,当前线程的逻辑没有执行完,其他线程也会乘虚而入。当然我们可以考虑引入可重入锁来解决,但是大部分情况下没有必要,这增加了客户端的复杂性。
方案二:Redis集群模式
假设有两个服务A、B都希望获得锁,有一个包含了5个redis master的Redis Cluster,执行过程大致如下:
- 客户端获取当前时间戳,单位: 毫秒
- 服务A轮寻每个master节点,尝试创建锁。(这里锁的过期时间比较短,一般就几十毫秒) RedLock算法会尝试在大多数节点上分别创建锁,假如节点总数为n,那么大多数节点指的是n/2+1。
- 客户端计算成功建立完锁的时间,如果建锁时间小于超时时间,就可以判定锁创建成功。如果锁创建失败,则依次(遍历master节点)删除锁。
- 只要有其它服务创建过分布式锁,那么当前服务就必须轮寻尝试获取锁。
进一步理解
- 借助Redis实现分布式锁时,有一个共同的缺陷: 当获取锁被拒绝后,需要不断的循环,重新发送获取锁(创建key)的请求,直到请求成功。这就造成空转,浪费宝贵的CPU资源。
- RedLock算法本身有争议,具体看这篇文章How to do distributed locking 以及作者的回复Is Redlock safe?
有人可能要进一步问了,那该怎么做才能保证锁的绝对安全呢?
对此我只能说,鱼和熊掌不可兼得,我们之所以用Redis作为分布式锁的工具,很大程度上是因为Redis本身效率高且单进程的特点,即使在高并发的情况下也能很好的保证性能,但很多时候,性能和安全不能完全兼顾,如果你一定要保证锁的安全性的话,可以用其他的中间件如db、zookeeper来做控制,这些工具能很好的保证锁的安全,但性能方面只能说是差强人意,否则大家早就用上了。
一般来说,用Redis控制共享资源并且还要求数据安全要求较高的话,最终的保底方案是对业务数据做幂等控制,这样一来,即使出现多个客户端获得锁的情况也不会影响数据的一致性。当然,也不是所有的场景都适合这么做,具体怎么取舍就需要各位看官自己处理啦,毕竟,没有完美的技术,只有适合的才是最好的。
基于客户端的实现方式
待续..