想象一下,你经营着一家只有一间客房的民宿。深夜十一点,两个客人同时在前台想订这件唯一的房间。传统做法很简单——老板拍板,谁先到谁住。但在互联网世界里,“民宿老板”换成了多台服务器,它们共同管理着一个共享资源(比如数据库里的一行记录、一个库存数字)。如果每台服务器都觉得自己有权决定,那就会发生两个顾客同时入住同一间房的事故——这就是所谓的“竞态条件”。 分布式锁的使命,就是让多台服务器像民宿前台一样,用一套“先来后到”的规则决定谁能暂时独享某个资源。本文要讲的 Redlock、etcd 和 Chubby,分别是三种不同的“前台管理制度”:有靠老板个人信誉的,有靠物业公司书面合同的,还有靠顶级酒店集团统一标准的。它们各有优劣,就像民宿管理办法也会有漏洞一样——当网络断线、服务器宕机时,“锁”能不能依然守得住规矩,这就引出了一个深水区问题:分布式系统里的“安全”与“可用”如何取舍。

岗位关联

多节点并发写入是生产事故的主要来源之一。掌握分布式锁的实现原理与选型策略,是架构师在高并发场景下守住数据一致性的基本功,也是排查线上故障时必须借助的思维地图。

目录
  1. 可视化演示
  2. 为什么需要分布式锁
  3. 基于Redis的锁实现
  4. Redlock多节点投票
  5. etcd与Chubby方案
  6. 网络分区与安全性
  7. 锁的工程实践选择
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

演示Redlock多节点多数获取、etcd基于Raft一致性、Fencing令牌防锁失效,展示多节点环境下的互斥访问与安全机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Redlock 多节点获取锁 向N个Redis节点依次获取 多数(≥N/2+1)成功即获锁 通过多节点容错降低单点风险

为什么需要分布式锁

如果你经营一家只有一间客房的民宿,前台只有一个服务员,那么“谁先登记谁入住”是再简单不过的规则。但假如你开了连锁店,三家分店共用一个订房系统,三个前台同时看到“空房”时,谁说了算?在传统单体架构里,我们用 Java 的 synchronized 或者数据库的行锁就能解决问题——因为所有请求都经过同一个进程,锁的裁决者只有一个。可当你的系统拆成了几十个微服务,每个服务跑在不同的机器上,问题就变成:谁来当那个唯一的“前台”?

这正是分布式锁诞生的土壤。多个节点要并发访问同一个共享资源,比如扣除库存、发放优惠券、执行定时任务,如果大家同时动手,就会产生“超卖”“重复发放”这类事故。分布式锁像民宿前台的一本登记簿:谁在上面签了名,谁就获得临时独占权,其他人只能在旁边排队等待。签名要能被所有人看到(共享存储),签了名之后要能被擦除(释放锁),如果签名的人突然跑掉了(节点宕机),登记簿还要有自动清理的机制(过期时间)。

从本质上说,分布式锁是在“无中心仲裁者”的环境下重建一个逻辑上的互斥机制。它有三个基本要求:互斥(同一时刻只有一个节点持有锁)、可重入(持有者可以再次申请)、容错(持有者崩溃后锁能被安全释放)。这三条是后续所有方案(Redis、Redlock、etcd、Chubby)都要回答的考卷。

回到民宿的例子。前台登记簿本身就是一把“锁”,但你能想象一本没有自动清理机制的登记簿吗?如果客人住一半突然跑路,房间永远显示“已占用”,这生意就做不下去了。分布式锁的过期时间就是“自动退房”规则,保证系统在异常情况下依然能自我恢复。

P(安全)=P(互斥)×P(无死锁)×P(无饥饿)P(\text{安全}) = P(\text{互斥}) \times P(\text{无死锁}) \times P(\text{无饥饿})
这把“安全三元组”公式把分布式锁的可靠性拆解为互斥、无死锁、无饥饿三件事的乘积——任何一项失败,整体安全性就归零。
  1. 互斥概率表示在同一时间点,最多只有一个客户端持有锁的概率。这个概率越接近 1,系统的互斥性越好。
    P(互斥)P(\text{互斥})
  2. 无死锁概率表示即使持有锁的节点崩溃,锁也能在有限时间内被释放(比如通过过期时间),不会导致所有请求永远等待。
    P(无死锁)P(\text{无死锁})
  3. 无饥饿概率表示等待锁的节点最终都能获得锁,而不是某个节点永远抢不到。
    P(无饥饿)P(\text{无饥饿})
  4. 整体安全性三个因素相乘。任何一个概率为 0,整个系统的锁安全性就崩溃——这提醒我们在设计锁时要同时兼顾三项指标。
    P(安全)=P(互斥)×P(无死锁)×P(无饥饿)P(\text{安全}) = P(\text{互斥}) \times P(\text{无死锁}) \times P(\text{无饥饿})

