Theory · Redis · Distributed Lock

Redis 分布式锁

从 SETNX 的三个坑到 Redlock 之争 —— 演进式纠错是这道题的正确打开方式

演进三步

SETNX+EXPIRE 两步不原子 → SET NX PX 原子 → 唯一值 + Lua 校验删除——每一步都对应一个真实事故

深层问题

主从异步复制下锁可双持(官方原文 SAFETY VIOLATION);Redlock 与 Kleppmann 的 fencing token 之争

工程结论

常规业务单实例锁 + TTL + 唯一值 + 看门狗;锁"正确性"换 ZooKeeper/etcd/DB——能不用锁就不用锁

分布式锁是 Redis 面试区分度最高的题之一,因为它天然是演进式纠错结构:先写 SETNX 加 EXPIRE,面试官指出不原子;改成 SET NX PX,又被指出误删他人锁;加唯一值和 Lua,又被问业务超时锁过期;讲到看门狗,又被追问主从切换锁双持;最后落到 Redlock 和 Kleppmann 的论战。这份 deck 就按这条追问链组织,每一页都是上一页的漏洞修补。所有官方结论都对着 redis.io 的 SET 命令文档和 distributed-locks 模式文档核过。

Why Distributed Lock Matters

先看事故:加了 sync.Mutex,还是超卖了 37 单

// 秒杀扣库存:这段代码本身"看起来"没问题
var mu sync.Mutex           // 加了锁

func deduct(id string) error {
    mu.Lock()
    defer mu.Unlock()
    stock := db.GetStock(id)  // ① 读库存
    if stock <= 0 {
        return ErrSoldOut
    }
    db.SetStock(id, stock-1)  // ② 写回
    return nil
}

// 上线形态:同一份代码部署了 8 个 Pod。
// 于是有 8 把互不相干的 mu —— 每个进程
// 各锁自己,8 个 Pod 之间毫无互斥。
// 结果:库存 100,实际卖出 137 单。
关键观察:sync.Mutex进程内的一块内存。多实例部署后"每个进程一把锁",等于没有锁。要让 8 个 Pod 互斥,必须有一个所有实例都能看到的公共记号——这就是分布式锁存在的唯一理由。

超卖是怎么发生的(两个 Pod 的四个瞬间)

t0 Pod-A 拿到自己的 mu,读到 stock=1
t1 Pod-B 拿到自己那把 mu(互不阻塞),也读到 stock=1
t2 A 判断 >0,写回 0,返回"下单成功"
t3 B 手里还是 t1 那个旧值 1,判断 >0,也写回 0,也返回"成功"
两单,只扣了一件库存。并发越高,重叠的窗口越多,超卖越多。

那用 Redis 加锁,是不是就一劳永逸了?

并不是——这也是这道题区分度极高的原因:最直觉的写法(SETNX 再 EXPIRE)本身就是错的,改对之后还会依次撞上"删掉别人的锁""业务没跑完锁先过期""主从切换后两个人同时持锁"三个坑。每一个坑都对应过真实的线上事故。

本 deck 的路线:一条演进式纠错链

先立"好锁"的判定标准 → 三版演进把简单实现修到生产可用(原子加锁 → 唯一值 + Lua → 看门狗续期)→ 再暴露架构级漏洞(主从异步复制)→ Redlock 与 Kleppmann 之争 → 最后给选型结论:什么时候可以用 Redis 锁,什么时候必须换武器

先记住一句结论(后面反复验证)

Redis 锁给的是"大概率互斥",不是"绝对互斥"。所以它适合"重复执行只是浪费"的场景;一旦"重复执行就是资损",答案不是把锁写得更花,而是换掉锁。

动机页:用"加了 sync.Mutex 还超卖"的具体事故开场,先让读者看到进程内锁在多实例下为什么等于没锁,再给四个瞬间的时间线。随后预告"用了 Redis 也有三个坑",把整份 deck 的演进式纠错结构立起来,并提前埋下"大概率互斥 vs 绝对互斥"这条主线。禁止开篇直接堆 SET NX PX 参数。

Prerequisites & Glossary

先把词认全:后面每一页都会用到它们

术语一句话理解(细节后面展开)
临界区同一时刻只允许一个执行者进入的那段逻辑(如"读库存 → 判断 → 扣减")
原子性一组动作"要么全做完,要么全不做",中间不会被别人插进来看到半成品
租约(lease)
/ TTL
锁不是"永久持有",而是租一段时间:到期自动作废。这样持有者崩溃了也不会永久堵死
token(唯一值)加锁时写进 key 的一串随机数,用来证明"这把锁是我的",释放前要先核对
Lua 脚本Redis 会整段不间断地执行一段 Lua——用它把"核对 + 删除"两步捆成一个原子操作
看门狗
(watchdog)
持锁期间后台每隔一段时间悄悄把租期续上,防止业务还在跑锁却过期了
可重入同一个持有者可以对同一把锁反复加锁而不自锁(内层方法也要加同一把锁时需要它)
异步复制 /
故障转移
主库写完立刻返回、之后才把数据传给从库;主库挂了从库被提升为新主——没传过去的写就丢了
多数派
(quorum)
N 个节点里超过一半(N/2+1)达成一致才算成功——Redlock 的核心手段
fencing token
(栅栏令牌)
每次获锁附带一个只增不减的编号,资源那一端拒绝编号更小的写入,用来兜住"锁已易主但我不知道"

不在本 deck 内的前置,按需回看

主从复制与故障转移 → 第 9 页那个"锁双持"漏洞的架构背景
缓存三大问题 → 击穿的互斥重建就是本 deck 这把锁的典型用法
死锁 → "锁"的通用语义、四个必要条件与预防手段
InnoDB 锁体系 → 最后一页"不用锁的替代"里唯一约束与乐观锁的底层

