分布式(一):CAP、一致性理论与分布式锁
分布式(一):CAP、一致性理论与分布式锁
导语:分布式面试的第一道分水岭不是"会不会用框架",而是能不能把"一致性"讲清楚。本篇从 CAP / BASE 这对经典取舍出发,补齐一致性模型的谱系(线性一致、顺序一致、因果一致、最终一致),再讲清一致性的实现基础——FLP 与两军问题、物理时钟的不可靠、Quorum NWR、奇数节点与脑裂,然后落到 Raft 的共识机制与 Paxos 的对比,最后给出"一致性的落地形态"——分布式锁(Redis / ZooKeeper / Redlock)的实现与选型,共 17 题。
一、CAP 与 BASE
1. CAP 定理是什么?为什么说"只能三选二"?
答: CAP 指分布式系统在以下三特性中最多同时满足两个:
- C(Consistency 一致性):每次读取都能拿到最新写入的结果(线性一致)。
- A(Availability 可用性):每个非故障节点收到的请求都能在有限时间内返回非错误响应(不保证最新,但不能拒绝或超时)。
- P(Partition tolerance 分区容错性):网络分区(节点间失联)发生时,系统仍能继续对外提供服务。
为什么"三选二"(更准确的说法是:P 必选,只能 C 与 A 取舍):
分布式系统的节点之间靠不可靠网络通信,网络分区(丢包、超时、机房断网)是必然事件而不是异常,因此 P 无法放弃——放弃 P 意味着"只部署一个节点",那就不是分布式系统了。
| 取舍 | 分区期间的行为 | 典型系统 |
|---|---|---|
| CP | 牺牲可用性:少数派分区拒绝写入/返回错误,保证数据一致 | ZooKeeper、etcd、HBase、TiKV |
| AP | 牺牲一致性:各分区继续服务,可能返回旧数据,事后收敛 | Eureka、Nacos(临时实例)、Cassandra、DynamoDB |
| CA | 在分布式场景不存在(只有单机或"假设网络永远不分区"的系统才谈得上) | 单机 MySQL、单点 Redis |
三个常见误区:
- "三选二 = 可以选 CA":错。分布式系统里 P 必选,真正的选择只有 CP 或 AP。
- "CP 系统永远强一致":错。CP 只是分区期间牺牲可用性来保一致;不发生分区时它既可用也一致。
- "AP 系统就是数据不可靠":错。AP 追求的是最终一致,不一致窗口通常只有毫秒到秒级。
答题加分点:CAP 常被追问"那 BASE 呢?"——BASE 正是AP 方向的工程化延伸(见下一题);再追问"分区只能在 C 和 A 之间二选一吗?"——可以答:分区期间能在不同操作上做不同取舍(如核心交易 CP、商品浏览 AP),这就是"分区时按业务分级降级"。
2. BASE 理论是什么?与 CAP 的关系?
答: BASE 是对 CAP 中 AP 方向的工程化延伸,承认无法时刻强一致,转而追求最终一致:
- Basically Available(基本可用):故障时允许有损服务——响应变慢、部分非核心功能不可用,而不是整体宕机。常见手段:降级(返回兜底数据)、限流排队。
- Soft state(软状态):允许系统中存在中间状态(数据副本同步有延迟,如"支付中""库存扣减中")。
- Eventually consistent(最终一致性):经过一段时间同步后,各副本最终达到一致(如订单状态异步通知、MySQL 主从延迟)。
与 CAP 的关系:
| CAP | BASE | |
|---|---|---|
| 定位 | 理论取舍(分区时 C 还是 A) | 工程实现路径(放弃强一致后怎么活得下去) |
| 目标 | 描述系统边界 | 给出落地手段(降级、异步、补偿、幂等) |
| 一致性 | 强一致(CP)或最终一致(AP) | 明确选择最终一致,且必须自己补上兜底 |
关键认知:BASE 不是"不保证一致性",而是把一致性从"强同步"换成"异步 + 补偿 + 幂等 + 对账"——它对接的正是分布式事务(TCC / Saga / 消息最终一致)那一整套方案(见《分布式(二)》)。
例:12306 高峰期把查询请求"排队限流"(基本可用),订单状态先落库再异步通知(软状态),支付结果最终一致(最终一致)——典型 BASE 实践。
3. 强一致性、弱一致性、最终一致性如何区分?
答: 三者是一条一致性谱系上的不同强度:
| 级别 | 含义 | 读到的数据 | 典型场景 |
|---|---|---|---|
| 强一致(线性一致) | 写成功后,任何后续读都能读到最新值,且全局有统一顺序 | 必是最新值 | 单库事务、ZK / etcd 读、Redis 主节点读 |
| 弱一致 | 读不保证读到最新值,也不保证多久能读到 | 可能是旧值 | 未收敛的异步复制 |
| 最终一致 | 弱一致的一种:经过一段时间后保证收敛一致 | 短暂旧值后一致 | DNS、MySQL 主从延迟读、消息驱动的数据同步 |
容易混淆的两个"强一致"(高频追问):
- 线性一致(Linearizability):关注单个对象的读写实时性——"写完成后,任何节点立刻读到新值"。它是 CAP 里的 C。
- 可串行化(Serializability):关注多个对象组成的事务的等价串行效果——它不要求实时顺序。所以在分布式数据库里常出现"可串行化但不线性一致"的组合;两者叠加才是 Strict Serializable。
- 另外,顺序一致(Sequential Consistency) 介于两者之间:所有节点看到的顺序一致,但不要求与实际时间对齐(弱于线性一致)。
中间一致性模型(按需选择,面试加分):
| 模型 | 解决的问题 |
|---|---|
| 因果一致(Causal) | 有因果关系的操作必须保序,无因果的可以乱序(性能好,如 MongoDB 会话因果一致) |
| 读己之所写(Read Your Writes) | 用户自己写完立刻能读到(下发写请求时粘住同一节点) |
| 单调读(Monotonic Reads) | 同一用户不会读到"更旧"的版本(用版本号/单调时间戳判断) |
| 单调写(Monotonic Writes) | 同一用户的写操作按顺序生效 |
实践结论:绝大多数业务不需要强一致,只需要「读己之所写 + 单调读」;真正要求线性一致的通常只有库存扣减、余额、分布式锁、元数据配置这几类。答题时按业务场景分级,比背定义更得分。
二、ACID 与事务模型
4. ACID 与 BASE 的区别与联系?
答:
- ACID(本地事务四特性):
- A 原子性:事务内操作要么全成功要么全回滚;
- C 一致性:事务前后数据满足业务约束(由 A、I、D 共同保证);
- I 隔离性:并发事务互不干扰(靠锁 / MVCC);
- D 持久性:提交后不丢失(靠 redo log / WAL)。
- 追求强一致,是单机关系型数据库的设计哲学。
- BASE:基本可用、软状态、最终一致,是分布式系统对 CAP 中 AP 方向的取舍,牺牲强一致换可用性与扩展性。
联系:二者不是对立的,而是一致性谱系的两端——ACID 是"刚性"、BASE 是"柔性"。一个真实的系统往往同时使用两者:
- 核心资金链路(下单、扣款、账务)用 ACID 强一致(本地事务 + XA / TCC);
- 外围链路(通知、积分、统计、搜索索引)用 BASE 最终一致(消息驱动、补偿、对账)。
一句话:ACID 管"钱不能错",BASE 管"信息最终对得上"。
5. 刚性事务与柔性事务分别指什么?怎么区分?
答:
| 维度 | 刚性事务 | 柔性事务 |
|---|---|---|
| 理论基础 | ACID,满足 CAP 的 CP | BASE,满足 AP |
| 典型实现 | XA(2PC / 3PC、JTA / JTS)、数据库原生 XA | TCC、Saga、本地消息表、可靠消息(RocketMQ 事务消息)、最大努力通知 |
| 一致性与隔离 | 与本地事务同等强一致,有原生回滚与隔离性 | 最终一致,存在中间态,无全局隔离 |
| 业务侵入 | 无侵入(框架托管) | 需业务改造(写 Try/Confirm/Cancel 或补偿逻辑) |
| 性能 | 同步阻塞、持锁时间长,并发低 | 异步化、不长期持锁,并发高、可扩展 |
| 适用 | 短事务、强一致、低并发(内部系统、少量跨库写) | 长流程、跨多服务、高并发(电商下单、旅行订票) |
选型口诀:短、强一致、核心 → 刚性(XA);长流程、高并发、可容忍最终一致 → 柔性。 互联网场景 95% 以上选柔性——因为 XA 的同步阻塞与全局持锁在秒杀级并发下不可接受(详见《分布式(二)》)。
三、一致性的实现基础
6. FLP 定理与两军问题分别说明了什么?工程上怎么绕过?
答: 这两个是分布式共识的理论天花板,面试常用来考察"是否理解为什么要超时和重试"。
(1)两军问题(Two Generals' Problem)
两支军队分处山谷两侧,只能靠可能被截获的信使通信,必须约定同时进攻。A 发"明早进攻"→ B 确认 → A 再确认 B 的确认 → ……确认的确认可以无限递归,永远无法让双方 100% 确信对方已知情。
- 结论:在不可靠信道上,不存在 100% 可靠的共识协议。
- 工程意义:既然"绝对确认"不可能,那就用 超时 + 重试 + 幂等 + 去重 来工程化处理——这正是"至少一次投递 + 消费幂等"成为标配的原因(见《分布式(二)》)。
(2)FLP 定理(Fischer-Lynch-Paterson)
在完全异步的网络中(消息延迟无上界),即使只有一个进程可能崩溃,也不存在同时保证"安全性(不会出错)"和"活性(最终一定会达成一致)"的确定性共识算法。
工程上如何绕过 FLP:
| 手段 | 说明 | 实例 |
|---|---|---|
| 引入超时(部分同步假设) | 给消息延迟加一个上界,超时就认为节点故障 | Raft / Paxos 的选举超时、心跳超时 |
| 随机化 | 用随机退避打破对称、避免活锁 | Raft 选举超时在 150~300ms 随机取值 |
| 多数派(Quorum) | 用"多数派达成"替代"全体达成",容忍少数派失联 | Raft / ZAB 的多数派提交 |
答题模板:两军问题说明"绝对共识不可能",FLP 说明"异步网络下确定共识不可能",所以工程上用"超时+随机化+多数派"在可用性与一致性之间做取舍——这恰好又回到了 CAP。
7. 分布式系统为什么不能依赖物理时钟?
答: 因为物理时钟不可靠:NTP 校时可能回拨(时间倒流)、机器晶振存在漂移、跨机房存在时钟偏差。而分布式系统里经常需要判断"事件 A 是否发生在事件 B 之前",直接用时间戳会得到错误结论(先发生的事件时间戳反而更大)。
解决方案(由弱到强):
| 方案 | 原理 | 特点 |
|---|---|---|
| Lamport 逻辑时钟 | 维护单调递增计数器:本地事件 +1;发消息带上时钟;收到消息取 max(本地, 消息)+1 | 只保证偏序(若 A→B 则 T(A) < T(B),反之不成立),无法判断并发 |
| 向量时钟(Vector Clock) | 每个节点维护一个向量,能判断"因果先后"与"并发冲突" | 可检测冲突(Dynamo / Riak 用),但向量随节点数增长,不适合大集群 |
| 混合逻辑时钟(HLC) | 物理时间 + 逻辑计数,兼顾接近真实时间与单调性 | CockroachDB、MongoDB 集群时间 |
| TrueTime(Google Spanner) | GPS + 原子钟给出误差区间 [earliest, latest],提交时 commit-wait 等待 2ε | 能实现外部一致(线性一致),代价是需要专用硬件与等待延迟 |
落到工程上的三个结论:
- 不要用时间戳判断事件因果,要用逻辑时钟/版本号/自增序列。
- 依赖时间的方案必须处理时钟回拨——雪花算法的 ID 生成就是典型(见《分布式(二)》)。
- 单机内可以用单调时钟(
System.nanoTime()/CLOCK_MONOTONIC)算耗时,但跨机不能用它排序事件。
8. Quorum NWR 机制是什么?
答: Quorum NWR 通过配置读写所需的副本数,在一致性与可用性之间做可调取舍(Dynamo / Cassandra 的经典思路):
- N:副本总数(副本因子);
- W:一次写操作成功所需的最少副本数;
- R:一次读操作成功所需的最少副本数。
核心公式:
W + R > N → 读写集合必有交集 → 读一定能读到最新写入(强读)
W + R ≤ N → 读写可能不重叠 → 只能保证最终一致(弱读)N = 3 时的配置取舍(面试常考):
| W | R | 行为 | 取舍 |
|---|---|---|---|
| 1 | 1 | 最快,但可能读到旧值 | 高可用、弱一致 |
| 2 | 2 | W + R = 4 > 3,强读 | 平衡,最常用 |
| 3 | 1 | 写要等全部副本,读很快 | 写可用性差(任一副本挂就写失败) |
| 1 | 3 | 写很快,读要等全部副本 | 读可用性差 |
| 2 | 1 | W + R = 3 = N,不保证读到最新 | 弱一致 |
两个配套机制(加分点):
- 读修复(Read Repair):读时发现副本版本不一致,顺手把最新值写回旧副本,让数据自然收敛。
- 反熵(Anti-Entropy):后台用 Merkle 树对比副本差异并同步,处理"长期没被读到的旧副本"。
工程对照:
- Kafka:
acks=all+min.insync.replicas本质是W = min.insync.replicas、N = 副本数; - Elasticsearch:
wait_for_active_shards/_primary_term+_seq_no处理写一致; - Redis:
WAIT numreplicas timeout可让写等待指定副本数(但不保证强一致,仅提升把握)。
注意:NWR 保证的是"能读到最新已提交值",不等于线性一致——它没有全局顺序与并发写冲突解决的语义,所以面试被追问"NWR 是强一致吗"时,要答"是强读(read-your-latest)而不是全局线性一致"。
9. 为什么分布式集群推荐部署奇数个节点(2n + 1)?
答: 因为共识协议靠多数派(Quorum,大于半数)决策,而多数派的容错能力与"到下一个多数派的距离"有关:
| 节点数 | 多数派 = ⌊N/2⌋ + 1 | 可容忍故障数 | 说明 |
|---|---|---|---|
| 2 | 2 | 0 | 挂一个就失去多数派,比单机还差 |
| 3 | 2 | 1 | 最低可用配置 |
| 4 | 3 | 1 | 与 3 节点容错相同,多花一台机器 |
| 5 | 3 | 2 | 推荐 |
| 6 | 4 | 2 | 与 5 节点容错相同,浪费 |
| 7 | 4 | 3 | 更高可用,但心跳/复制开销上升 |
结论:偶数节点容错能力与"少一个的奇数节点"相同,但成本更高,所以推荐 3 / 5 / 7。
其他两个加分理由:
- 避免平票:偶数节点在网络分区时可能两边各占一半,无法选出 Leader,陷入长期不可用;奇数节点天然存在多数派。
- 跨机房部署:ZK 官方推荐 3 机房按 2 : 2 : 1 或 5 节点按 2 : 2 : 1 分布,保证任一大机房故障后仍有多数派存活。
注意:节点数不是越多越好——Raft/ZAB 的写需要多数派确认,节点越多,复制与心跳开销越大、写延迟越高;生产多为 3 或 5。
10. 什么是脑裂(Split-Brain)?分布式系统如何防止脑裂?
答: 脑裂指网络分区导致集群同时出现两个(或多个)自认为"主"的节点,两边都接受写入——轻则数据互相覆盖、重则永久不一致(如两个 MySQL 主、两个 Redis 主、两个 ES master)。
典型场景:主从 + 哨兵、MySQL MHA / MGR、Redis Sentinel / Cluster、ES 选主、ZK 集群。
防止手段(按思路分类):
| 手段 | 说明 | 实例 |
|---|---|---|
| 多数派仲裁(Quorum) | 只有拿到多数派票的节点才能成为主/提交写,少数派分区自动停写 | Raft/ZAB、ZK、ES(7.x 前 discovery.zen.minimum_master_nodes=N/2+1,7.x 起自动) |
| 见证节点(Witness/Tiebreaker) | 用半个节点(只有投票权、不存数据)打破平票 | Redis Sentinel 的 quorum、SQL Server 见证节点 |
| 租约(Lease) | 主节点持有的"领导权"有期限,到期未续则自动失效,防止老主长期乱写 | MySQL 主备切换、HDFS、Kafka Controller |
| Fencing(隔离 / STONITH) | 切主前先强制隔离旧主(关机、断网、吊销存储访问),确保"旧主写不进去" | 数据库 HA 工具、分布式集群运维 |
| 客户端/网关路由保护 | 客户端只认可多数派协商出的主;连不上主就快速失败,不乱连 | Redis Cluster MOVED 重定向、影子主 |
答题结构:先定义(分区导致双主)→ 说危害(双写冲突、数据错乱)→ 讲防治(多数派说了算 + 旧主必须被隔离)→ 举例(Redis Sentinel 的 quorum/min-replicas、ES 的 minimum_master_nodes)。
一句话:防脑裂的本质是"让任何一个分区都无法独自构成多数派,且旧主必须先被隔离才能切主"。
四、共识算法
11. Raft 的核心思想是什么?与 Paxos 有什么区别?
答: Raft 把共识问题拆成三个可独立理解的子问题,并用"强 Leader"简化整体设计,因此比 Paxos 更易理解与实现:
- Leader 选举(Leader Election):节点只有三种状态 Leader / Follower / Candidate;靠任期(term)标识时代,Follower 在随机选举超时(150~300ms)内没收到心跳就自增任期、发起选举,获得多数派票者成为新 Leader。
- 日志复制(Log Replication):所有写请求都走 Leader;Leader 追加日志并并行复制给 Follower,多数派确认后提交(commit)并通知所有节点应用状态机。
- 安全性(Safety):通过选举限制与提交规则保证"已提交的日志不会被覆盖"(下一题展开)。
注意:任何时刻最多只有一个 Leader,它由多数派选举产生,任期单调递增——因此即便出现网络分区,少数派分区里的旧 Leader 也无法提交任何写。
与 Paxos 的对比:
| 维度 | Paxos(Basic / Multi) | Raft |
|---|---|---|
| 定位 | 共识的理论原型,描述"如何对单个值达成一致" | 面向工程实现的共识协议(可视为 Multi-Paxos 的工程化) |
| 角色 | Proposer / Acceptor / Learner(对等) | Leader / Follower / Candidate(强 Leader) |
| 流程 | 两个阶段:Prepare/Promise → Accept/Accepted | 选举 + 日志复制(日志本身即"多轮 Paxos") |
| 日志空洞 | Multi-Paxos 允许日志有空洞、乱序提交 | 日志连续、按序提交,不允许空洞 |
| 活锁 | 两个 Proposer 互相顶掉提案,可能活锁 | Leader 唯一,配合随机超时不存在活锁 |
| 可理解性 | 极难理解(Raft 论文的动机就是"Paxos 太难懂") | 相对容易,配套成员变更/快照定义 |
| 工程实现 | Chubby、部分自研系统 | etcd、Consul、TiKV、RocketMQ DLedger、Nacos(Raft 模式) |
顺带一提(加分):
- ZAB(ZooKeeper 用)与 Raft 很像:也有 Leader、也在多数派后才提交,区别在阶段划分——ZAB 分广播(Broadcast)与恢复(Recovery)两阶段,主要用于原子广播(保证所有副本按同一顺序执行);Raft 则以"日志复制"为主线。
- 面试若被问"Paxos 和 Raft 谁更好":Paxos 是理论基石,Raft 是工程实现,不存在谁替代谁,落地选 Raft 系生态更成熟。
12. Raft 如何保证日志的一致性?选举限制与提交规则是什么?
答: Raft 用三条规则保证"已提交的日志绝不会被覆盖":
(1)日志匹配特性(Log Matching Property)
- 若两个日志在同一 index 且同一 term,则它们内容相同;
- 且该条日志之前的所有日志也完全相同(通过
prevLogIndex/prevLogTerm一致性检查递归保证)。 - 一旦不符,Follower 拒绝追加,Leader 回退 index 重试,直到找到共同前缀,用 Leader 的日志覆盖 Follower 的冲突日志。
(2)选举限制(Election Restriction)
候选人的日志必须至少和多数派中任何节点一样新才能当选:
(lastLogTerm 更大) 或 (lastLogTerm 相同 且 lastLogIndex 更大) → 才有资格当选- 含义:只有"日志更全"的节点才能当 Leader,因此已提交的日志不可能出现在落选节点上被"漏掉"。
- 这是"已提交日志不会丢失"的关键一环——多数派已提交的日志,必然至少存在于多数派节点中,而候选人要拿到多数派票,就必然会碰上至少一个"拥有该日志"的节点,从而不会当选为"日志更旧"的 Leader。
(3)提交规则(Commit Rule)
Leader 只能直接提交"当前任期"的日志,不能仅凭"多数派已复制"就提交旧任期的日志。
- 原因:旧任期日志可能被后续 Leader 覆盖(经典反例见 Raft 论文 Figure 8)。
- 实践中:Leader 当选后先追加一条本任期的空日志(no-op),一旦它被多数派复制并提交,它之前的所有日志就顺带被安全提交。
- 提交后通知所有 Follower 更新
commitIndex并应用到状态机。
补充三个高频追问:
| 追问 | 回答要点 |
|---|---|
| 网络分区时旧 Leader 会怎样? | 它所在的少数派永远拿不到多数派确认,commitIndex 无法推进,所有写请求挂起或失败;多数派分区选出新 Leader 后,旧 Leader 回归时会自动回退位 Follower 并覆盖冲突日志 |
| 为什么日志必须连续、不允许空洞? | Raft 用 commitIndex 之前的日志"必定一致"这一不变量简化实现;空洞会让"提交点"无法单调推进 |
| 日志无限增长怎么办? | 用快照(Snapshot)+ InstallSnapshot RPC 压缩日志:把状态机快照与 lastIncludedIndex/Term 落盘,Leader 对落后太多的 Follower 直接发快照 |
一句话总结:选举限制保证"选出来的 Leader 日志足够新",提交规则保证"旧任期日志不被误提交",日志匹配特性保证"不一致的日志一定被覆盖"——三者合起来,Raft 才能保证已提交的日志永不丢失、永不冲突。
五、分布式锁
分布式锁是一致性最典型的落地形态:同一时刻只允许一个操作者进入临界区。它既是防止并发冲突的基础设施,也与「幂等」互为补充——锁会失效,幂等不会(幂等见《分布式(二)》第 16-18 题)。
13. 分布式锁需要满足哪些条件?常见实现怎么对比?
答: 分布式锁是在多个进程 / 多台机器之间实现的互斥。一个合格的分布式锁必须满足:
| 条件 | 含义 | 不满足的后果 |
|---|---|---|
| 互斥性 | 任意时刻,同一把锁只有一个客户端能持有 | 并发写冲突、重复扣款 |
| 防死锁 | 客户端崩溃 / 网络断开后锁能自动释放 | 锁永久占用,业务全面阻塞 |
| 谁加锁谁解锁 | 不能"误删"别人的锁 | 客户端 A 的锁被 B 释放,并发进入临界区 |
| 可重入(可选) | 同一线程可重复加锁(计数) | 递归/嵌套调用自己把自己锁死 |
| 高可用 + 容错 | 锁服务不能是单点;故障后锁语义仍可预期 | 锁服务挂掉 → 全站不可用 |
| 性能与公平性 | 加解锁耗时可接受;是否支持等待与公平排队 | 自旋打爆 CPU、饥饿 |
常见实现对比:
| 实现 | 一致性模型 | 性能 | 防死锁 | 可重入 | 适用 |
|---|---|---|---|---|---|
| Redis | AP(主从异步复制,可能丢锁) | 最高(毫秒级) | 靠过期时间 + 看门狗 | 需自行实现(Redisson 支持) | 高并发、可容忍极小概率丢锁 |
| ZooKeeper | CP(多数派写,不丢锁) | 中(写要多数派) | 靠临时节点 + 会话失效 | 需自行实现 | 强一致、不可丢锁 |
| etcd | CP(Raft) | 中 | 靠 Lease(租约)+ Revision | 无原生 | 云原生、K8s 生态 |
数据库(唯一索引 / SELECT ... FOR UPDATE) | 强一致(单库) | 最差 | 需手动清理(无自动过期) | 悲观锁天然可重入 | 低频、短临界区、无 Redis/ZK 时兜底 |
重要认知:没有任何一种锁能在所有故障模型下 100% 保证互斥(客户端 GC 停顿、网络延迟、时钟漂移都会让"锁已释放但业务还在跑")。所以生产上正确的姿势是:分布式锁 + 业务幂等兜底,而不是指望锁解决一切。
14. Redis 如何实现分布式锁?需要注意什么?
答:
(1)加锁:用一条原子命令,不要用 SETNX + EXPIRE 两步
SET lock_key unique_value NX PX 30000NX:key 不存在才设置(互斥);PX 30000:过期时间 30s(防死锁);unique_value:用 UUID + 线程 ID 之类唯一值,保证"只删自己的锁"。- 必须是一条命令:
SETNX成功后再EXPIRE,如果中间宕机就会产生永不过期的锁(死锁)。
(2)解锁:必须用 Lua 脚本保证"比对 + 删除"的原子性
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end先 GET 判断是不是自己的锁,再 DEL。若分两步执行,判断通过后、删除前锁恰好过期并被别人抢到,就会误删他人的锁。
(3)四个必须注意的坑:
| 坑 | 说明 | 解法 |
|---|---|---|
| 锁过期但业务没跑完 | 业务执行时间 > 锁 TTL,锁提前释放,别人进来了 | 看门狗(Watchdog)自动续期:Redisson 默认 lockWatchdogTimeout=30s,后台线程每 1/3 超时时间(10s)续一次,直到显式解锁或客户端下线 |
| 误删他人锁 | DEL 前不做值比对 | 上面的 Lua 比对删除 |
| 不可重入 | 同一线程再次加锁会失败 | 用 Redis Hash:key → {uuid:threadId: 重入次数},加锁 HINCRBY、解锁递减到 0 才删 key(Redisson 的实现) |
| 主从切换丢锁 | 主节点写完锁还没同步到从就宕机,从节点升级后锁"消失" → 两个客户端同时持锁 | ① Redlock(多独立节点多数派);② 或用 ZK/etcd;③ 工程上多为接受极小概率 + 业务幂等兜底 |
(4)获取锁失败怎么办?
- 自旋重试:循环
SET NX,简单但有网络与 CPU 开销; - 订阅通知(推荐):
SUBSCRIBE锁释放的 channel,别人解锁时收到消息再抢(Redisson 的tryLock(waitTime)就是"自旋 + 发布订阅"混合实现),避免无脑轮询。
一句话:一条
SET NX PX加锁 + Lua 比对删除解锁 + 看门狗续期 + 业务幂等兜底,就是 Redis 分布式锁的标准答案。
15. Redlock 算法是什么?为什么争议很大?生产上怎么选?
答: Redlock 是 Redis 作者 antirez 提出的多节点版分布式锁,目的是解决"单实例/主从切换丢锁"的问题。
算法流程:
- 部署 N 个完全独立的 Redis 主节点(没有主从、没有哨兵,如 N = 5);
- 客户端依次向每个节点用
SET key value NX PX ttl加锁,并记录总耗时; - 只要在 多数派节点(≥ N/2 + 1,如 5 个里拿到 3 个) 成功,且总耗时 < 锁有效期,就认为加锁成功;
- 若失败,则向所有节点发起解锁(包括没加成功的);
- 锁的真实有效时间 = 初始 TTL − 加锁总耗时(因为网络耗时已经消耗掉一部分窗口)。
争议焦点(著名的 Kleppmann vs antirez 论战):
| 质疑方(Martin Kleppmann,分布式系统领域专家) | 要点 |
|---|---|
| 依赖时钟假设 | Redlock 的正确性建立在"各节点时钟不会大幅漂移"上,但进程 GC 停顿 / 时钟跳变都会破坏这个假设 |
| 锁过期 ≠ 业务停止 | 客户端拿到锁后发生长时间 STW GC,锁已过期、别人也拿到锁,两个客户端同时进临界区 |
| 无法保证一致 | 即使多数派加锁成功,也无法阻止"已过期锁的持有者继续写" |
| 建议改用 fencing token | 让锁服务返回单调递增的 token,存储层拒绝比已处理 token 更小的写入——从根上防住"过期锁的迟到写" |
| 回应方(antirez) | 要点 |
|---|---|
时钟漂移可通过 CLOCK_MONOTONIC 与人工运维约束降低影响 | |
| 强一致语义本就不是 Redis 的目标,Redlock 是"效率优先的锁" | |
| 若需要严格正确性,应使用基于共识的锁(ZK/etcd) |
生产上的结论(面试这样答最稳妥):
- Redlock 不适合作为"正确性依赖"的强一致锁——它没有 fencing token,无法阻止过期锁的迟到写;且部署 5 个独立实例成本高、运维复杂;
- 更实用的选择:
- 普通业务:单实例 Redis + 看门狗续期(Redisson)+ 业务幂等,简单可靠;
- 需要强一致/不可丢锁:用 ZooKeeper / etcd(基于共识,天然有单调序号可作 fencing token);
- 需要 fencing 语义:在存储层校验版本号 / 单调 token(如
UPDATE ... WHERE version < :token)。
- 一票否决的原则:任何"锁"都不能替代幂等——临界区操作必须可重复执行而无副作用。
加分回答:fencing token 的思想可以脱离 Redlock 单独使用——只要下游存储能拒绝"旧 token 的写",就从根本上解决了"锁过期但业务继续写"的问题,这是比"把锁做得更可靠"更本质的思路。
16. ZooKeeper 如何实现分布式锁?什么是羊群效应?
答: ZK 基于 临时顺序节点(Ephemeral Sequential Node) 实现公平锁:
- 抢锁时,客户端在
/lock下创建临时顺序节点,如/lock/lock-0000000001; - 判断自己是不是当前序号最小的节点:
- 是 → 获得锁,进入临界区;
- 不是 → 只 watch 自己前一个序号节点(
getData+exists注册监听),然后阻塞等待;
- 前一个节点被删除(持锁者释放或会话失效)时,收到
NodeDeleted通知,重新判断自己是否最小(此时通常直接拿到锁); - 释放锁:删除自己的节点(或客户端宕机 → 会话超时 → 临时节点自动删除,天然防死锁)。
为什么用"临时"节点:会话(Session)断开后 ZK 自动清理,避免持锁进程崩溃导致死锁。
什么是羊群效应(Herd Effect)?
若所有等待者都 watch 同一个节点(如根节点 /lock 或前一个"锁节点"),那么锁释放瞬间,成百上千个客户端被同时唤醒、一起发起"判断 + 创建/查询"请求——ZK 瞬间承受请求风暴,甚至引发抖动。
解决:只 watch 前一个序号节点(链式监听)——锁释放只会精准唤醒一个客户端,其余客户端继续安睡。这是 ZK 分布式锁的标准实现,也是面试的采分点。
ZK 锁的两个注意点:
- 性能:写操作需要多数派确认,加解锁延迟高于 Redis;
- "锁提前释放"风险:若客户端长时间 GC 停顿导致会话超时,ZK 会删除临时节点释放锁,但客户端可能仍在临界区执行——所以仍需要业务幂等(与 Redis 锁同理);
- 可扩展:ZK 还能实现共享锁 / 读写锁(读请求创建临时顺序节点时序号后缀决定读/写优先级)。
17. Redis 分布式锁 vs ZooKeeper 分布式锁,怎么选?
答:
| 维度 | Redis | ZooKeeper / etcd |
|---|---|---|
| 一致性模型 | AP:主从异步复制,主宕机可能丢锁 | CP:多数派写入,不丢锁 |
| 性能 | 高(内存 + 单命令,毫秒级) | 中(写需多数派 + 落盘) |
| 防死锁 | 过期时间 + 看门狗续期 | 临时节点 + 会话失效(自动) |
| 释放语义 | TTL 到期即释放(可能与业务执行重叠) | 会话失效即释放(同样有 GC 停顿风险) |
| 获取锁 | 需自旋 / 发布订阅等待,非公平 | 顺序节点保证公平排队,watch 前一个即可 |
| 可重入 | 需自行实现(Redisson 已支持) | 需自行实现 |
| 运维复杂度 | 低(通常已有 Redis) | 中(需额外维护 ZK/etcd 集群) |
| 典型场景 | 高并发、短临界区、可容忍极小概率丢锁 | 强一致要求、绝不允许丢锁(如主备切换、选主) |
选型建议:
- 绝大多数的业务互斥(防重复下单、任务调度去重、库存扣减):用 Redis(Redisson)足够,简单、快、已有中间件;
- 对锁正确性要求极高(如数据库主备切换、分布式选主、元数据变更):用 ZK / etcd;
- 无论选哪个:都必须加业务幂等兜底,并且临界区尽量短(长临界区 = 更高的锁过期/会话失效风险);
- 若"锁"是为了保证一次执行(而非互斥),优先考虑用唯一索引 / 状态机做幂等,而不是加锁。
一句话:要快用 Redis,要稳用 ZK/etcd,要正确性最终靠幂等。
