redis分布式锁相关思考

随着数据量不断增大,单机部署整套应用程序已经不可能,此时需要水平扩展多台机器均摊负载。然而水平拓展带来的代价是共享内存的消失,在单机项目中,我们可以轻松通过锁在编码层面轻松控制并发,但是在分布式架构下每个节点都是独立的,他们拥有各自的内存空间和指令流。

扩展多台机器之后,我们依然期望像使用单机一样使用这个分布式集群。然而,当多台机器同时对数据层发起同一个请求时,由于失去了单机内存的天然隔离,并发安全便成了头等大事。为了保证同一时刻只有一个节点能操作共享资源,分布式锁应运而生,只有获取了分布式锁的机器,才可以对数据层发起请求。

这篇文章主要记录我在看完DDIA作者Martin Kleppmann的《How to do distributed locking》中对于Redlock的批判,和Redis作者antirez的Is Redlock Safe之后引发的对于分布式锁的思考,由于我们最常使用Redis单机分布式锁和Redlock,下文主要对此进行讨论。

参考链接:

  1. How to do distributed locking
  2. Is Redlock safe?

使用分布式的的目的

分布式系统专家 Martin Kleppmann 在其著名的《How to do distributed locking》一文中,将分布式锁的需求归纳为两个截然不同的维度:效率和正确性。

在效率方面,取锁是为了避免不必要的重复工作或者小小的不便(用户收到两次同样的邮件通知),在这种情况下,即使锁失效,两个节点同时做了同样的工作,结果也仅仅是成本略微增加,比如说有节点做了没必要的工作(重复计算如何覆盖已经计算的文件)。这种"没什么大不了"的场景正是 Redis 的闪光点。如果你只是为了效率,运行 5 个 Redis 节点并求助于多数派投票的Redlock算法就显得过于沉重和复杂。一个带异步复制的单实例 Redis 往往就足够了,大家心里都清楚:这把锁是近似的,只用于非关键用途。

在正确性方面,取锁是为了防止并发进程相互干扰并破坏系统状态,例如库存扣减、银行转账、甚至是给患者服用药物的剂量控制,如果锁定失败,两个节点同时操作同一段数据,结果将是文件损坏、永久性数据不一致、甚至是严重的医疗事故。这方面也是争议爆发的焦点,如果使用单机 Redis 分布式锁的话会有单机故障和瓶颈问题,一旦 Redis 节点宕机,整个系统的并发控制就会瘫痪;即便引入主从复制,由于 Redis 的主从同步是异步的,如果主节点在将锁同步给从节点前挂掉,从节点切换为主后,新的锁请求依然能成功,导致一把锁被两个节点持有。

基于此Redis作者提出了Redlock算法,下面给出Redlock算法的主要步骤:

  1. 在尝试获取锁之前,客户端先记录当前精确的开始时间T1。
  2. 客户端使用相同的 Key 和唯一的随机 Value,尝试在N个独立的 Redis 实例中逐个获取锁。为了防止客户端在某个宕机的节点上浪费太多时间,获取单个锁的超时时间必须远小于锁的TTL。
  3. 在尝试完所有节点后,客户端计算为了获取这把锁总共花了多少时间:当前时间-T1。同时,只有当客户端在大多数节点(N/2 + 1,例如 5 个中的 3 个)都成功拿到了锁,才进入下一步。
  4. 如果满足以下两个条件,说明锁有效:客户端拿到了超过半数的锁,获取锁的总耗时必须小于锁的TTL。如果加锁成功的话,锁的实际有效时间需要扣除获取锁时的消耗,即TTL - 获取锁总耗时。
  5. 加锁成功后客户端可以开始操作资源,并在完成后向所有节点发送释放锁的指令(即使加锁时没成功的节点也要发,确保残留的锁能被清除)。如果客户端没能拿到多数派,或者发现有效时间已经为负数意味着加锁失败,它必须立即向所有节点发送解锁指令,防止这些节点被这把"残缺的锁"占用。

使用分布式锁保护资源的问题

现在我们先不讨论Redlock的不足之处,我们先说一下分布式锁的一般使用方式和带有TTL的分布式锁普遍存在的问题。

我这里就以马丁文章中的示例说明,假设你有一个应用程序,客户端需要更新共享存储中的文件(例如 HDFS 或 S3)。客户端首先获取锁,然后读取文件做一些修改,写回修改后的文件,最后释放锁。锁防止两个客户端同时执行读-修改-写循环,否则会导致更新丢失。代码可能如下:

java 复制代码
function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) {
        throw 'Failed to acquire lock';
    }

    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release();
    }
}

此时无论你的分布式锁是使用单机Redis,Redlock还是使用共识算法实现的etcd分布式锁,只要这个锁采用了TTL机制,那么上述代码就可能会出现问题,示例如下:

示例中client1获取锁之后进入了长时间的gc,如果gc暂停时间超过了锁的TTL,而client1没有意识到已过期,可能会做出一些不安全的更改。如上图所示,有两个客户端可以并发修改数据库,锁的正确性失效了。因为client1在检测之后发现自己持有锁,打算进行数据库修改操作,此时发生gc,长时间后gc完成,但是此时锁服务的TTL过期,client2可以获取到锁,然后client2获取锁之后就会修改数据库,此时client1从gc恢复之后也发起请求,造成并发问题。

你不能通过在写入存储前对锁的到期进行检查来解决这个问题,因为gc可能发生在任意时刻,可能就在你检查完之后发生。

当然,这种情况也不一定是只可能在发生gc的情况下触发,比如说你的进程被操作系统延迟调度,或者触发了缺页异常,导致读取磁盘时耗费大量时间,还可能会发生网络延迟的情况,比如client1发出请求后这个请求被延迟了,然后锁服务的TTL过期,导致client2获取到锁,然后请求服务,当client2发出的请求到达数据库时,client1延迟的请求也到达了,此时还是有两个请求并发修改。

解决方法

这个问题是使用带有自动过期时间的分布式锁的通用问题,本质上就是分布式环境下进程和网络的不可靠性,导致TTL过期的问题,下面介绍马丁给出的解决方法:Fencing token

本质上就是不依赖不可靠的时钟,而是选择了类似Raft算法中任期类似的思想。在每次写入请求中包含一个围栏令牌 。在此语境下,围栏令牌仅是一个数字,每当客户端获得锁时,它就会增加(例如通过锁服务递增)。

如上图所示,client1获取了锁和token33,但随后进入长时间暂停,租约到期。client2在超时之后获取了锁和token34,然后将其写入内容发送给存储服务,包括token34。随后,client1恢复功能,向存储服务发送写入,包括其token34。然而,存储服务器会记得它已经处理过一个令牌号更高的写入(34),因此它拒绝了令牌号33的请求。

注意,这要求存储服务器积极检查令牌,并拒绝任何令牌反向写入。其实很简单,单机的话记录这个字段就好了,多机的话就对这个字段做单值共识就好。

然而这也引出了Redlock算法的问题,他没有生成Fencing token的能力,因为生成集群中递增的fencing token其实是靠共识去做的,Redlock算法不会生成任何每次客户端获得锁时都会增加的数字。这意味着即使算法在其他方面完美无缺,也无法安全使用,因为当一个客户端暂停或数据包延迟时,无法阻止客户端之间的竞争条件。

Redlock其他缺点

刚才说过了Redlock的最大的问题在于它无法生成单调递增的fencing token。这意味着如果一个客户端因为gc或其他原因暂停,等它恢复时,由于没有令牌校验,它可能会在锁已过期的情况下错误地写回数据,导致数据不一致。除此,Redlock还有一些问题,这些都是基于马丁的文章总结。

在分布式系统中,我们一般采用部分同步模型,这是现实世界中网络模式的缩影:进程可能因为gc随时暂停,数据包可能在网络中任意延迟,而且时钟随时可能出错,但是大部分情况下运行良好。在这种环境下,一个健壮的分布式算法必须区分两个核心属性:安全性和活性。其中安全性保证坏事拥有不会发生,例如绝不会有两个客户端同时持有同一把锁。活性保证好事最终会发生,系统最终能从故障中恢复,不会永久死锁。

Redlock的设计缺陷在于,它错误的将安全性寄托在了物理时间上,在理想的分布式算法中,时钟仅仅被应用于实现活性(比如通过超时来避免无限等待),而绝对不能用来保证安全性。Redlock依赖Redis的过期时间来确保锁的释放,然而,这种对时间的依赖在复杂的分布式环境中是极其脆弱的。

Redis默认使用系统时钟而非单调时钟来判断key是否过期,假如发生了NTP时钟对齐或者服务器的管理员手动校准了系统时间,尽管物理时间只过了 1 秒,但 Redis 会认为 10 秒已过,从而立即释放该锁。此时,原本持有锁的客户端 A 还在处理任务,而客户端 B 已经可以成功获取同一把锁。此时由于对于时钟的依赖导致正确性出现了问题。

所以Redlock的安全性实际上建立在一个名为最大时钟漂移的乐观假设之上,它假设网络延迟,进程停顿,时钟偏差都远小于TTL,而在分布式系统的学术定义里,一旦安全性需要依赖对时序的假设,它就不再是一个健壮的算法,相比之下,基于共识算法的系统,通过Fencing Token而非物理时钟来保护资源,才是真正符合分布式系统规律的解决方案。

Redlock在工程上的实际可用性

