Redis(远程字典服务)分布式锁全景
本章不把“拿到一个键”当成分布式锁的全部。真正要回答的是:保护谁、在哪个故障模型下保持什么性质、旧持有者恢复后谁阻止它继续写、业务副作用如何幂等、锁服务失效时系统选择正确性还是可用性。结论先行:Redis(远程字典服务)锁适合低延迟协调和削峰,但不能独立承担资金、库存与任务状态的最终正确性。

1. 从进程内互斥推导分布式锁
1.1 JVM(Java 虚拟机)锁、数据库锁、Redis(远程字典服务)锁与 Zookeeper(分布式协调服务)锁
进程内 synchronized(同步锁)或 Lock(锁接口)只协调同一 JVM(Java 虚拟机)内共享内存;服务水平扩展后,同一订单可能被不同机器同时处理,本地锁彼此不可见。数据库行锁把互斥与权威数据事务放在一起,正确性边界清楚,但长事务会占连接、放大死锁与锁等待。Redis(远程字典服务)锁延迟低、吞吐高、容易设置租约,代价是复制和故障切换默认不是共识提交。Zookeeper(分布式协调服务)锁利用临时顺序节点和会话语义,通常更偏一致性协调,但延迟、运维和惊群治理成本更高。
flowchart LR
RQ["并发请求"] --> S{"共享资源位于哪里"}
S -->|"单进程内存"| J["JVM(Java 虚拟机)锁"]
S -->|"数据库权威行"| D["数据库条件更新或行锁"]
S -->|"跨实例短临界区"| R["Redis(远程字典服务)租约锁"]
S -->|"强协调与顺序排队"| Z["Zookeeper(分布式协调服务)临时顺序节点"]
R --> G["幂等、状态机、条件更新兜底"]
Z --> G| 方案 | 保护范围 | 故障后的所有权依据 | 主要优势 | 主要边界 | 典型场景 |
|---|---|---|---|---|---|
| JVM(Java 虚拟机)锁 | 单进程共享对象 | 进程存活与线程状态 | 纳秒到微秒级、语义丰富 | 跨进程无效 | 单实例缓存刷新 |
| 数据库锁或条件更新 | 权威数据行与事务 | 数据库事务日志和锁管理器 | 约束与事务同源 | 连接、锁等待、死锁 | 库存扣减、订单状态迁移 |
| Redis(远程字典服务)锁 | 跨实例短临界区 | 键、持有者令牌与租约 | 低延迟、高吞吐 | 异步复制切换可能丢锁 | 防重复重建、任务抢占 |
| Zookeeper(分布式协调服务)锁 | 跨实例协调 | 会话、临时顺序节点与法定提交 | 顺序、公平和会话语义较清楚 | 延迟与运维成本较高 | 主节点选举、强协调任务 |
数据演绎 1:本地锁失效。 两台 WMS(仓储管理系统)实例各收到同一 SKU(库存单位)扣减请求,实例 A 的本地锁对象地址为 0xA1,实例 B 为 0xB7。两个线程都能进入各自临界区,先读到可用库存 1,再各自生成出库动作;本地锁完全正确,却保护错了边界。若数据库执行 UPDATE stock SET available=available-1 WHERE sku=? AND available>=1,只有一个请求影响 1 行,另一个影响 0 行,权威约束才真正阻止超卖。
热门面试题
问题(基础题):为什么服务部署多实例后 synchronized(同步锁)不能防止超卖?
- 考点:锁的可见范围与保护对象。
- 回答思路:先定位锁状态存放在哪里,再定位库存事实存放在哪里。
- 详细答案:synchronized(同步锁)的监视器状态属于单个 JVM(Java 虚拟机)内的对象头和监视器结构,不同进程拥有不同堆与锁对象。两个实例即使锁键字符串相同,也没有共享同一个监视器,所以都能进入临界区。库存正确性应由数据库条件更新、唯一约束或原子脚本落地,分布式锁只能减少竞争。
- 进阶追问:单实例时本地锁是否就足够?
- 进阶回答:还要考虑进程重启、异步消费者、定时任务和人工补偿是否走同一入口;只要存在旁路,本地锁就不能覆盖全部写路径。
问题(原理题):数据库行锁与 Redis(远程字典服务)锁如何选择?
- 考点:权威数据、事务边界和性能代价。
- 回答思路:从正确性位置、临界区长度和失败恢复判断。
- 详细答案:若操作最终就是修改一行权威数据,优先用数据库条件更新或短事务行锁,让判断和写入处在一个事务中。若要协调跨实例的缓存重建、短任务抢占或昂贵计算,可用 Redis(远程字典服务)锁削减并发,但最终落库仍需版本号、状态机或唯一键验证。不能为省一次数据库竞争而把最终约束移到异步复制锁服务。
- 进阶追问:数据库锁一定比 Redis(远程字典服务)锁安全吗?
- 进阶回答:取决于数据库部署、隔离级别和写路径;错误的长事务、无索引更新或读写分离同样会失败,但权威约束与事务同源通常更易证明。
问题(项目追问题):什么场景更适合 Zookeeper(分布式协调服务)锁?
- 考点:一致性协调、顺序等待和会话失效。
- 回答思路:对比低延迟缓存锁与强协调需求。
- 详细答案:当业务更看重严格协调、可观察的排队顺序、会话断开后临时节点自动消失,并且能接受更高延迟时,可考虑 Zookeeper(分布式协调服务)。例如少量控制面主任务选举。高频库存请求不应让所有请求排队持锁,而应使用条件更新和分片削峰。
- 进阶追问:临时节点删除后旧进程是否绝对不会继续执行?
- 进阶回答:不会。旧进程可能经历长暂停而不知道会话已失效,恢复后仍可调用下游,因此仍需栅栏令牌或下游状态检查。
2. 锁的正确性模型与失败预算
2.1 互斥、所有权、租约、进展性与业务不变量
一把工程可用的分布式锁至少要区分五个命题:同一时刻是否至多一个有效持有者;只有持有者能否释放;持有者崩溃后是否最终可恢复;等待者是否会永久饥饿;获得锁后产生的业务副作用是否仍满足不变量。租约用时间限制资源占用,解决的是失联后的可恢复性,却引入“客户端认为仍持有、服务端已经过期”的知识差。网络分区和进程暂停下,互斥与持续可用往往不能同时无条件保证,必须明确偏向哪一边。
stateDiagram-v2
[*] --> Free: "锁不存在"
Free --> HeldA: "A 获取,租约 10 秒"
HeldA --> HeldA: "A 在有效期内续期"
HeldA --> Free: "A 校验所有权后释放"
HeldA --> Expired: "租约到期"
Expired --> HeldB: "B 获取新租约"
HeldB --> RejectedOldA: "下游拒绝 A 的旧栅栏令牌"
RejectedOldA --> HeldB| 性质 | 要回答的问题 | Redis(远程字典服务)基础租约能否单独保证 | 需要的补充机制 |
|---|---|---|---|
| 互斥 | 是否存在两个自认为有效的持有者 | 正常单节点路径可近似实现 | 拓扑故障分析、下游校验 |
| 所有权 | 非持有者能否释放 | 需要唯一令牌和原子比较删除 | Lua(脚本语言)原子脚本 |
| 可恢复 | 持有者崩溃是否永久死锁 | 过期时间可解决 | 合理租约、监控与补偿 |
| 进展性 | 等待者是否最终获得机会 | 不保证公平 | 有界重试、随机退避或公平队列 |
| 业务安全 | 外部副作用是否只生效一次 | 不能 | 唯一键、幂等、状态机、栅栏令牌 |
数据演绎 2:租约与业务耗时预算。 某任务正常耗时 800 毫秒,99.9 分位为 4 秒,最长垃圾回收暂停 3 秒,网络尾延迟 1 秒。若租约只设 5 秒,4 秒业务加 3 秒暂停已超过租约,双持有者并非小概率理论问题。若盲目设 60 秒,进程崩溃后任务至少阻塞一分钟。更稳妥的做法是缩短单次原子操作、以 30 秒初始租约配合续期、设置 10 秒业务截止时间,并让下游用任务版本或栅栏令牌拒绝迟到写。
热门面试题
问题(基础题):分布式锁需要保证哪些性质?
- 考点:安全性与进展性的区别。
- 回答思路:从互斥、所有权、恢复、公平和业务不变量回答。
- 详细答案:最基本的是同一资源的有效临界区尽量互斥、只有持有者能释放、持有者失联后锁最终可回收。工程上还要考虑等待有界、重试不形成风暴,以及旧持有者恢复后不能覆盖新结果。最后一项通常不能只靠锁,需要下游版本检查、栅栏令牌与幂等约束。
- 进阶追问:安全性和可用性冲突时怎么选?
- 进阶回答:资金和库存优先拒绝不确定写;缓存重建可偏可用并接受重复计算。选择必须由业务损失模型决定。
问题(原理题):为什么租约既解决问题又制造问题?
- 考点:自动释放与客户端认知滞后。
- 回答思路:分别说明崩溃恢复和过期后继续执行。
- 详细答案:没有租约,持有者崩溃可能留下永久锁;有租约,服务端能自动回收。但客户端可能因暂停、网络分区或调度延迟错过续期,恢复时仍持有旧业务上下文,此时新客户端已经获得锁。租约解决资源占用,不证明旧客户端已经停止。
- 进阶追问:把租约设得足够长能否消除风险?
- 进阶回答:不能,只是降低概率并增加故障恢复时间;不可预测暂停和网络分区没有有限上界。
问题(项目追问题):如何为 Runner(执行器)任务确定租约?
- 考点:分位耗时、暂停预算和截止时间。
- 回答思路:用历史分布而不是平均值估算,并设计失效兜底。
- 详细答案:采集任务执行 99.9 分位、最长垃圾回收暂停、网络尾延迟和续期调度抖动,初始租约覆盖一次续期间隔的数倍。任务本身设置截止时间和可重入检查,长任务拆成带检查点的小步骤,下游更新附任务版本。这样即便租约失效,旧执行器的迟到结果也会被拒绝。
- 进阶追问:任务无法拆分怎么办?
- 进阶回答:把外部副作用设计成幂等提交,使用权威状态机和结果查询;无法隔离旧写时,不应只依赖时间租约。
3. 基础加锁协议
3.1 SET key(键) token(令牌) NX PX(键不存在时设置持有者令牌并设置毫秒租约)
基础协议必须用一条原子命令同时表达“不存在才写入”和“写入即带过期时间”。键标识业务资源,例如库存维度或任务编号;值不能只写线程名或固定常量,而应是每次获取尝试唯一的 owner token(持有者令牌),通常由进程标识、线程标识和高随机数共同构成。PX(设置毫秒过期)给出租约,但客户端拿到成功响应后还要记录截止时间;若响应超时,结果是未知而不是简单失败,重试前应查询是否由自己的令牌持有。
SET lock:stock:sku-1001 app-7:thread-21:7f3c... NX PX 10000sequenceDiagram
participant A as "客户端 A"
participant R as "Redis(远程字典服务)"
participant B as "客户端 B"
A->>R: "SET 锁键 A-唯一令牌 NX PX 10000"
R-->>A: "成功,租约开始"
B->>R: "SET 锁键 B-唯一令牌 NX PX 10000"
R-->>B: "失败,键已存在"
A->>R: "原子比较令牌并删除"
R-->>A: "释放成功"
B->>R: "随机退避后再次获取"
R-->>B: "成功"sequenceDiagram
participant C as "错误客户端"
participant R as "Redis(远程字典服务)"
C->>R: "SETNX(不存在则设置)锁键"
R-->>C: "1,设置成功"
C-xC: "进程在 EXPIRE(设置过期)前崩溃"
Note over R: "锁键永久存在,没有租约"| 字段 | 正确职责 | 常见错误 | 后果 |
|---|---|---|---|
| 锁键 | 唯一标识受保护资源 | 粒度过粗或遗漏租户 | 无谓串行或越权冲突 |
| 持有者令牌 | 区分每一次获取尝试 | 使用固定服务名 | 旧请求可能删除新锁 |
| NX(不存在才设置) | 保证不存在时才建立租约 | 先读再写 | 检查与写入间竞态 |
| PX(设置毫秒过期) | 与写入原子建立租约 | 后置 EXPIRE(设置过期) | 中途崩溃形成永久锁 |
| 客户端截止时间 | 避免在剩余时间不足时开工 | 只信服务端成功响应 | 临界区刚开始锁就到期 |
数据演绎 3:未知结果。 客户端在 12:00:00.000 发送加锁,服务端 2 毫秒内成功写入,但响应包在网络中丢失;客户端 100 毫秒后超时。若它直接生成新令牌重试,只会看到获取失败,可能把自己的第一把锁误判为他人持有并空等 9.9 秒。客户端应保留本次令牌,查询当前值并结合本地状态决定继续、释放或等待;更重要的是业务调用必须可幂等,因为任何网络超时都可能是“已执行但未收到响应”。
热门面试题
问题(基础题):为什么不能用 SETNX(不存在则设置)再 EXPIRE(设置过期)?
- 考点:跨命令原子性。
- 回答思路:指出两条命令之间的崩溃窗口。
- 详细答案:SETNX(不存在则设置)成功后,客户端可能在 EXPIRE(设置过期)执行前宕机或断网,锁键没有租约,会永久阻塞其他请求。把 NX(不存在才设置)和 PX(设置毫秒过期)放进同一条 SET(设置键值)命令,才能让建立所有权与建立租约原子完成。
- 进阶追问:用事务把两条命令包起来可以吗?
- 进阶回答:可以减少命令间窗口,但仍要正确处理执行结果和版本语义;直接使用单命令更简单,故障面更小。
问题(原理题):为什么锁值必须唯一?
- 考点:所有权识别与迟到释放。
- 回答思路:构造 A 过期、B 获取、A 再释放的时序。
- 详细答案:锁键只说明资源当前被占用,唯一令牌才说明由哪一次获取持有。A 的租约过期后 B 获得新锁,A 恢复并执行释放;若值固定,A 无法区分新旧持有者,会删除 B 的锁。每次获取都生成高熵唯一令牌,释放时原子比较,才能阻止迟到释放。
- 进阶追问:只用线程编号是否足够?
- 进阶回答:不够,线程编号可能复用,跨进程也可能相同;应组合实例唯一标识与每次请求随机值。
问题(项目追问题):获取锁超时后能否直接返回失败?
- 考点:分布式调用的未知结果。
- 回答思路:区分明确失败、明确成功和结果未知。
- 详细答案:网络超时不证明服务端未执行。客户端应保留本次令牌,必要时读取锁值确认;业务侧还要用幂等键查询结果,防止加锁成功且业务已执行,只因响应丢失就重复扣款或重复扣库存。不能把传输失败直接映射为业务失败。
- 进阶追问:读取发现是自己的令牌就一定能继续吗?
- 进阶回答:还要检查剩余租约是否足以完成临界区;不足时应续期成功后继续,或放弃并进入补偿。
4. 原子释放、续期与租约失效
4.1 Lua(脚本语言)比较删除、比较续期与暂停窗口
释放必须把“读取当前值、比较持有者令牌、删除键”放进同一个 Lua(脚本语言)脚本中执行。先 GET(读取键值)再 DEL(删除键)存在竞态:读取后锁可能过期并被新持有者获得,旧客户端随后删除新锁。续期同理,只能在当前值仍等于自己的令牌时延长租约;续期成功证明脚本执行时仍持有,不证明脚本返回后未来一直持有。客户端在严重暂停后恢复,应把自己视为已经失锁,停止产生副作用,而不是先盲目续期。
-- 原子释放:只有仍为当前持有者才能删除
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0-- 原子续期:只有仍为当前持有者才能延长租约
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
end
return 0sequenceDiagram
participant A as "旧客户端 A"
participant R as "Redis(远程字典服务)"
participant B as "新客户端 B"
A->>R: "GET(读取键值)看到令牌 A"
Note over A,R: "A 暂停,原租约到期"
B->>R: "获取成功,写入令牌 B"
A->>R: "DEL(删除键)"
Note over R: "错误删除 B 的有效锁"sequenceDiagram
participant A as "客户端 A"
participant R as "Redis(远程字典服务)"
participant B as "客户端 B"
A->>R: "获取 9 秒租约"
A->>A: "暂停 12 秒"
R->>R: "9 秒时租约过期"
B->>R: "10 秒时获取新锁"
R-->>B: "成功"
A->>A: "12 秒时恢复"
A->>R: "比较续期"
R-->>A: "失败:令牌已不是 A"
Note over A: "必须停止并让下游拒绝旧写"| 操作 | 原子条件 | 成功含义 | 失败后的动作 |
|---|---|---|---|
| 释放 | 当前值等于自己的令牌 | 删除了自己仍持有的锁 | 不得删除,记录失锁 |
| 续期 | 当前值等于自己的令牌 | 在该时刻延长租约 | 立即停止新副作用 |
| 临界区提交 | 下游版本或栅栏令牌仍有效 | 本次状态迁移被权威端接受 | 查询结果或进入补偿 |
| 客户端超时 | 无法直接判断 | 结果未知 | 查询所有权与业务结果 |
数据演绎 4:续期调度。 初始租约 30 秒,每 10 秒续期。0 秒获得锁,10 秒与 20 秒续期成功;21 秒进程因宿主机暂停 25 秒,锁在 50 秒到期,46 秒恢复时尚有 4 秒,调度线程可能来得及续期,也可能因网络尾延迟失败。若暂停 35 秒,56 秒恢复时锁早已被新客户端持有。设计不能以“通常每 10 秒运行”证明安全,必须在执行副作用前检查取消信号,并由下游版本或栅栏令牌做最终拒绝。
热门面试题
问题(基础题):为什么释放锁要用 Lua(脚本语言)脚本?
- 考点:比较与删除的原子性。
- 回答思路:用读取后过期并被重获的窗口解释。
- 详细答案:释放必须先确认当前值还是自己的唯一令牌,再删除。若拆成 GET(读取键值)和 DEL(删除键),两条命令之间原锁可能到期并由新客户端获得,旧客户端会删掉新锁。Lua(脚本语言)脚本在服务端把比较和删除作为一次不可插入的执行单元。
- 进阶追问:脚本执行超时怎么办?
- 进阶回答:客户端结果未知,不能重复执行任意副作用;比较删除本身是幂等的,可查询键值确认,同时监控慢脚本对事件循环的阻塞。
问题(原理题):续期成功是否说明业务一定安全?
- 考点:时点证明与未来状态。
- 回答思路:区分续期脚本执行时和后续提交时。
- 详细答案:续期成功只说明脚本执行瞬间锁值仍属于该客户端并延长了期限。返回之后仍可能发生网络分区、进程暂停或故障切换。尤其外部数据库写入发生在更晚时刻,必须由数据库条件、状态机或栅栏令牌再次验证,不能把一次续期当成永久授权。
- 进阶追问:每次数据库写前都续期可以吗?
- 进阶回答:能缩小风险,但仍有续期返回到数据库提交之间的窗口;最终应由下游原子校验关闭窗口。
问题(项目追问题):业务执行超过租约时该怎么办?
- 考点:截止时间、可取消任务和补偿。
- 回答思路:先阻止继续产生副作用,再判断结果与补偿。
- 详细答案:客户端检测到续期失败或剩余时间不足后,应停止领取新工作并取消可取消步骤;不可中断的外部调用使用幂等键,返回后查询结果。数据库提交带版本条件或栅栏令牌,失败则读取新状态,不覆盖新持有者结果。长任务拆检查点,记录进度后由新持有者接续。
- 进阶追问:直接延长到一小时是否更简单?
- 进阶回答:会把崩溃恢复阻塞拉长一小时,并仍无法消除超过一小时或暂停后迟到写的问题。
5. Redisson(Redis 客户端)可重入锁实现
5.1 Hash(哈希)持有者、重入计数与 Pub/Sub(发布订阅)唤醒
Redisson(Redis 客户端)不是把普通字符串锁简单包装一层。常见版本的可重入锁把锁名对应到 Hash(哈希)结构,字段通常组合客户端实例标识和线程标识,字段值是重入计数。首次获取时创建字段并设置租约;同一持有者再次进入时把计数加一并刷新租约;每次解锁减一,减到零才删除锁键并通过 Pub/Sub(发布订阅)通知等待者。等待者收到通知只代表“状态可能变化”,仍必须重新执行原子获取,通知丢失也应由租约和等待超时兜底。
sequenceDiagram
participant T as "线程 A"
participant R as "Redisson(Redis 客户端)"
participant S as "Redis(远程字典服务)"
participant W as "等待线程 B"
T->>R: "第一次 lock(加锁)"
R->>S: "原子创建 Hash(哈希)字段,计数 1"
T->>R: "同线程再次 lock(加锁)"
R->>S: "同字段计数增至 2"
W->>S: "获取失败并订阅释放频道"
T->>R: "第一次 unlock(解锁)"
R->>S: "计数减至 1,不通知"
T->>R: "第二次 unlock(解锁)"
R->>S: "计数归零、删键并发布通知"
S-->>W: "状态变化通知"
W->>S: "重新竞争获取"| 机制 | 服务端状态 | 解决的问题 | 不能保证的事情 |
|---|---|---|---|
| Hash(哈希)字段 | 客户端与线程组合标识 | 区分持有者 | 故障切换不丢键 |
| 重入计数 | 正整数计数 | 同线程嵌套进入 | 跨线程自动继承所有权 |
| Pub/Sub(发布订阅) | 临时通知频道 | 减少固定轮询 | 通知持久化与公平顺序 |
| Lua(脚本语言) | 获取、解锁、续期脚本 | 单节点原子状态变更 | 跨节点共识与外部事务 |
数据演绎 5:重入与释放。 线程 A 首次获取后计数为 1,业务方法调用同类内部方法再次加锁,计数变 2。内层 finally(最终清理)只把计数减到 1,线程 B 仍不能进入;外层 finally(最终清理)减到 0,键删除并发通知。若外层因异常漏解锁,租约最终回收;若显式租约 60 秒而任务仅需 2 秒,遗漏解锁会制造接近一分钟的业务停顿,因此自动释放不是忽略 finally(最终清理)的理由。
热门面试题
问题(基础题):Redisson(Redis 客户端)可重入锁如何记录重入次数?
- 考点:Hash(哈希)持有者字段与计数。
- 回答思路:说明锁键、字段和字段值三层含义。
- 详细答案:锁名对应 Redis(远程字典服务)中的 Hash(哈希)键,字段标识某客户端实例的某线程,字段值保存重入次数。同一字段再次加锁时计数递增;解锁递减,只有归零才删除键。具体字段格式和脚本应以项目使用的 Redisson(Redis 客户端)版本源码为准。
- 进阶追问:另一个线程能代替解锁吗?
- 进阶回答:通常不能,服务端脚本会验证持有者字段;错误线程解锁应得到非法监视器状态异常或失败结果,而不是删锁。
问题(原理题):为什么 Pub/Sub(发布订阅)通知不能保证公平?
- 考点:通知与所有权授予的区别。
- 回答思路:指出通知后所有等待者仍要重新竞争。
- 详细答案:释放通知只是告诉等待者锁状态可能改变,不携带持久化排队权。网络调度、线程唤醒和请求到达顺序都会改变竞争结果,晚来的请求可能更早到服务端。公平锁需要显式等待队列、顺序和失效等待者清理,成本更高。
- 进阶追问:通知丢失会永久等待吗?
- 进阶回答:正确实现还会依据返回的剩余租约、等待超时和重试再次检查;若只依赖一次通知,确实可能永久挂起。
问题(项目追问题):可重入特性适合哪些代码结构?
- 考点:嵌套调用与锁职责。
- 回答思路:给出同一业务调用链重复进入的场景和风险。
- 详细答案:同一线程的订单编排方法调用库存子方法,而两层都保护同一资源时,可重入能避免自锁。但更应检查职责是否重复、锁粒度是否过大。异步切线程后持有者身份改变,不应假定上下文自动重入,应重构临界区或显式传递业务令牌。
- 进阶追问:响应式或协程代码怎么办?
- 进阶回答:线程身份可能切换,要使用与执行模型匹配的接口和所有者语义,并把最终正确性放在业务版本而非线程编号上。
6. 看门狗、锁变体与客户端失效
6.1 watchdog(看门狗)续期、公平锁与读写锁
当调用未显式提供 leaseTime(租约时间)时,Redisson(Redis 客户端)常用 watchdog(看门狗)为仍在本进程持有的锁周期续期;常见默认锁看门狗超时是 30 秒,续期常在其约三分之一处调度,但具体值、调度实现和异常行为必须核对项目版本与配置。显式传入 leaseTime(租约时间)通常表示固定租约并关闭自动续期。watchdog(看门狗)只在客户端进程、调度线程、网络和目标主节点都能正常工作时有效;长暂停、事件循环饥饿、网络分区、进程崩溃或锁键切换都可能让续期失败。
flowchart TD
A["获取锁成功"] --> E{"是否显式给 leaseTime(租约时间)"}
E -->|"是"| F["固定租约,不依赖自动续期"]
E -->|"否"| W["登记 watchdog(看门狗)续期任务"]
W --> C{"持有者仍存在且连接可达"}
C -->|"是"| N["原子校验持有者并续期"]
N --> C
C -->|"否"| X["停止续期,等待过期"]
F --> X
X --> G["业务提交仍需状态机或栅栏令牌"]| 锁类型 | 核心语义 | 成本 | 易误用点 |
|---|---|---|---|
| 可重入锁 | 同持有者计数进入 | 服务端脚本与续期 | 跨线程误认为可重入 |
| 公平锁 | 尽量按等待顺序授予 | 排队状态、失效等待者清理、额外延迟 | 把“尽量公平”当严格实时顺序 |
| 读写锁 | 多读共享、写独占 | 状态更复杂,升级易死锁或饥饿 | 缓存读也误当权威一致读 |
| 固定租约锁 | 到期自动释放 | 需要准确执行预算 | 任务超时后旧持有者继续写 |
| watchdog(看门狗)锁 | 活跃持有者自动续期 | 调度、网络和服务端负担 | 把续期当绝对不会过期 |
数据演绎 6:续期容量。 2 万把活跃锁都使用 30 秒看门狗超时,约每 10 秒续期一次,平均产生 2000 次每秒续期脚本;若同一时刻批量获得,会形成续期尖峰。一次 12 秒宿主机暂停可能错过一轮调度但仍有机会恢复;超过 30 秒则锁可能全部到期。设计时要限制并发持锁数、打散调度、监控续期失败和剩余租约,并避免用长时间持锁代替任务队列。
热门面试题
问题(基础题):Redisson(Redis 客户端)看门狗何时生效?
- 考点:固定租约与自动续期边界。
- 回答思路:先区分是否显式指定租约,再说明版本核对。
- 详细答案:常见用法中,不显式指定 leaseTime(租约时间)时才登记 watchdog(看门狗)续期;显式指定后锁按固定时间到期。默认超时与续期间隔不能只背数字,必须核对当前 Redisson(Redis 客户端)版本、配置和所用锁类型。
- 进阶追问:看门狗线程挂了会怎样?
- 进阶回答:无法按时续期,租约到期后其他客户端可获得锁;原持有者必须检测失锁,且下游拒绝它的迟到写。
问题(原理题):公平锁为什么更慢?
- 考点:等待队列、顺序维护和失效清理。
- 回答思路:对比普通竞争和显式排队的服务端状态。
- 详细答案:公平锁需要登记等待顺序、唤醒队首、处理超时或宕机等待者,并防止后来请求插队。这增加脚本复杂度、网络交互和清理延迟。公平只在确有饥饿或顺序要求时使用,不应把吞吐型库存扣减全部排成公平长队。
- 进阶追问:公平锁能保证业务提交顺序吗?
- 进阶回答:只能约束获取顺序,持有者执行耗时、外部调用和事务提交仍可能改变最终完成顺序。
问题(项目追问题):读多写少能否直接用分布式读写锁?
- 考点:读共享收益与一致性边界。
- 回答思路:先确认读是否真的需要锁,再评估写饥饿和故障。
- 详细答案:若读取只是缓存查询,通常无需分布式读锁;若读取与跨资源写入必须形成短暂一致视图,读写锁可能有用,但会增加远程交互、写等待和失效恢复复杂度。权威数据一致快照更适合交给数据库事务或版本化读模型。
- 进阶追问:读锁升级写锁安全吗?
- 进阶回答:多个读者同时升级容易互相等待,应释放后重新竞争并重新验证版本,或直接设计为写锁路径。
7. 单机与主从复制下的锁
7.1 单机进程故障、异步复制与双持有者窗口
单机 Redis(远程字典服务)中,单线程命令执行能保证 Lua(脚本语言)脚本内部原子,但机器宕机时锁服务不可用;重启后持久化策略还可能决定锁键是否恢复。主从架构提升读冗余,不会自动增强锁写入的强一致性。典型风险是主节点接受锁 A 并返回,但复制流尚未到从节点,主节点立即故障,从节点被提升后看不到锁 A,于是客户端 B 获取同一资源,A 与 B 同时执行。
sequenceDiagram
participant A as "客户端 A"
participant M as "旧主节点 M"
participant S as "从节点 S"
participant B as "客户端 B"
A->>M: "获取锁 A"
M-->>A: "成功"
Note over M,S: "锁写入尚未异步复制"
M-xM: "故障"
S->>S: "提升为新主节点"
B->>S: "获取同一锁 B"
S-->>B: "成功"
Note over A,B: "出现双持有者,需下游栅栏或状态约束"| 拓扑 | 可用性 | 锁写入确认 | 关键故障 | 业务策略 |
|---|---|---|---|---|
| 单机 | 节点故障即不可用 | 单节点内存执行 | 宕机、重启恢复旧锁或丢锁 | 可降级,权威端保持正确 |
| 主从无自动切换 | 主故障需人工处理 | 默认异步复制 | 人工切换时丢未复制锁 | 先隔离旧主再恢复 |
| 使用 WAIT(等待副本确认) | 可确认若干副本收到偏移 | 不是共识提交 | 确认后副本仍可能未被选中 | 只缩小窗口,不宣称绝对安全 |
| 持久化增强 | 改善重启恢复 | 取决于刷盘策略 | 恢复旧租约或尾部丢失 | 锁键可丢弃,业务状态可重建 |
数据演绎 7:复制窗口。 主节点在偏移量 1050 写入锁 A 并返回,从节点只复制到 1049。故障后从节点提升,客户端 B 在新主写入锁 B。即使旧主 3 秒后恢复,它仍可能继续处理 A 的业务代码;如果网络隔离没有彻底阻止旧主写业务数据库,就会并发提交。WAIT(等待副本确认)可要求一个副本确认 1050,缩小窗口,但不能保证该副本一定成为新主,也不隔离旧客户端。
热门面试题
问题(基础题):Redis(远程字典服务)单线程为什么不能证明分布式锁绝对安全?
- 考点:单节点原子性与分布式故障模型。
- 回答思路:区分命令执行顺序和节点故障切换。
- 详细答案:单线程保证同一节点上命令和脚本按顺序执行,不会在脚本中间插入另一条命令。它不解决复制延迟、主节点故障、网络分区、客户端暂停和旧持有者继续执行。分布式锁安全性必须分析这些节点外部事件。
- 进阶追问:开启 AOF(追加日志)每次同步是否足够?
- 进阶回答:只能增强本机掉电恢复,不能让异步从节点同步成为共识,也不能阻止旧客户端迟到写。
问题(原理题):WAIT(等待副本确认)能否把锁变成强一致?
- 考点:复制确认与法定提交的区别。
- 回答思路:说明确认了什么、没有确认什么。
- 详细答案:WAIT(等待副本确认)能等待指定数量副本确认到达复制偏移量,降低未复制就切换的概率。但它不保证确认副本落盘、不保证故障时一定选该副本,也没有形成阻止旧主继续服务的共识提交,因此不能等价为线性一致锁。
- 进阶追问:那是否完全没价值?
- 进阶回答:有价值,可按延迟预算缩小数据丢失窗口,但业务表达必须保留失败边界和下游兜底。
问题(项目追问题):主从锁丢失时库存如何不超卖?
- 考点:锁优化与数据库最终约束。
- 回答思路:让 Redis(远程字典服务)锁削峰,让数据库决定结果。
- 详细答案:获取锁后仍执行数据库条件扣减,条件是可用量不小于扣减量,并检查影响行数。双持有者同时到达时只有满足条件的一方成功;失败方返回售罄或重读状态。订单号和库存流水用唯一键防止同一请求重复执行,异步补偿对账守恒关系。
- 进阶追问:那为什么还要锁?
- 进阶回答:锁可减少热点行竞争、下游调用和重复计算,但不是正确性的唯一来源;收益不足时可直接移除。
8. 哨兵切换与脑裂
8.1 故障检测、切换时延、旧主隔离与客户端重连
哨兵负责发现故障、形成客观下线判断、选出领导者并提升从节点,它解决的是自动故障转移,不是同步复制协议。切换期间客户端可能仍连旧主、连接新主失败或重试;网络分区中的旧主若仍接受写,会形成双主窗口。min-replicas-to-write(最少可写副本)等保护可以在副本不足时拒绝旧主写入,降低脑裂风险,但会牺牲可用性,也无法阻止已经获得锁的业务线程继续调用数据库。
flowchart LR
C1["客户端 A 与旧主同侧"] --> OM["旧主节点"]
OM -."网络分区".- S["哨兵法定侧"]
S --> NM["从节点提升为新主"]
C2["客户端 B"] --> NM
OM -->|"若仍可写"| LA["旧锁 A"]
NM -->|"未复制旧锁"| LB["新锁 B"]
LA --> DB["下游权威数据库"]
LB --> DB
DB --> F["唯一键、状态机、栅栏令牌拒绝冲突"]| 故障阶段 | 可能现象 | 锁风险 | 保护手段 |
|---|---|---|---|
| 主观下线判断 | 部分哨兵看不到主节点 | 客户端仍在旧主工作 | 请求截止时间、失锁检测 |
| 客观下线与选举 | 切换尚未完成 | 获取请求超时并重试 | 幂等重试、随机退避 |
| 从节点提升 | 新主缺少最后写入 | 同一锁再次获得 | 副本确认仅缩窗、下游约束兜底 |
| 旧主孤岛 | 仍可能接受写 | 脑裂双持有者 | 最少副本保护、网络隔离 |
| 客户端拓扑刷新 | 部分连接仍指旧主 | 读写分叉 | 客户端事件监听与连接治理 |
数据演绎 8:切换时间线。 0 秒客户端 A 获得 30 秒锁;2 秒旧主与哨兵法定侧分区,锁尚未复制;7 秒哨兵完成切换,客户端 B 在新主获得锁;12 秒 A 的业务线程仍能访问数据库并尝试提交。即使 30 秒时旧锁自然过期,7 到 12 秒已经存在并发执行窗口。只有数据库状态机从“待处理”条件迁移到“处理中”,或令牌 42 拒绝令牌 41,才能关闭旧线程的写入。
热门面试题
问题(基础题):哨兵模式下分布式锁会不会丢?
- 考点:自动切换与异步复制。
- 回答思路:直接给出主节点未复制即故障的时序。
- 详细答案:会。锁写入在旧主成功但尚未复制时,旧主故障,从节点提升后没有该锁,新客户端可再次获得。哨兵让服务更快恢复,不把最后写入变成法定提交,因此锁仍需业务兜底。
- 进阶追问:提高切换时间能否等复制完成?
- 进阶回答:只能降低特定故障概率,真正故障可能发生在任意复制窗口;延长切换还会降低可用性。
问题(原理题):最少副本写保护如何影响脑裂?
- 考点:一致性与可用性交换。
- 回答思路:说明旧主失去足够副本后拒绝写。
- 详细答案:配置主节点只有在足够副本且延迟可接受时才写,可让孤立旧主更可能拒绝新的加锁和续期,缩小双主窗口。但法定侧不足时正常主也可能拒写;而已经在客户端执行的旧临界区不会被自动终止。
- 进阶追问:为什么仍需要栅栏令牌?
- 进阶回答:因为服务端拒绝续期不等于旧业务线程停止,栅栏令牌由真正资源端拒绝旧授权。
问题(项目追问题):切换期间客户端重试怎样避免放大故障?
- 考点:未知结果、退避和业务幂等。
- 回答思路:限制重试预算并查询权威状态。
- 详细答案:每次调用带稳定业务幂等键,超时后先查询订单或任务状态;获取锁采用有界次数、指数加随机退避,不让所有线程同时重连。达到预算后快速失败或入队,保护新主和数据库。恢复后按幂等键补偿,不用无限重试掩盖拓扑故障。
- 进阶追问:能否切到数据库锁降级?
- 进阶回答:可以预先设计,但要限流并验证数据库容量;不能在事故中临时把全部热点流量压到权威库。
9. Cluster(集群)分片与网络分区
9.1 锁键槽位、分片主从切换与多资源原子性
Redis Cluster(Redis 集群)先按键计算 slot(槽),一个锁键只落到一个主分片及其从节点,并不会同时写入多个独立主节点形成多数派锁。该分片内部仍面临异步复制和提升丢锁。hash(哈希) tag(标签)可以把相关键放到同一 slot(槽),便于 Lua(脚本语言)原子操作,但也会制造热点;跨 slot(槽)的多资源加锁无法靠一个普通脚本完成。客户端还要处理 MOVED(迁移中标记)、ASK(临时重定向)和迁槽期间的拓扑更新,未知结果同样需要幂等和查询。
flowchart TB
K["lock:{order-9}:pay"] --> H["hash(哈希) tag(标签)order-9"]
H --> S["slot(槽)7342"]
S --> M2["主分片 M2"]
M2 -."异步复制".-> R2["从分片 R2"]
X["另一个锁键"] --> S3["其他 slot(槽)"]
S3 --> M3["主分片 M3"]
Note1["单个锁键只有一个主分片写入点"] --> M2
Note2["Cluster(集群)不等于多主多数派锁"] --> M3| Cluster(集群)能力 | 对锁的帮助 | 仍然存在的风险 | 设计动作 |
|---|---|---|---|
| slot(槽)分片 | 扩展容量和吞吐 | 单锁仍由单主分片负责 | 热锁拆业务粒度而非复制键 |
| hash(哈希) tag(标签) | 相关键同槽可用脚本 | 热点集中、跨槽受限 | 只把必须原子的少量键同槽 |
| 分片故障转移 | 自动恢复服务 | 未复制锁可能丢失 | 下游条件与栅栏令牌 |
| 槽迁移 | 在线扩缩容 | 重定向、超时和未知结果 | 集群感知客户端、幂等重试 |
| 多分片 | 故障域与容量拆分 | 不提供单键多数派语义 | 不把集群节点数当锁副本数 |
数据演绎 9:槽与热点。 100 万个普通键均匀分布在 16384 个 slot(槽),但所有库存请求都锁 lock:{sku-hot},它们只命中一个 slot(槽)和一个主分片。集群有 6 个主分片也不能让这把锁吞吐乘 6。若把同一资源复制成 6 个锁键,客户端可能各拿到不同锁,反而破坏互斥。正确方向是用数据库条件扣减、分段库存或请求合并降低热点,而不是复制逻辑锁。
热门面试题
问题(基础题):Redis Cluster(Redis 集群)中的一把锁会写到几个主节点?
- 考点:槽路由与单键归属。
- 回答思路:从键到槽再到主分片说明。
- 详细答案:一个锁键计算到一个 slot(槽),由该 slot(槽)当前所属的一个主分片处理,再异步复制给它的从节点。集群有多个主分片只是不同槽的分片,不表示同一个锁键同时获得多个独立主节点确认。
- 进阶追问:这与 RedLock(红锁)有什么区别?
- 进阶回答:RedLock(红锁)明确对多个相互独立的主节点分别获取并要求多数成功;普通 Cluster(集群)单键只是一个分片主从组。
问题(原理题):为什么多资源锁在 Cluster(集群)中困难?
- 考点:跨槽脚本限制与死锁。
- 回答思路:区分同槽原子脚本和跨槽协调。
- 详细答案:Lua(脚本语言)脚本中的键通常必须位于同一 slot(槽),否则节点无法在本地原子执行。把键用 hash(哈希) tag(标签)强制同槽会集中热点;分别获取多把锁又要定义全局顺序、失败回滚和租约,容易死锁和部分成功。更适合用数据库事务、业务状态机或合并资源边界。
- 进阶追问:固定按资源编号排序加锁能解决吗?
- 进阶回答:能降低循环等待,但不能解决中途过期、节点切换和部分外部副作用,仍需补偿与下游验证。
问题(项目追问题):槽迁移期间锁请求超时如何处理?
- 考点:重定向、未知结果与幂等。
- 回答思路:不假定超时等于未加锁,先保留令牌和业务键。
- 详细答案:使用 Cluster(集群)感知客户端处理 MOVED(迁移中标记)与 ASK(临时重定向),每次获取保留相同持有者令牌并限制重试。超时后查询当前所有权和业务状态,外部写继续使用幂等键。迁槽窗口降低并发和变更频率,监控重定向比例与尾延迟。
- 进阶追问:迁槽时能否暂停所有业务?
- 进阶回答:关键低频控制任务可以维护窗口暂停,高频业务通常应依靠客户端协议和业务幂等平滑处理。
10. RedLock(红锁)的算法、假设与边界
10.1 多数派获取、有效租期、时钟漂移与客观评价
RedLock(红锁)设有 N 个彼此独立的 Redis(远程字典服务)主节点,客户端用同一随机令牌在每个节点尝试短超时获取;只有在多数节点成功,并且总获取耗时小于租约时才算成功。剩余有效期应扣除总耗时和时钟漂移预算;失败时向所有节点尝试释放。它的目标是让少数节点故障不立即产生另一多数,但证明依赖独立故障、时钟速率漂移有界、客户端能在有效租期内完成,以及暂停后的旧客户端不会绕过资源端保护。
sequenceDiagram
participant C as "客户端"
participant N1 as "独立主节点 1"
participant N2 as "独立主节点 2"
participant N3 as "独立主节点 3"
participant N4 as "独立主节点 4"
participant N5 as "独立主节点 5"
C->>N1: "令牌 T,短超时获取"
C->>N2: "令牌 T,短超时获取"
C->>N3: "令牌 T,短超时获取"
C->>N4: "令牌 T,短超时获取"
C->>N5: "令牌 T,短超时获取"
N1-->>C: "成功"
N2-->>C: "成功"
N3-->>C: "成功"
N4-->>C: "失败"
N5-->>C: "超时"
Note over C: "3/5 成功且耗时小于租约,计算剩余有效期"| 维度 | RedLock(红锁)要求 | 风险或争议 | 工程态度 |
|---|---|---|---|
| 节点 | 多个独立主节点 | 同机房、同电源或同运维操作会相关失效 | 明确真实故障域 |
| 多数派 | 超过半数获取成功 | 网络分区和迟到响应使认知复杂 | 有界超时并释放全部节点 |
| 时间 | 总耗时小于租约并扣漂移 | 时钟跳变、调度暂停、尾延迟 | 不用时间假设保护不可逆写 |
| 客户端 | 有效期内完成临界区 | 长暂停后恢复仍可能写 | 下游栅栏令牌与状态检查 |
| 场景 | 提高低延迟锁容错 | 部署与理解成本高 | 效率锁可评估,正确性锁慎用 |
数据演绎 10:有效期。 租约 10 秒,依次访问 5 个节点共耗时 2.4 秒,预留 100 毫秒漂移预算,剩余有效期约 7.5 秒。业务预计 6 秒,看似足够;但客户端在第 3 秒暂停 8 秒,恢复时本地代码仍在临界区,而多数节点租约已到期,新客户端可能获得多数。算法层面的多数获取不能阻止旧代码继续调用数据库,必须由资源端拒绝旧授权。
热门面试题
问题(基础题):RedLock(红锁)成功条件是什么?
- 考点:多数节点与有效租期。
- 回答思路:按唯一令牌、短超时、多数成功和耗时判断回答。
- 详细答案:客户端以同一唯一令牌尝试多个独立主节点,在超过半数节点获取成功,且总耗时小于租约时才成功;实际可用时间还要扣除耗时与漂移预算。失败或放弃时应向所有节点尝试比较释放,避免残留少数锁。
- 进阶追问:为什么不能只看 3 个节点返回成功?
- 进阶回答:如果获取已经耗尽租约,多数锁可能立即过期,客户端没有有效临界区时间,所以必须同时检查耗时。
问题(原理题):RedLock(红锁)的核心争议是什么?
- 考点:租约时间假设和旧持有者问题。
- 回答思路:客观区分算法目标与不可逆资源写入。
- 详细答案:争议不只是“有没有多数”,而是安全证明依赖时钟漂移、进程暂停和通信耗时在预算内。客户端可在租约到期后从长暂停恢复并继续操作资源,而新客户端已经获得多数。若下游不识别授权新旧,多数派租约也不能阻止迟到写。
- 进阶追问:所以 RedLock(红锁)完全不能用吗?
- 进阶回答:不是。对重复执行只造成额外计算的效率锁,可在明确假设、故障域和降级策略后使用;对资金、库存等正确性锁,应让权威存储和栅栏令牌兜底。
问题(项目追问题):是否应该把单实例锁升级为 RedLock(红锁)?
- 考点:收益、复杂度和业务损失模型。
- 回答思路:先问重复执行损失,再看故障域与资源端能力。
- 详细答案:若单实例故障只导致缓存重复重建,升级的运维成本可能高于收益;若重复执行会破坏业务,应先建设幂等、状态机和资源端拒绝,再评估 RedLock(红锁)是否进一步降低协调失败概率。不能用更复杂的锁替代可证明的业务约束。
- 进阶追问:五个节点能放在同一集群吗?
- 进阶回答:若共享同一故障域或只是同一 Cluster(集群)的分片,独立故障假设会减弱;必须按算法假设审视部署。
11. Fencing Token(栅栏令牌)与资源端拒绝
11.1 单调授权序号、下游比较更新与多资源限制
Fencing Token(栅栏令牌)不是随机持有者令牌。随机令牌只用于证明“删除的是自己的锁”,栅栏令牌则是每次成功授权递增的序号,代表新旧关系。客户端把序号随写请求传给真正的资源端;资源端保存已接受的最大序号,只接受更大的值并原子更新。令牌必须由能保证单调性的权威组件生成,若用可能在故障切换时回退的单节点 INCR(自增命令)却没有持久化和一致性保证,序号可能重复或倒退。
sequenceDiagram
participant A as "客户端 A"
participant T as "权威令牌服务"
participant L as "租约锁服务"
participant D as "资源端数据库"
participant B as "客户端 B"
A->>T: "申请授权序号"
T-->>A: "41"
A->>L: "获得租约后暂停"
B->>T: "申请授权序号"
T-->>B: "42"
B->>L: "租约过期后获得锁"
B->>D: "以令牌 42 更新"
D-->>B: "接受并记录 42"
A->>D: "恢复后以令牌 41 更新"
D-->>A: "拒绝:41 不大于 42"| 令牌类型 | 目的 | 生成要求 | 校验位置 | 失败边界 |
|---|---|---|---|---|
| owner token(持有者令牌) | 防止误删他人锁 | 每次获取高概率唯一 | 锁服务 Lua(脚本语言) | 不表示新旧顺序 |
| Fencing Token(栅栏令牌) | 拒绝旧持有者迟到写 | 全局或资源维度单调递增 | 真正资源端原子比较 | 所有写入口都必须校验 |
| 业务幂等键 | 合并同一业务请求 | 同一业务动作稳定不变 | 数据库唯一键或接口端 | 不区分不同授权时代 |
| 乐观版本号 | 防止覆盖新状态 | 每次状态更新递增 | 数据库条件更新 | 需要冲突后重读与决策 |
数据演绎 11:数据库比较更新。 任务表记录 last_token=41,status=RUNNING。新执行器获得令牌 42,执行 UPDATE task SET result=?,last_token=42,status='DONE' WHERE id=? AND last_token<42 AND status='RUNNING',影响 1 行。旧执行器随后携令牌 41 提交,相同条件影响 0 行;它必须读取最新状态并丢弃本地结果。若对象存储或第三方支付接口无法比较令牌,就要在调用前由本地权威状态机取得唯一执行权,并使用第三方幂等键和结果查询。
热门面试题
问题(基础题):持有者令牌与 Fencing Token(栅栏令牌)有什么区别?
- 考点:唯一性与单调性的不同职责。
- 回答思路:分别说明释放锁和拒绝旧写。
- 详细答案:持有者令牌只需高概率唯一,用于释放或续期时确认当前锁属于自己;它没有大小顺序。Fencing Token(栅栏令牌)必须单调递增,并随业务写传给资源端,资源端通过比较拒绝较小的旧授权。两者不能互相替代。
- 进阶追问:随机唯一标识能当栅栏令牌吗?
- 进阶回答:不能,随机值无法判断哪个授权更新;除非另有权威映射赋予全序关系。
问题(原理题):为什么栅栏令牌必须由下游校验?
- 考点:旧客户端不能被锁服务远程终止。
- 回答思路:说明锁服务只能改变授权记录,不能撤销已运行代码。
- 详细答案:租约到期后,锁服务知道旧授权无效,却无法强制暂停旧进程。旧进程恢复后仍可拿着已准备的数据调用数据库或文件系统。只有真正接收副作用的资源端比较令牌,才能在最后一步拒绝迟到写,形成可证明的隔离。
- 进阶追问:只有一个下游校验够吗?
- 进阶回答:所有可能被旧持有者写入的资源都要校验,或把副作用统一收口到一个能校验的权威服务;否则未校验的旁路仍可被污染。
问题(项目追问题):如何生成可靠的栅栏令牌?
- 考点:单调序列与故障切换回退。
- 回答思路:把令牌正确性与锁服务可用性分开设计。
- 详细答案:可由数据库事务序列、带一致性保证的协调服务或资源行版本原子递增产生。若使用 Redis(远程字典服务)INCR(自增命令),必须分析持久化、复制切换和序号回退;对关键正确性不能只依赖可能丢失尾部写的计数器。令牌可按资源维度递增,减少全局热点。
- 进阶追问:序号跳跃有问题吗?
- 进阶回答:通常不要求连续,只要求新授权严格大于旧授权;跳号不影响拒绝逻辑。
12. 库存、支付与 Runner(执行器)的正确落地
12.1 锁、幂等、条件更新、状态机与补偿的组合
库存防超卖的核心不变量是可用量不能小于零,同一业务单据只扣一次;首选数据库条件更新或 Redis(远程字典服务)原子脚本,锁用于降低热点竞争。支付回调的核心是不重复记账、状态只能按允许边迁移,依靠渠道流水唯一键、订单状态机、事务内记账和对账补偿,锁只减少并发回调。Runner(执行器)调度的核心是同一任务版本只有一个有效提交者,使用任务状态条件抢占、租约心跳、栅栏令牌、检查点和超时回收。
flowchart TD
E["业务事件进入"] --> K["稳定幂等键"]
K --> L{"是否需要 Redis(远程字典服务)锁削峰"}
L -->|"需要"| RL["短租约、唯一持有者令牌、有界等待"]
L -->|"不需要"| S["直接进入权威状态机"]
RL --> S
S --> C["唯一键、条件更新或版本迁移"]
C --> O["事务内记录事件或流水"]
O --> X["外部调用携幂等键"]
X --> Q["结果查询、补偿扫描与对账"]| 场景 | 权威不变量 | Redis(远程字典服务)锁角色 | 最终兜底 | 失败恢复 |
|---|---|---|---|---|
| WMS(仓储管理系统)库存 | 可用量非负、单据只扣一次 | 热点削峰、减少重复计算 | 条件更新、流水唯一键 | 库存流水对账与补偿 |
| 支付回调 | 同一渠道流水只入账一次 | 合并并发回调 | 唯一键、状态机、事务记账 | 主动查询渠道与日终对账 |
| Runner(执行器)调度 | 同一任务版本只接受一个结果 | 快速抢占与租约 | 任务版本、栅栏令牌 | 超时回收、检查点接续 |
| 缓存重建 | 避免大量并发回源 | 主要协调手段 | 旧值、逻辑过期、请求合并 | 锁失败时限流降级 |
| 外部接口 | 同一业务动作不重复生效 | 可减少本地并发 | 对方幂等键、结果查询 | 重试队列和人工核对 |
数据演绎 12:三层保护。 100 个重复支付回调同时到达,Redis(远程字典服务)锁把并发压到 1 个,但在主从切换中又放进 1 个。数据库 channel_trade_no 唯一键使第二次流水插入冲突;订单状态机只允许“待支付”变“已支付”,第二次更新影响 0 行;事务提交后 Outbox Pattern(发件箱模式)事件由下游幂等消费。锁失效只增加一次无效竞争,没有形成重复入账。若只依赖锁而无后三层,切换窗口就可能直接变成资金事故。
热门面试题
问题(基础题):为什么分布式锁不能替代幂等?
- 考点:并发协调与重复请求语义。
- 回答思路:说明锁只限制某时刻进入,幂等处理跨时间重试。
- 详细答案:锁能减少同一时刻并发进入,却可能过期、丢失或切换;请求还可能在锁释放后因消息重投、网络超时再次到达。幂等键把多次同一业务动作映射到同一结果,覆盖跨时间和跨故障重试。因此两者职责不同,关键业务必须独立幂等。
- 进阶追问:幂等是否就不需要锁?
- 进阶回答:正确性可能不需要,但热点竞争、重复外部查询和计算成本高时,锁仍可作为性能优化。
问题(原理题):支付回调如何做到不重复入账?
- 考点:唯一约束、状态机、事务与对账。
- 回答思路:沿接收、验签、去重、迁移、记账和补偿回答。
- 详细答案:先验签和校验金额商户,再以渠道交易号建立唯一约束;事务内读取或条件更新订单状态,只允许合法边迁移,同时写资金流水和待发送事件。唯一冲突或状态已完成时返回已有结果。事务外消息用业务键幂等消费,渠道结果通过主动查询与对账补偿。锁只能降低并发,不承担账本正确性。
- 进阶追问:回调先到、主动查询也到怎么办?
- 进阶回答:两条路径使用同一渠道交易号和同一状态机,谁先提交都可,后者读到终态并返回同一结果。
问题(项目追问题):Runner(执行器)如何防止两个节点提交同一任务?
- 考点:任务抢占、租约、版本和迟到结果。
- 回答思路:把执行权与结果接纳权分开。
- 详细答案:节点通过数据库条件更新或短 Redis(远程字典服务)锁抢占任务,获得递增任务版本或栅栏令牌;执行中续租并写检查点。提交结果时数据库要求状态仍为运行中且令牌不旧,影响 0 行表示已失去资格。外部调用带任务幂等键,超时任务由扫描器回收,新节点从检查点继续。
- 进阶追问:心跳正常为何还要版本?
- 进阶回答:心跳只能说明最近可达,不能排除暂停、分区和切换窗口;版本在提交点拒绝旧执行器。
13. 从章节知识到综合表达
13.1 综合题库使用说明
本小节是非知识型过渡,不使用知识标记。下面 28 道题按 3 至 5 分钟口述设计,每题都要先给选择结论,再解释协议、故障模型、项目不变量和验证方式。复习时不要只背 Redis(远程字典服务)命令,应能画出“旧锁过期、新锁获得、旧持有者恢复”的时间线,并说明真正资源端如何拒绝错误结果。
14. 综合面试题库
问题(综合题):为什么需要分布式锁,它与线程锁的本质差异是什么?
- 口述答案:我的结论是,是否需要分布式锁不取决于“项目用了微服务”,而取决于同一业务资源是否可能被多个相互独立的执行主体同时修改。线程锁把所有权保存在一个 JVM(Java 虚拟机)的堆对象和监视器中,进程之间没有共享锁状态;当两个 WMS(仓储管理系统)实例同时处理同一 SKU(库存单位)时,各自的 synchronized(同步锁)都能成功,锁实现没有错,错的是保护边界。分布式锁把所有权放到多个实例都可见的协调服务,并增加唯一持有者令牌、租约和原子释放,解决跨进程的短临界区互斥。但它并不自动保证数据库事务提交、外部接口只调用一次,也无法远程终止经历长暂停的旧客户端。因此我会先识别权威资源和不变量:库存要求可用量非负,支付要求同一渠道流水只记账一次,任务要求同一版本只接受一个结果。权威数据库的条件更新、唯一键和状态机负责最后正确性,Redis(远程字典服务)锁只减少热点竞争与重复工作。上线验收既要压测正常互斥,也要注入进程暂停、网络超时和主从切换,证明锁丢失时业务不变量仍成立。 进一步落地时,我会建立资源写入口清单,逐项确认接口、消息消费者、定时任务和人工后台是否执行同一幂等与版本规则;任何旁路都可能绕开互斥。容量评估还要把锁请求次数、平均持有时长、竞争率和失败重试换算成 Redis(远程字典服务)每秒脚本量。发布采用灰度对照,比较锁开启与关闭时数据库冲突、吞吐和尾延迟;若锁只增加复杂度而没有可测收益,就保留权威约束并删除这层协调。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:单实例部署是否永远不需要分布式锁?回答:还要检查消息消费者、定时任务、运维脚本和未来扩容是否形成其他写入口;所有写入口都在同进程时,本地锁更合适。
- 追问:用了分布式锁能否去掉数据库唯一键?回答:不能,重试可能发生在锁释放之后,故障切换也可能丢锁,唯一键仍是最终防线。
- 追问:如何判断锁有没有价值?回答:比较加锁前后的数据库冲突、回源次数、尾延迟和失败率;只有性能收益明显且复杂度可控才保留。
- 详细章节
问题(综合题):请比较 JVM(Java 虚拟机)锁、数据库锁、Redis(远程字典服务)锁和 Zookeeper(分布式协调服务)锁。
- 口述答案:我会从保护范围、所有权依据、故障模型、延迟和业务落点五个维度比较。JVM(Java 虚拟机)锁只保护单进程内存,延迟最低、可重入和条件队列成熟,适合单实例共享对象;多实例之间完全不可见。数据库行锁或条件更新直接作用在权威数据上,可与事务、唯一约束和状态迁移组合,正确性边界最清楚,但长事务会占连接、造成锁等待和死锁,热点行吞吐受限。Redis(远程字典服务)锁用原子命令、租约和持有者令牌提供低延迟跨实例协调,适合缓存重建、短任务抢占和热点削峰;它的主从复制与故障切换默认不是共识提交,存在锁丢失和双持有者窗口。Zookeeper(分布式协调服务)通过临时顺序节点、会话和法定提交更适合控制面协调、公平排队与领导者选举,但交互延迟和运维成本更高。我的选择原则不是“哪个更高级”,而是让最终约束尽量靠近权威资源:库存扣减先选数据库条件更新,缓存击穿可用 Redis(远程字典服务)锁,少量强协调任务可评估 Zookeeper(分布式协调服务)。无论哪种远程锁,旧客户端恢复问题都要由资源端版本或栅栏令牌解决。 选型还要看团队能否维护对应故障模型。数据库锁要有慢事务、死锁和索引监控;Redis(远程字典服务)锁要观察续期、切换和热键;Zookeeper(分布式协调服务)锁要关注会话过期、法定节点与等待队列。迁移方案也必须可回退,例如协调服务不可用时,资金写选择拒绝并排队,缓存重建则允许旧值降级。最后通过同一组暂停、断网和节点故障实验比较候选方案,而不是只看正常请求的平均延迟。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:数据库锁性能一定差吗?回答:短事务、命中索引和合理分片下通常足够,性能要用真实冲突率和事务耗时验证。
- 追问:Zookeeper(分布式协调服务)临时节点能否消灭旧持有者?回答:会话失效会删除节点,但旧进程恢复后仍可能调用下游,所以仍需资源端校验。
- 追问:Redis(远程字典服务)锁最大的优势是什么?回答:低延迟、接入简单和租约自动回收,适合作为效率优化,但要诚实表达故障边界。
- 详细章节
问题(综合题):如何定义一把分布式锁的正确性?
- 口述答案:我不会只用“同一时刻只能一个线程进入”定义正确性,而是拆成安全性、进展性和业务不变量。安全性包括同一资源尽量至多一个有效持有者,以及只有当前持有者能释放或续期;进展性包括持有者崩溃后锁最终可回收、等待者不会无限阻塞;业务层还要求即使锁服务出现极端故障,资金、库存和任务状态也不能被旧结果覆盖。租约能避免永久死锁,却产生客户端认知与服务端状态不一致:客户端暂停超过租约后仍可能继续运行,而新客户端已经获得锁。因此“锁键互斥”不是完整安全证明。工程设计要明确故障假设,包括进程崩溃、长时间暂停、网络分区、时钟异常、主从切换、响应丢失和重试风暴,并为每种情况决定偏正确性还是偏可用性。支付与库存遇到所有权不确定时应拒绝写,由唯一键、状态机、条件更新和栅栏令牌兜底;缓存重建可允许重复计算换可用性。验收时我会用故障注入制造双持有者,再检查下游是否只接受新授权,不能只在无故障压测里统计获取成功率。 设计评审时我会把保证写成可验证断言,例如“同一业务键重复 100 次最多产生一条流水”“令牌 42 被接受后令牌 41 的更新影响行数必须为 0”,而不是写成模糊的“保证一致性”。每条断言对应监控和告警:旧令牌拒绝、唯一键冲突和条件更新失败既是兜底,也是发现锁异常的证据。事故后按时间线核对客户端认知、锁服务状态和资源端版本,才能判断哪一层失效。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:公平性是不是正确性的必要条件?回答:通常不是安全性的必要条件,但长期饥饿会破坏服务目标,需要有界等待、退避或公平队列治理。
- 追问:能否同时做到网络分区下始终可用且绝对互斥?回答:在一般异步网络模型下不能无条件兼得,必须拒绝一侧或接受并发风险。
- 追问:如何定义锁服务指标?回答:监控获取耗时、竞争率、续期失败、租约到期释放、持有时长、错误解锁和业务冲突兜底次数。
- 详细章节
问题(综合题):为什么租约不能彻底解决客户端宕机和长暂停问题?
- 口述答案:租约解决的是锁服务端的资源回收,而不是旧代码的物理终止。没有租约时,客户端在加锁后宕机,锁键可能永久存在;设置过期时间后,服务端到期会自动删除,其他客户端可以继续工作。但客户端可能只是被长时间垃圾回收暂停、宿主机冻结或网络隔离,并没有真正死亡。它在暂停期间无法续期,租约到期后客户端 B 获得新锁;客户端 A 恢复时仍保留调用栈和待提交结果,如果直接写数据库,就出现两个逻辑持有者。把租约从 10 秒延长到 10 分钟只降低概率,同时让真正崩溃后的恢复等待变长,无法形成数学上的消除。我的做法是三层防护:第一,租约按任务高分位耗时、暂停和网络预算配置,续期失败后停止领取新工作;第二,长任务拆检查点并设置业务截止时间;第三,提交到资源端时携带递增授权版本或使用状态条件,旧持有者更新影响 0 行。对外部支付接口则带稳定幂等键并在超时后查询结果。故障演练要主动暂停旧进程超过租约,确认新进程完成后旧结果被拒绝。 对垃圾回收暂停尤其要避免只看平均停顿。假设租约 30 秒、每 10 秒续期,一次 35 秒暂停足以跨过到期点;即使 99.99% 请求不发生,剩余概率在全年数十亿次任务中也可能出现。客户端恢复后首先检查取消标记和任务版本,不能先提交再判断。监控要关联垃圾回收日志、容器冻结事件、续期失败和栅栏拒绝时间,演练则暂停旧进程、让新进程完成,再恢复旧进程验证拒绝闭环。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:客户端恢复后先续期再提交是否可行?回答:若锁已被新持有者获取,比较续期会失败;即使成功,续期到提交间仍有窗口,下游仍需校验。
- 追问:怎样确定暂停预算?回答:结合垃圾回收日志、容器冻结、调度延迟和网络高分位,且承认它只能是工程预算而非绝对上界。
- 追问:无法拆分的长任务怎么办?回答:让最终提交幂等且带版本,必要时改成异步作业和可查询结果,不能靠无限租约。
- 详细章节
问题(综合题):请完整解释 SET key(键) token(令牌) NX PX(键不存在时设置持有者令牌并设置毫秒租约)协议。
- 口述答案:基础协议用一条 SET(设置键值)命令同时完成四件事:锁键定位受保护资源,唯一持有者令牌标识本次获取,NX(不存在才设置)保证只有键不存在时写入,PX(设置毫秒过期)让所有权自动到期。不能先 SETNX(不存在则设置)再 EXPIRE(设置过期),因为客户端可能在两条命令之间崩溃,留下永久锁。令牌也不能使用固定服务名,应该组合实例标识和每次尝试的高随机值,防止旧请求释放新锁。客户端得到成功响应后记录本地截止时间,剩余租约不足时不应开始长操作;获取失败采用有界随机退避,避免热点请求固定频率冲击服务端。网络超时属于结果未知:服务端可能已经写入,只是响应丢失,所以客户端要保留同一令牌,必要时查询当前值与业务状态,而不是立刻换令牌无限重试。完成业务后使用 Lua(脚本语言)原子比较令牌并删除。即便协议全部正确,它只提供单节点上的租约互斥,主从切换、长暂停和外部副作用仍由下游约束处理。验证包括中断两条旧协议、丢响应、超租约暂停和错误令牌释放四类实验。 实际封装还应返回明确状态:已获取、明确未获取、结果未知,而不是只有布尔值;调用方根据业务损失选择查询、排队或失败。锁值中可携带追踪所需的实例和请求标识,但不能包含敏感数据;日志记录键摘要、令牌摘要、租约和耗时,不能记录完整凭证。服务端脚本、客户端超时和业务截止时间要成套版本化,升级时灰度验证旧客户端与新客户端不会互相误删或无限等待。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:为什么要用毫秒租约?回答:便于按短临界区精细预算,但精度不等于实时保证,调度和网络仍有抖动。
- 追问:获取失败能否一直自旋?回答:不能,应设置等待截止时间、指数加随机退避,必要时快速失败或排队。
- 追问:锁键粒度怎么定?回答:按真正冲突资源设计,过粗降低并发,过细则无法覆盖共同不变量,还要包含租户边界。
- 详细章节
问题(综合题):为什么必须使用唯一令牌和 Lua(脚本语言)原子释放?
- 口述答案:唯一令牌解决“这把锁是不是我这一轮获取的”,Lua(脚本语言)解决“判断和删除之间不能被其他命令插入”,两者缺一不可。构造时间线:客户端 A 获得 10 秒锁,业务执行 12 秒;第 10 秒锁自动过期,客户端 B 写入新锁;A 第 12 秒结束。如果 A 直接 DEL(删除键),会删除 B 的有效锁。即使 A 先 GET(读取键值)确认值等于自己,再 DEL(删除键),读取后仍可能暂停,原锁过期且 B 获得新锁,随后 A 继续删除。正确做法是在服务端脚本中读取当前值,只有等于 A 的唯一令牌才删除;同一脚本执行期间不会插入其他普通命令。续期也使用相同的比较条件,只延长自己的租约。令牌应每次获取重新生成,不能只用线程编号,因为编号可能跨进程重复或被复用。释放返回 0 时不要再强删,而要记录失锁并查询业务结果。脚本原子性只限目标节点,不能证明故障切换后锁键仍存在,所以数据库版本和栅栏令牌仍是最终保护。测试要覆盖 A 过期、B 获取、A 迟到释放,确认 B 的锁不受影响。 释放逻辑必须位于 finally(最终清理)中,但只有获取成功的代码路径才允许执行;错误地在获取失败后释放,虽然令牌校验通常会挡住,也会制造无效脚本和误报警。客户端崩溃场景则依赖租约回收,所以监控中要区分主动释放与到期删除比例,后者突然升高常表示超时、暂停或漏解锁。版本升级还要回放旧脚本结果,确认返回值语义没有被业务代码错误解释。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:随机令牌碰撞怎么办?回答:使用足够高熵标识使概率工程上可忽略,同时可加入实例与请求信息便于追踪。
- 追问:删除脚本返回 0 是异常吗?回答:表示已失去所有权或锁不存在,业务上应停止后续副作用,不能把它当普通成功忽略。
- 追问:脚本是否会阻塞 Redis(远程字典服务)?回答:执行期间会占用主执行路径,因此脚本必须短小、键数可控并监控慢脚本。
- 详细章节
问题(综合题):加锁或解锁请求超时时,如何处理结果未知?
- 口述答案:分布式调用必须区分明确成功、明确失败和结果未知。客户端发送加锁后超时,可能是请求根本没到,也可能服务端已经写入而响应丢失;直接用新令牌重试会看到锁被占用,却不知道占用者就是自己,甚至随后执行重复业务。我的处理是为一次获取尝试生成稳定令牌并保留到结论明确,超时后按业务重要性查询锁值和权威状态;若锁值是自己且剩余租约足够,可继续,若不足则先比较续期或放弃;若值不是自己,就按失败处理。解锁超时也不能盲目 DEL(删除键),可以重复执行比较删除脚本,因为它对同一令牌幂等,或者等待租约回收。更关键的是锁内业务调用同样会出现未知结果:支付渠道超时不代表未扣款,库存数据库超时不代表事务未提交。因此每个副作用使用稳定业务幂等键,并提供结果查询;补偿程序根据权威状态决定重试,而不是根据网络异常猜测。验收要通过代理丢弃响应包,验证客户端不会生成重复流水、无限重试或错误解锁。 对未知结果应建立持久化的处理状态,而不是只在内存里重试。例如支付请求记录“请求已发送、结果待确认”,补偿任务按同一商户单号查询;库存请求记录单据号,数据库流水存在即返回成功。每次重试都有总时限和次数预算,超过后告警并转人工,避免事故期间形成无界流量。恢复后用幂等键统计首次成功、重复合并和人工处理数量,证明系统没有把传输不确定性转化为重复副作用。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:读取锁值会不会又产生竞态?回答:读取只用于诊断和决策,真正续期或释放仍要原子比较;业务提交仍由下游条件校验。
- 追问:查询也超时怎么办?回答:达到重试预算后进入不确定状态队列,由异步查询和人工兜底,不继续不可逆操作。
- 追问:为什么同一令牌重试更好?回答:能识别第一次请求是否已建立所有权,避免把自己的未知成功当成另一持有者。
- 详细章节
问题(综合题):如何设计锁续期,watchdog(看门狗)有哪些失效边界?
- 口述答案:续期的本质是持有者仍存活时延长租约,不能把它理解为永久锁。常见 Redisson(Redis 客户端)用法在未显式指定 leaseTime(租约时间)时启动 watchdog(看门狗),常见默认超时为 30 秒,并约在三分之一周期调度续期;具体数字、线程模型和锁类型必须核对项目版本。续期脚本先验证 Hash(哈希)中的持有者字段仍属于当前客户端,再延长过期时间。它可能因进程崩溃、长时间垃圾回收暂停、宿主机冻结、续期调度线程饥饿、网络分区、目标节点切换或服务端阻塞而失败。固定租约和自动续期各有取舍:固定租约可预测但要求任务在期限内完成,自动续期适合耗时波动任务,却增加周期请求与“以为永不过期”的误用。我的设计会限制持锁数量,监控续期失败、剩余租约和实际持有时长;检测失锁后停止新步骤,最终提交仍带业务版本或栅栏令牌。对 2 万活跃锁按每 10 秒续期,平均就有 2000 次每秒脚本,容量和批量调度尖峰也必须压测。 续期线程不能与容易阻塞的业务线程池共享有限执行资源,否则业务拥塞会反过来饿死续期。监控不仅看续期请求是否成功,还要记录计划时间与实际执行时间的偏差、目标节点变化和锁剩余时间。故障时若连续续期失败,客户端应触发失锁事件并停止新步骤;即使网络随后恢复,也不能自动恢复旧临界区,必须重新获取新授权并从权威状态重新计算。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:显式指定租约后看门狗是否续期?回答:常见实现不会自动续期,应以当前 Redisson(Redis 客户端)版本接口文档和源码确认。
- 追问:续期失败后能否继续只读操作?回答:若读操作无副作用且允许陈旧,可按业务降级;任何写入和外部调用都应停止或重新验证授权。
- 追问:续期间隔越短越好吗?回答:能缩小错过窗口,但增加服务端和网络负担,仍不能消除长暂停,需按容量和风险折中。
- 详细章节
问题(综合题):Redisson(Redis 客户端)可重入锁底层如何工作?
- 口述答案:常见版本的 Redisson(Redis 客户端)可重入锁使用 Hash(哈希)而不是单字符串保存所有权。锁名是键,字段组合客户端实例标识和线程标识,字段值是重入计数。第一次获取时,Lua(脚本语言)脚本在键不存在时创建字段、计数设为 1 并设置租约;同一持有者再次获取时把计数加一并刷新租约,其他持有者得到剩余过期时间并进入等待。解锁脚本验证字段属于当前持有者,计数减一;大于零表示仍在嵌套临界区,只有减到零才删除键并发布状态变化通知。这样同一线程的嵌套调用不会把自己阻塞,但异步切换线程后持有者身份改变,不能假定自动继承。Pub/Sub(发布订阅)用于减少固定轮询,等待者收到通知后仍要重新竞争,因为通知不是所有权授予,也不保证公平或持久。具体字段格式、脚本和异常类型随版本可能变化,面试中应说明要核对所用版本。业务代码仍需 finally(最终清理)解锁;租约自动回收只是进程异常兜底,不能成为漏解锁的常规机制。 可重入计数还要求加锁与解锁严格成对,异常分支少解一次会让键一直保留到租约到期,多解一次则应被所有权校验拒绝。代码审查时我会限制锁对象生命周期,禁止把它跨线程、跨异步回调隐式传递,并为嵌套调用记录当前重入深度。压测同时覆盖同线程重入、其他线程竞争、内层异常和客户端崩溃,确认计数、租约与通知在各路径一致。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:重入次数为什么要放服务端?回答:多个实例都必须看到所有权状态,服务端脚本才能原子判断当前持有者和计数。
- 追问:内层解锁会释放整把锁吗?回答:不会,只把计数减一;外层最后一次解锁归零才删除。
- 追问:线程池中是否容易误用?回答:任务切线程或线程复用会混淆调用上下文,应保证同线程成对调用并把业务令牌独立于线程身份。
- 详细章节
问题(综合题):Pub/Sub(发布订阅)唤醒与公平锁分别解决什么问题?
- 口述答案:Pub/Sub(发布订阅)唤醒解决的是等待效率,不解决所有权和公平。普通远程锁若所有等待者持续轮询,会制造大量网络与脚本请求;持有者最终释放时发布状态变化,等待者被唤醒后重新尝试,可减少无效轮询。但通知可能在订阅建立前发生、连接可能断开,等待者也可能同时被唤醒,所以实现还要结合剩余租约、等待超时和再次检查,不能把一次通知当可靠消息。公平锁则额外维护等待顺序,希望先等待的请求先获得,需要登记队列、唤醒队首、清理超时或死亡等待者,并处理网络延迟造成的顺序观察差异。这会增加服务端状态、脚本复杂度和故障恢复时间,吞吐通常低于普通竞争锁。公平获取也不代表业务完成按同一顺序,因为持有者执行耗时和外部事务提交可能不同。我的项目中只有明确存在饥饿且顺序有业务价值时才用公平锁;库存热点更倾向条件扣减和请求削峰,不让数千请求排长队。验证要统计等待分布、饥饿次数、失效等待者清理时间和通知丢失后的恢复。 等待策略还要设置总截止时间,不能因为每次通知都重新计算完整等待期而无限延长。热点资源可将请求先在应用层合并,只保留少量竞争者,避免锁释放时大量线程同时醒来冲击单节点。观测上分别记录订阅建立时间、通知时间、重新竞争时间和最终获取顺序;如果公平锁的排队成本已经高于数据库条件更新,就应重新审视是否需要远程排队。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:发布订阅消息丢失会不会死锁?回答:锁本身有租约,正确客户端还会按等待期限重新检查;只依赖通知的实现会有永久等待风险。
- 追问:公平锁能否避免惊群?回答:显式队列可主要唤醒队首,能降低竞争,但失效清理和通知仍有额外成本。
- 追问:先到服务端还是先调用算公平?回答:远程系统通常只能按服务端观察到的排队顺序定义,无法保证真实世界绝对时间顺序。
- 详细章节
- 问题(综合题):读写锁、固定租约锁和自动续期锁应该如何选择?
- 口述答案:选择前先判断业务是否真的需要远程锁。分布式读写锁允许多个读持有者并发、写持有者独占,适合少量需要跨实例形成短暂一致视图的场景,但多数缓存读不需要加读锁;给每次读都增加远程交互会放大延迟,写请求还可能因持续读流量饥饿。读锁升级写锁尤其危险,多个读者同时等待升级可能互相阻塞,更稳妥的是释放后重新竞争并重新校验版本。固定租约锁适合执行时间有明确上界的短操作,调用方知道到期时刻,系统也不会持续产生续期请求;缺点是尾部耗时超过预算时会出现旧持有者。自动续期锁适合耗时波动但客户端能感知取消的任务,它依赖调度线程、连接和服务端持续健康,并增加续期容量。我的原则是锁粒度尽量小,临界区内不做不可控远程调用;固定租约覆盖高分位耗时并留预算,长任务改成检查点和状态机。无论选择哪一种,数据库提交都带版本条件,外部接口都带幂等键。验收不仅压测吞吐,还主动制造写饥饿、续期失败和超租约任务,验证降级路径。 还要把锁模式写进接口契约:读锁允许看到什么版本,写锁等待多久,固定租约到期后是否取消,自动续期失败后谁接管。生产指标按模式拆分,否则普通锁的低等待会掩盖写锁饥饿。若业务需要把读取和写入组成权威一致快照,我会优先评估数据库隔离级别与乐观版本,而不是让大量读取跨网络持锁;只有收益经压测确认,才承担更复杂的远程读写状态。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:读多写少一定适合读写锁吗?回答:不一定,若读的是可陈旧缓存或数据库快照,读锁的远程成本可能完全没有收益。
- 追问:固定租约设为最大历史耗时可以吗?回答:历史最大值不代表未来上界,还会拉长崩溃恢复;应配合截止时间和资源端校验。
- 追问:自动续期能否用于无限任务?回答:不应,任务应有业务截止时间、检查点和可接管状态,否则故障恢复无法收敛。
- 详细章节
- 问题(综合题):单机 Redis(远程字典服务)作为锁服务有哪些可靠性边界?
- 口述答案:单机模式的优点是模型简单:所有锁命令在一个事件循环上顺序执行,Lua(脚本语言)脚本可以原子比较、续期和删除,不存在主从切换导致的尾部锁丢失。但单机故障时整个锁服务不可用,客户端必须选择快速失败、排队还是回退到权威数据库;不能在事故中默认“无锁继续执行”关键写。重启还涉及持久化语义:锁键本质是临时租约状态,若持久化尾部丢失,可能提前消失;若恢复了故障前的旧锁,也要依赖过期时间和绝对过期语义回收。更重要的是,即使锁服务本身完全正确,客户端长暂停后仍可能超过租约,新客户端获得锁,旧客户端恢复继续写。我的设计会把单机锁限定在重复执行损失较低、可降级的场景,例如缓存重建;库存和支付最终由数据库条件与唯一键保证。容量上监控脚本耗时、活跃锁数、过期回收、内存、事件循环延迟和连接队列。故障演练包括杀进程、重启、丢持久化尾部、客户端暂停和锁服务不可达,验收业务不变量与恢复时间,而不是只看 Redis(远程字典服务)重新上线。 恢复预案要提前定义客户端行为:锁服务不可达时,缓存重建可返回旧值并限流,Runner(执行器)停止领取新任务,支付和库存关键写进入队列或直接拒绝。切回后不能让积压请求同时竞争,应按租户和资源分批放流。恢复验收还要核对旧锁是否因重启被恢复、绝对过期时间是否正确、客户端是否仍持有过期上下文,并通过业务版本阻止迟到提交。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:单机是否反而比主从更不容易出现双持有者?回答:没有自动切换的复制丢锁窗口,但可用性更差,客户端暂停问题仍存在。
- 追问:锁服务不可用能否降级为本地锁?回答:多实例下本地锁不能恢复跨进程互斥,关键写应拒绝或转入权威状态机,不应静默降级。
- 追问:锁键需要持久化吗?回答:锁是可重建协调状态,重点是租约和业务兜底;不要把持久化锁当业务事实恢复来源。
- 详细章节
- 问题(综合题):请演绎主从异步复制下的锁丢失过程,并给出兜底方案。
- 口述答案:典型时间线是:客户端 A 向主节点写入锁令牌 A,主节点内存执行成功并返回,此时复制偏移量已经前进,但从节点还停留在前一个偏移;主节点立即宕机,从节点被提升为新主,因为没有锁 A,所以客户端 B 能写入同一锁令牌 B。A 已收到成功,可能仍在执行,于是出现双持有者。即使使用 WAIT(等待副本确认)等待一个或多个副本到达目标偏移,也只是缩小窗口:确认副本未必被选为新主,确认也不代表共识提交或稳定存储,更不能终止 A。我的兜底分三层。锁层使用唯一令牌、短租约和失锁检测;资源层用数据库条件更新、业务版本或 Fencing Token(栅栏令牌)拒绝旧提交;流程层用幂等键、事务事件和对账补偿处理未知结果。库存扣减执行
available>=quantity条件,支付流水有渠道交易号唯一键,任务结果要求令牌大于已接受令牌。演练时在主节点返回后、复制前强制故障,确保 B 能获得锁的同时,A 的旧写被权威端拒绝,证明系统不是靠“概率很低”维持正确。 为了量化风险,我会记录主节点返回锁写的偏移量、各副本确认偏移量、故障发生和新主提升时刻,再将双持有窗口与业务提交日志对齐。若每秒 5000 次锁写、复制尾延迟 20 毫秒,窗口虽短,全年暴露次数仍不可忽略。治理优先缩小故障单元、设置合理副本写保护和快速隔离旧主,但验收标准仍是任何窗口内数据库不变量不破坏,而不是把窗口概率降到口头上的零。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。 - 追问与回答:
- 追问:同步复制能否彻底解决?回答:需要完整的共识提交和领导权隔离语义,简单等待副本确认并不等价;还要解决旧客户端迟到写。
- 追问:A 如何知道锁丢了?回答:续期失败、连接切换事件或提交条件失败都可发现,但发现可能晚于租约失效,因此资源端必须先防住。
- 追问:是否应在切换期间暂停业务?回答:关键写可短暂拒绝以偏正确性,普通业务可幂等重试;策略按损失模型和恢复目标预先设计。
- 详细章节
- 问题(综合题):哨兵模式为什么仍会脑裂,如何降低风险?
- 口述答案:哨兵通过多个监控节点判断主观下线和客观下线,选举领导者并提升从节点,解决的是故障发现与自动恢复,不是把每次写变成同步法定提交。网络分区时,旧主可能与部分客户端同侧,仍认为自己是主并接受加锁或续期;哨兵法定侧看不到旧主,提升从节点形成新主。若旧锁没有复制,新侧又能发放同一资源的锁,两个客户端都会继续。配置 min-replicas-to-write(最少可写副本)和允许延迟,可以让失去足够副本的旧主拒绝新写,降低脑裂概率,但这以分区时拒写为代价,而且已经在 A 进程内运行的临界区不会被取消。客户端还可能在拓扑刷新前继续连接旧主,重试会放大新主压力。完整方案是网络层隔离旧主、客户端监听拓扑变化并有界退避、关键业务在不确定期拒绝写,以及数据库状态机或栅栏令牌拒绝旧持有者。验收应记录故障检测、提升、客户端重连和旧主隔离四个时间点,主动在窗口内提交新旧结果,验证只有合法版本被接受。 事故响应中还要防止客户端重连风暴:统一设置指数加随机退避、单请求总预算和连接创建速率,避免新主刚提升就被所有等待者压垮。切换完成后核对旧主角色、客户端目标地址和未确认写入,不能只看哨兵宣布成功。对资金写可在拓扑不确定窗口主动拒绝,待新主、资源端版本和补偿扫描都稳定后再分批恢复,这是一致性优先的明确取舍。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:哨兵数量越多越安全吗?回答:更多节点改善故障判断容错,但不改变主从异步复制本质,部署故障域和法定参数更关键。
- 追问:最少副本保护会带来什么代价?回答:副本延迟或网络抖动时主节点可能拒写,降低可用性,需要业务降级和容量设计。
- 追问:旧主恢复后会怎样?回答:通常被重新配置为从节点,但恢复前的业务线程可能已经产生外部写,仍依赖资源端校验和补偿。
- 详细章节
- 问题(综合题):Redis Cluster(Redis 集群)是否天然提供更安全的分布式锁?
- 口述答案:不是。Redis Cluster(Redis 集群)把键空间划分为 16384 个 slot(槽),一个锁键根据哈希结果落到一个 slot(槽),由该 slot(槽)的一个主分片处理,再异步复制给从分片。多个主分片负责不同槽,只是容量分片,不是对同一锁键做多数派确认。因此单锁仍会在所属分片主节点故障、尾部未复制时丢失。hash(哈希) tag(标签)能把锁键和相关状态键放到同一 slot(槽),使 Lua(脚本语言)脚本本地原子执行,但会把流量集中到一个分片;把同一资源复制成多个锁键则会让不同客户端拿到不同锁,直接破坏互斥。迁槽和扩缩容期间,客户端还要处理 MOVED(迁移中标记)、ASK(临时重定向)和请求超时,超时仍可能是未知成功。我的做法是使用集群感知客户端、稳定持有者令牌、有界重试和拓扑监控;关键业务继续由数据库条件、唯一键和栅栏令牌保护。热点锁无法靠增加集群节点横向扩展时,应拆业务资源、合并请求或采用分段库存,而不是把“六主六从”误说成一把锁有六份安全副本。 扩缩容期间我会冻结非必要槽迁移与锁协议版本变更,避免多个变量同时改变。监控按 slot(槽)聚合获取失败、重定向、脚本延迟和热键,发现单槽过载时先限流和请求合并,再评估业务分片。客户端收到重定向后保留原业务幂等键和持有者令牌,不能把一次迁移超时转换成新的业务动作;切换完成后通过旧令牌释放测试和资源端版本核对确认无残留影响。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:同槽的多个键能否原子加锁?回答:可用短 Lua(脚本语言)脚本处理同槽键,但要分析锁顺序、租约和热点,跨槽仍不行。
- 追问:集群故障转移与哨兵有什么共同风险?回答:都可能基于异步复制提升缺少最后锁写入的副本,形成新旧持有者。
- 追问:如何发现热锁?回答:结合键访问统计、获取失败率、等待时间、分片吞吐和业务资源分布定位单槽热点。
- 详细章节
- 问题(综合题):需要同时锁多个资源时,如何避免死锁和部分成功?
- 口述答案:多资源加锁比单锁多出循环等待、部分成功、跨槽原子性和租约不同步四类问题。最基本的防死锁方法是把资源标识规范化并按全局稳定顺序获取,例如始终按仓库、货主、SKU(库存单位)排序;每把锁都有短等待截止时间,任一失败就按逆序比较释放已获得的锁并随机退避。这样能降低循环等待,却不能消除客户端在释放中崩溃、先获得的锁提前过期、跨 Cluster(集群)slot(槽)无法用一个脚本处理,以及已执行外部副作用无法回滚。更重要的设计问题是是否真的需要多锁:库存转移可在数据库事务中对行按固定顺序加锁,支付与订单可由状态机和事务事件编排,避免把跨服务事务模拟成一组租约。若必须协调多个远程资源,应记录操作状态、每步幂等键和补偿动作,把它当可恢复工作流而不是一次原子锁。提交时每个权威资源仍校验版本,任何部分失败进入明确补偿状态。测试要覆盖第二把锁获取超时、第一把锁过期、释放响应丢失和客户端中途崩溃,证明不会永久占用或静默部分完成。 如果业务必须修改多个库存资源,我会优先把它们放入同一数据库事务并按稳定主键顺序访问,让数据库死锁检测和事务回滚承担原子性。跨服务场景则建立操作记录,状态从准备、执行、补偿到完成单向迁移,每步都有幂等键;锁只防止同时启动两个编排器。监控部分成功状态的停留时间,超阈值自动补偿或人工介入,保证系统最终能从任何中断点继续,而不是依赖所有租约同时存活。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:固定顺序是否完全消除死锁?回答:可消除同一规则下的循环等待,但不能解决租约过期、旁路调用和资源端事务死锁。
- 追问:能否把多个键放同一 hash(哈希) tag(标签)?回答:可以获得同槽脚本原子性,但会形成热点,且只适用于同一 Redis(远程字典服务)资源域。
- 追问:部分成功怎样回滚?回答:锁状态可比较释放,业务副作用必须依靠幂等补偿和状态机,不能假定锁释放等于业务回滚。
- 详细章节
- 问题(综合题):请完整说明 RedLock(红锁)的执行步骤和成功条件。
- 口述答案:RedLock(红锁)假设存在 N 个彼此独立的 Redis(远程字典服务)主节点,常见示例是 5 个。客户端记录开始时间,使用同一个高随机持有者令牌,分别以远小于总租约的连接和命令超时尝试在各节点执行不存在才设置并带过期的加锁。完成尝试后统计成功节点数和总耗时;只有成功数超过半数,并且总耗时小于租约,才认为获取成功。真正剩余有效时间还要从租约中扣除获取耗时和时钟漂移预算,业务必须在剩余时间内完成。若未达多数、耗时已超限或客户端主动放弃,应向所有节点发送按令牌比较释放,不能只释放已知成功节点,因为超时节点也可能实际写入。多个主节点必须尽量处于独立故障域,否则同一电源、宿主机或运维误操作会破坏独立性假设。即使算法获取成功,客户端长暂停后仍可能在租约失效时继续写,所以对正确性敏感的资源端还要检查栅栏令牌或业务版本。上线验证包括少数节点故障、网络延迟、响应丢失、时钟调整和客户端暂停,并计算剩余有效期是否覆盖真实临界区。 实施时还要确认节点真的独立:不同主机、供电、网络故障域和发布批次,且不能被同一个自动化命令同时重启。客户端访问多个节点可并行降低总耗时,但仍需统一截止时间并正确统计未知响应。每次成功都记录成功节点数、获取耗时、计算后的剩余有效期和业务完成时间;剩余时间不足时立即放弃,不因已经拿到多数就冒险开始不可逆操作。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:为何每个节点要短超时?回答:避免单个不可达节点耗尽整个租约,使多数判断仍有可用临界区时间。
- 追问:获取失败为何向所有节点释放?回答:超时不等于未写入,向所有节点比较释放可以清理未知成功的残留锁。
- 追问:能否用同一 Cluster(集群)的五个分片代替?回答:要审视故障独立性和单键路由,普通分片不是算法假设中的独立同资源主节点集合。
- 详细章节
- 问题(综合题):如何客观评价 RedLock(红锁)的争议和适用边界?
- 口述答案:我不会简单回答“能用”或“不能用”,而会先区分效率锁和正确性锁。效率锁失效只造成重复计算、额外回源或短暂资源浪费,例如缓存重建;在明确独立节点、超时、租约和降级后,RedLock(红锁)可以降低单节点故障导致的重复概率。正确性锁失效会造成资金错误、库存负数或不可逆外部写,这时不能把时间租约和客户端自觉停止当最终证明。争议核心在于算法依赖时钟速率漂移、通信与进程暂停在预算内,而现实中垃圾回收暂停、宿主机冻结、网络分区和时钟跳变可能超过预算。旧客户端在租约过期后恢复,无法从锁服务收到“撤销执行”的物理强制,新客户端已经获得多数,两者都可能访问资源。多数派只能管理锁记录,不能隔离资源写。因此关键系统应使用资源端 Fencing Token(栅栏令牌)、数据库状态机和唯一约束,或采用提供共识与领导权隔离的协调方案。选型时量化重复执行损失、节点独立性、运维复杂度与尾延迟,故障注入后再决定,而不是因为库里有接口就默认安全。 对正确性锁,我会把评审问题改成“即使两个客户端同时进入,资源会怎样”,这样可以直接暴露系统是否缺少最终防线。若答案是可能重复扣款,就先修账务协议;若只是两次计算同一缓存,则可以接受较宽松方案。技术文档明确列出依赖的时钟、网络和暂停假设,并用超出假设的故障实验观察后果,使团队知道 RedLock(红锁)降低了什么概率、没有保证什么,而不是形成错误安全感。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:有栅栏令牌后还需要 RedLock(红锁)吗?回答:栅栏负责最终拒绝旧写,RedLock(红锁)仍可减少并发执行和资源浪费,是否值得取决于收益。
- 追问:系统时钟不回拨是否就安全?回答:还存在速率漂移、进程暂停、通信尾延迟和资源端不校验问题,不能只控制回拨。
- 追问:面试中如何避免站队?回答:明确算法假设、失败模型和业务损失,给出适用场景与必要兜底,比口号式结论更专业。
- 详细章节
- 问题(综合题):Fencing Token(栅栏令牌)如何解决旧持有者恢复后的写入问题?
- 口述答案:Fencing Token(栅栏令牌)把每次成功授权映射为严格递增序号,例如 A 获得 41,租约到期后 B 获得 42。客户端把序号随业务写发送给真正的资源端;资源端保存已接受的最大序号,只允许新序号大于旧序号,并在同一原子操作中更新数据和最大序号。B 先以 42 完成后,A 从长暂停恢复再提交 41,数据库条件
last_token < 41不成立,更新影响 0 行,旧结果被拒绝。它与随机持有者令牌职责不同:随机令牌用于锁服务比较删除,只需唯一;栅栏令牌用于判断授权新旧,必须单调。令牌服务也必须可靠,若普通 Redis(远程字典服务)计数在主从切换后回退,就可能发出重复或较小序号,破坏证明。可以用数据库事务序列、资源行版本或具备一致性保证的协调服务生成,并按资源维度分片避免全局热点。所有可能接收副作用的写入口都要校验;不能校验令牌的第三方接口,应由本地权威状态机先取得唯一执行权,再用对方幂等键和结果查询保护。 资源端还要定义相等令牌的语义:同一令牌、同一请求摘要可返回历史结果,不同摘要则拒绝并告警,防止重试参数漂移。令牌与业务状态一起持久化,不能先更新数据再异步记录最大令牌,否则崩溃会留下可被旧写覆盖的窗口。监控旧令牌拒绝时保存任务、实例、暂停和拓扑信息,既不把正常重试当事故,也能在拒绝激增时快速定位失锁来源。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。 - 追问与回答:
- 追问:令牌需要连续吗?回答:不需要,允许跳号,只要同一资源的新授权严格大于旧授权。
- 追问:资源端如何原子校验?回答:把版本条件放入数据库更新谓词,或由资源服务在事务内比较并更新最大令牌。
- 追问:A 在 B 之前提交 41 会怎样?回答:资源端可先接受 41,随后 42 仍可覆盖;关键是 42 生效后任何更旧令牌都不能再覆盖。
- 详细章节
- 问题(综合题):如何生成可靠的栅栏令牌,并确保所有下游都执行校验?
- 口述答案:令牌设计有两个问题:谁保证单调,谁执行拒绝。生成端可以使用权威数据库的事务序列、任务行版本原子递增,或具备共识语义的协调服务;若直接使用 Redis(远程字典服务)INCR(自增命令),必须分析持久化、异步复制和故障切换是否会让计数回退,对资金和库存不能在没有证明时使用。为了降低全局序列热点,通常按资源或分片维护单调版本,只要求同一受保护资源内有序。消费端在数据库更新中加入
incoming_token > last_token条件,并在同一事务写入新值;对象存储、文件系统或第三方接口若无法理解令牌,应通过一个资源代理服务统一收口,或先在本地状态机原子取得发送权,再携带业务幂等键调用外部。还要清点旁路:定时补偿、人工后台、旧版本服务和数据修复脚本若不校验,会绕过栅栏。上线前建立写入口清单,通过契约测试发送旧、新、重复和跳号令牌,确认旧令牌统一被拒绝;监控拒绝次数,因为它既证明保护生效,也可能暴露暂停或脑裂问题。 推广前我会给资源服务提供统一校验组件和数据库迁移模板,避免各团队自行实现大小比较而遗漏事务原子性。审计扫描所有写接口是否要求令牌或业务版本,旧客户端在兼容期只能走受控代理。令牌服务故障时关键写选择拒绝,不能退化为随机令牌继续;恢复后验证新令牌严格大于历史最大值,再放开流量。这样单调性、传播和执行三段都有明确责任人和证据。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。 - 追问与回答:
- 追问:数据库自增主键能直接当令牌吗?回答:只有它与同一资源授权关系明确且故障下单调,通常应使用专门版本字段或序列,不要混用业务主键语义。
- 追问:多数据库如何统一校验?回答:尽量把最终接纳权收口到单一权威服务;跨库副作用使用状态机、事务事件和各端幂等,不幻想一个令牌自动形成分布式事务。
- 追问:令牌溢出怎么办?回答:选择足够宽的整数并监控增长速率,协议定义比较和迁移方案;工程上通常可提前多年预警。
- 详细章节
- 问题(综合题):WMS(仓储管理系统)库存防超卖应该怎样组合分布式锁与数据库约束?
- 口述答案:库存的核心不是“同一时刻只有一个请求”,而是可用量永不小于零、同一业务单据只扣一次、冻结与释放可追溯。我的第一道防线是稳定单据号和库存流水唯一键,重复请求直接返回原结果;第二道防线是数据库条件更新,例如按仓库、货主和 SKU(库存单位)执行
available >= quantity的原子扣减,影响 1 行才算成功,影响 0 行表示库存不足或版本冲突;冻结、确认和释放由明确状态机约束。Redis(远程字典服务)锁只在热点 SKU(库存单位)上削减并发,让大量失败请求不同时冲击数据库或重复调用下游,锁键粒度包含租户和库存维度,临界区保持短小。即使主从切换使两名客户端同时进入,数据库条件仍只允许满足不变量的更新,流水唯一键阻止同一单据重复扣减。若吞吐仍不足,可做库存分段、请求合并和异步排队,而不是延长锁。事故恢复通过库存总量等于可用、冻结、占用与已出库的守恒对账,差异按流水补偿。压测同时注入锁过期、数据库超时和消息重投,验证没有负库存且重复请求返回一致结果。 容量设计时还要用数据判断锁是否值得:例如峰值 2 万次每秒请求集中在 100 个热点 SKU(库存单位),先按资源统计条件更新冲突和锁等待;若锁让数据库失败更新下降但尾延迟上升,就要调整粒度或采用请求合并。发布后监控负库存告警、唯一键冲突、条件更新失败和锁丢失兜底次数,任何一项都能还原正确性链路。人工调账和补偿任务也必须走同一流水与版本规则,不能成为旁路。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。 - 追问与回答:
- 追问:数据库条件更新已经正确,为什么还要 Redis(远程字典服务)锁?回答:只为减少热点行竞争、重复计算和下游压力,收益不足时可移除,不承担最终正确性。
- 追问:分段库存会不会卖多?回答:各段有独立非负约束,总量由分配与回收流程守恒,需处理段间不均和补充调度。
- 追问:数据库更新超时怎么办?回答:按单据幂等键查询库存流水和订单状态,结果未知时不直接再次扣减。
- 详细章节
- 问题(综合题):支付回调并发和重复到达时,为什么不能只依赖 Redis(远程字典服务)锁?
- 口述答案:支付回调会因渠道重试、主动查询、消息补发和用户刷新跨时间重复,锁只控制某一短时间窗口,租约过期或主从切换还可能同时放行两个处理者,所以最终必须依赖账务规则。处理流程先验证签名、商户、订单、币种和金额,使用渠道交易号建立数据库唯一约束;事务内按订单当前状态执行允许的状态迁移,例如只允许“待支付”到“已支付”,同时写支付流水、资金分录和 Outbox Pattern(发件箱模式)事件。唯一冲突或状态已完成时读取原结果并向渠道返回成功,避免其继续重试。Redis(远程字典服务)锁可以按订单号合并瞬时并发,降低重复验签和数据库冲突,但锁失败、超时或丢失不应改变记账结果。渠道调用或事务响应超时时进入未知状态,通过渠道查询接口和本地流水确认,绝不凭异常直接再次扣款。下游消费以支付流水号幂等,日终对账比较渠道账单、订单、流水和资金分录,差异进入补单或人工审核。故障演练让锁在两个实例间双持有,验收数据库仍只有一笔有效流水和一组守恒分录。 我还会保存原始回调摘要、验签结果、渠道时间和本地处理版本,便于争议审计,但敏感字段按安全规范脱敏。锁等待设置很短,拿不到时不让渠道持续阻塞,可快速查询已存在结果或把事件入可靠队列;消费者仍按同一交易号处理。对账不仅比订单状态,还检查金额、币种、手续费、退款和结算批次守恒,发现差异时冻结自动补偿范围,避免错误规则批量放大资金影响。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:为什么回调处理完成要返回成功?回答:已处理的重复回调应返回渠道可接受的成功响应,避免无意义重试风暴,具体遵循渠道协议。
- 追问:金额不一致怎么办?回答:拒绝状态迁移并告警,保留原报文和验签证据,不能以渠道成功状态覆盖本地订单金额。
- 追问:主动查询和回调同时到达怎么办?回答:共享同一渠道交易号、唯一键和状态机,谁先提交都可,后者得到同一终态。
- 详细章节
- 问题(综合题):Runner(执行器)调度如何使用租约和栅栏令牌防止重复结果?
- 口述答案:调度系统要把“谁在执行”和“谁的结果能被接纳”分开。节点可以用 Redis(远程字典服务)短租约快速抢占任务,或直接在任务表执行条件更新,把“待执行”改成“运行中”,同时生成递增 execution_version(执行版本)。运行期间定期续租和写检查点,但心跳只代表最近一段时间可达,不是永久执行权。节点 A 暂停超过租约后,扫描器把任务判定为可接管,节点 B 获得更大的版本 42,从最后检查点继续;A 恢复携版本 41 提交时,数据库要求状态仍为运行中且传入版本等于当前版本,更新影响 0 行,旧结果被丢弃。外部步骤携任务编号和步骤号作为幂等键,超时后先查询结果,避免接管后重复调用。任务定义明确最大执行时长、重试次数、退避和死信状态,长任务拆分为可检查点步骤。Redis(远程字典服务)锁降低扫描器和执行器竞争,数据库任务状态、版本和结果唯一约束才保证接纳正确。演练暂停 A、让 B 接管,再恢复 A,确认只有版本 42 的结果进入完成状态,且外部副作用没有重复。 调度容量还要控制租约心跳放大:活跃任务数乘续期间隔得到稳定请求量,批量启动时对心跳打散。任务表记录领取者、版本、最近心跳、截止时间和检查点摘要,排障时能重建谁在何时失去资格。接管不是直接重跑全部逻辑,而是先查询外部步骤结果,再从最后确认检查点继续;版本冲突、旧结果拒绝和超时接管次数都进入监控,用于发现执行器暂停或网络分区。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:任务续租失败是否立即杀线程?回答:先发出取消信号并停止新步骤,无法强制终止的调用依靠幂等和提交版本拒绝。
- 追问:检查点写入会不会成为瓶颈?回答:按步骤或时间批量持久化,权衡重复计算与写放大,并按任务类型配置。
- 追问:扫描器本身重复运行怎么办?回答:领取任务使用条件更新和批次标识,多个扫描器可并发但同一任务只允许一个版本迁移成功。
- 详细章节
- 问题(综合题):调用不支持栅栏令牌的第三方接口时,怎样处理旧持有者和未知结果?
- 口述答案:多数第三方支付、物流或通知接口不会理解内部栅栏令牌,因此不能指望它替我们拒绝旧客户端。解决方法是把唯一发送权收口到本地权威状态机:事务内按订单版本把“待发送”迁移为“发送中”,记录稳定 幂等键、请求摘要和执行版本;只有状态迁移成功的执行者可以发起调用。请求始终携同一个第三方幂等键,网络超时后先使用查询接口按商户单号查结果,不生成新单号重发。旧执行者恢复后,发送前再次读取状态和版本,若已被新执行者接管就停止;若它已经发出请求才暂停,第三方幂等键合并重复,新执行者通过查询得到同一结果。第三方完全不支持幂等与查询时,风险无法被软件协议彻底消除,应缩小自动重试、引入人工确认或更换渠道,不能用 Redis(远程字典服务)锁包装后宣称恰好一次。回调同样进入本地唯一键与状态机,最终通过对账确认。测试通过丢弃请求响应、暂停旧执行者和重复发送,核对第三方只产生一个业务对象或异常进入明确人工队列。 本地还应建立请求与渠道对象的映射,记录每次尝试但只允许一个业务结果生效。补偿扫描按状态停留时间分层,先查询再重试,达到渠道最大时限后转人工;人工操作同样复用原幂等键并留下审计记录。选择渠道时把幂等、查询和对账能力作为可靠性接口,不只比较费率。若对方无法提供任何确认机制,系统只能明确风险上限和人工流程,不能承诺自动化的恰好一次。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:幂等键应该每次重试都变化吗?回答:同一业务动作的重试必须稳定不变,新业务动作才使用新键,否则对方无法合并。
- 追问:状态改成发送中后进程崩溃怎么办?回答:记录租约或更新时间,由补偿扫描器在查询第三方结果后接管,不能直接假定未发送。
- 追问:请求参数变化还能共用幂等键吗?回答:不应,系统应校验相同键对应相同请求摘要,参数冲突需报警和人工处理。
- 详细章节
- 问题(综合题):分布式锁如何与数据库事务、消息和补偿协作?
- 口述答案:锁、事务和消息解决不同问题,不能通过把它们放进一个方法就获得原子性。Redis(远程字典服务)锁协调多个实例尽量不要同时进入;数据库事务保证本库状态和约束原子提交;消息把提交后的事实传播给下游,通常只能做到至少一次,所以消费者必须幂等。我的典型流程是先以有界等待获取短租约锁,事务内执行唯一键检查、状态机条件更新、业务流水和 Outbox Pattern(发件箱模式)事件写入,然后提交事务并尽快释放锁。独立投递器发送事务内事件,成功后标记,失败则重试;消费者按事件标识或业务版本去重。不能在持有数据库行锁时长时间等待 Redis(远程字典服务)锁,也不能在数据库事务中调用不可控外部接口,否则会放大死锁和连接占用。若锁在事务前丢失,事务条件仍保护正确性;若事务提交成功但响应丢失,幂等查询返回原结果;若消息重复,下游去重;若消息长期失败,扫描和告警补偿。验收分别注入锁丢失、事务回滚、提交后宕机、消息重复和消费失败,检查业务状态与事件最终一致,而不是只走一次顺利路径。 流程中还要避免锁租约短于数据库事务:事务开始前检查剩余时间,预计不足就放弃并重新获取,事务内只做必要查询和写入。事件投递状态不能与业务事实分离更新,否则会出现业务成功但永久无消息;消费者处理完成也用业务版本确认,而不是只提交消息位移。运维看板把锁竞争、事务冲突、待投递事件、消费重试和补偿积压串成一条链,才能判断瓶颈和一致性缺口位于哪层。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:先开事务还是先加锁?回答:通常先获取短锁再开短事务,避免持有数据库资源等待远程锁;仍要结合锁顺序统一设计。
- 追问:事务提交后释放锁失败怎么办?回答:比较释放可重试,最终租约回收;业务已经由事务和幂等确定,不应回滚已提交事实。
- 追问:消息恰好一次能否省幂等?回答:跨系统端到端很难只靠消息中间件保证,消费者业务状态仍应幂等处理。
- 详细章节
- 问题(综合题):线上出现锁等待暴增或重复执行,你如何排查?
- 口述答案:我先区分“拿不到锁”和“锁失效后重复执行”两类现象。影响面上确认资源键、租户、请求量、等待高分位、错误率和业务不变量是否受损;资金或库存异常优先关闭高风险写入和保留证据。锁侧采集活跃锁数、持有时长、剩余租约、获取失败、续期失败、错误解锁、脚本耗时、连接超时和 Redis(远程字典服务)事件循环延迟;客户端侧查看线程栈、垃圾回收暂停、调度线程是否饥饿、连接池和重试次数;拓扑侧对齐主从偏移、故障切换、网络分区、MOVED(迁移中标记)与 ASK(临时重定向)比例。业务侧查询幂等流水、状态版本、条件更新失败和栅栏拒绝次数。等待暴增常见于热键、临界区内慢外部调用、漏解锁、租约过长或重试同步;重复执行常见于租约过期、续期失败、主从切换和未知结果重试。止血可限流、缩短临界区、暂停非关键任务、切换到预案中的数据库条件路径,不能直接批量 DEL(删除键)。复盘要重放时间线并故障注入验证修复。 排障证据必须统一时间基准,至少保留客户端请求标识、持有者令牌摘要、锁节点角色、复制偏移、业务版本和数据库事务时间。若怀疑垃圾回收暂停,读取暂停区间并与续期计划对齐;若怀疑网络分区,核对两侧连接与哨兵选主日志;若怀疑热键,按资源统计竞争而非只看实例总量。修复后重放同一故障,旧持有者必须被资源端拒绝,等待队列和重试流量也要在预算内恢复。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:能否直接删除长时间锁键?回答:先确认持有者、业务状态和租约,盲删可能制造双持有者;应通过受控脚本和业务接管流程处理。
- 追问:栅栏拒绝次数增加说明什么?回答:可能是正常保护旧提交,也可能暴露长暂停、重复调度或脑裂,需要关联客户端与拓扑事件。
- 追问:如何识别热锁?回答:按锁键维度统计竞争次数、等待时间和持有时长,结合业务分布判断是否粒度过粗或单资源热点。
- 详细章节
- 问题(综合题):请给出分布式锁选型和是否使用的决策框架。
- 口述答案:我的决策从业务不变量开始,而不是从中间件开始。第一问是重复执行的损失:只是重复计算,可选择简单租约或请求合并;会导致资金、库存或外部不可逆操作,先建设唯一键、状态机、条件更新和对账。第二问是资源权威位置:单 JVM(Java 虚拟机)内存用本地锁,数据库行状态优先数据库条件或短事务,跨实例短临界区且吞吐敏感可用 Redis(远程字典服务)锁,少量强协调和顺序排队可评估 Zookeeper(分布式协调服务)。第三问是故障模型:是否接受主从切换丢锁、网络分区拒写、客户端暂停和时间租约假设;不能接受旧写时必须让资源端校验栅栏令牌。第四问是临界区:是否足够短、可取消、无不可控远程调用,租约和续期容量能否量化。第五问是运维:监控、限流、降级、补偿和故障演练是否具备。最后用对照压测验证锁是否真正降低冲突和成本;若数据库条件更新已满足吞吐,删除锁往往更简单可靠。设计文档明确保证与不保证的性质,避免团队把低概率窗口误认为不存在。 决策结果应产出一页保证矩阵:正常并发、客户端崩溃、垃圾回收暂停、节点故障、网络分区和时钟异常下,系统保证什么、拒绝什么、如何恢复。每项保证都有责任组件和实验编号,例如“数据库条件更新阻止负库存”“令牌比较阻止旧任务结果”。上线门槛不是锁库的单元测试,而是跨层故障演练通过、监控可观测、降级容量已压测;业务变化后还要重新评估临界区和损失模型。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:什么情况下坚决不用 Redis(远程字典服务)锁?回答:业务把它当唯一资金或库存正确性来源、无法接受双持有者且下游又不能校验时,不应使用该方案兜底。
- 追问:如何比较 Redis(远程字典服务)与 Zookeeper(分布式协调服务)?回答:比较一致性语义、延迟、顺序需求、会话模型、故障域和团队运维能力,不用单一性能标签决策。
- 追问:锁粒度如何验证?回答:观察冲突资源分布、并发收益和跨资源不变量,做粗细两组压测并检查正确性边界。
- 详细章节
- 问题(综合题):如果让你从零设计一个生产级 Redis(远程字典服务)分布式锁方案,你会怎么讲?
- 口述答案:我会先声明目标:这是跨实例短临界区的低延迟协调,不承诺单独保证资金或库存正确。协议使用按租户和资源规范化的锁键,每次获取生成高熵持有者令牌,以单条 SET key(键) token(令牌) NX PX(键不存在时设置持有者令牌并设置毫秒租约)建立所有权和租约;获取失败采用有界等待、指数加随机退避,超时视为结果未知并保留令牌查询。释放和续期都使用 Lua(脚本语言)原子比较,业务设置截止时间,失锁后停止新副作用。耗时波动任务可使用经过版本核对的 Redisson(Redis 客户端)watchdog(看门狗),同时监控续期容量和失败;临界区保持短小,不包含不可控远程调用。拓扑方面明确单机不可用、主从与 哨兵可能丢未复制锁、Cluster(集群)单锁只在一个分片,不把高可用误写成强一致。正确性关键场景在资源端使用数据库唯一键、条件更新、状态机和 Fencing Token(栅栏令牌);外部接口用稳定幂等键和结果查询。最后建设指标、限流、补偿和故障演练,主动制造暂停、分区、切换和丢响应,证明双持有者出现时旧结果仍被拒绝。 工程交付还包括统一客户端封装、版本锁定、超时默认值、指标与告警、应急开关和使用规范,禁止业务自行拼接 SETNX(不存在则设置)与过期命令。代码评审检查稳定幂等键、finally(最终清理)释放、剩余租约、数据库条件和外部结果查询。容量评估覆盖正常请求、批量续期和故障重试;灾难演练报告记录是否出现双持有者、旧写是否被拒绝和恢复耗时。只有这些证据齐备,方案才是生产级,而不是一段能运行的加锁代码。 最终验收不以“锁命令返回成功”为标准,而是故障注入后检查唯一流水、合法状态迁移、旧令牌拒绝和恢复时长;指标、日志和补偿记录必须能重建完整时间线。
- 追问与回答:
- 追问:这套方案最重要的一句话是什么?回答:锁降低并发冲突,资源端约束决定最终正确性,两者不能互相替代。
- 追问:什么时候引入 RedLock(红锁)?回答:只有评估独立故障域、时间假设、复杂度与重复执行损失后,把它作为降低协调失败概率的手段,而非取消下游兜底。
- 追问:上线前最低限度做哪些实验?回答:并发互斥、错误令牌释放、超租约暂停、主从切换、响应丢失、续期失败、重试风暴和旧栅栏令牌拒绝。
- 详细章节
15. 复习与实战检查清单
- 能从锁的保护范围解释为什么 JVM(Java 虚拟机)锁不能跨实例。
- 能画出 SET key(键) token(令牌) NX PX(键不存在时设置持有者令牌并设置毫秒租约)、Lua(脚本语言)释放和续期时序。
- 能演绎主节点返回成功、复制前故障、从节点提升后的双持有者窗口。
- 能解释 Redisson(Redis 客户端)Hash(哈希)可重入计数、Pub/Sub(发布订阅)唤醒和 watchdog(看门狗)失效条件。
- 能说明 哨兵与 Cluster(集群)提升可用性,却不让单锁天然成为强一致多数派。
- 能客观讲清 RedLock(红锁)的多数派、有效租期、独立故障与时间假设。
- 能区分 owner token(持有者令牌)、Fencing Token(栅栏令牌)、业务幂等键和乐观版本号。
- 能写出资源端拒绝旧栅栏令牌的条件更新,并说明所有写入口都要校验。
- 能用 WMS(仓储管理系统)库存、支付回调和 Runner(执行器)任务分别说明锁的优化角色与权威兜底。
- 能按影响确认、证据采集、止血、根因、修复、故障演练组织锁事故复盘。
