☰
拿到锁不等于一直有锁:分布式锁的过期、误删与 Fencing Token 防线
2026/10/21 2:19:49 网站建设 项目流程

目录

一、锁离开单机之后:三个新增的敌人

(一)互斥在单机上为什么是容易的

(二)分布式环境多出来的三件事

(三)效率型与正确性型:先回答你丢不起什么

二、一条命令起步:Redis 锁的基本形与三个必须

(一)加锁必须原子:一条命令完成"检查并占用"

(二)必须设过期:超时是代持有者收尸的机制

(三)值必须唯一:为释放和续期留下所有权凭证

三、第一道裂缝:锁过期了,业务还没跑完

(一)过期时间的两难

(二)超时的触发比想象中容易

(三)双持锁的事故时序

四、第二道裂缝:误删了别人的锁

(一)无校验删除的事故时序

(二)先查再删依然是错的:检查与动作之间的窗口

(三)用 Lua 把校验与删除压成一步

(四)可重入与凭证的传递边界

五、续期:把过期时间从静态猜测变成动态心跳

(一)看门狗的工作方式

(二)续期能治的与不能治的

(三)续期的工程细节

六、第三道裂缝:主从切换把锁弄丢了

(一)异步复制的窗口

(二)多数派加锁:Redlock 的思路

(三)Redlock 的争议:它守得住什么,守不住什么

七、最后一道防线:Fencing Token

(一)核心思想:让资源方拒绝过时的授权

(二)时序推演:陈旧客户端如何被挡在门外

(三)令牌从哪里来

(四)资源端怎么校验

1、数据库类资源

1.1、令牌列加条件更新

1.2、事务内先检旗后写入

2、支持条件写的存储

3、自研服务类资源

(五)资源端无法校验时怎么办

八、换一种地基:ZooKeeper 与 etcd 的锁

(一)ZooKeeper:临时顺序节点、会话与羊群效应

(二)etcd:租约、revision 与事务

(三)会话与租约同样会过期:没有银弹,只有位置更好的防线

九、把防线装配成清单

(一)按丢失代价分层选型

(二)上线前自查清单

(三)绕开锁:很多互斥需求另有解法

十、写在最后

参考资料


干货分享,感谢您的阅读!

一段被分布式锁保护的代码,最危险的时刻往往不是加锁失败,而是加锁成功之后:客户端以为自己还持有锁,锁服务却早已把钥匙交给了别人。过期时间到了业务还没跑完、释放时删掉了别人的锁、主从切换把锁记录弄丢、一个停顿了几十秒的陈旧客户端带着过时的授权杀回来写数据——这四类事故的共同点是,互斥在"加锁成功"的那一刻是成立的,在"写入资源"的那一刻已经不成立了。本文从一条最简单的加锁命令出发,逐层推演这四道裂缝的成因与时序,给出续期、所有权校验、多数派加锁这三层缓解手段各自的能与不能,最后落到 Fencing Token 这道把裁决权交还给资源方的最终防线,并对照协调服务上的锁实现与一份可执行的选型自查清单。

一、锁离开单机之后:三个新增的敌人

(一)互斥在单机上为什么是容易的

先把参照系立好。在同一个进程里,synchronized、ReentrantLock这类同步设施之所以可靠,是因为三件事由运行时和操作系统直接担保:第一,仲裁者与竞争者在同一块内存里,获取锁与判断持有者是一次本地原子操作,没有网络参与;第二,持有锁的线程如果崩溃,进程随之消失,锁自然释放,不存在"持有者失联但锁还占着"的悬挂状态;第三,锁的状态与业务的状态在同一个故障域里,要死一起死,不会出现"锁的账本活着、业务的账本死了"这种账实分离。

跨进程互斥把这三条担保全部拆掉了。仲裁者(Redis、ZooKeeper、etcd 或一张数据库表)搬到了网络另一端,每一次加锁、释放、续期都变成一次可能超时、可能丢失、可能乱序到达的远程调用;持有者崩溃后不会自动放手,必须靠超时机制代为收尸;锁的账本与业务的数据分属两个故障域,其中任何一个的抖动都会让另一个的判断失真。

(二)分布式环境多出来的三件事

分布式系统理论把这类环境称为异步系统模型:消息延迟没有上界、进程执行速度没有下界、时钟误差没有保证。落到分布式锁的语境里,就是三个具体的敌人。

第一个敌人是进程停顿。垃圾回收的全局暂停、缺页中断后等待换页、虚拟机被宿主机挂起迁移、容器被 CPU 配额限流、甚至一次同步日志刷盘,都能让一段"看起来只需要几毫秒"的代码在任意位置停顿数秒到数十秒。关键在于:停顿的进程自己并不知道自己停顿过,恢复执行时它的程序计数器指向哪里,它就从哪里继续,携带着停顿前的全部认知——包括"我持有锁"这个已经过期的事实。