基于Redis的锁实现

民宿老板的第一版登记簿,用的是一块挂在墙上的白板。谁想住房,就在白板上写“XX已订”,订完划掉。这块白板就是单机 Redis。用 Redis 实现分布式锁是互联网公司最常用的入门方案,因为它足够简单:Redis 是单线程执行命令的,天然保证了两条命令之间不会插入其他客户端的操作。

加锁的经典写法是 SET key value NX EX seconds。这条命令做了两件事:只有当 key 不存在时才设置成功(NX,即 Not eXists),同时设置过期时间(EX)。用民宿来类比:只有白板上还没有“已订”两个字时,你才能写下自己的名字,而且这个字迹会在规定时间后自动消失。为什么必须把“检查是否存在”和“设置过期时间”合并成一条命令?因为如果是两条命令,中间随时可能插入其他客户端的操作,白板上就会出现两个名字——上一秒还在检查“空房吗”,下一秒别人就抢先登记了。

释放锁时,需要先验证自己是不是锁的持有者(防止误删别人的锁),再用 Lua 脚本保证“检查+删除”的原子性。这一步像退房前确认柜台登记的名字确实是你本人,然后把名字擦掉。这个版本能解决多进程互斥问题,但有一个致命隐患:单点故障。如果 Redis 服务器宕机,白板就没了,所有客户端都找不到“是否已订”的答案。更危险的场景是主从切换——如果 master 挂了,从节点接管,但锁的信息还没来得及同步,另一个客户端就能在从节点上再次加锁成功,原来的锁就形同虚设。

白板方案虽然好用,但民宿老板很快发现:白板只有一块,如果它碎了(Redis 宕机),预订系统就瘫痪了。更麻烦的是,如果老板把白板的内容同步到分店,主分店之间出现延迟,就会发生“两个客人都以为自己订到了房间”的尴尬场面。要解决这个问题,不能靠一块白板,得靠多个酒店前台一起做交叉确认——这就是 Redlock 的思路。

SET(key, val, NX, EX, T)\text{SET(key, val, NX, EX, T)}
这条命令用原子操作同时完成“占位”与“计时”:NX 保证只有空位才能写入,EX 保证占用时间有限。
  1. 写入目标key 是锁的名字(比如 "room:101"),val 是持有者的唯一标识(比如 UUID),用于释放时验明正身。
    key, val\text{key, val}
  2. NX 条件Only set if key does NOT exist —— 只有 key 不存在时才写入,这保证了互斥。
    NX\text{NX}
  3. EX 过期设置锁的自动过期时间 T,避免持有者崩溃后锁永久不释放。
    EX, T\text{EX, } T
  4. 原子性SETNX 和 EXPIRE 合在一条命令里,避免两步操作之间的竞态窗口。
    SET(key, val, NX, EX, T)\text{SET(key, val, NX, EX, T)}

Redlock多节点投票

民宿老板痛定思痛,决定不再依赖一块白板,而是请五家独立的物业公司同步记录预订信息。客人订房时,必须同时向这五家公司确认:只要其中有至少三家(超半数)表示“这间房还没有人订,我为你登记”,这间房就定下来了。这就是 Redlock 的核心思想:不是把宝押在单个 Redis 节点上,而是让客户端向 N 个相互独立的 Redis 节点依次尝试加锁(通常 N 是 5),只有获得超过半数节点的同意(即 N/2 + 1),锁才真正建立。释放锁时,则要向所有节点发送释放命令——即使某个节点当时没收到加锁请求,也可能记录了锁的痕迹,不留死角,确保干净。

Redlock 之所以这样设计,是因为它想“即使某个节点挂了,其他节点还能维持锁的可靠性”。五家物业公司,就算倒闭两家,剩下三家依然能确认预订状态。但数学家们(包括 Redis 作者的好友、分布式系统专家们)指出了一个问题:如果各家物业公司的时钟不一样(时钟漂移),就会导致混乱。举个例子:A 公司时钟走得快,认为租约 5 秒到期就自动释放了;B 公司时钟走得慢,还认为租约有效。这时候两个客人就同时手持“有效”的钥匙——锁的安全性被破坏。服务器时钟可能因为 NTP 同步、手动调整或虚拟机暂停而跳跃,这个风险在 Redlock 里无法根除,只能人为控制节点数来降低概率。