两个词请务必分清(全 deck 的分水岭)

效率锁:加锁只为"别重复干活",偶尔两个人同时干也不出错(定时任务防重、缓存重建)。
正确性锁:两个人同时干就是资损(扣款、精确扣库存)。
Redis 锁的全部争议,都在于它只适合前者——第 12 页会把这条结论落成选型表。

最小心智模型(后面所有版本都在改它)

分布式锁 = 去一个大家都看得见的地方,抢一个带租期的记号:抢到的人干活,干完把自己的记号删掉;万一没删(崩溃了),租期到点自动作废。
后面的演进只在修三件事:① 抢记号和设租期要一次做完;② 删之前要确认记号是自己的;③ 活还没干完租期就到了怎么办

前置页:十个术语先定义再使用,其中"租约""token""Lua 原子""异步复制""多数派""fencing token"分别是后面六页的核心零件。中间单独用一块把"效率锁 vs 正确性锁"提前讲清——它是第 12 页选型结论的判据。最小心智模型把三版演进压成三个待修问题,读者带着这三个问题往下看即可。

Safety & Liveness

判定标准:四要素定义一个"好锁"

开场那 8 个 Pod 需要的"公共记号",到底要满足什么条件才算一把合格的锁?先把检查清单立好——后面每一版实现都拿它逐条打分

单机锁的失效场景

Go 的 sync.Mutex 只在进程内生效。服务多实例部署(K8s 多副本 / 负载均衡多 Pod)后,"每个进程一把锁"= 互斥形同虚设:两个实例同时执行"扣库存 / 发奖 / 定时任务"。必须引入所有实例共享的外部互斥点——分布式锁。

典型使用场景

① 分布式定时任务防并发执行;② 缓存击穿的互斥重建(cache-patterns deck);③ 防止重复提交/重复消费的兜底互斥;④ 临界资源操作(库存、配额)。注意前三类多是效率锁,只有真正修改共享状态的是正确性锁(第 11 页的分水岭)。

四要素含义Redis 锁如何满足
互斥任一时刻至多一个客户端持有锁(safety)SET NX 的"不存在才写";主从切换场景有漏洞(第 8 页)
防死锁持有者崩溃/分区后,锁最终能被再次获取(liveness A)TTL 自动过期释放——"租约"语义
容错锁服务部分节点挂掉仍能加解锁(liveness B)单实例:实例挂则锁不可用;Redlock:多数存活即可
可重入持有者可重复获取自己已持有的锁string 值无法计数 → hash 结构实现(第 7 页)
官方三性质(distributed-locks 文档):Safety 互斥、Liveness A 防死锁(TTL 保证)、Liveness B 容错(多数节点存活可用)。面试用这四/三条当检查清单,逐条说清方案如何满足。
开场先立"为什么":sync.Mutex 是进程内的,多实例部署后互斥直接失效,这是分布式锁存在的唯一理由。然后给出四要素检查清单:互斥、防死锁、容错、可重入,后面每一页的方案演进都用这个清单检验。特别要建立"效率锁和正确性锁"的分野:定时任务防重是效率锁,偶尔双持无所谓;扣库存是正确性锁,双持就是事故。这个分野直接决定最后一页的选型结论。

Evolution · Step 1

第一版:SETNX + EXPIRE —— 两步之间会死

// 错误示范:加锁是两条命令
SETNX lock:order:1001 1   // ① 不存在才写入
EXPIRE lock:order:1001 30 // ② ② 再设过期

// 问题:① 与 ② 不是原子的。
// 客户端在两步之间崩溃 / 网络 断开 →
// 锁已写入、永不过期 = 死锁。

// 后果:所有其他客户端永远
// SETNX 失败,业务全体阻塞,
// 只能人工 DEL 处理。

缺陷本质

"加锁 + 设 TTL"需要原子性,而两条命令之间存在任意长的窗口(客户端崩溃、连接断开、GC 停顿)。TTL 是防死锁的保险,两步写法让保险在最需要时装不上。

修复版本线

2.6.12 起 SET 命令支持 NX/PX 选项——一条命令原子完成"不存在才写 + 带过期"。官方 SET 文档明确:SET 的选项可取代 SETNX/SETEX/PSETEX/GETSET,这些旧命令未来可能废弃。旧版本(<2.6.12)只能用 Lua 把两步打包。

面试句式:"SETNX+EXPIRE 的致命伤是加锁与设过期不原子,持锁客户端在两步之间挂掉就产生永不释放的死锁。2.6.12 后用 SET key value NX PX 30000 一条命令解决——这也是官方文档推荐的标准姿势。"
第一版锁的坑是所有八股的开场:SETNX 和 EXPIRE 是两条命令,中间挂了就死锁。要能说清后果:锁永不过期,其他客户端全部阻塞,只能人工删。修复的版本线要报出来:2.6.12 起 SET 带 NX 和 PX 选项,一条命令原子完成,官方文档明确说 SET 的选项可以取代 SETNX 系列旧命令。旧版本 Redis 的兜底方案是用 Lua 把两步打包成原子脚本。

Evolution · Step 2

第二版:SET NX PX 原子了 —— 但 DEL 会删掉别人的锁

事故时序

① A SET lock A NX PX 30s 成功;② A 的业务卡住(慢查询/Full GC)超过 30s,锁过期;③ B 用同一命令成功获锁;④ A 恢复执行完业务,随手 DEL lock——删掉的是 B 的锁;⑤ C 顺势获锁 → B、C 同时持锁,互斥破坏。

两个层次的问题