第二个敌人是网络分区与消息丢失。加锁请求发出去了,响应没回来,客户端无法区分"锁没加上"与"锁加上了但确认丢了";释放请求同理。任何基于"我发了命令所以状态一定变了"的推断,在分布式环境里都不成立。

第三个敌人是时钟漂移。过期时间本质上是一份对时间的契约:锁服务承诺"这把锁在十秒内属于你"。但锁服务的十秒和客户端的十秒未必一样长——时钟同步协议的校正会让时钟跳变,虚拟化环境下的时钟节拍可能被压缩或拉伸。凡是依赖"双方对已经过去了多久达成一致"的协议,都要为时钟不一致留出余量。

(三)效率型与正确性型:先回答你丢不起什么

在讨论任何实现之前,必须先回答一个分类问题:这把锁是为了效率,还是为了正确性。这个区分在分布式系统领域是讨论锁安全性时的标准起点。

效率型的锁,防的是重复劳动。十个副本同时收到定时任务触发,只希望一个副本去跑报表;缓存失效瞬间一百个请求涌向数据库,只希望一个请求去回源重建。这类场景里锁偶尔失效的后果是多算了一次、多查了一次,成本是浪费,不是错误。

正确性型的锁,防的是数据损坏。扣减最后一件库存、执行一笔转账的记账、对同一份文件做读改写——两个执行者同时进入临界区会直接产生错账、超卖、覆盖丢失,事后可能无法修复。

这个分类决定了后文所有方案的取舍:效率型场景用单实例 Redis 加合理超时就够了,失效概率低、失效代价小,不值得为它引入更重的设施;正确性型场景则要沿着本文的路线走到底——所有权校验、续期、多数派,直到 Fencing Token,而且最终会发现,仅靠锁服务单方面努力是不够的,资源端必须参与裁决。

二、一条命令起步:Redis 锁的基本形与三个必须

(一)加锁必须原子:一条命令完成"检查并占用"

Redis 上的最小可用锁是一条命令:

SET lock:order:9527 f2a1c0de-7b3e-4e19-a2d5-1c8e6f0b9a44 NX PX 30000

NX表示键不存在时才写入,PX 30000表示三十秒后自动过期,值是一个客户端生成的唯一标识。这条命令在服务端原子执行,"检查是否被占用"与"占用"之间不存在别的命令插入的可能。返回OK即加锁成功,返回空即锁被他人持有。

早年流传的分步写法——先SETNX再EXPIRE——存在一个经典缺陷:两条命令之间客户端崩溃,就会留下一把没有过期时间的锁,所有后来者永久阻塞。这类"检查与动作分离"的窗口是本文会反复出现的母题,第一次出现在加锁,后面还会在释放、续期时以不同面目再现。

(二)必须设过期:超时是代持有者收尸的机制

过期时间不是性能参数,是活性保障。持有锁的客户端可能崩溃、可能与 Redis 之间网络断开、可能永远不回来,如果锁没有过期时间,一次客户端故障就把资源永久锁死。过期机制的本质是:锁服务无法探知持有者的死活,只能约定"超过这个时间没有动静,我就当你死了"。

但这份保障的另一面是本文第三章的全部内容:锁服务"当你死了"的判断可能是错的——你没死,只是慢了。超时机制天然无法区分"死亡"与"缓慢",这是异步系统模型的根本限制,不是实现缺陷。

(三)值必须唯一:为释放和续期留下所有权凭证

值写成唯一标识(通常是 UUID 加线程标识),是很多入门实现最容易省略、也最不该省略的一步。这个值是所有权凭证:释放锁时要凭它证明"这把锁确实是我加的",续期时要凭它证明"我还是持有者"。如果所有客户端都写同一个值(比如写死一个常量,或者干脆写1),锁服务就无法区分持有者,第四章的误删事故就无从防御。

凭证要满足两个条件:全局唯一,避免两个客户端撞出同一个凭证;由客户端自己生成并保存在本地,这样校验时不需要额外的查询往返。

三、第一道裂缝:锁过期了,业务还没跑完

(一)过期时间的两难

过期时间是一个静态猜测,猜的是"业务在临界区里最多待多久"。这个猜测两头都有代价。

设短了,破坏互斥。业务偶发地慢——一次慢查询、一次下游抖动——超过过期时间,锁被自动释放,第二个客户端加锁成功进入临界区,此时第一个客户端还在里面跑,互斥失效。