多节点投票机制把单点故障的风险降低了,但引入了新的问题——时钟。就像五个物业公司如果手表走时不准,就会出现“同一间房被预订两次”的纠纷。Redlock 假定各节点的时钟是同步的,但在真实世界里,这个假设并不总是成立。这也是为什么很多架构师在追求更强一致性的场景下,会选择把“物业公司”换成“中央仲裁机构”——etcd 和 Chubby 正是这样的角色。

N=5,quorum=⌊N2⌋+1=3N = 5, \quad \text{quorum} = \left\lfloor \frac{N}{2} \right\rfloor + 1 = 3
五个节点选三个作为“仲裁线”,超半数即获锁。为什么用奇数?因为偶数节点在投票时可能平票,导致决策僵局。
  1. 节点总数 NRedlock 标准建议用 5 个独立节点,越多容错性越强,但加锁的耗时也越长。
    N=5N = 5
  2. 多数派 quorum加锁成功需要至少 N/2 + 1 个节点同意,这个阈值称为 quorum。5 节点时 quorum 是 3。
    quorum=⌊N2⌋+1\text{quorum} = \left\lfloor \frac{N}{2} \right\rfloor + 1
  3. 奇数节点的数学优势奇数节点避免平票僵局。比如 4 节点时,如果 2 个同意 2 个反对,既不能宣布锁成功,也不能快速失败,产生尴尬的灰色地带。
    ⌊42⌋+1=3≠42+1\left\lfloor \frac{4}{2} \right\rfloor + 1 = 3 \neq \frac{4}{2} + 1
  4. 超半数即获锁只要 3 个节点回 ACK,锁即成立,无需等待所有节点。这保证了部分节点故障时锁仍然可用。
    3>⌊52⌋3 > \left\lfloor \frac{5}{2} \right\rfloor

etcd与Chubby方案

如果说 Redlock 是多位物业公司交叉确认,那么 etcd 和 Chubby 就是引入了一家“权威物业托管中心”。这家托管中心用租约(Lease) 机制管理所有钥匙:每把钥匙都有一个租期,租期内只有租客能使用;租客必须定期签到来续租,如果不续租,租约到期后钥匙自动作废,其他租客就能申请了。

etcd 基于 Raft 共识算法,多个节点构成一个集群,写入的数据在大多数节点上落盘后才算成功。它的分布式锁 API 基于“创建 key 时带上租约 ID,只有创建成功的人才持有锁”。如果持有者宕机,租约到期,key 自动被删除——这就是续约防过期机制。对应到民宿,物业托管中心不仅记下“谁住了房”,还跟租客签了一份“定期续签”合同:你每 10 秒来前台刷一次卡,证明你还活着;如果 30 秒没来,前台自动取消你的入住资格。

Chubby 是 Google 内部使用的分布式锁服务,基于 Paxos 协议,虽然不开源,但它的设计思路和 etcd 非常相似:提供一个文件系统风格的 API,客户端通过“创建临时文件”来加锁,通过“删除文件”来释放锁。关键在于 Chubby 有锁延迟(lock-delay) 机制:当持有锁的客户端崩溃后,Chubby 不会立刻把锁释放给其他人,而是等待一个可配置的延迟时间(默认 1 分钟),确保旧客户端可能正在执行的收尾工作不会被新客户端打断。这就像酒店在你退房后预留十分钟的“清理时间”,避免新的住客还没来得及入住,上一个住客又跑回来取行李。

物业托管中心的模式比 Redlock 更稳健,因为它不做“超半数投票”,而是用强一致的存储服务作唯一权威。租约机制解决了锁的“自动释放”问题,续约机制解决了“持锁时间超过预期”的问题,而强一致存储保证了不同节点看到的锁状态永远一致。但它的代价是引入了额外的运维组件——你需要维护一个 etcd 集群或使用云上的协调服务,而不是像 Redis 那样随手可得的缓存。

Lease=Timenow+Durationlease\text{Lease} = \text{Time}_\text{now} + \text{Duration}_\text{lease}
租约是一个绝对时间点。客户端必须在这个时间点之前不断续约,否则租约过期,锁自动释放。
  1. 当前时刻租约生效的起始时间,由 etcd/Chubby 节点的本地时钟决定。
    Timenow\text{Time}_\text{now}
  2. 租约时长租约的有效期,通常由客户端在创建时指定(例如 30 秒)。
    Durationlease\text{Duration}_\text{lease}
  3. 租约截止当前时刻加上租约时长,得到租约的绝对过期时间点。
    Lease=Timenow+Durationlease\text{Lease} = \text{Time}_\text{now} + \text{Duration}_\text{lease}
  4. 续约机制客户端通过心跳定期更新租约截止时间,相当于不断延长这个公式中的 Duration。
    Lease←Timenew+Durationlease\text{Lease} \leftarrow \text{Time}_\text{new} + \text{Duration}_\text{lease}