表层:DEL 无差别删除——不校验持有者。深层:即使校验后删除(GET 比较 + DEL)也不行——GET 和 DEL 两条命令之间锁可能恰好过期易主,校验通过的瞬间已不是你的锁。所以校验和删除必须原子 → Lua(下一页)。

常见错误修复为什么仍是错的
把 TTL 调大(如 10 分钟)治标:窗口变小但存在;持有者崩溃时锁释放延迟变长——可用性换安全,方向反了
GET 后比较值再 DEL(两条命令)非原子:比较通过后、DEL 前,锁恰好过期被 B 拿走 → 还是删了 B 的锁
用 WATCH/MULTI 乐观事务功能上可行但复杂;官方路径就是 Lua 脚本(或 8.4 的 DELEX IFEQ)
引出标准解:加锁时写入唯一随机值(20 字节 /dev/urandom 或 UUID),释放时用 Lua "值匹配才删"。官方 SET 文档原文:把固定字符串换成 token、把 DEL 换成值校验脚本,"avoids that a client will try to release the lock after the expire time deleting the key created by another client"。
第二版锁暴露两个层次的问题。表面是 DEL 不认人:A 超时后锁过期被 B 拿走,A 恢复后一删把 B 的锁删了。更深一层是很多人答不到的:就算 GET 比较后再 DEL 也不行,因为比较和删除是两条命令,中间锁可能恰好易主,校验通过的瞬间已经不是你的锁了。所以校验和删除必须原子。顺带否定两个常见伪修复:调大 TTL 是用可用性换安全方向反了,WATCH 事务可行但没必要。

Evolution · Step 3 · The Canonical Form

第三版(标准形态):唯一值 + Lua 原子校验删除

// 官方解锁脚本(redis.io SET 文档原文)
if redis.call("get", KEYS[1]) == ARGV[1]
then
    return redis.call("del", KEYS[1])
else
    return 0
end

// 调用:
// EVAL <script> 1 lock:order:1001 <token>

// Redis 8.4+ 新原生命令(等价):
// DELEX key IFEQ <token>
// (对比删除,条件删除一条命令完成)
// Go 生产模板(go-redis v9)
token := uuid.NewString()
ok, err := rdb.SetNX(ctx,
    "lock:order:1001", token,
    30*time.Second).Result()
if !ok { return ErrLockBusy }
defer release(ctx, key, token) // Lua

// release: EVAL check-and-del 脚本;
// 释放失败(已过期易主)仅记录,不重试加锁

要点:token 唯一不可猜(/dev/urandom 20 字节官方建议);释放失败不重试——锁已不属于你

官方对"简单模式"的定位

SET 文档把该模式标注为 "discouraged in favor of the Redlock algorithm"——但 distributed-locks 文档同时承认:单实例锁是"race condition from time to time is acceptable"场景下可行且正确的方案(viable solution)。两句话连读:能容忍极小概率竞态 → 单实例锁够用;不能 → Redlock/共识系统。

还剩什么没解决?

① 业务超时锁先过期(续期问题,第 6 页);② 不可重入(第 7 页);③ 主从异步复制下锁双持(第 8 页)——最后这个是架构级问题,客户端技巧无法修补,直接引出 Redlock 与共识之争。

第三版就是生产标准形态:加锁 SET NX PX 带唯一 token,解锁用官方那个值匹配才删的 Lua 脚本。脚本要能默写,调用方式 EVAL 一资源名一 token。两个加分点:一是 Redis 8.4 新增了 DELEX IFEQ 原生命令做条件删除,说明官方也在把这套模式内置;二是官方对简单模式的定位要会连读——SET 文档劝你用 Redlock,distributed-locks 文档又说单实例在容忍偶发竞态的场景是可行方案,这两句合起来就是选型依据。Go 模板里释放失败不要重试加锁,锁已经不属于你。

Watchdog · Lock Granularity

业务超时 vs 锁过期:看门狗自动续期

问题:TTL 是拍脑袋估的

TTL 设短 → 业务没跑完锁先过期 → 他人进入临界区(互斥破坏);TTL 设长 → 持有者崩溃后其他客户端等锁时间变长(可用性损失)。业务耗时天然波动(DB 抖动、GC),静态 TTL 无法两全。

解法:看门狗 watchdog

持有锁期间后台定时续期:Lua 校验 value 仍是自己的 token → PEXPIRE 延长 TTL。Redisson 机制:lock() 不指定 leaseTime 时启用,lockWatchdogTimeout 默认 30000ms,每 1/3(约 10s)把过期时间续回 30s;指定 leaseTime 则看门狗关闭。Go 侧可用 goroutine + time.Ticker 手写等价物(go-redsync 已内置)。

追问答案
看门狗的边界?进程崩溃 → 看门狗随进程消失 → 锁在 TTL 内自然过期——防死锁语义不被破坏;续期请求也要走网络,Redis 抖动时可能续期失败,业务应有兜底(临界区操作幂等)
TTL 到底设多长?启用看门狗时 TTL= 续期周期(默认 30s);不用看门狗时按"业务耗时 P99 × 2~3"估,并接受"崩溃后等锁变慢"
锁粒度怎么设计?越细越好:lock:order:{orderId} 而非 lock:order——不同订单互不阻塞;临界区内只放必须互斥的操作,慢 IO(RPC/DB 大查询)尽量移出锁外
等锁方怎么等?自旋 + 随机退避(防惊群);带总超时;能订阅发布(锁释放通知)更好——避免空转打爆 Redis
TTL 的两难:设短了业务没跑完锁先过期,设长了持有者崩溃后大家干等。看门狗是标准解:持有期间后台定时用 Lua 校验 token 再 PEXPIRE。Redisson 的机制要报具体数字:不指定 leaseTime 时启用看门狗,lockWatchdogTimeout 默认三十秒,每三分之一约十秒续一次,指定 leaseTime 看门狗就关闭——这条因果链经常被追问。锁粒度题的答案是一句原则:按资源加锁、临界区最小化,把慢操作移出锁外。