设长了,拖慢恢复。持有者真的崩溃时,锁要等完整个过期时间才能被别人拿到。过期时间设为十分钟,就意味着一次进程崩溃最坏会让这个资源上的所有操作停摆十分钟。对于秒杀扣库存这类锁竞争密集的场景,这个代价往往不可接受。

两难的根源在于:一个静态数字要同时刻画"业务正常执行的上界"(希望长)与"故障后恢复的等待"(希望短),而这两个量根本没有关系。第五章的续期机制正是冲着拆开这两个量去的。

(二)超时的触发比想象中容易

很多工程师直觉上认为"过期时间三十秒,业务平均三百毫秒,一百倍余量足够安全"。这个直觉低估了长尾停顿的量级。使临界区执行时间冲破三十秒的,通常不是业务逻辑本身,而是以下几类环境事件。

  • 垃圾回收停顿:老年代回收或整堆压缩在大堆上可以达到数秒到数十秒;即便用了低停顿收集器,分配速率突增时的兜底降级仍可能触发长停顿。
  • 缺页与换页:内存超卖的宿主机上,一次缺页中断可能等待磁盘几十到几百毫秒,密集缺页累积成秒级停顿。
  • 容器配额限流:CPU 配额用尽后整个容器在当前调度周期内被冻结,典型冻结粒度是几十毫秒一次,突发负载下会连续触发。
  • 同步磁盘写与网络重传:日志刷盘遇上磁盘抖动、跨机房调用遇上丢包重传,都能贡献秒级等待。
  • 虚拟机迁移与快照:宿主机维护触发的热迁移会让客户机整体暂停,时长从百毫秒到数秒不等。

这些事件的共同点是:发生在业务代码的任意两条指令之间,业务逻辑对此完全无感。测出来的 P99 耗时对预估过期时间几乎没有参考价值,因为这类停顿在压测环境里很难复现,却在生产环境里以月为周期稳定出现。

(三)双持锁的事故时序

把停顿放进时序里,事故是这样发生的。

客户端甲在零秒时刻加锁成功,过期时间三十秒,开始执行临界区。第五秒,甲的进程进入一次长垃圾回收,全部线程冻结。第三十秒,Redis 如约删除锁——从锁服务的视角,这是完全正确的行为,持有者三十秒没有任何动静。第三十二秒,客户端乙加锁成功,进入临界区,开始读取资源、计算、写入。第四十秒,甲的垃圾回收结束,线程解冻,从冻结点继续执行——它没有任何机制知道自己冻结过,更不知道锁已经易主,于是带着"我持有锁"的过时认知继续执行剩余的临界区代码,与乙并发写同一份资源。

此刻系统里有两个都认为自己独占资源的写入者。如果临界区是"读余额、算新值、写回去",两边会互相覆盖,后写的把先写的抹掉;如果是"检查库存再扣减",会双双通过检查造成超卖。注意这个事故里没有任何组件出错:Redis 正确地执行了过期,乙正确地获得了锁,甲正确地执行了它的代码——错的是协议本身,它假设了"持有者在过期前要么完成要么死亡",而现实里存在第三种状态:还活着,但慢了。

四、第二道裂缝:误删了别人的锁

(一)无校验删除的事故时序

释放锁最直接的写法是一条DEL。它的问题在双持锁场景里会把事故再放大一级:客户端甲停顿恢复后执行完临界区,走到释放逻辑,一条DEL把锁删了——但此刻锁属于乙。锁被删除后,第三个客户端丙立即加锁成功,于是乙和丙同时持锁,事故从"甲乙并发"传染成"乙丙并发",并且只要停顿反复出现,这个错误会像接力棒一样一直传下去:每个超时的客户端在退出时都误删下一个持有者的锁,把再下一个客户端放进来。

这就是为什么第二章强调值必须唯一:无校验的DEL之所以危险,是因为删除动作没有验证所有权。删除的语义应该是"释放我的锁",而DEL执行的语义是"释放当前的锁",这两者在锁易主之后是不同的键值语义。

(二)先查再删依然是错的:检查与动作之间的窗口

意识到要校验所有权之后,第二个直觉写法是先GET出值比对,是自己的再DEL:

value = GET lock:order:9527 if value == 我的凭证: DEL lock:order:9527 # 危险:GET 与 DEL 之间是一个窗口

这个写法把校验做了,但校验与删除是两次独立的网络往返,中间存在窗口。完全可能发生:GET返回时锁确实还是自己的,DEL命令在网络上飞行的几毫秒里锁过期、被乙抢到,DEL到达时删掉的已经是乙的锁。窗口很小,但秒杀场景下锁的易主频率极高,小窗口乘以高频率就是稳定复现的线上事故。

这是"检查与动作分离"母题的第二次出现。单机并发编程里对应的教训是"先判空再调用"的竞态,解法是把检查与动作放进同一个原子块;分布式环境里同理,只是原子块的位置必须在服务端——因为只有服务端能保证两个操作之间没有别的命令插入。