网络分区与安全性

宿老板的民宿越开越大,五家物业公司在两个城市各自管理着部分客房。某天,连接两个城市的光缆被挖掘机挖断了——这就是网络分区。城市 A 的物业公司联系不上城市 B 的物业公司,两边都觉得自己是总负责人,于是把同一间房同时订给了两位客人。在分布式系统中,这就是著名的“脑裂”:两个节点都认为自己是锁的持有者(双主),同时操作共享资源,导致数据损坏。

网络分区是分布式锁最难解的题。为什么?因为无论你的锁存储在 Redis、etcd 还是 Chubby 中,只要集群被分成两半,一半节点无法与另一半通信,就可能出现“两个客户端各持有一把锁”的局面。在 Redlock 中,如果 5 个节点分为 3 和 2 两组,客户端 1 从 3 个节点那拿到锁,客户端 2 从另外 2 个节点拿不到锁,这是安全的;但极端情况下(时钟漂移 + 网络分区同时发生),两个客户端各自从不同节点集合拿到“超半数”锁,安全性就破了。

业界常用的缓解手段是 Fencing 令牌。这不是阻止第二个客户端获得锁,而是让第二个客户端即使获得了锁,它的数据写入也会被拒绝。具体做法是:每次成功加锁时,锁服务返回一个单调递增的令牌(token),客户端写数据时带上这个令牌,存储端拒绝令牌小于已记录值的写入请求。设计 Fencing 令牌机制的思路也值得学习:它把“锁”与“数据安全”解耦,即使在最坏情况下(比如锁出现双主),也能保证数据本身安全。

回到民宿的场景,物业公司即使产生了双主争议,只要客人在入住时拿到一个“最新批次号”(令牌),并在入住时出示给门禁系统,门禁系统只认批次号更大的客人——即使小批次号的客人也有一把钥匙,也无法进入房间。这给了我们一个重要启示:分布式锁并非银弹,业务的最终防线往往是一道不依赖锁的“校验闸门”。

tokennew>tokenold⇒允许写入\text{token}_\text{new} > \text{token}_\text{old} \Rightarrow \text{允许写入}
Fencing 令牌为每次写操作打一个“版本戳”,存储端只接受递增的戳,从机制上杜绝了旧锁写入覆盖新数据。
  1. 旧令牌上一次成功写入(或加锁)时分配的令牌值。
    tokenold\text{token}_\text{old}
  2. 新令牌当前请求携带的令牌值。
    tokennew\text{token}_\text{new}
  3. 比较判定只有新令牌大于旧令牌时,写入才被允许;否则拒绝。
    tokennew>tokenold\text{token}_\text{new} > \text{token}_\text{old}
  4. 写入授权当条件成立,存储端接受写入,否则拒绝。无论持有锁的是谁,令牌是唯一判据。
    允许写入\text{允许写入}

锁的工程实践选择

回到最初的问题:民宿老板到底应该选白板(单机 Redis)、五家物业(Redlock)还是物业托管中心(etcd/Chubby)?答案取决于你的业务能承受多大风险。我们用三个维度来拆解:

第一,性能与一致性权衡。 单机 Redis 锁的延迟最低(亚毫秒级),适合对一致性要求不高、追求吞吐的场景,比如秒杀系统的请求拦截层——即使极端情况下两个请求同时通过(概率极低),下游数据库的唯一索引也可以兜底。Redlock 需要串行请求 5 个节点,延迟较高,但它把单点风险分散到了多节点,适合需要比单机更强保障、但又不愿意引入重量级协调组件的场景。etcd/Chubby 提供强一致的租约锁,可靠性最好,但需要额外的运维成本,适合金融交易、任务调度这类“锁失败会造成资损”的严肃业务。

第二,锁超时与续约。 无论选哪种方案,你都需要回答一个问题:持锁时间超过预期怎么办?以 Redis 锁为例,如果业务执行时间超过锁的过期时间,锁自动被其他节点抢走,原来的持有者还在写数据,就会产生冲突。解决方案有两种:一是把过期时间设得很长(比如 10 倍于预估业务时间),牺牲“死锁恢复速度”换取“安全性”;二是实现续约线程,定期检查剩余时间并续约(参考 etcd 的租约机制)。后者更优雅,但复杂度更高。续约不是锦上添花,在长事务场景中是必需品。