Reentrant Lock · Hash Structure

可重入:string 不够用,hash 记持有者与计数

为什么需要可重入

临界区内方法层层调用又对同一把锁加锁:不可重入就自己锁死自己(自等待)。单机锁天然可重入(ReentrantLock/sync),分布式锁需显式实现。

结构:HASH 替代 STRING

HSET lock:order field=<持有者ID> value=<重入次数>。持有者 ID = 客户端唯一标识 + 线程标识;value 是重入计数。Redisson RLock 正是此实现。

// 加锁 Lua(骨架)
if redis.call('exists', KEYS[1]) == 0
then
  redis.call('hincrby', KEYS[1], ARGV[1], 1)
  redis.call('pexpire', KEYS[1], ARGV[2])
  return 1        // 首次加锁
end
if redis.call('hexists', KEYS[1], ARGV[1]) == 1
then
  redis.call('hincrby', KEYS[1], ARGV[1], 1)
  redis.call('pexpire', KEYS[1], ARGV[2])
  return 1        // 重入:计数 +1 并刷新 TTL
end
return 0          // 别人的锁:失败
// 解锁 Lua(骨架)
if redis.call('hexists', KEYS[1], ARGV[1]) == 0
then
  return 0      // 不是你的锁:拒绝(防误删)
end
local n = redis.call('hincrby',
                     KEYS[1], ARGV[1], -1)
if n > 0
then
  redis.call('pexpire', KEYS[1], ARGV[2])
  return 1      // 还有未释放的重入层
else
  redis.call('del', KEYS[1])
  return 1      // 计数归零才真删
end
三个细节:① 每次加锁都 PEXPIRE 刷新 TTL;② 计数减到 0 才 DEL,否则漏掉外层调用者;③ field 校验天然防误删。
可重入的实现是结构升级:string 只能存一个值,换成 hash,field 存持有者标识,value 存重入计数。加锁 Lua 三个分支:锁不存在 HINCRBY 建档;field 存在说明是自己的锁计数加一并刷新 TTL;否则失败。解锁反向:field 不匹配直接拒绝,匹配则计数减一,减到零才 DEL。三个细节要讲:每次加锁都刷新过期时间、计数归零才真删、field 校验天然继承防误删能力。Redisson 的 RLock 就是这套结构,报出来即可。

Failover Race · Safety Violation

架构级漏洞:主从异步复制下的锁双持

主从切换导致两个客户端同时持有锁的时序 客户端 A 在主库加锁成功,主库在将锁写入复制到从库前宕机,从库晋升为新主库但不含该锁,客户端 B 在新主库加锁成功,A 与 B 同时持锁,官方文档称此为 SAFETY VIOLATION。 Master Replica(异步复制) 客户端 A 客户端 B ① A:SET lock token NX PX 成功 master:lock=tokenA ✓ ② 异步复制 lock(尚未到达…) ③ Master 宕机 lock 未同步 → 丢失 ④ Replica 晋升新主 lock 不存在(没收到复制) ⑤ B 向新主 SET lock NX PX → 成功 结果:A(旧主侧视角)与 B(新主上)同时认为自己在临界区 —— 官方文档原文 "SAFETY VIOLATION!" 根源:Redis 复制是异步的——加锁成功 ≠ 已在多数副本上;failover 后锁状态可能清零 为什么"从库也存了锁"救不了 同步复制(WAIT 2)可以缩小窗口,但官方明说: WAIT 只保证 ack 数量,failover 仍可能丢已确认写—— 同步复制把延迟加上去,安全性还是不闭合。 官方结论与出路 "This is unfortunately not viable… because Redis replication is asynchronous."—— 主从+故障转移的锁永远有此窗口; 出路:容忍(效率锁) / Redlock 多数派(第 9 页) / 共识系统
这是分布式锁的架构级漏洞,客户端技巧救不了。时序五步:A 在主库加锁成功,锁还没复制到从库主库就宕机,从库晋升后锁不存在,B 在新主上顺利加锁——A 和 B 同时持锁,官方文档原文就是 SAFETY VIOLATION。两个延伸:加 WAIT 做半同步也救不了,官方明说 WAIT 不保证 failover 不丢已确认写;官方的结论是主从加故障转移的锁永远有这个窗口,出路只有三条——容忍、Redlock 多数派、换共识系统。这道题答出官方原话就是满分。

Redlock Algorithm

Redlock:N 个独立实例的多数派加锁

前提与五步(官方原文流程)

N 个完全独立的 master(无复制,示例 N=5)。① 记当前毫秒时间 t1;② 并行向所有实例用相同 key+token SET NX PX(连接超时远小于 TTL,如 5~50ms);③ 获得多数(≥N/2+1)总耗时 < TTL → 加锁成功;④ 有效期 = TTL − 耗时 − 时钟漂移余量;⑤ 失败则向所有实例解锁(含没抢到的)。

配套机制

重试须随机延迟(防多客户端同步竞争脑裂);未取多数时尽快释放已抢到的锁。持久化坑:实例崩溃重启丢锁 → 需 AOF everysec + 崩溃后延迟重启 ≥ max TTL(让旧锁全部自然过期)。锁延长:Lua 校验 token 续期到多数实例,且重试次数要有限(官方明说无限续期会破坏 liveness)。