(三)用 Lua 把校验与删除压成一步

Redis 执行 Lua 脚本期间不会穿插执行其他命令,这让脚本成为服务端原子块的标准载体。释放锁的正确写法:

-- KEYS[1] = 锁的键名, ARGV[1] = 我的所有权凭证 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end

脚本返回 1 表示确实删掉了自己的锁;返回 0 表示锁已经不是自己的了——要么过期后被别人拿走,要么过期后还没人拿。返回 0 这个信号值得认真对待:它意味着自己刚刚执行的临界区可能与别人并发过,正确性型场景应当就地告警,并触发对这段时间内写入数据的核对,而不是静默返回。

同样的原子化要求适用于续期:续期操作"检查锁还是我的,是则延长过期时间"同样是检查加动作,同样必须写成脚本:

-- 续期:只给自己的锁续 if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("PEXPIRE", KEYS[1], ARGV[2]) else return 0 end

(四)可重入与凭证的传递边界

生产级实现(如 Redisson)通常用哈希结构而非字符串来存锁:字段名是"客户端标识加线程标识",字段值是重入计数。同一线程再次加锁时计数加一,释放时减一,减到零才真正删除键。这解决了同一线程嵌套获取同一把锁的自我死锁问题,代价是加锁、释放、续期的脚本都更复杂。

凭证的传递有一条容易被忽略的边界:凭证标识的是"这一次获取",不是"这个业务流程"。如果临界区里把任务提交给了线程池、或者发消息给下游服务继续处理,锁的保护范围并不会跟着延伸过去——持锁线程返回后锁被释放,异步任务还在裸奔。要么让异步部分在锁外用幂等保护自己,要么显式地把锁的生命周期管理权(含凭证)移交给接手方,两个执行体同时以为自己负责释放,就回到了误删的老路。

五、续期:把过期时间从静态猜测变成动态心跳

(一)看门狗的工作方式

第三章留下的两难——过期时间既要长过业务上界、又要短于可接受的恢复等待——解法是把静态猜测换成动态心跳:过期时间设一个适中的值,持有锁的客户端在后台周期性地把它刷新回满值,业务跑多久,锁就自动维持多久;客户端崩溃后心跳停止,锁在一个心跳周期级别的时间内自然过期。这就是看门狗(watchdog)机制。

以 Redisson 的实现为例:加锁时若不显式指定持有时间,锁的过期时间设为看门狗超时(默认三十秒),同时注册一个定时任务,每隔超时的三分之一(十秒)执行一次续期脚本,把过期时间重置回三十秒。业务方法返回、显式释放锁时,定时任务随之取消。"三分之一周期"的设计给了连续两次续期失败的重试余地:即使一次续期因网络抖动丢失,下一次续期仍在锁过期之前到达。

续期任务必须运行在独立于业务的调度线程上,且续期脚本必须校验所有权(上一章末尾的第二个脚本)——锁如果已经易主,续期必须失败,否则就是在给别人的锁续命。

(二)续期能治的与不能治的

看门狗解决的是"正常慢":业务逻辑本身要跑五分钟的批处理、偶发的慢查询把临界区拖到一分钟——只要进程还在正常调度、续期线程还能按时发出心跳,锁就不会中途过期。第三章两难中的"设短了破坏互斥"被基本消除,过期时间可以放心设短,换来故障后的快速恢复。

但看门狗治不了"停顿"。这是理解分布式锁安全边界的关键一步:续期线程和业务线程在同一个进程里,垃圾回收暂停的是整个进程——业务线程冻结的同时,续期线程也冻结了。停顿超过锁的过期时间,锁照样丢,业务线程解冻后照样带着过时认知继续执行。看门狗把"锁中途丢失"的触发条件从"业务慢于静态超时"收窄到了"进程停顿长于过期时间",概率显著下降,但没有归零。

换句话说:续期改善的是活性与可用性,不封闭安全性的口子。垃圾回收停顿三十秒在大堆、高分配速率的服务上并非罕见事件,正确性型场景不能把宝押在"停顿不会那么长"上。

(三)续期的工程细节