第三,锁的可观测性。 生产环境排查分布式锁问题时,最怕的就是“锁哪里去了”完全不可见。好的实践是:锁的 key 带上业务标识(如 order:pay:12345),value 带上持有者 ID(如 UUID),同时监控锁的获取成功率、平均等待时间、持有时间等指标。这些数据能帮助你在故障发生时快速判断:是锁被长期持有,还是锁的竞争过于激烈导致请求超时。

民宿老板最终的选择,取决于他的客户是谁。如果只是几个熟客,一块白板足够了;如果是 OTA 平台上的全球旅客,那还是老老实实引入物业托管中心吧。分布式锁没有“最好的方案”,只有“最适合你业务风险容忍度的方案”。记住:加锁只是手段,保护数据一致性才是目的;当锁本身不可靠时,请用 Fencing 令牌和幂等设计作为最后的防线。

选型评分=α×一致性+β×可用性+γ×运维成本\text{选型评分} = \alpha \times \text{一致性} + \beta \times \text{可用性} + \gamma \times \text{运维成本}
选型的本质是给一致性、可用性、运维成本这三项赋权重,不同业务的 α/β/γ 不同,答案自然不同。
  1. 一致性权重对数据安全要求越高,一致性权重 α 越大,越倾向于 etcd/Chubby。
    α×一致性\alpha \times \text{一致性}
  2. 可用性权重对服务连续性要求越高,可用性权重 β 越大,越倾向于多节点方案(Redlock)。
    β×可用性\beta \times \text{可用性}
  3. 运维成本权重对团队人力敏感时,运维成本权重 γ 越大,越倾向于简单方案(单机 Redis)。
    γ×运维成本\gamma \times \text{运维成本}
  4. 加权求和综合三项打分,选择总分最高的方案。这不是数学上的精确公式,而是工程决策的思维框架。
    选型评分=α×一致性+β×可用性+γ×运维成本\text{选型评分} = \alpha \times \text{一致性} + \beta \times \text{可用性} + \gamma \times \text{运维成本}

AI 应用场景

秒杀系统的库存扣减

阶段:高并发请求拦截与限流

解决的问题:传统秒杀场景中,多个用户的请求同时到达订单服务,如果每个请求都直接操作数据库库存,会产生大量行锁冲突,拖垮数据库。同时,因为请求的到达顺序与数据库操作的顺序不完全一致,容易出现“超卖”。

具体案例:某电商平台在大促期间,用 Redis 分布式锁对每个商品的库存 KEY 进行加锁,只有拿到锁的请求才能进入订单创建流程。锁的过期时间设置为 5 秒,配合 Lua 脚本实现“扣减库存+检查库存”的原子操作。流量高峰期,通过分布式锁将并发请求串行化,数据库的 QPS 从 20000 降到 800,超卖率为零。

分布式定时任务调度

阶段:任务调度与幂等执行

解决的问题:多台应用服务器部署同一个定时任务(例如每天凌晨跑批对账),如果每台服务器都执行一次,就会产生重复扣款、重复发短信等问题。传统的解决方案是“配置文件里指定哪台机器跑”,但机器宕机或扩容时难以维护。

具体案例:某支付公司的对账系统采用 etcd 租约锁来实现任务的“主节点选举”。每台应用服务器启动时尝试在 etcd 中创建同一个任务 KEY(带租约),创建成功的节点成为主执行者,其他节点进入 standby 状态。主节点每 10 秒续约一次,如果它宕机,租约到期,其他 standby 节点自动顶上。整个切换过程对业务无感知,任务不会重复执行。

分布式缓存双写一致性保障

阶段:缓存更新与数据一致性保障

解决的问题:在“先更新数据库,再删除缓存”的标准模式中,两个并发请求可能导致缓存中残留旧数据。加分布式锁可以保证同一个 KEY 的更新操作是串行的,避免出现“后写先删”的脏缓存。

具体案例:某社交平台的热点内容详情页使用 Redlock 实现多节点锁,确保同一篇内容的缓存重建不会并发执行。当缓存过期时,只有一个请求会触发回源数据库查询并重建缓存,其他请求等待锁释放后直接读取新缓存,避免了缓存击穿。

Python 演示