关键设计问题答案
为什么要求"耗时 < TTL"?加锁过程本身要花时间;若抢到多数时已耗时超过 TTL,第一个 key 可能已过期——锁实际无效。有效期的减法就是为此设计
为什么要并行 + 短连接超时?加锁耗时越短,有效期越长、竞争窗口越小;某个实例挂死时不能被它拖住整个流程
对时钟的假设?不要求跨节点时钟同步,但要求本地时钟漂移速率小且可忽略跳变(官方:local time updates at approximately the same rate)——这正是 Kleppmann 攻击点(下页)
为什么不用主从而是 5 个独立实例?主从异步复制正是第 8 页漏洞的根源;独立实例 + 多数派把"单点状态"换成"仲裁状态"——用 5 倍资源买互斥概率
官方免责声明(distributed-locks "Disclaimer about consistency"):"You should implement fencing tokens" + "Redis is not using monotonic clock for TTL… a wall-clock shift may result in a lock being acquired by more than one process."——官方自己承认两个弱点,面试引用这两句即达文档级准确。
Redlock 的骨架是五步加三个配套。五步背熟:记时间、并行抢五个独立实例、多数派加耗时小于 TTL 才算成功、有效期做减法、失败向全部实例解锁。三个配套:随机延迟重试、崩溃实例延迟重启至少一个最大 TTL、续期次数有限。两个关键设计问答:为什么耗时必须小于 TTL,为什么不用主从而用五个独立实例——后者正是为了绕开上一页的复制漏洞。最后必须引用官方免责声明里那两句:建议实现 fencing token、TTL 不用单调时钟——官方自己认的弱点。

The Great Debate · 2016

Kleppmann × antirez:Redlock 到底安不安全

Kleppmann《How to do distributed locking》

进程暂停/GC 停顿:持锁客户端停顿超过 TTL,恢复后不知道锁已易主,继续写共享资源——锁没有补救手段;② 时钟假设:Redlock 的安全性依赖"时间大致均匀流逝"(时钟跳变、leap second 都可能双持)——在异步系统模型下不可接受;③ 正解是 fencing token:每次获锁拿递增令牌,存储层拒绝旧令牌的写——但这需要线性一致的存储来发号,若已有它,锁的必要性本身存疑。

antirez《Is Redlock safe?》反驳

① 进程暂停问题对任何基于租约的锁(含 ZK)都存在,非 Redlock 独有;② 时钟假设现实中可控:NTP 校准 + 单调钟保护,跳变是运维可防的操作事故;③ fencing token 要求数据存储自带"拒绝旧令牌"能力——这等价于要求一个线性一致存储;Redis 的目标场景(性能优先、可容忍极小概率)本就不追求那个级别的正确性;④ 场景分离:效率锁 vs 正确性锁——Redlock 定位是前者偏强的选项。

争议点Kleppmann 立场antirez / 官方回应
GC 停顿 / 进程暂停锁过期后客户端无感知,必须靠 fencing token 兜底任何租约锁同病;token 是好实践——官方文档也把 "implement fencing tokens" 写进 disclaimer
时钟跳变异步模型下时间假设不可靠运维可控(NTP/单调钟);官方 disclaimer 承认 wall-clock shift 风险
fencing token 的可得性应该用(如果有线性存储就该用它做锁)token 生成需线性一致存储——有它就不需要 Redlock;场景定位不同
面试标准结论:"之争的本质是安全性模型的分歧:Kleppmann 站在'暂停与时钟不可信'的严格模型,antirez 站在工程现实。落地判断:锁是效率优化(防重复执行)→ 单实例锁/Redlock 都可,配幂等兜底;锁是正确性约束(扣款、库存)→ fencing token 思路或直接换 ZooKeeper/etcd/DB 乐观锁,别和 Redis 锁较劲。"
这场论战是分布式锁题的天花板。Kleppmann 两个攻击点:GC 停顿让持锁客户端在不知情下越权写,只有 fencing token 能救;Redlock 依赖时间均匀流逝的假设,时钟跳变就双持。antirez 三点反驳:暂停问题对 ZooKeeper 同样存在,时钟风险运维可控,fencing token 本身需要线性一致存储来发号、有那玩意就不需要锁了。把官方 disclaimer 的两句原话引上,然后给出场景化结论:效率锁配幂等兜底,正确性锁换系统。能讲清"场景分离"这个收尾,这题就是你的主场。

Decision Framework

选型结论:效率锁与正确性锁分而治之

锁的性质典型场景推荐方案兜底
效率锁(防重复)定时任务防并发、缓存重建互斥、防重复提交的辅助单实例锁:SET NX PX + 唯一 token + Lua 释放 + 看门狗;主从+哨兵架构即可业务幂等(重复执行无害);偶尔双持可容忍
正确性锁(错了就是资损)扣款、库存精确扣减、配额不用 Redis 锁:DB 事务 + 唯一约束/条件更新;或 ZooKeeper/etcdfencing token 思路:DB 自增版本号/etcd revision 做令牌,存储层校验拒绝旧令牌
确需 Redis 但要求更高互斥概率Redlock(5 独立实例)——注意它仍非严格安全同上;接受极小概率竞态并监控
读写组合读多写少的共享配置/路由表通常不需要锁:版本号 + 本地快照比对

fencing token 落地实例

锁服务发号递增 token → 资源存储记录"见过的最大 token" → 写入时 token 更小则拒绝。实践:用 etcd 的全局 revision、DB 的 AUTO_INCREMENT、或 Snowflake 单调 ID。这是把"锁的正确性"从锁转移到资源端校验——Kleppmann 方案的工程形态。

一句话总纲(背)