把看门狗写对,有几处容易踩空的细节。

  • 续期失败的处置要分级。网络抖动导致的单次失败可以重试;续期脚本返回 0(所有权已丢失)则必须立即向业务线程传递"锁已丢失"信号——常见做法是置位一个易失性标志,业务代码在写入资源前检查它,或者直接中断业务线程。悄悄停掉续期任务而不通知业务,是把安全问题静默化。
  • 定时任务的生命周期必须与锁严格对齐。忘记取消续期任务的后果是锁永不过期:业务异常退出没走到释放逻辑,续期任务还在忠实地每十秒续一次命,形成事实上的死锁。释放逻辑要放在 finally 块里,且释放动作要先取消任务再删键。
  • 心跳周期与过期时间的比例决定容错次数。周期取过期时间的三分之一,容忍连续一次心跳丢失;取四分之一,容忍两次。周期太密会放大 Redis 的负载(每把活跃锁每秒一次脚本调用不可小觑),太疏则一次丢包就可能丢锁。
  • 续期不是无限授权。给续期设一个总时长上限(比如十分钟)是合理的兜底:业务如果真的跑了远超预期的时间,更可能是卡死而不是正常慢,无限续期会让一把被卡死进程持有的锁永远无法被回收——这恰好复活了"没有过期时间"的原始缺陷。

六、第三道裂缝:主从切换把锁弄丢了

(一)异步复制的窗口

前三道防线都建立在一个隐含假设上:锁服务自身的记忆是可靠的。单实例 Redis 显然不满足——实例宕机,全部锁记录蒸发。于是生产环境自然使用主从加哨兵(或集群)的高可用部署,但这引入了一个更隐蔽的窗口:Redis 的主从复制是异步的。

加锁命令在主节点执行成功、给客户端返回OK的那一刻,这条写入可能还没到达任何从节点。如果主节点恰好在这个瞬间宕机,哨兵把一个尚未收到这条写入的从节点提升为新主——新主的世界里,这把锁从来不存在。第二个客户端向新主加锁,成功。两个客户端同时持锁,而且这一次,所有权凭证、Lua 脚本、看门狗全都没有失职:从每个组件各自的视角看,一切都按规范运行,锁丢在了复制链路的时间差里。

这个窗口平时极窄(复制延迟通常是毫秒级),但故障转移恰恰发生在系统最不正常的时刻——主节点过载、网络拥塞时复制延迟会拉大到秒级,而这正是主节点最可能被判定下线的时刻。窗口的宽度与故障的概率正相关,这让"平时测不出来"和"出事时一起出"同时成立。

(二)多数派加锁:Redlock 的思路

针对这个窗口,Redis 官方文档给出的方案是 Redlock:不再依赖复制,而是部署五个完全独立、互不复制的 Redis 实例,加锁时依次向五个实例发出同一条加锁命令,在多数派(至少三个)实例上成功、且从第一个请求发出到最后一个成功响应收到的总耗时小于锁的有效期时,才算加锁成功;锁的实际有效时间要扣除这段获取耗时。释放时向全部五个实例发出释放脚本,无论加锁时在该实例上是否成功。

多数派的意义在于:任何两个"多数派"必然相交,第二个客户端不可能在不与第一个客户端的授权实例集合重叠的情况下凑齐自己的多数派,因此单实例宕机、单实例的记忆丢失都不再直接导致双持锁。扣除获取耗时则是为了保证"五个实例上的过期时刻大致对齐",避免锁在最早写入的实例上已经过期、在最晚写入的实例上还活着。

(三)Redlock 的争议:它守得住什么,守不住什么

Redlock 发布后,分布式系统研究者 Martin Kleppmann 发表了著名的批评文章,Redis 作者 antirez 随后撰文回应,这场公开辩论本身就是学习分布式锁最好的材料。双方的分歧可以压缩成两点。

第一点是时钟假设。Redlock 的安全性依赖各实例的时钟以大致相同的速率前进:如果某个实例的时钟被同步协议向前跳了一大步,它上面的锁会提前过期,多数派相交的前提被打破。批评方认为,一个正确性协议不应该把安全性押在时钟行为上——时钟跳变在运维现实中并不罕见;回应方认为,量级合理的时钟漂移可以通过缩短有效期来吸收,跳变属于可以通过运维纪律避免的故障。这个分歧本质上是对"什么样的故障必须被协议容忍、什么样的可以交给运维"的立场差异。

第二点更致命,也是通往下一章的桥:即使五个实例的时钟完美、协议执行无瑕,进程停顿的问题依然原样存在。客户端从五个实例凑齐多数派、拿到锁,然后停顿六十秒,锁在全部五个实例上过期,另一个客户端凑齐了新的多数派——停顿结束后,陈旧客户端照样带着过时认知去写资源。Redlock 加固的是锁服务侧的记忆可靠性,而第三章的裂缝根本不在锁服务侧,在客户端侧。任何"锁服务单方面努力"的方案——单实例也好、多数派也好、换成共识协议加持的协调服务也好——都对停顿后的陈旧客户端无能为力,因为陈旧客户端在它自己的世界里持有一份完全合法的历史授权。

要封死这最后一道口子,裁决权必须离开锁服务和客户端,交给第三方:被保护的资源本身。