python78 行
import timeimport uuidimport threading  class MockRedis:    """模拟 Redis 单机版分布式锁的核心操作"""     def __init__(self):        self._store = {}        self._lock = threading.Lock()     def set(self, key, val, nx=False, ex=None):        with self._lock:            if nx and key in self._store:                return False            self._store[key] = (val, time.time() + ex if ex else None)            return True     def get(self, key):        with self._lock:            item = self._store.get(key)            if item is None:                return None            val, expire_at = item            if expire_at is not None and time.time() > expire_at:                del self._store[key]                return None            return val     def delete(self, key):        with self._lock:            return self._store.pop(key, None) is not None  class SimpleLock:    """基于过期时间的简单分布式锁封装"""     def __init__(self, redis, key, token, ttl=5):        self._redis = redis        self._key = key        self._token = token        self._ttl = ttl        self._acquired = False     def acquire(self):        self._acquired = self._redis.set(            self._key, self._token, nx=True, ex=self._ttl        )        return self._acquired     def release(self):        if not self._acquired:            return False        # 校验 token 防止误删他人的锁        if self._redis.get(self._key) == self._token:            return self._redis.delete(self._key)        return False  def main():    client_id = "client-" + uuid.uuid4().hex[:6]    redis = MockRedis()    lock = SimpleLock(redis, "order:pay:10001", client_id, ttl=3)     if lock.acquire():        print(f"{client_id} 加锁成功,开始执行任务")        time.sleep(1)        if lock.release():            print(f"{client_id} 释放锁成功")        else:            print(f"{client_id} 释放锁失败:锁已过期或丢失")    else:        print(f"{client_id} 加锁失败:锁被其他客户端持有")  if __name__ == "__main__":    main()
目的:模拟了基于 Redis SET NX EX 的分布式锁最小实现,演示加锁、释放、防误删的核心逻辑。
  1. self._store = {}用进程内字典模拟 Redis 的键值存储,演示锁的核心操作。
  2. if nx and key in self._store: return False模拟 SETNX 的互斥语义:key 已存在时拒绝加锁。
  3. self._store[key] = (val, time.time() + ex if ex else None)存入 token 和过期时间点,模拟 EX 过期机制。
  4. return self._store.pop(key, None) is not None删除 key 并返回是否删除成功,模拟释放锁。
  5. lock.acquire()尝试加锁,返回布尔值,对应真实场景中的 SET key val NX EX ttl。
  6. if self._redis.get(self._key) == self._token释放前校验 token 是否属于自己,防止误删其他客户端的锁。
  7. lock.release()释放操作封装了“确认持有者 + 删除 key”两步逻辑,真实项目里通常用 Lua 脚本保证原子性。

常见误区

误区把锁的过期时间设成 10 分钟甚至 1 小时,认为这样业务执行过程中锁一定不会过期,就不会出问题。

正解过期时间越长,锁的安全窗口越大,但“死锁恢复”的速度就越慢。如果持有锁的节点突然宕机(没有机会释放锁),所有请求都要等到过期时间结束后才能重新竞争锁。10 分钟的等待对于一个高并发系统来说是不可接受的。更合理的做法是设置一个适中的过期时间(如 5-30 秒),同时实现续约机制:业务没有执行完时,后台线程不断延后过期时间。

为何常见:直觉上认为“锁不消失就等于安全”,却忽略了锁的作用是“临时互斥”而非“永久保护”。持有锁的节点崩溃才是需要重点防范的场景,而过期时间正是为这个场景设计的逃生舱。

误区既然 Redlock 用了 5 个节点做多数派投票,那它一定比单机 Redis 安全一万倍,任何场景下都不会出现两个客户端同时持有锁。

正解Redlock 在“节点故障”场景下确实比单机 Redis 可靠,但它无法应对“时钟漂移 + 网络分区”同时发生的极端情况。如果 5 个节点被分成两组(3+2),同时时钟发生跳跃,理论上两个客户端可以各获得 3 个节点的同意,出现双主。Martin Kleppmann 与 Redis 作者 Salvatore Sanfilippo 曾为此展开过一场著名的论战,核心焦点就是:不稳定的时钟 + 网络分区会让任何基于超时判定的算法失效。所以 Redlock 适用于“多数场景下的高可用”,但不是数学意义上的绝对安全。

为何常见:人们容易把“更复杂”等同于“更正确”。不知道分布式系统理论中的 FLP 不可能定理和时钟问题的深刻性,就容易对任何“多节点方案”产生盲目的信任。

误区加锁后直接调用 DEL 命令删除 key 就算释放锁了,反正只有持有者才会调用 DEL。

正解在真实的高并发场景中,一个客户端的锁可能因为业务执行超过过期时间而“自动失效”,另一个客户端随后获取了同一把锁。如果第一个客户端这个时候才执行 DEL,就会把第二个客户端的锁删掉,导致两个客户端同时持锁。正确做法是:加锁时 value 设置一个随机 token(如 UUID),释放时先 GET 确认 value 与自己的一致,再执行 DEL;为了保证原子性,这两个操作要放在 Lua 脚本里执行。