"Redis 锁的正确姿势 = SET NX PX 原子加锁 + 唯一值 + Lua 校验释放 + 看门狗续期;它的互斥保证受异步复制与进程暂停限制,所以只用于效率锁;正确性场景用 DB 约束/乐观锁或共识系统,配合 fencing token。"常规业务单实例锁 + 容忍极小概率,是官方文档与社区实践的公约数。

选型这页是全 deck 的落点,表格按锁的性质分治。效率锁用标准四件套加幂等兜底就够了;正确性锁直接换武器:DB 约束条件更新或 ZK etcd,需要 Redis 时 Redlock 也要认清它仍非严格安全。fencing token 的落地要会说:资源端记录见过的最大 token,旧令牌拒绝写入,把正确性从锁转移到资源端校验。最后那句话术是整份 deck 的浓缩,建议直接背下来。

Redis vs ZooKeeper vs etcd

三个锁服务横评:CAP 取向、性能与实现复杂度

维度Redis 锁ZooKeeper 锁etcd 锁
一致性取向AP(异步复制,主从切换有双持窗口)CP(ZAB 多数派写)CP(Raft 多数派写)
互斥可靠性效率级;TTL 租约语义;fencing 需自建强:临时顺序节点,会话断开自动释放,天然公平排队强:lease + 事务(CAS on revision),可做 fencing(revision 单调)
性能最高(内存 + 单 key O(1),10w+ QPS)低(写走多数派 + 落盘,百级~千级写 QPS)中(Raft + boltdb,千级~万级读 / 千级写)
实现复杂度低(一条命令 + 一个脚本),但正确细节多(token/续期/重入)高(部署 3~5 节点、会话/临时节点语义、Java 栈)中(部署轻,gRPC API 简洁;K8s 生态标配)
失效释放机制TTL 过期(可能误伤:持有者仍在跑)会话超时 + 临时节点删除(心跳维持)lease 到期自动撤销(keepalive 续约)
适用高并发短临界区、效率锁、缓存协同强一致选主/互斥、延迟不敏感的控制面强一致控制面、K8s 场景、需要 fencing 的中小规模协调
答题要点:"三者不是替代关系:Redis 用性能换互斥概率(AP),ZK/etcd 用 CP 换强互斥但吞吐低一个量级以上。ZK 的临时顺序节点还免费送了公平排队(羊群效应可用 watch 缓解);etcd 的 revision 天然可当 fencing token。按'性能需求 + 正确性要求'二维选型,而不是背谁更好。"
三个锁服务的横评抓六个维度。CAP 取向是分水岭:Redis 是 AP,互斥是概率性的;ZK 和 etcd 是 CP,多数派写保证强互斥。性能差一个量级以上,这决定了它们不在一个战场:高并发短临界区只有 Redis 能扛,强一致控制面才轮到 ZK 和 etcd。失效释放机制三家都靠"租约",但 ZK 的临时顺序节点附赠公平排队,etcd 的 revision 天然能当 fencing token——这两个细节是对比表的加分项。

Lock-free Alternatives

能不用锁就不用锁:四种替代设计

① DB 唯一约束(幂等的标准答案)

防重复提交:唯一索引 + INSERT IGNORE / ON DUPLICATE KEY。重复请求第二次插入必然失败——互斥语义由存储引擎保证,无需任何锁。分布式锁防重的场景九成可换成它。

② 乐观锁(版本号 / CAS)

UPDATE t SET v=v+1, version=version+1 WHERE id=? AND version=?——影响行数为 0 说明并发冲突,重试即可。读多写少时零锁开销;冲突激烈时重试风暴,退化为悲观锁/队列。

③ 状态机条件更新

订单流转:UPDATE orders SET status='paid' WHERE id=? AND status='pending'——只有"当前状态匹配"才允许迁移。把互斥问题转化为状态合法迁移,天然幂等、天然防并发错序。

④ 天然幂等设计 + 去重表

消息消费/回调:请求带唯一 bizId,去重表记录已处理 ID,处理前占位(唯一键)。配合"先落库后动作"的事务顺序,重复消息到达也只生效一次——锁在消息场景的正确替代品。

选型心法:"分布式锁保护的是'检查-执行'两步的原子性;如果能把同样的原子性下沉到存储(唯一约束、条件更新、版本号),就根本不需要锁。锁是最后的手段——面试先答替代方案再答锁实现,层次立刻不同。"
这页是高级候选人的分水岭:分布式锁保护的本质是检查加执行两步的原子性,而这个原子性多数时候可以下沉到存储层。唯一约束治重复提交,乐观锁治并发更新,状态机条件更新治流转错序,去重表治消息重复。四招覆盖九成的锁场景,剩下的才用锁。答题顺序反过来:先讲不用锁的方案,再讲锁的正确实现,面试官会认为你有架构判断力。

Cheat Sheet

一页带走:三段模板、五个坑、参数与选型

① 生产模板:三段命令

// 加锁:一条命令原子完成(token 必须唯一不可猜)
SET lock:order:1001 <token> NX PX 30000

// 解锁:Lua 校验 token 才删(8.4+ 可用 DELEX key IFEQ <token>)
if redis.call("get", KEYS[1]) == ARGV[1]
then return redis.call("del", KEYS[1]) else return 0 end

// 续期(看门狗,每 TTL/3 一次):同样先校验再续
if redis.call("get", KEYS[1]) == ARGV[1]
then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end

② 五个坑与修法(按演进顺序)

SETNX + EXPIRE 两步中间崩溃 → 永不过期死锁 → SET NX PX 一条命令
裸 DEL 释放删掉别人的锁 → 写唯一 token,Lua 校验后删
GET 比较后再 DEL两步之间锁可能易主 → 必须放进同一段 Lua
业务比 TTL 慢锁提前过期、他人进入 → 看门狗续期 + 临界区幂等
主从切换锁未复制就 failover → 双持 → 只当效率锁 / Redlock / 换共识系统