七、最后一道防线:Fencing Token

(一)核心思想:让资源方拒绝过时的授权

Fencing Token(栅栏令牌)的机制只有两句话:锁服务每授出一次锁,附带一个单调递增的令牌(第一次授出是 33,下一次授出必然大于 33);客户端向资源写入时必须携带令牌,资源端记录见过的最大令牌,拒绝一切令牌小于等于该值的写入。

这个设计的深刻之处在于它改变了信任结构。此前所有方案里,资源是被动的:谁来写就接受谁,鉴别写入者是否仍持有锁完全是锁服务和客户端之间的事。Fencing Token 让资源成为最后一道关卡:即使锁服务的授权判断出了错、即使客户端带着过时认知冲进来,写入动作本身要过资源端的令牌校验。锁的互斥可以被停顿打破,但令牌的单调性不会——新持有者的令牌必然大于旧持有者的,旧持有者的写入必然被拒。

用一个类比:过期时间是"门票有效期",靠持票人自觉;所有权校验是"退场时验票",防止拿错别人的票;Fencing Token 是"入场闸机上的流水号",闸机记住最后一个入场流水号,旧号一律不放行——防线从"人"移到了"门"。

(二)时序推演:陈旧客户端如何被挡在门外

把第三章的双持锁事故重放一遍,这次带上令牌。

客户端甲加锁成功,拿到令牌 33,进入临界区后停顿。锁过期,客户端乙加锁成功,拿到令牌 34——单调性保证了 34 大于 33。乙携带令牌 34 写入存储,存储记录"最大已见令牌 = 34",写入成功。甲停顿结束,带着令牌 33 发起写入,存储比对 33 小于等于 34,拒绝写入并返回错误。甲收到拒绝,此刻它才第一次得知自己的授权已经过时,正确的处置是放弃本次操作、按业务语义决定重试或报错。

注意两个细节。第一,即使乙还没来得及写入、甲先带着 33 写成功了,随后乙的 34 也能正常写入且此后 33 再也写不进来——只要资源端的比较与更新是原子的,最终收敛到新持有者胜出,旧持有者最多完成一次"其实无害"的写入(它写的时候确实没有并发者)。第二,令牌校验拦截的是写入,不是读取:陈旧客户端读到旧数据做出的本地计算无法阻止,防线的承诺是"过时的结果写不进去",而非"过时的计算不会发生"。

(三)令牌从哪里来

令牌的唯一硬要求是随每次授锁单调递增,几类现成来源如下。

  • ZooKeeper:用临时顺序节点实现锁时,节点名自带的序号本身就是现成的令牌;或者用锁节点的cversion(子节点版本号)、事务标识zxid,都随每次变更单调递增。
  • etcd:每次写入都会推进全局的revision,加锁成功时拿到的 revision 天然是全局单调的令牌;租约机制配合事务能把"检查持有并写入令牌"做成原子操作。
  • 数据库:一张带自增主键的授锁流水表,每次授锁插入一行,拿回的自增值就是令牌;或者用序列(sequence)。
  • Redis:可以在授锁脚本里对一个计数器执行INCR并把结果一并返回。但要清醒:这个计数器与锁记录在同一个实例上,主从切换丢锁的同时也可能丢计数器进度——计数器回退会发出重复令牌,单调性被破坏。所以基于单实例或主从 Redis 的令牌,其可靠性上限与锁本身一致;真正把 Fencing Token 用于正确性场景时,令牌来源应当选择有共识协议护底的系统(ZooKeeper、etcd)或有持久化单调保证的数据库序列。

(四)资源端怎么校验

令牌只有在资源端被强制校验才有意义,不同类型的资源有不同的落点。

1、数据库类资源

1.1、令牌列加条件更新

给被保护的行加一列fencing_token,所有写入走条件更新,一条语句同时完成"比较"与"更新",原子性由数据库保证:

UPDATE inventory SET stock = stock - 1, fencing_token = 34 WHERE item_id = 9527 AND fencing_token < 34; -- 影响行数为 0 即令牌过时,本次写入被拒
1.2、事务内先检旗后写入

写入逻辑复杂、涉及多行多表时,在同一个事务里先以加锁读(SELECT ... FOR UPDATE)取出当前令牌高水位并比较,通过后再执行业务写入与高水位更新,令牌校验与业务写入的原子性由事务隔离保证。这种形态下值得追问一句:既然资源端已经有事务与行锁,部分场景可以直接用数据库自身的锁与唯一约束解决互斥,分布式锁退化为减少无效争抢的效率优化。

2、支持条件写的存储

