读腾讯技术工程《一文讲透 Redis 分布式锁安全问题》
为什么需要分布式锁?
在单机环境下编写多线程程序时,为了避免多个线程同时操作同一个资源,我们往往会通过加锁来实现互斥,以保证同一时间只有一个操作者对某个资源执行操作,在单机多进程的情况下,如果们想操作同一个共享资源,我们也可以通过操作系统提供的文件锁和心好凉来实现互斥,这些都是单台机器上的操作。而在分布式环境下,如果不同机器上的不同进程需要同时操作某一个共享资源,我们同样也需要这样一个统一的锁来实现互斥。这个时候,我们就需要一个平台来提供这样一个互斥的能力,通常我们会采用一些能够提供一致性的服务,比如 ZooKeeper、 etcd 来满足对一致性要求较高的场景下的互斥需求,当然,也有些服务会用数据库,比如 MySQL,来实现互斥,然而在某些高并发业务场景下,我们通常会采用 Redis来实现。
如何使用 Redis 来实现分布式锁?
最简单的方式,使用 SETNX 命令,这个命令会实现 Set if not exist,在执行时,只有成功设置值才会返回 1,否则返回0。那么在某个客户端通过 SETNX 加锁之后,其他的客户端无法加锁。之后释放锁可以通过 DEL 命令删除这个 key 即可。这种方式存在的问题在于,如果加锁成功的客户端没有正确释放锁,那么其他客户端就有可能永远拿不到这个锁,这就是“锁饥饿”。
那么如何避免这种情况呢?最简单的方法就是加一个过期时间(lease,租期),预估加锁后执行操作的时间,在达到该时间后如果持有锁的客户端没有释放锁,那么锁就被自动释放。在 Redis 里面实现时,我们可以给这个 key 加一个过期时间,那么无论客户端是否能够主动释放锁,只要一到过期时间,这个 key 就会被自动清理,锁就会自动释放。
在老的 Redis 版本中,SETNX 操作和 Expire 操作是两条命令,这样就有可能出现我们加租期的操作没法正常完成,从而还会存在无法自动释放锁的情况。而在新的 Redis 版本中,Redis 扩展了 SET 命令,可以直接在 SET 时指定 EX(Expire,过期时间)和 NX(If not exist,如果不存在才 SET),这样就保证了加锁和设置自动释放这两个操作的原子性。
这里提到了另外一个情况,如何保证加锁和解锁是由同一个客户端来执行,其实很简单,加锁的时候加个唯一标志就行了,可以直接写个 lua 脚本来实现。“因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。”
Redis 集群下的分布式锁
前面提到的场景完全是单个 Redis 实例下的情况,在使用 Redis 时,我们往往采用主从集群+哨兵的模式来部署,在主节点异常时,哨兵可以实现自动切换,把从库转为主库,这个过程中存在一个问题,如果加锁时的写入操作没有同步到从库时主库就挂了,那这个锁就会在主从切换时丢失,怎么解决这个问题呢?这里提到了 Redlock(红锁)方案。
Redlock 的方案基于两个前提:1. 不再部署从库和哨兵实例,只部署主库;2. 主库要部署多个,官方推荐至少 5 个实例。这里部署的 Redis 就不是什么集群了,就是五个相互独立的 Redis 实例。Redlock 主要有五个步骤:
- 客户端先获取「当前时间戳 T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
听描述,这里居然是顺序发起加锁请求,我看了一下原文档描述也是加锁 sequentially,不过这里其实 in parallel 的话是不是效率更高呢?
整个方案的逻辑其实挺简单,加锁时取大多数,通过时间戳进行加锁超时检查,解锁时全量解锁。文中介绍了一下 Redis 的作者 Antirez 和分布式专家 Martin Kleppmann 关于 Redlock 的讨论中的一些重点,我们可以跟着过一遍:
首先是 Martin 对于 Redlock 的质疑
使用分布式锁的目的是什么?Martin 认为一种是为了效率,避免重复的工作浪费资源,这种情况即使出现锁失效也无伤大雅,第二种是为了正确性,避免并发场景下多方操作同一份数据导致的数据错误、丢失等问题。如果是为了效率,那么用原始方案就行了,偶尔锁失效也没事儿,但是如果是为了正确性,Redlock 是无法满足足够的安全性要求。
锁在分布式场景下会遇到什么问题呢?其实就是分布式系统中的三个主要问题:NPC(N:Network Delay,网络延迟,P:Process Pause,进程暂停(GC)C:Clock Drift,时钟漂移)这里举了一个例子,在某个客户端拿到锁之后如果发生进程暂停,比如 GC,那么在 Redis 上所有锁过期之后,其他客户端仍然会重复加锁,发生锁冲突。
另一方面,这个机制对时钟的正确性有强依赖,但事实上 time drift 是在数据中心里面经常发生的情况。下面是反例:
- 客户端 1 获取节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E
- 节点 C 上的时钟「向前跳跃」,导致锁到期
- 客户端 2 获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B
- 客户端 1 和 2 现在都相信它们持有了锁(冲突)
于是 Matrin 提出了一个 fecing token 的方案,来保证正确性,流程如下:
- 客户端在获取锁时,锁服务可以提供一个「递增」的 token
- 客户端拿着这个 token 去操作共享资源
- 共享资源可以根据 token 拒绝「后来者」的请求
Martin 表示,一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。
Martin 的结论:
1、Redlock 不伦不类:它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的。
2、时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
3、无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。
下面是 Redis 作者 Antirez 的回应:
首先是时钟问题,Redlock 并不要求准确的时钟,只要时钟误差不要超过锁失效时间即可。关于进程暂停的问题,如果进程暂停发生在检查时间戳差值的步骤之前,那么在检查时间戳时就能发现锁已经失效,如果发生在其之后,那么其他锁服务也会遇到同样的问题,这并不是 Redlock 本身的问题。
对于 fencing token 的方案,Redis 作者提出了两个问题,第一,这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。第二,即使 Redlock 没有提供 fencing token 的能力,但 Redlock 已经提供了随机值,利用这个随机值,也可以达到与 fencing token 同样的效果。大概流程如下:
- 客户端使用 Redlock 拿到锁
- 客户端在操作共享资源之前,先把这个锁的 VALUE,在要操作的共享资源上做标记
- 客户端处理业务逻辑,最后,在修改共享资源时,判断这个标记是否与之前一样,一样才修改(类似 CAS 的思路)
相比 fencing token,这个方法只能保证互斥,并不能保证操作的顺序性。
基于 ZooKeeper 的分布式锁
文章下面介绍了基于 ZooKeeper 的分布式锁。
- 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
- 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
- 客户端 1 操作共享资源
- 客户端 1 删除 /lock 节点,释放锁
这里使用的是临时节点,要求客户端和 ZK 的连接不断开,只要断开,临时节点就会自动删除,从而释放锁。这里本质上是什么呢?本质上其实是 ZK 的客户端通过不断的心跳来维持 session,从而实现 lease 的续约。但是这里仍然也存在进程暂停导致的锁失效的问题。
基于 etcd 的分布式锁
实现流程如下:
- 客户端 1 创建一个 lease 租约(设置过期时间)
- 客户端 1 携带这个租约,创建 /lock 节点
- 客户端 1 发现节点不存在,拿锁成功
- 客户端 2 同样方式创建节点,节点已存在,拿锁失败
- 客户端 1 定时给这个租约「续期」,保持自己一直持有锁
- 客户端 1 操作共享资源
- 客户端 1 删除 /lock 节点,释放锁
这个 lease续期 和 ZK 里面的心跳类似,这里就不多赘述。其实本质上也会受到上面问题的影响。
但是从功能层面而言,ZK 和 etcd 能够提供 Watch 机制,即监听节点的变化,从而感知锁的状态。
作者最后介绍了一下自己的理解,从应用层面,作者个人倾向于常规 Redis 主从+哨兵的模式来实现分布式锁,正确性可以参考 fencing token 的实现,通过业务层面进行兜底,可以通过版本号(乐观锁)来避免锁失效时的数据错误。