③ 参数与数值(面试报得出即加分)

token官方建议 /dev/urandom 20 字节;工程上 UUID 即可
SET 的 NX/PX2.6.12 起支持,可取代 SETNX/SETEX;8.4 起有 DELEX ... IFEQ
Redisson 看门狗lockWatchdogTimeout 默认 30000ms,每 1/3(约 10s)续回 30s;指定 leaseTime 则关闭
无看门狗时 TTL业务耗时 P99 × 2~3,并接受"崩溃后等锁变慢"
RedlockN=5 个互不复制的实例;多数派 ≥3 且总耗时 < TTL;有效期 = TTL − 耗时 − 漂移;崩溃实例延迟重启 ≥ 最大 TTL
锁粒度lock:order:{id} 而非 lock:order;慢 IO 移出临界区

④ 选型判定:先问一句话

"重复执行会不会造成资损?"
· 不会(效率锁) → Redis 单实例锁即可:SET NX PX + 唯一 token + Lua 释放 + 看门狗,业务幂等兜底;
· 会(正确性锁) → 别和 Redis 锁较劲:优先把原子性下沉到存储(唯一约束 / 条件更新 / 版本号),或用 ZooKeeper / etcd,并按 fencing token 思路在资源端校验令牌;
· 确需 Redis 且要更高互斥概率 → Redlock(5 独立实例),但仍非严格安全,必须配监控与幂等。
速查页四块:可直接抄的三段命令模板、按演进顺序排列的五个坑与修法、要报得出的参数数值、以及用一句话判定的选型树。回看与考前只看这一页。

Interview QA · 1/2

实现细节 8 连问

先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;这一组都按"缺陷 → 修复 → 残留风险"三段答。

1 · SETNX + EXPIRE 为什么是错的?

非原子死锁

两条命令之间客户端可能崩溃/断连:锁已写入但过期时间没设 → 永不释放的死锁,其他客户端全部阻塞。2.6.12 起用 SET key val NX PX 30000 一条命令原子完成。

2 · 为什么要用唯一值(token)?

防误删官方文档方案

持锁超时后锁过期被 B 获取,A 恢复后 DEL 会删掉 B 的锁 → 第三人再进入 → 多重持锁。加锁写入不可猜测的随机 token,释放时 Lua 校验值匹配才删——官方 SET 文档的 Patterns 原方案。

3 · GET 比较后再 DEL 为什么不行?

校验与删除非原子

GET 与 DEL 是两条命令:比较通过的瞬间锁可能恰好过期易主,随后的 DEL 删掉新持有者的锁。校验与删除必须在一个原子操作内——Lua 脚本(或 8.4 的 DELEX IFEQ)。

4 · 业务执行超时、锁先过期怎么办?

看门狗续期Redisson 30s/10s

看门狗:持锁期间后台定时用 Lua 校验 token 后 PEXPIRE。Redisson 不指定 leaseTime 时默认 lockWatchdogTimeout=30s、每 1/3(约 10s)续期;指定 leaseTime 则看门狗关闭。

5 · 可重入分布式锁怎么实现?

hash + 计数HINCRBY + PEXPIRE

key 用 hash:field=持有者标识,value=重入计数。加锁:无则建档计数 1,field 匹配则 +1 并刷新 TTL。解锁:field 不匹配拒绝;匹配 −1,到 0 才 DEL。Redisson RLock 即此实现。

6 · TTL 设多长?等锁方怎么等?

P99×2~3 或看门狗随机退避

有看门狗:TTL = 续期周期(30s)。没有:业务耗时 P99 × 2~3,接受崩溃后等锁变长。等锁方:自旋 + 随机退避防惊群、带总超时、可用 pub/sub 订阅锁释放通知减少空转。

7 · 锁粒度怎么设计?

按资源临界区最小化

按最小资源单位加锁(lock:order:{orderId} 而非 lock:order),不同订单互不阻塞;临界区内只放必须互斥的操作,慢 IO(RPC、大查询、发消息)移出锁外——锁持有时间越短,冲突与持锁超时风险越小。

8 · Go 里怎么写一个生产级 Redis 锁?

SetNX + tokenLua releasego-redsync

go-redis:rdb.SetNX(ctx, key, uuid, ttl) 成功后 defer 执行 check-and-del 的 Lua;需要续期/重入/Redlock 时用 go-redsync。要点:token 用 UUID、释放失败不重试加锁、监控行锁等待时长。

实现细节这组按演进链背。第三题是多数人漏掉的深水区:GET 比较 DEL 依然不原子。第四题报出 Redisson 的具体数字:三十秒默认、三分之一周期续期。第五题可重入的 hash 结构和三个分支。第八题落到 Go 工程:go-redis 的 SetNX 加 UUID,进阶需求用 go-redsync。每题都用缺陷、修复、残留风险三段式,体现的是纠错演进的思维而不是背答案。

Interview QA · 2/2

架构与选型 8 连问

9 · 主从 + 哨兵下锁为什么会失效?

异步复制SAFETY VIOLATION

A 在 master 加锁成功 → 锁未同步到 replica 时 master 宕机 → replica 晋升后无此锁 → B 在新主加锁成功 → A、B 双持。官方文档明确称 "SAFETY VIOLATION"。WAIT 半同步也救不了(不保证 failover 不丢已确认写)。

10 · Redlock 的流程?哪些关键约束?

5 独立实例多数派 + 耗时<TTL延迟重启