对象存储、KV 存储普遍提供条件写原语——按版本号写入(version check)、比较并交换(CAS)、按前值匹配写入。把令牌放进版本条件里即可。一些对象存储的做法是把令牌编码进对象名或利用"若不存在才创建"原语实现等效的栅栏。

3、自研服务类资源

服务在接口层增加令牌参数,内存里按资源维度维护最大已见令牌(重启后从持久层恢复),用原子比较交换更新。要求所有写路径统一过这道校验——绕过校验的旁路写入会让整个机制形同虚设。

(五)资源端无法校验时怎么办

Fencing Token 的最大限制是它要求资源端可改造。调用的是第三方接口、写入的是不支持条件语义的遗留系统时,栅栏立不起来。此时的务实路线是承认互斥无法绝对保证,转而控制并发发生时的损失。

  • 幂等设计:让"同一操作执行两次"与"执行一次"结果相同——以业务单号做唯一键、写入前查重、用插入代替更新。幂等不能防止两个不同操作交错,但能吃掉最常见的重复执行事故。
  • 事后对账:对锁保护的关键资源建立周期核对(例如释放脚本返回 0 时触发的定向核对),把"错了但不知道"变成"错了但很快发现"。
  • 缩小临界区与降低停顿概率:临界区里不做远程调用、控制堆大小与分配速率、避开容器配额节流——不能消除停顿,但能把概率压低一个量级。

这些手段与 Fencing Token 不是替代关系而是纵深关系:栅栏能立则立,立不了就让每一层各自兜住一部分。

八、换一种地基:ZooKeeper 与 etcd 的锁

(一)ZooKeeper:临时顺序节点、会话与羊群效应

ZooKeeper 的锁配方与 Redis 思路不同:每个竞争者在锁目录下创建一个临时顺序节点,序号最小者持锁;其余竞争者各自监听(watch)恰好排在自己前面的那个节点,前驱消失时被唤醒并重新检查排位。"临时"意味着节点生命周期绑定客户端会话——会话超时(客户端心跳中断超过协商时长),节点自动删除,锁自动释放,这相当于把 Redis 世界里"过期时间加看门狗续期"内置成了会话层心跳。而"只监听前驱"的设计避免了羊群效应:锁释放时只唤醒下一位,而不是全体竞争者蜂拥而上重试一轮。

这套配方还免费赠送了两样东西:公平性(按序号排队,先到先得,不会有 Redis 自旋重试下的插队与饥饿)和现成的令牌(节点序号单调递增,天然是 Fencing Token)。代价是写入要走 ZAB 协议的多数派提交,加锁吞吐与延迟都不如单实例 Redis,锁竞争极高的场景要掂量协调服务的承载能力。

(二)etcd:租约、revision 与事务

etcd 的锁建立在三个原语上。租约(lease):客户端申请一个带存活时间的租约并周期性发送保活(keepalive),锁键绑定租约,客户端失联则租约到期、键自动删除——同样是内置的看门狗。事务(txn):加锁用"若键的创建版本为零则写入"的原子事务完成,等价于SET NX但表达力更强。全局 revision:每次写入推进的单调版本号,加锁成功那次写入的 revision 直接可用作 Fencing Token。etcd 的数据面由 Raft 共识协议护底,写入在多数派落盘后才确认,不存在 Redis 主从异步复制的丢锁窗口。

(三)会话与租约同样会过期:没有银弹,只有位置更好的防线

必须戳破一个常见误解:"换成 ZooKeeper/etcd 就没有过期问题了"。会话与租约的本质仍是超时判活:客户端进程整体停顿,会话心跳与租约保活一样会停,停顿超过会话超时,临时节点照样删除、租约照样到期、锁照样易主,陈旧客户端解冻后照样带着过时认知回来。第三章的裂缝在任何基于超时判活的系统里都存在——这是异步系统模型的性质,不因换了实现而消失。

协调服务真正改善的是两点:其一,锁服务自身的记忆由共识协议保证,第六章主从切换丢锁的窗口被封死;其二,单调令牌是原生产物,Fencing Token 唾手可得。换句话说,换地基买到的不是"没有裂缝",而是"裂缝更少、且封最后一道裂缝的材料是现成的"。真正封死陈旧客户端的,自始至终只有资源端校验这一道防线。

九、把防线装配成清单

(一)按丢失代价分层选型

把前文的全部构件按场景的丢失代价装配。

  • 效率型(锁失效代价为浪费):单实例或主从 Redis,SET NX PX加唯一凭证加 Lua 释放,过期时间按业务上界放宽裕量。不必上看门狗与多数派——为一个可以容忍的失效概率支付复杂度是负收益。
  • 正确性型且资源端可改造(推荐路线):锁服务选 etcd/ZooKeeper(或退而求其次的 Redis 加严谨续期),同时在资源端落地 Fencing Token 校验。安全性的重心放在资源端,锁服务侧的选择反而变得宽容——因为最后一道防线不依赖它。
  • 正确性型但资源端不可改造:承认无法绝对互斥,幂等加对账加缩小临界区,把损失控制住;同时重新审视业务能否绕开锁(见下文)。