为何常见:在很多人的心理模型中,“加锁/解锁”像线程的 lock/unlock 一样,是严格配对的。但在分布式系统中,锁的【自动过期】打破了这种配对关系,持有者身份校验才是保障安全的最后一道保险。

误区etcd 和 Chubby 基于 Raft/Paxos 算法,是“强一致”的,所以它们的分布式锁一定不会出现两个持有者同时持锁的情况。

正解强一致性指的是“集群内部各节点的数据状态一致”,但不是“分布式锁的整个生命周期绝对安全”。租约可能因为网络抖动而迟迟无法续约,导致持有者以为自己还持有锁,但锁已经在 etcd 中被释放了;集群的 Leader 节点可能在网络分区中被隔离,新的 Leader 产生后,旧 Leader 的租约记录已经丢失。因此 etcd/Chubby 的锁需要配合 Fencing 令牌或业务层的幂等校验,才能构建更坚固的防线。

为何常见:“强一致”这个词汇给了人一种“物理级安全”的错觉。实际上,一致性算法保证的是“存储层的确定性”,而不是“分布式系统的全部安全性”。

误区既然有了分布式锁保护,那就在锁的临界区里随便执行耗时操作吧——比如一次加载几十万行数据、调用多个外部 API。

正解锁的作用是“串行化互斥访问”,而不是“让耗时代码变快”。持锁时间越长,其他请求等待的时间就越长,系统的吞吐量大幅下降,同时锁的过期时间也会难以设置(设短了业务没执行完,设长了死锁恢复慢)。最佳实践是尽量缩小临界区的范围——只锁“检查+写”的核心操作,把重计算、外部调用放到锁外面。临界区里的代码应该像手术一样精准,而不是一辆塞满货物的卡车。

为何常见:把锁理解成“安全罩”而不是“互斥信号”,以为锁能自动解决所有并发问题。实际上,锁只是把并发问题变成了排队问题,排队时间依然是性能瓶颈。

面试题

Q1. 在基于 Redis 的分布式锁中,SET key value NX EX 30 这条命令的核心作用是什么?

单选题 · mid

  • A.只设置 key 的值,不做任何互斥判断
  • B.原子地完成“不存在才写入”和“设置过期时间”← 正确答案
  • C.设置一个永不超时的全局唯一 key
  • D.在所有 Redis 节点上同步写入 key

答案:B。选项 B 正确。NX 保证只有 key 不存在时才能写入(互斥),EX 30 设置 30 秒过期时间(自动释放),两者合并在一条命令中是原子性的。A 忽略了 NX 的互斥语义;C 的“永不超时”与 EX 矛盾;D 是 Redlock 多节点的行为,不是单条 SET 命令的职责。

Q2. 下列关于分布式锁过期时间(TTL)的说法,哪一项是正确的?

单选题 · easy

  • A.TTL 越长,系统越安全
  • B.TTL 越短,死锁恢复越快← 正确答案
  • C.TTL 应该在所有场景下设置为固定值
  • D.TTL 与业务执行时间无关

答案:B。TTL 是双刃剑。设置过短,业务没执行完锁就失效,导致并发冲突;设置过长,持有者崩溃后其他客户端要等很久才能获取锁(死锁恢复慢)。B 正确。A 错误,TTL 过长会拖慢故障恢复;C 错误,TTL 应根据业务耗时定制;D 错误,TTL 必须与业务执行时间匹配。

Q3. Redis 单机分布式锁的最主要缺点是下列哪一项?

单选题 · mid

  • A.加锁速度比数据库锁慢
  • B.无法处理锁的自动过期
  • C.单点故障导致锁服务不可用← 正确答案
  • D.不能设置锁的过期时间

答案:C。Redis 单机锁最致命的短板是可用性问题——如果唯一的 Redis 节点宕机,所有客户端都无法加锁或释放锁。C 正确。A 错误,Redis 锁的延迟极低;B、D 错误,Redis 的 EX 参数正是用来设置过期时间的。

Q4. Redlock 算法获得“多数派同意”后即可加锁,它的目标是解决什么问题?

单选题 · hard

  • A.完全消除网络分区下的双主问题
  • B.提高锁的持有时间上限
  • C.在部分节点故障时仍能维持锁的可用性← 正确答案
  • D.使所有客户端获得相同的锁等待时间

答案:C。Redlock 的设计初衷是容错——当 5 个节点中少数节点故障时,客户端依然能从多数节点获得锁,保证锁服务的可用性。C 正确。A 错误,Redlock 无法完全消除网络分区 + 时钟漂移导致的极端双主场景;B 错误,Redlock 不改变锁的持有时间逻辑;D 错误,锁等待时间因节点负载而异。

