Theory · Redis · Distributed Lock
从 SETNX 的三个坑到 Redlock 之争 —— 演进式纠错是这道题的正确打开方式
SETNX+EXPIRE 两步不原子 → SET NX PX 原子 → 唯一值 + Lua 校验删除——每一步都对应一个真实事故
主从异步复制下锁可双持(官方原文 SAFETY VIOLATION);Redlock 与 Kleppmann 的 fencing token 之争
常规业务单实例锁 + TTL + 唯一值 + 看门狗;锁"正确性"换 ZooKeeper/etcd/DB——能不用锁就不用锁
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 互斥,必须有一个所有实例都能看到的公共记号——这就是分布式锁存在的唯一理由。
t0 Pod-A 拿到自己的 mu,读到 stock=1
t1 Pod-B 拿到自己那把 mu(互不阻塞),也读到 stock=1
t2 A 判断 >0,写回 0,返回"下单成功"
t3 B 手里还是 t1 那个旧值 1,判断 >0,也写回 0,也返回"成功"
→ 两单,只扣了一件库存。并发越高,重叠的窗口越多,超卖越多。
并不是——这也是这道题区分度极高的原因:最直觉的写法(SETNX 再 EXPIRE)本身就是错的,改对之后还会依次撞上"删掉别人的锁""业务没跑完锁先过期""主从切换后两个人同时持锁"三个坑。每一个坑都对应过真实的线上事故。
先立"好锁"的判定标准 → 三版演进把简单实现修到生产可用(原子加锁 → 唯一值 + Lua → 看门狗续期)→ 再暴露架构级漏洞(主从异步复制)→ Redlock 与 Kleppmann 之争 → 最后给选型结论:什么时候可以用 Redis 锁,什么时候必须换武器。
Redis 锁给的是"大概率互斥",不是"绝对互斥"。所以它适合"重复执行只是浪费"的场景;一旦"重复执行就是资损",答案不是把锁写得更花,而是换掉锁。
Prerequisites & Glossary
| 术语 | 一句话理解(细节后面展开) |
|---|---|
| 临界区 | 同一时刻只允许一个执行者进入的那段逻辑(如"读库存 → 判断 → 扣减") |
| 原子性 | 一组动作"要么全做完,要么全不做",中间不会被别人插进来看到半成品 |
| 租约(lease) / TTL | 锁不是"永久持有",而是租一段时间:到期自动作废。这样持有者崩溃了也不会永久堵死 |
| token(唯一值) | 加锁时写进 key 的一串随机数,用来证明"这把锁是我的",释放前要先核对 |
| Lua 脚本 | Redis 会整段不间断地执行一段 Lua——用它把"核对 + 删除"两步捆成一个原子操作 |
| 看门狗 (watchdog) | 持锁期间后台每隔一段时间悄悄把租期续上,防止业务还在跑锁却过期了 |
| 可重入 | 同一个持有者可以对同一把锁反复加锁而不自锁(内层方法也要加同一把锁时需要它) |
| 异步复制 / 故障转移 | 主库写完立刻返回、之后才把数据传给从库;主库挂了从库被提升为新主——没传过去的写就丢了 |
| 多数派 (quorum) | N 个节点里超过一半(N/2+1)达成一致才算成功——Redlock 的核心手段 |
| fencing token (栅栏令牌) | 每次获锁附带一个只增不减的编号,资源那一端拒绝编号更小的写入,用来兜住"锁已易主但我不知道" |
主从复制与故障转移 → 第 9 页那个"锁双持"漏洞的架构背景
缓存三大问题 → 击穿的互斥重建就是本 deck 这把锁的典型用法
死锁 → "锁"的通用语义、四个必要条件与预防手段
InnoDB 锁体系 → 最后一页"不用锁的替代"里唯一约束与乐观锁的底层
效率锁:加锁只为"别重复干活",偶尔两个人同时干也不出错(定时任务防重、缓存重建)。
正确性锁:两个人同时干就是资损(扣款、精确扣库存)。
Redis 锁的全部争议,都在于它只适合前者——第 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 页) |
Evolution · Step 1
// 错误示范:加锁是两条命令 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 把两步打包。
SET key value NX PX 30000 一条命令解决——这也是官方文档推荐的标准姿势。"Evolution · Step 2
① 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) |
Evolution · Step 3 · The Canonical Form
// 官方解锁脚本(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 与共识之争。
Watchdog · Lock Granularity
TTL 设短 → 业务没跑完锁先过期 → 他人进入临界区(互斥破坏);TTL 设长 → 持有者崩溃后其他客户端等锁时间变长(可用性损失)。业务耗时天然波动(DB 抖动、GC),静态 TTL 无法两全。
持有锁期间后台定时续期: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 |
Reentrant Lock · Hash Structure
临界区内方法层层调用又对同一把锁加锁:不可重入就自己锁死自己(自等待)。单机锁天然可重入(ReentrantLock/sync),分布式锁需显式实现。
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
Failover Race · Safety Violation
Redlock Algorithm
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 倍资源买互斥概率 |
The Great Debate · 2016
① 进程暂停/GC 停顿:持锁客户端停顿超过 TTL,恢复后不知道锁已易主,继续写共享资源——锁没有补救手段;② 时钟假设:Redlock 的安全性依赖"时间大致均匀流逝"(时钟跳变、leap second 都可能双持)——在异步系统模型下不可接受;③ 正解是 fencing token:每次获锁拿递增令牌,存储层拒绝旧令牌的写——但这需要线性一致的存储来发号,若已有它,锁的必要性本身存疑。
① 进程暂停问题对任何基于租约的锁(含 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;场景定位不同 |
Decision Framework
| 锁的性质 | 典型场景 | 推荐方案 | 兜底 |
|---|---|---|---|
| 效率锁(防重复) | 定时任务防并发、缓存重建互斥、防重复提交的辅助 | 单实例锁:SET NX PX + 唯一 token + Lua 释放 + 看门狗;主从+哨兵架构即可 | 业务幂等(重复执行无害);偶尔双持可容忍 |
| 正确性锁(错了就是资损) | 扣款、库存精确扣减、配额 | 不用 Redis 锁:DB 事务 + 唯一约束/条件更新;或 ZooKeeper/etcd | fencing token 思路:DB 自增版本号/etcd revision 做令牌,存储层校验拒绝旧令牌 |
| 确需 Redis 但要求更高互斥概率 | Redlock(5 独立实例)——注意它仍非严格安全 | 同上;接受极小概率竞态并监控 | |
| 读写组合 | 读多写少的共享配置/路由表 | 通常不需要锁:版本号 + 本地快照比对 | — |
锁服务发号递增 token → 资源存储记录"见过的最大 token" → 写入时 token 更小则拒绝。实践:用 etcd 的全局 revision、DB 的 AUTO_INCREMENT、或 Snowflake 单调 ID。这是把"锁的正确性"从锁转移到资源端校验——Kleppmann 方案的工程形态。
"Redis 锁的正确姿势 = SET NX PX 原子加锁 + 唯一值 + Lua 校验释放 + 看门狗续期;它的互斥保证受异步复制与进程暂停限制,所以只用于效率锁;正确性场景用 DB 约束/乐观锁或共识系统,配合 fencing token。"常规业务单实例锁 + 容忍极小概率,是官方文档与社区实践的公约数。
Redis vs ZooKeeper vs etcd
| 维度 | 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 的中小规模协调 |
Lock-free Alternatives
防重复提交:唯一索引 + INSERT IGNORE / ON DUPLICATE KEY。重复请求第二次插入必然失败——互斥语义由存储引擎保证,无需任何锁。分布式锁防重的场景九成可换成它。
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/PX | 2.6.12 起支持,可取代 SETNX/SETEX;8.4 起有 DELEX ... IFEQ |
| Redisson 看门狗 | lockWatchdogTimeout 默认 30000ms,每 1/3(约 10s)续回 30s;指定 leaseTime 则关闭 |
| 无看门狗时 TTL | 业务耗时 P99 × 2~3,并接受"崩溃后等锁变慢" |
| Redlock | N=5 个互不复制的实例;多数派 ≥3 且总耗时 < TTL;有效期 = TTL − 耗时 − 漂移;崩溃实例延迟重启 ≥ 最大 TTL |
| 锁粒度 | lock:order:{id} 而非 lock:order;慢 IO 移出临界区 |
Interview QA · 1/2
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;这一组都按"缺陷 → 修复 → 残留风险"三段答。
两条命令之间客户端可能崩溃/断连:锁已写入但过期时间没设 → 永不释放的死锁,其他客户端全部阻塞。2.6.12 起用 SET key val NX PX 30000 一条命令原子完成。
持锁超时后锁过期被 B 获取,A 恢复后 DEL 会删掉 B 的锁 → 第三人再进入 → 多重持锁。加锁写入不可猜测的随机 token,释放时 Lua 校验值匹配才删——官方 SET 文档的 Patterns 原方案。
GET 与 DEL 是两条命令:比较通过的瞬间锁可能恰好过期易主,随后的 DEL 删掉新持有者的锁。校验与删除必须在一个原子操作内——Lua 脚本(或 8.4 的 DELEX IFEQ)。
看门狗:持锁期间后台定时用 Lua 校验 token 后 PEXPIRE。Redisson 不指定 leaseTime 时默认 lockWatchdogTimeout=30s、每 1/3(约 10s)续期;指定 leaseTime 则看门狗关闭。
key 用 hash:field=持有者标识,value=重入计数。加锁:无则建档计数 1,field 匹配则 +1 并刷新 TTL。解锁:field 不匹配拒绝;匹配 −1,到 0 才 DEL。Redisson RLock 即此实现。
有看门狗:TTL = 续期周期(30s)。没有:业务耗时 P99 × 2~3,接受崩溃后等锁变长。等锁方:自旋 + 随机退避防惊群、带总超时、可用 pub/sub 订阅锁释放通知减少空转。
按最小资源单位加锁(lock:order:{orderId} 而非 lock:order),不同订单互不阻塞;临界区内只放必须互斥的操作,慢 IO(RPC、大查询、发消息)移出锁外——锁持有时间越短,冲突与持锁超时风险越小。
go-redis:rdb.SetNX(ctx, key, uuid, ttl) 成功后 defer 执行 check-and-del 的 Lua;需要续期/重入/Redlock 时用 go-redsync。要点:token 用 UUID、释放失败不重试加锁、监控行锁等待时长。
Interview QA · 2/2
A 在 master 加锁成功 → 锁未同步到 replica 时 master 宕机 → replica 晋升后无此锁 → B 在新主加锁成功 → A、B 双持。官方文档明确称 "SAFETY VIOLATION"。WAIT 半同步也救不了(不保证 failover 不丢已确认写)。
并行向 N=5 个互不复制的 master SET NX PX(短连接超时);获多数且总耗时 < TTL 才成功,有效期 = TTL − 耗时 − 漂移;失败向全部实例解锁;重试随机延迟;实例崩溃后延迟重启 ≥ max TTL;续期次数有限。
① GC 停顿/调度暂停超过 TTL:客户端恢复后不知锁已易主,继续写共享资源——唯一补救是 fencing token;② Redlock 安全性依赖"时间均匀流逝"(本地时钟漂移小、无跳变),异步系统模型下该假设不可靠。
每次获锁获得单调递增令牌,资源存储记录见过的最大 token,旧令牌的写入直接拒绝。落地:etcd revision、DB 自增版本号、Snowflake 单调 ID。本质是把锁的正确性从"锁本身"转移到"资源端校验"。
反驳:暂停问题对任何租约锁(含 ZK)都存在;时钟假设运维可控;fencing token 需要线性一致存储发号——有它就不需要 Redlock。官方文档 disclaimer 兼听两家:建议实现 fencing tokens、承认 TTL 不用单调时钟的风险。
Redis:AP、性能最高、TTL 租约、互斥是概率性——效率锁首选。ZK:CP、临时顺序节点 + 会话超时、公平排队、吞吐低。etcd:CP、lease + revision(天然 fencing token)、K8s 标配。强互斥低频控制面选 ZK/etcd,高并发短临界区选 Redis。
DB 唯一约束(INSERT IGNORE/ON DUPLICATE KEY)防重复提交;乐观锁版本号 UPDATE ... WHERE version=?;状态机条件更新(WHERE status='pending');消费幂等用去重表占位 bizId。原子性下沉到存储层,锁是最后的手段。
资金扣减、库存精确防超卖、配额强约束——双持即资损的场景:用 DB 事务 + 约束/条件更新,或 ZK/etcd。另外临界区需要长事务/跨服务协作时,Redis 锁的 TTL 语义也不合适——改用业务状态机推进。
Related · Cross-links
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 同源)
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.html | Kleppmann 对 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 notes | DELEX IFEQ/IFDNE 条件删除命令(替代 Lua check-and-del) |