(二)上线前自查清单

  • 加锁是一条原子命令或事务,不存在"占用与设过期分离"的窗口。
  • 锁值是本次获取的唯一凭证,客户端本地留存。
  • 释放与续期都用服务端原子脚本校验所有权,释放返回"锁已非我持有"时有告警与核对动作。
  • 释放逻辑在 finally 中,先取消续期任务再删键;续期任务有总时长上限。
  • 续期失败向业务线程有信号传递路径,业务在关键写入前检查锁状态标志。
  • 明确回答过"主节点故障转移时这把锁会怎样",正确性场景有多数派或共识协议护底。
  • 正确性场景的每一条写路径都带令牌并在资源端强制校验,没有旁路。
  • 压测里包含过停顿注入(模拟长垃圾回收)与故障转移演练,观察双持锁是否被资源端拒绝。

(三)绕开锁:很多互斥需求另有解法

最后要说的是,分布式锁经常是被过度使用的工具,动手实现前值得先问三个问题。能不能幂等化?如果操作天然幂等或可以低成本改造成幂等,重复执行无害,锁就只剩效率价值。能不能队列化?把对同一资源的操作路由到同一个分区、由单一消费者串行处理(单写者原则),互斥由架构保证而不是由锁保证,没有过期、没有误删、没有陈旧客户端。能不能交给资源自己?数据库的唯一约束、条件更新、事务隔离级别,本身就是资源端最成熟的互斥设施——很多"分布式锁需求"其实是一条带WHERE条件的UPDATE就能解决的事。锁应当是这三问都答"否"之后的选择,而不是默认起点。

十、写在最后

回看全文的推演线索:过期时间解决了"持有者不回来",制造了"持有者慢了等于死了";所有权凭证与原子释放解决了"删错别人的锁",但删对删错的前提——锁没有中途易主——仍不受控;看门狗续期把中途易主的触发条件从"业务慢"收窄到"进程停顿",却治不了停顿本身;多数派与共识协议封死了锁服务自身失忆的窗口,却对停顿后的陈旧客户端无能为力。四层手段层层收窄事故概率,而真正封口的是第五层——Fencing Token 把最终裁决权交给资源方,让"过时的授权"在写入的那一刻被拒绝。

这条线索背后是一个值得带走的一般性结论:在异步系统里,任何基于超时的"持有"都只是概率意义上的独占,把正确性完全寄托于锁服务单方面的承诺是不可靠的;可靠的互斥最终是一份三方协议——锁服务负责减少冲突,客户端负责及时感知失效,资源端负责拒绝过时写入。工程上的功夫,一半花在把每一次"检查与动作"压成原子,另一半花在诚实地回答:如果这把锁悄悄失效了,我的数据由谁来兜底。

参考资料

  • Redis 官方文档:SET 命令与 NX/PX 选项语义 —— SET | Docs
  • Redis 官方文档:Distributed Locks with Redis(Redlock 算法完整描述) —— Distributed Locks with Redis | Docs
  • Martin Kleppmann:How to do distributed locking(Fencing Token 与对 Redlock 的批评) —— How to do distributed locking — Martin Kleppmann’s blog
  • antirez(Salvatore Sanfilippo):Is Redlock safe?(对上文批评的回应) —— Is Redlock safe? - <antirez>
  • Redisson Wiki:Distributed locks and synchronizers(看门狗与可重入锁实现) —— https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers
  • Redis 官方文档:EVAL 命令与脚本原子性 —— EVAL | Docs
  • Redis 官方文档:Replication(异步复制语义) —— Redis replication | Docs
  • Apache ZooKeeper Recipes:Locks(临时顺序节点锁配方) —— ZooKeeper: Because Coordinating Distributed Systems is a Zoo
  • etcd 文档:API 与并发原语(租约、事务、revision) —— etcd API | etcd
  • Mike Burrows:The Chubby lock service for loosely-coupled distributed systems(Google 分布式锁服务论文,sequencer 即令牌思想的出处之一) —— https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/
  • Martin Kleppmann:《Designing Data-Intensive Applications》第八、九章(分布式系统的时钟、停顿与共识) —— Designing Data-Intensive Applications (DDIA) — an O’Reilly book by Martin Kleppmann (The Wild Boar Book)
  • Apache Curator Recipes:InterProcessMutex(ZooKeeper 锁的生产级实现) —— Recipe: Shared Reentrant Lock | Apache Curator

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询