Q5. 在基于 etcd 的分布式锁实现中,租约(Lease)机制解决了哪些问题?

多选题 · mid

  • A.锁持有者崩溃后锁自动释放← 正确答案
  • B.周期续约防止锁提前过期← 正确答案
  • C.多个客户端同时持锁
  • D.网络分区下的数据不一致

答案:AB。租约机制的核心价值是“故障自愈”:客户端崩溃后租约到期,锁被自动释放,对应 A。同时,客户端可以通过心跳续约,防止业务未完成时锁提前过期,对应 B。C 和 D 不是租约机制直接解决的——多个客户端同时持锁需要 Fencing 令牌等机制防御;网络分区下的一致性问题由 Raft 共识算法解决。

Q6. 关于 Fencing 令牌机制,下列说法正确的是?

单选题 · easy

  • A.令牌的作用是阻止所有后续客户端加锁
  • B.每次加锁成功后,锁服务返回一个单调递增的令牌← 正确答案
  • C.令牌只在 Redlock 中可用
  • D.令牌是一个随机字符串,每次请求都不同

答案:B。Fencing 令牌的机制是:每次加锁成功时,锁服务分配一个单调递增的数字,客户端写数据时携带该数字,存储端拒绝旧令牌。B 正确。A 错误,令牌不阻止加锁,而是防止旧锁的数据写入覆盖新数据;C 错误,令牌可以在任何锁服务上实现;D 错误,要求随机而非递增,令牌没有单调性就无法比较新旧。

Q7. 某业务使用 Redis 分布式锁,锁的过期时间设为 5 秒,但业务有时运行 8 秒。最合理的解法是?

单选题 · mid

  • A.把过期时间改为 60 秒,保证锁不提前失效
  • B.让业务在锁外等待,直到锁超时
  • C.实现续约机制,业务运行期间定期续期← 正确答案
  • D.不用分布式锁,改用数据库行锁

答案:C。业务耗时不稳定时,固定 TTL 无法覆盖所有场景,正确的做法是实现“看门狗”续约——后台线程定期检查剩余时间并续约,业务完成后主动释放。C 正确。A 虽然能避免锁提前失效,但持有者崩溃时其他请求要等 60 秒才能恢复,死锁窗口太长;B 无法解决问题;D 引入新组件不是最优解。

Q8. 网络分区导致分布式锁出现“双主”(两个客户端同时持锁),哪些措施可以降低或避免其危害?

多选题 · hard

  • A.增加锁节点数量,例如从 5 个增加到 9 个
  • B.引入 Fencing 令牌,让存储端拒绝旧令牌写入← 正确答案
  • C.用 etcd 的租约锁替代 Redis 单机锁
  • D.业务层增加幂等校验,重复写入也能被识别并忽略← 正确答案

答案:BD。Fencing 令牌(B)从存储端拒绝旧锁写入,是业界公认的防双主最佳实践。业务层幂等(D)则是最后的兜底防线,即使双主发生,重复数据也不会造成资损。A 增加节点数只能提高容错性,不能消除网络分区本身;C etcd 租约锁能减少双主概率,但不能完全杜绝——旧 Leader 的租约可能因为网络隔离而延迟确认。

Q9. 为什么 Redlock 在大多数实现中建议使用奇数个节点?

单选题 · easy

  • A.奇数可以防止 Redis 节点之间的时钟漂移
  • B.奇数保证超半数计算时不会出现平票← 正确答案
  • C.奇数节点让 Redis 的复制更高效
  • D.奇数节点可以降低网络延迟

答案:B。多节点投票需要“多数派”才能获锁。以 4 个节点为例,如果 2 个同意、2 个反对,系统既无法认定锁成功,也无法快速失败,处于僵局。奇数节点(如 5)保证任意一方都能形成多数,避免平票。B 正确。A、C、D 都是无关特性。

Q10. 在释放 Redis 分布式锁时,为什么要先比较 token 再删除 key?

单选题 · mid

  • A.为了确认锁是当前客户端持有的,避免误删别人的锁← 正确答案
  • B.为了提高删除操作的性能
  • C.为了避免 Redis 单线程阻塞
  • D.为了记录锁的历史使用记录

答案:A。当锁因为过期被自动释放(业务超时),另一个客户端可能已经获取了同一把锁。如果原持有者直接执行 DEL,就会删除新持有者的锁,导致两个客户端同时持锁。先比较 token 再删除,可以确保只删除自己持有的锁。A 正确。B、C、D 都不是 token 校验的目的。