下面是Redlock作者关于马丁观点的反驳,可以概括为:"马丁提出的很多问题是所有分布式锁的通病,而 Redlock 在实际工程中是足够鲁棒的。"

首先马丁认为在分布式系统中时间是不可靠的,但是antirez认为时钟偏移的范围是有限的,不同机器的时钟范围在一个置信区间内部,所以Redlock算法引入了时钟偏移来综合计算,每次获取锁的时候都会再减去一个时钟偏移来计算锁的有效时间。可以这样做的原因是如果一个系统的时钟发生了巨大的、非线性的跳变,那么这个系统本身的基础设施已经损坏了。在这种极端情况下,不仅是 Redlock,绝大多数分布式算法都会面临预料之外的行为。而且通过现代运维手段,可以将系统的时钟偏差控制在毫秒级,远小于TTL。

antirez还提出了一个观点,对于进程暂停或者网络延迟中的gc时出现的多个进程同时对数据层并发修改的问题,马丁使用fencing token方案解决的问题,这就要求锁服务可以维持递增的token,否则存储层无法判断谁先谁后。但是在实际中,即时你的etcd给你发了一个token,如果你的数据库(比如一个简单的文件系统或不支持版本号的旧数据库)本身不具备检查令牌并且执行原子写的能力,那这个令牌就没用了。

所以安全性的最后一道防线永远在数据库手里,如果数据库支持版本校验,那么它其实并不关心这个token是连续递增的数字,还是一个唯一的随机字符串,只要它能区分出当前请求是否来自于最新的合法持锁者即可。但是使用随机字符串的话就不止要在数据库内部维护一个全局的 max_token 字段了,需要维护当前合法持有锁者的唯一标识,类似于IF (request.uuid == database.current_lock_uuid) THEN 执行写入,类似于CAS操作。

那么既然安全性最终由数据库兜底,那我们为什么还要分布式锁呢?因为锁服务拦截掉了大部分的并发请求,它让绝大多数冲突在进入数据库之前就被消化掉,保证了系统的高性能。而存储层则是拦截那部分极端情况下分布式锁失效的请求。

马丁担心网络延迟会拖慢加锁流程,导致客户端拿到锁时,它其实已经快过期了。但 antirez 指出,Redlock 算法本身就内置了一套减法逻辑: <math xmlns="http://www.w3.org/1998/Math/MathML"> V a l i d i t y T i m e = T T L − ( T 2 − T 1 ) − D r i f t Validity \space Time = TTL - (T_{2} - T_{1}) - Drift </math>Validity Time=TTL−(T2−T1)−Drift,其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( T 2 − T 1 ) (T_{2} - T_{1}) </math>(T2−T1) 是获取多数派锁所消耗的网络总时间。如果网络发生严重延迟(比如延迟了 9 秒,而 TTL 只有 10 秒),客户端在完成加锁后会立即进行二次检查,此时会发现剩余有效时间不够了,于是认定加锁失败。

总结

这场辩论本质上是分布式系统中"学术严谨性"与"工程实用主义"的深度碰撞,马丁警示我们物理时钟的不可靠性使得任何单纯依赖 TTL 的算法在异步模型下都存在安全漏洞,因此主张引入Fencing Token这种逻辑屏障来确保绝对的正确性。而antirez则代表了务实的开发者视角,认为在运维良好的现实环境下,时钟偏移和极端延迟是有界的,Redlock 在高性能与鲁棒性之间找到的平衡点足以应对 99% 的业务场景。两者的共识其实隐藏在争论背后------即分布式锁的安全性不应孤立存在,锁服务通过高效拦截绝大多数并发冲突来换取系统的高性能,而存储层基于逻辑序号或版本校验实施的并发安全控制机制,才是守护数据一致性最后的屏障。

相关推荐
彭于晏Yan1 小时前
SpringBoot如何调用节假日API
java·spring boot·后端
我爱娃哈哈1 小时前
Spring AI Alibaba 教程:集成阿里云大模型服务实战
后端
寻见9031 小时前
告别只会 CRUD!Spring 核心原理吃透,这一篇就够了(Java 程序员必藏)
java·后端·spring
Moe4881 小时前
基于 AOP 与 Redisson 的分布式锁实现:自动加锁、解锁与 SpEL 参数解析
java·后端·架构
vx-程序开发1 小时前
springboot名士会所会员管理系统-计算机毕业设计源码57546
spring boot·后端·课程设计
泉城老铁2 小时前
springboot 项目jar 如何部署到docker
后端
韩立学长2 小时前
基于Springboot的商品库存管理系统369jr3t9(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
java·数据库·spring boot·后端
y = xⁿ2 小时前
【Java八股锁机制的认识】synchronized和reentrantlock区分,锁升级机制
java·开发语言·后端