并行向 N=5 个互不复制的 master SET NX PX(短连接超时);获多数且总耗时 < TTL 才成功,有效期 = TTL − 耗时 − 漂移;失败向全部实例解锁;重试随机延迟;实例崩溃后延迟重启 ≥ max TTL;续期次数有限。

11 · Kleppmann 对 Redlock 的两个攻击点?

进程暂停时钟假设

① GC 停顿/调度暂停超过 TTL:客户端恢复后不知锁已易主,继续写共享资源——唯一补救是 fencing token;② Redlock 安全性依赖"时间均匀流逝"(本地时钟漂移小、无跳变),异步系统模型下该假设不可靠。

12 · fencing token 是什么?怎么落地?

递增令牌资源端校验

每次获锁获得单调递增令牌,资源存储记录见过的最大 token,旧令牌的写入直接拒绝。落地:etcd revision、DB 自增版本号、Snowflake 单调 ID。本质是把锁的正确性从"锁本身"转移到"资源端校验"。

13 · antirez 怎么反驳?官方现在什么立场?

问题非 Redlock 独有场景定位不同

反驳:暂停问题对任何租约锁(含 ZK)都存在;时钟假设运维可控;fencing token 需要线性一致存储发号——有它就不需要 Redlock。官方文档 disclaimer 兼听两家:建议实现 fencing tokens、承认 TTL 不用单调时钟的风险。

14 · Redis 锁 vs ZooKeeper/etcd 怎么选?

AP vs CP性能差一个量级

Redis:AP、性能最高、TTL 租约、互斥是概率性——效率锁首选。ZK:CP、临时顺序节点 + 会话超时、公平排队、吞吐低。etcd:CP、lease + revision(天然 fencing token)、K8s 标配。强互斥低频控制面选 ZK/etcd,高并发短临界区选 Redis。

15 · 不用分布式锁怎么保证互斥/幂等?

唯一约束乐观锁状态机

DB 唯一约束(INSERT IGNORE/ON DUPLICATE KEY)防重复提交;乐观锁版本号 UPDATE ... WHERE version=?;状态机条件更新(WHERE status='pending');消费幂等用去重表占位 bizId。原子性下沉到存储层,锁是最后的手段。

16 · 什么场景应该直接放弃 Redis 锁?

正确性敏感资损边界

资金扣减、库存精确防超卖、配额强约束——双持即资损的场景:用 DB 事务 + 约束/条件更新,或 ZK/etcd。另外临界区需要长事务/跨服务协作时,Redis 锁的 TTL 语义也不合适——改用业务状态机推进。

架构这组是拉分题。第九题报官方原话 SAFETY VIOLATION。第十题 Redlock 五步加四个约束一次说全。第十一十二题是论战核心:两个攻击点加 fencing token 的落地方式。第十三题给出 antirez 立场和官方 disclaimer 的兼容态度,体现你读过原文而不是二手转述。第十五十六题反向收尾:多数锁场景可下沉到存储层原子性,资损场景直接换系统——这个判断比任何锁技巧都值钱。

Related · Cross-links

相关知识点

Redis 领域 · 同批 deck

cache-patterns · 缓存三大问题与双写一致性(击穿互斥锁 = 本 deck SET NX 的直接应用)
replication-cluster · 主从/哨兵/Cluster(锁双持的根源:异步复制 + failover 丢写)
memory-policy · 过期删除与内存淘汰(锁 key 的 TTL 过期由同套机制执行)
thread-model · 单线程模型(Lua 脚本原子性的执行模型依据)

跨领域 · 答题串联

MySQL 锁体系:悲观锁/乐观锁对照——分布式锁是应用层悲观锁,版本号 CAS 是应用层乐观锁
通用线索:租约(lease)语义(TTL/会话/lease 三家同构)、多数派仲裁(Redlock/ZAB/Raft)、fencing token(etcd revision 同源)

收尾串联:缓存击穿的互斥锁就是本 deck 的 SET NX 应用,锁双持的根源在主从复制那本 deck 的异步复制与丢写窗口。跨领域对照 MySQL 锁体系:分布式锁是应用层悲观锁,版本号 CAS 是应用层乐观锁,思路同源。

References

参考来源

本 deck 全部结论可溯源至下列一手材料——数字与版本结论以原文为准。

redis.io/docs/latest/commands/set/NX/PX 选项(2.6.12 起,取代 SETNX/SETEX)、Patterns 章节:token + check-and-delete Lua 脚本原文、"discouraged in favor of Redlock"
redis.io/docs/latest/develop/clients/patterns/distributed-locks/三性质定义、failover race(SAFETY VIOLATION 原文)、单实例锁 viable 定位、Redlock 五步/时钟假设/延迟重启/续期限制、DELEX IFEQ(8.4)、Disclaimer(fencing tokens / monotonic clock)
martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.htmlKleppmann 对 Redlock 的批评:GC 停顿、时钟假设、fencing token 必要性
antirez.com/news/101(Is Redlock safe?)antirez 反驳:问题非 Redlock 独有、时钟运维可控、fencing token 需线性一致存储、效率/正确性场景分离
Redisson 官方文档(locks-and-synchronizers / Config Javadoc)lockWatchdogTimeout 默认 30000ms、按 1/3 周期续期、指定 leaseTime 关闭看门狗;RLock 的 hash+HINCRBY 重入结构
github.com/redis/redis 8.4 release notesDELEX IFEQ/IFDNE 条件删除命令(替代 Lua check-and-del)
参考页里两个 URL 建议真的点开读一遍:Kleppmann 和 antirez 的原文都不长,读完你会发现面试官的追问基本都在这两篇文章的分歧点里。8.4 的 DELEX IFEQ 是最新的官方命令,报出来说明你跟进版本演进。