🔺 高级难度📚 分布式
「CAP 只能三选二,那我选 CA 行不行?—— 这题一问就露馅。」
本篇是从「写业务」走向「设计系统」的分水岭。第一节讲 分布式理论(CAP、BASE、一致性模型、Raft 与 ZAB、时钟问题);第二节讲 分布式事务与锁(方案选型、2PC 缺陷与 TCC 补偿、Redis/ZK/etcd 锁对比、锁 + 幂等组合);第三节讲 分布式组件(分布式 ID 与雪花算法、ZooKeeper 选主、一致性哈希、分布式 Session)。13 题、每题约 450 字,读完能撑起一场 30 分钟的分布式追问。
🗂️ 本篇速查索引(一屏总览)
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
① 分布式理论
Q01🔸 中级
🎙️ 面试官:CAP 理论是什么?为什么 P 是不可放弃的?
💬 面试者:
先说三个字母的准确含义:① C(Consistency,一致性)——「所有节点在同一时刻看到的数据是相同的」,即「一次写成功之后,后续任何读都能读到这个新值」。② A(Availability,可用性)——「每个请求都能在有限时间内收到一个『非错误』的响应」——注意定义里没有要求「返回的一定是最新数据」,只要「不超时、不报错」就算可用。③ P(Partition tolerance,分区容错性)——「系统在出现网络分区(部分节点之间无法通信)时,仍能继续对外提供服务」。
为什么 P 不可放弃(本题核心):因为「网络分区」是客观存在的物理现象,不是你能选择的——网线会被挖断、交换机故障、机房之间有网络抖动、GC 停顿导致「看起来像分区」。只要你的系统部署在「多于一个节点」上,就必须接受「消息可能丢失、延迟可能无限大」。所以 P 是分布式系统的「入场券」,不是选项。真正的取舍只在 C 和 A 之间:当分区发生时,你是「宁可拒绝服务也要保证数据一致(选 CP)」,还是「宁可返回旧数据也要继续服务(选 AP)」。
一个必须澄清的常见误解:CAP 不是说「任何时刻都只能在 C 和 A 里选一个」——它描述的是「分区发生时」的极端情况。网络正常时,系统完全可以同时满足 C 和 A。所以更准确的表述是:「当没有分区时,系统可以同时保证 C 和 A;当分区发生时,必须在 C 和 A 之间二选一」。这也是为什么「说我选 CA」是错的——选 CA 意味着「放弃 P」,也就是「不允许网络分区」,那你只能做单机系统,就不是分布式了。
举两个典型例子:① CP 系统(ZooKeeper、etcd、HBase)——分区发生时,「少数派」节点会「拒绝服务」(因为它无法与多数派通信,不知道自己的数据是否过期),宁可不可用,也不返回可能是脏的数据。ZK 在选举期间整个集群不可写,就是这个取舍的体现。② AP 系统(Eureka、Cassandra、DNS)——分区发生时,每个节点继续用自己的本地数据响应,比如 Eureka 的服务列表可能返回「已经下线的实例」,靠客户端重试和自我保护来兜底。
加分点:PACELC——CAP 的扩展:「如果有分区(P),则在 A 和 C 之间取舍(AC);否则(E),在延迟(L)和一致性(C)之间取舍」。它点出了 CAP 没覆盖的现实:即使网络正常,「强一致」也是要付出「延迟」代价的(因为要等多数派确认)。
Q02🔸 中级
🎙️ 面试官:BASE 理论是什么?和 ACID 有什么区别?
💬 面试者:
BASE 是 eBay 架构师提出的,本质是「CAP 中选择 AP 之后的工程落地方案」——CAP 只告诉你「要取舍」,BASE 告诉你「取了 A 之后,怎么把一致性做回来」。三个词:① Basically Available(基本可用)——系统允许「部分可用」或「降级可用」:比如大促时「响应时间从 50ms 涨到 500ms」(性能上的降级),或者「商品评论暂时不可看,但下单正常」(功能上的降级)。注意「基本可用」不等于「不可用」,它是「有意识地放弃一部分,保住核心」。
② Soft state(软状态)——允许系统中的数据存在「中间状态」,并且这个中间状态的存在「不影响系统整体可用性」。对比理解:ACID 要求「状态必须是硬的(Hard state)」,即「事务提交后数据立刻就是确定的」;而软状态承认「数据会有一段时间处于『正在同步』的不确定阶段」,比如订单状态从「支付中」到「支付成功」中间有几百毫秒的不一致窗口。③ Eventually consistent(最终一致)——「软状态」经过一段时间后,系统最终会收敛到一致状态,这个时间窗口叫「不一致窗口」。重点在于:BASE 不保证「实时一致」,但保证「没有新的写操作后,所有副本最终会一致」。
BASE vs ACID 的完整对比:ACID 是「悲观、强一致、面向单库事务」(原子性、一致性、隔离性、持久性),追求「任何时候数据都正确」;BASE 是「乐观、最终一致、面向分布式」,追求「高可用 + 数据最终正确」。两者不是对立关系,而是「不同场景的选择」。
最终一致在工程上怎么落地(这是面试官真正想听的):① 异步消息驱动——本地事务 + 消息投递(本地消息表或 MQ 事务消息,见 Q06);② 重试 + 幂等——失败就重试,靠幂等保证重试不出错;③ 定时对账——兜底手段,定期比对上下游数据(如「订单表 vs 支付流水表」),发现不一致就补偿;④ 读修复 / 反熵——读的时候发现副本不一致就触发修复(Cassandra),或后台定期比对修复。一句话:BASE 的落地 = 异步化 + 重试幂等 + 对账兜底,这三者是最终一致性的标配三件套。
Q03🔸 中级
🎙️ 面试官:一致性模型有哪些?强一致 / 弱一致 / 最终一致怎么区分?
💬 面试者:
一致性模型描述的是「系统对『读到什么值』的承诺强度」。从强到弱有四类,面试能按这个梯度答出来就很完整:
① 线性一致性(Linearizability,最强)——也叫「强一致」或「原子一致」。承诺:「任何一次读,都能读到『最近一次成功写入』的值」,并且「所有操作看起来像是在某个瞬间原子地发生的」,存在一个全局的、与真实时间一致的顺序。直观理解:就像系统只有一份数据、一个副本,写完立刻可见。代价:每次读写都要走共识(多数派确认),延迟高。例子:etcd/ZK 的 sync 读、数据库的 serializable、Spanner 的外部一致。
② 顺序一致性(Sequential Consistency,次强)——承诺:「所有节点看到的操作顺序是一致的」,但不要求「与真实时间一致」。和线性一致的区别:线性一致要求「如果操作 A 在真实时间上早于 B 完成,那么全局顺序里 A 一定在 B 前」;顺序一致只要求「大家看到的顺序一样」,可以「整体滞后」。例子:ZooKeeper 默认是顺序一致(客户端的更新按 FIFO 顺序被所有副本看到),但读可能读到稍旧的数据(从 Follower 读),要调 sync() 才是线性读。这是面试常考的细节。
③ 因果一致性(Causal Consistency)——承诺:「有因果关系的操作,所有节点看到它们的顺序一致;没有因果关系的(并发)操作,顺序可以不同」。例子:A 发了一条帖子,B 评论了它——「评论」因果依赖于「帖子」,所以所有人必须先看到帖子再看到评论;但另一个人 C 在同时发无关的帖子,它和前两者的顺序无所谓。实现:向量时钟(Vector Clock)。例子:很多社交系统的评论流采用因果一致。
④ 最终一致性(Eventual Consistency,最弱)——承诺:「如果没有新的写操作,经过一段时间后所有副本会收敛到一致」。它不承诺「多久」(这就是它的弱所在)。现实中还有几个「升级版」,面试提到会很加分:a) 读己之所写(Read-your-writes)——「我自己写完一定能读到自己的新值」(这是最常用、业务最需要的一条);b) 单调读(Monotonic read)——「不会读到比上一次更旧的值」;c) 单调写——「我的写操作按顺序生效」。实现方式:MySQL 主从就常做成「写走主库、读己之所写走主库(或等 binlog 追平),其余读走从库」——这是「用部分强一致换性能」的典型工程折中。
Q04🔺 高级
🎙️ 面试官:Raft 共识算法讲一下?和 ZAB 有什么区别?
💬 面试者:
先说 Raft 的设计目标:Paxos 难懂难实现,Raft 就是为了「可理解性」而设计的(它把共识拆成三个独立的子问题:Leader 选举、日志复制、安全性)。这个设计目标本身就是考点,因为它是 Raft 流行(etcd、Consul、TiKV、Nacos 的 CP 模式都用它)的根本原因。
① 角色与任期——三种角色:Leader(主,接收写请求)、Follower(从,被动)、Candidate(候选,选举期间的临时状态)。任期(Term)是「逻辑时钟」,每个任期最多一个 Leader;所有 RPC 都带 term,节点收到「比自己大的 term」就立刻更新并降级为 Follower。这个机制保证了「过期的 Leader 一旦发出请求就会被拒绝」——这是 Raft 安全性的关键。
② Leader 选举——Follower 有个随机化选举超时(如 150~300ms),超时没收到心跳就变成 Candidate,自增 term、投自己一票、向所有节点发 RequestVote。获胜条件:获得「超过半数」的选票。两个精巧设计:a) 随机超时——让节点「不太可能同时发起选举」,避免选票瓜分;b) 日志至少一样新才有资格当选——投票时会比较「最后一条日志的 term 和 index」,比自己旧的 Candidate 会被拒绝。这保证了「新 Leader 一定包含所有已提交的日志」——这就是 Raft 的「选举限制」,也是安全性核心。
③ 日志复制——Leader 收到写请求 → 追加本地日志 → 并行发 AppendEntries 给所有 Follower → 「超过半数」节点成功追加后就「提交」并应用到状态机 → 返回客户端成功 → 后续心跳通知所有 Follower 提交。注意:超过半数即返回,不是全部——所以「少数慢节点」不影响可用性。日志一致性:Leader 为每个 Follower 维护 nextIndex,不一致就递减重试,直到找到「匹配点」,然后覆盖 Follower 之后的日志——所以 Raft 的日志是「连续的」。
和 ZAB 的区别(本题关键,分四点答):① 设计目标不同——Raft 是「通用共识算法」,目标是复制状态机(replicated state machine);ZAB 是「为 ZooKeeper 量身定制的原子广播协议」,本质是主备(primary-backup)模型。② 日志同步方式不同——Raft 的日志必须连续,Leader 用 nextIndex 逐条探测对齐;ZAB 的 Leader 可以直接「把自己有而 Follower 没有的事务『批量』发给它」(用 TCP 的顺序性保证不乱序),分为「差异同步(DIFF)」和「全量快照同步(SNAP)」——所以 ZAB 的同步效率更高、实现更简单。
③ 概念命名不同——Raft 用「term(任期)」,ZAB 用「epoch(纪元)」;Raft 的日志条目用 (term, index) 标识,ZAB 用 64 位的 zxid(高 32 位是 epoch,低 32 位是自增计数器)。④ 选举视角不同——Raft 是「Candidate 主动拉票」,比较的是「日志新旧」;ZAB 是「互相交换选票」,比较的是 (epoch, zxid, myid) 三元组,先比 zxid、再比 myid(详见 Q11)。相同点:都是「过半派(quorum)」、都依赖单一 Leader 顺序写、都能容忍「少于半数」节点故障。
Q05🔺 高级
🎙️ 面试官:分布式系统为什么不能依赖本地时间?逻辑时钟怎么解决?
💬 面试者:
先说为什么本地时钟不可靠(三个物理原因):① 晶振漂移——每台机器的时钟靠石英晶振计时,而晶振频率受温度、电压、老化影响,每天漂移几毫秒到几秒都很常见。Google 的数据是「普通服务器时钟漂移可达 200ppm(百万分之二百),也就是每天最多 17 秒」。② NTP 同步本身不精确——NTP 通过网络校时,而网络延迟是不确定的,校时结果有误差;更糟的是,如果本地时钟比标准时间「快」,NTP 可能让它「往回调」(时钟回拨),也可能用「缓慢调整(slew)」。③ 时钟跳变——运维手动改时间、虚拟机迁移/休眠、闰秒,都可能造成时间突然前进或后退。
这会导致什么真实故障(举三个例子,很有说服力):① 雪花算法生成重复 ID(时钟回拨导致同一毫秒的序列号重新从 0 开始,见 Q10);② Last-Write-Wins(LWW)丢数据——多副本用「时间戳大的覆盖小的」解决冲突,如果节点 A 的时钟慢,它后发生的写反而时间戳更小,被旧写覆盖,数据静默丢失;③ 分布式锁/租约误判过期——节点认为自己「还没到超时时间」,而另一个节点按自己的时钟认为「锁已过期」并加锁成功,导致两把锁同时生效;④ 证书/Token 校验、订单超时判断、定时任务重复触发都受影响。
解法一:区分「单调时钟」与「墙上时钟」——这是一条必须记住的工程准则:a) 测量「耗时/间隔」用单调时钟(Java 的 System.nanoTime())——它保证只增不减,不受校时影响;b) 表示「时间点」(如日志时间戳、过期时间)才用墙上时钟(System.currentTimeMillis()),并且要对「时间倒退」做防御。反面例子:用 currentTimeMillis 计算接口耗时,NTP 校时一拨就可能出现负耗时。
解法二:逻辑时钟(Logical Clock)——Lamport 时间戳:不再依赖物理时间,只记录「因果顺序」。规则:每个节点维护一个计数器;发生事件就 +1;发送消息时带上自己的计数器;接收方把自己的计数器更新为 max(本地, 收到的) + 1。性质:如果 A happens-before B,则 A 的时间戳 < B 的时间戳(反过来不成立)。局限:它只能给出「全序」,无法判断「两个事件是否并发」。于是有了向量时钟(Vector Clock):每个节点维护一个「所有节点的计数器数组」,能准确判断「并发 / 因果」,代价是空间 O(N)。Dynamo、Riak 用它做冲突检测。
解法三:混合逻辑时钟 HLC 与 TrueTime——HLC(Hybrid Logical Clock)把「物理时间」和「逻辑计数」组合在一起,既保留「接近物理时间」的直观性,又保证单调不回退,且空间是 O(1),CockroachDB、YugabyteDB 都在用。TrueTime(Spanner)是另一个极端:用 GPS 和原子钟把时钟误差控制在几毫秒内,并明确返回一个「不确定区间 [earliest, latest]」,事务提交时「等这个区间过去」才返回——用硬件 + 等待换来了外部一致性(最强的一致)。这就是 Spanner 敢说「全球强一致」的底气,但代价是每次提交要等约 7ms。
② 分布式事务与锁
Q06🔺 高级
🎙️ 面试官:分布式事务有哪些方案?怎么选型?
💬 面试者:
先明确问题:分布式事务要解决的,是「跨多个服务/数据库的操作,如何保证要么都成功、要么都失败」。比如「下单 → 扣库存 → 扣余额」,三者在三个库。六类方案,按「一致性强度」从高到低排列:
① 2PC / XA(强一致)——由协调者统一指挥两个阶段(准备、提交),数据库原生支持(MySQL XA)。优点:强一致、业务侵入低。缺点:同步阻塞、协调者单点、性能差(详见 Q7)。适用:传统金融核心、单机房低并发。
② 3PC(改进 2PC,几乎不用)——加了 CanCommit 预询阶段,并给参与者加超时(超时默认提交),降低阻塞。但仍有网络分区导致的不一致,且多一轮通信,生产几乎不用,面试提一句即可。
③ TCC(强一致,业务层)——Try 预留资源 → Confirm 确认 → Cancel 释放。优点:一致性接近 2PC,但不需要长时间持有数据库锁,性能好很多。缺点:侵入性极强,每个参与者要写三套逻辑,还要处理空回滚、幂等、悬挂(见 Q7)。适用:支付、资金、交易核心。
④ Saga(最终一致,长事务首选)——把长事务拆成一系列「本地短事务」,每个都有对应的「补偿动作」;正向执行 T1 → T2 → T3,某步失败就「反向补偿」C3 → C2 → C1。两种模式:编排式(Orchestration,有中央协调器,如 Seata Saga 状态机)和协同式(Choreography,各服务通过事件自行触发下一步)。优点:适合「长流程」(如订机票 + 订酒店 + 租车,耗时几秒到几分钟)、无长时间锁。缺点:不保证隔离性——可能出现「脏读」(中间状态被其他事务看到),需要通过「语义锁」(如给订单加 冻结 状态)和业务设计来规避。适用:电商订单履约、旅行预订。
⑤ 本地消息表(最终一致,最朴素实用)——在「本地数据库事务」里同时写「业务表」和「消息表」,再由定时任务扫消息表投递 MQ,失败重试直到成功,消费端幂等。优点:简单可靠、不依赖 MQ 特殊能力、利用了本地事务保证原子。缺点:消息表与业务库耦合、实时性略差。适合中小系统。
⑥ MQ 事务消息(最终一致,互联网首选)——RocketMQ 的「半消息 + 本地事务 + 回查」(详见 S16 Q03)。优点:完全异步、吞吐高、侵入小。适用:下单送积分、支付通知这类「主流程 + 异步副作用」场景。
选型结论(可直接背):① 能用「本地事务」就别用分布式事务(最有效的一招是「把相关表放同一个库」或「设计成最终一致」);② 异步副作用类(90% 场景)用「MQ 事务消息 / 本地消息表」;③ 长流程、跨多服务用 Saga;④ 资金、支付等强一致用 TCC;⑤ 传统金融低并发才考虑 2PC/XA;⑥ 无论如何,消费端/参与者都要做幂等和对账。
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Q07🔺 高级
🎙️ 面试官:2PC 有什么问题?TCC 的补偿要怎么做?
💬 面试者:
2PC(两阶段提交)流程回顾:阶段一「准备(Prepare)」——协调者问所有参与者「能不能提交」,参与者执行事务、写 undo/redo 日志、锁住资源,但不提交,回复 Yes/No;阶段二「提交(Commit)」——协调者若收到全部 Yes 就发 Commit,否则发 Rollback,参与者执行后释放锁。
2PC 的四个致命问题:① 同步阻塞(最严重)——在「阶段一完成、阶段二指令到达之前」,所有参与者都要「持有数据库锁并挂起等待」。这段时间取决于最慢的节点和网络,可能是几百毫秒到几十秒。期间相关资源被完全锁住,吞吐量断崖式下降。② 协调者单点——协调者挂了,所有参与者就永远卡在「不知道该提交还是回滚」的状态,只能靠人工介入。③ 数据不一致——如果阶段二只有部分节点收到 Commit(网络分区),那么收到提交的提交了、没收到的回滚了,数据就不一致了(这是 2PC 最被诟病的地方,它并不能 100% 保证一致)。④ 容错保守——任何一个参与者失败,整个事务就回滚,没有「降级」余地。
3PC 为什么没解决:它加了 CanCommit 阶段和「参与者超时自动提交」,减少了阻塞,但在网络分区时,超时自动提交反而可能造成更严重的不一致,且多一轮 RTT,所以生产不用。
TCC 的补偿怎么做(本题第二部分,必答三要素):TCC 把业务拆成 Try(预留,如「冻结库存 1 件」「冻结余额 100 元」)→ Confirm(确认,把冻结真正扣掉)→ Cancel(取消,把冻结释放)。补偿指的是 Cancel,但它有三个必须处理的经典问题(面试必考):
① 空回滚(Empty Rollback)——场景:Try 请求因为网络问题根本没到达,但协调者超时后直接发了 Cancel。问题:Cancel 去「释放一个根本不存在的预留」,如果代码没做判断,可能报错或产生错误数据(如「余额 +100 凭空多出来」)。解法:在执行 Cancel 前先「查分支事务记录」,确认 Try 确实执行过;没执行过就「插入一条状态为『已回滚』的记录」并直接返回成功——关键是「留痕」,让后续的 Try 无法再执行(见悬挂)。
② 幂等——场景:网络抖动或协调者重试,导致 Confirm/Cancel 被调用多次。问题:重复扣款/重复释放。解法:每个分支事务用「全局事务 ID + 分支 ID」做唯一键,执行前先查状态;已执行过就直接返回成功。所有 TCC 框架(Seata、Hmily)都强制要求幂等。
③ 悬挂(Hanging)——场景:Cancel 比 Try 先到(网络延迟导致 Try 被排队滞后),Cancel 空回滚完成后,Try 请求姗姗来迟并执行了预留。问题:资源被永久冻结(再也没有 Cancel 来释放它)。解法:Try 执行前先查「分支事务记录」,如果已有「已回滚」的记录,就拒绝执行 Try(这就是「空回滚要留痕」的原因)。
记忆口诀:「 Cancel 前先查(防空回滚)、执行前先查(防重复)、Try 前先查(防悬挂)」——三查全靠一张「分支事务状态表」。
Q08🔺 高级
🎙️ 面试官:分布式锁:Redis vs ZooKeeper vs etcd 怎么选?
💬 面试者:
先说分布式锁的最低要求(先立标准,再比方案):① 互斥性(同一时刻只有一个客户端持有);② 可重入 / 无死锁(持有者崩溃后锁必须能被释放);③ 容错性(部分节点挂了,锁服务仍可用);④ 高性能与低延迟。三个方案的差异,本质就是在这四点上做不同取舍。
① Redis(AP,性能优先)——实现:SET lock_key random_value NX PX 30000(NX 保证互斥、PX 保证崩溃后自动过期、random_value 保证「只能解自己的锁」);释放必须用 Lua 脚本「先比对 value 再删除」,保证原子(直接 DEL 可能误删别人的锁)。生产用 Redisson:它提供 RLock,内置「看门狗(Watchdog)」——加锁成功后启动一个后台线程,每 10 秒(默认 lockWatchdogTimeout 30 秒的 1/3)自动把锁续期到 30 秒,解决了「业务没执行完锁就过期」的问题;业务完成或线程挂掉(watchdog 停)时自动释放。优点:性能极高(微秒级)、使用简单、生态好。缺点(必须说清):a) 主从切换可能「丢锁」——客户端在 Master 加锁成功,但数据还没同步给 Slave 时 Master 挂了,Slave 升主后没有这把锁,另一个客户端又能加成功,出现「两把锁」。Redlock 试图用「多个独立 Master 同时加锁、超过半数才算成功」来解决,但它依赖「时钟」假设,Martin Kleppmann 与 antirez 有过著名的公开争论,业界普遍认为它「复杂且有边界风险」,生产很少用。
② ZooKeeper(CP,正确性优先)——实现:「临时顺序节点 + Watcher」。加锁:在 /lock 下创建「临时顺序节点」,然后判断「自己是不是序号最小的」,是就获得锁;不是就「只监听前一个节点」(这个优化避免了「所有客户端都监听最小节点」导致的惊群效应),前一个被删除(释放)时自己被唤醒。释放:删除节点,或「会话断开(Session 超时)」时 ZK 自动删除临时节点。优点:a) 天然无死锁(会话断了自动删);b) 公平锁(顺序节点天然排队,而 Redis 是非公平的);c) 一致性由 ZAB 保证,不存在「丢锁」。缺点:性能较低(每次加锁解锁都要「写 + 过半确认」,毫秒级)、有羊群效应风险(可用「只监听前驱」规避)、依赖 ZK 集群运维、网络抖动可能导致「会话超时 → 锁被误释放」(需要业务端配合幂等/ fencing token)。
③ etcd(CP,云原生时代首选)——实现:基于 Raft 的强一致 KV。加锁:利用「Revision(全局递增版本号)」——所有客户端在同一 key 下创建带租约(Lease)的 key,etcd 返回各自的 revision,「revision 最小者获锁」,其余监听(Watch)比自己小的前一个 revision。租约(Lease)机制天然解决「崩溃不释放」(租约到期自动删 key,客户端需「续租 keepalive」)。优点:强一致 + 性能优于 ZK(Raft 实现精简,Go 语言)+ 原生支持 Watch 和 Lease + 是 K8s 的标准组件(运维复用)。缺点:生态和客户端成熟度不如 Redis。
选型结论(可直接复述):① 追求性能、并发高、允许极端情况下偶发失效(可通过业务幂等兜底)→ Redis / Redisson(90% 的互联网业务:防重复提交、定时任务抢主);② 锁的是「关键资源」、不能出错、并发不高 → ZK(如金融核心、分布式任务调度 ElasticJob 用 ZK 做主节点选举);③ 已在 K8s / 云原生体系、需要强一致又要性能 → etcd。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Q09🔸 中级
🎙️ 面试官:分布式锁和幂等是什么关系?怎么配合使用?
💬 面试者:
先纠正一个最常见的误解:「加了分布式锁就不用做幂等了」——这是错的。两者解决的是完全不同的问题:① 分布式锁解决的是「同一时刻的并发互斥」——它保证「多个请求不会同时进入临界区」;② 幂等解决的是「同一请求的重复执行」——它保证「同一个操作执行一次和执行 N 次,结果一样」。关键区别:锁是「排他的」,但「锁释放之后,重复的请求照样会再执行一次」——比如用户快速点了两次提交,第一个请求拿到锁执行完并释放,第二个请求接着拿到锁又执行了一遍,结果就是「扣了两次款」。所以:锁管「并发」,幂等管「重复」,两者都要有。
正确的三层防护(这是本题的标准答案):
第一层:防重(拦截重复请求)——在进入业务逻辑之前,用「唯一键」把重复请求挡掉。常见做法:a) 前端防重(按钮置灰、Token 机制);b) 服务端 Token(幂等令牌)——进入表单页时服务端生成一个 token 存入 Redis 并返回给前端,提交时带上,服务端 DEL token 成功才放行(DEL 是原子的,重复提交会删除失败);c) 数据库唯一索引(如「订单号」唯一、「用户 + 活动 + 日期」唯一),靠数据库兜底。
第二层:加锁(保证并发下的串行)——对「共享资源」加分布式锁,锁的粒度要选对:比如「扣库存」应该锁「商品 ID」(lock:sku:1001),而不是锁「整个下单流程」(那样性能极差)。锁的 key 设计原则:用「业务实体标识」,保证「同一资源的并发请求被串行,不同资源仍然并行」。
第三层:幂等(保证重复执行无副作用)——在「真正写数据」这一步做幂等:a) 状态机 / 乐观锁——UPDATE order SET status='PAID' WHERE order_id=? AND status='CREATED',用 affected rows 判断;b) 去重表——用「业务唯一键」建唯一索引(详见 S16 Q09)。关键点:幂等键必须用「业务标识」(订单号 + 操作类型),不要用「请求 ID」或「消息 ID」。
一个完整的下单流程示例(可以把这四步连起来讲):① 用户提交 → 校验幂等 Token(DEL token),重复提交直接返回;② 加分布式锁 lock:order:{userId}(防同一用户的并发提交);③ 执行业务:用「订单号唯一索引 + 状态机」保证幂等,扣库存用「WHERE stock > 0 的乐观锁」;④ 释放锁。补充两个细节:a) 锁的超时时间要大于「业务最大执行时间」,并开启看门狗,否则「业务没跑完锁就没了」;b) 释放锁必须在 finally 里,且只能释放自己加的锁(比对 value)。
③ 分布式组件
Q10🔺 高级
🎙️ 面试官:分布式 ID 怎么生成?雪花算法有什么坑?
💬 面试者:
分布式 ID 的核心要求(先立标准):① 全局唯一(最基本);② 趋势递增 / 单调递增(为了数据库索引性能——B+ 树「顺序插入」避免页分裂,如果用 UUID 这种随机值做主键,InnoDB 的聚簇索引会频繁页分裂,写入性能大幅下降);③ 高性能高可用(ID 生成是「所有写操作的前置依赖」,不能有单点瓶颈);④ 信息安全(有些场景不希望 ID 连续,避免被「爬数据」(如订单号能被猜到一天的单量))。
五种方案对比:① UUID——本地生成、无网络开销、全球唯一;缺点:36 位字符串、太长、无序(B+ 树插入性能差)、无法承载业务信息。结论:MySQL 主键不要用 UUID(MySQL 8 的 UUID_TO_BIN + 重排字节序可以缓解)。② 数据库自增(单点)——简单,但「单库单表」扛不住,且扩展困难。③ 数据库号段模式(Leaf-segment,美团/滴滴在用,推荐)——思路:ID 生成器不是「每次取一个」,而是「一次取一个号段(如 1~1000)」缓存在本地,用完再去数据库取下一段。优化:双 Buffer 异步预取(当号段消耗到 10% 时,后台线程提前取下一段,避免「取号段时阻塞」)。优点:性能极高(几乎无数据库访问)、ID 趋势递增、数据库压力极小。缺点:ID 不严格递增(不同机器持有的号段不同)、号段用完重启会「跳号」(业务上一般可接受)。
④ Redis INCR——原子自增;缺点:依赖 Redis 持久化(宕机可能重复)、要处理持久化策略(AOF 每次刷盘性能差,RDB 会丢一段)。⑤ 雪花算法 Snowflake(Twitter 开源,最主流)——见下。
雪花算法的结构(必背):64 位 long = 1 位符号位(恒为 0,保证正数)+ 41 位时间戳(毫秒,可用约 69 年)+ 10 位机器位(5 位 datacenterId + 5 位 workerId,最多 1024 台机器)+ 12 位序列号(同一毫秒内最多生成 4096 个 ID)。所以它的理论上限是「单机器每毫秒 4096 个,约 400 万 QPS」。优点:本地生成无网络开销、整体按时间趋势递增、性能极高、ID 含时间信息(可用于粗略排序)。
四个经典坑(本题重点):① 时钟回拨(最致命)——如果机器的时钟往回调,就会生成「与之前相同的时间戳」,可能重复生成 ID。解法:a) 回拨幅度小(如 < 100ms)就「等待时钟追上」;b) 回拨幅度中等,用「历史序列号缓存」(记录上一毫秒的最大序列号,回拨后从它继续递增);c) 回拨幅度大就直接报错、摘除该节点、报警人工处理;d) 百度 UidGenerator 的做法:它不用「当前时间」,而是用「启动时的时间 + 自增的秒数」(即「消费未来时间」),彻底摆脱对时钟的依赖,并用 RingBuffer 缓存 ID。
② 机器 ID(workerId)分配——10 位机器位要求「每台机器唯一」,手动配置容易重复(重复就会生成相同 ID)。解法:a) 用 ZooKeeper 的「持久顺序节点」分配并持久化;b) 用数据库表分配(启动时插入一条记录拿自增 ID);c) K8s 环境下用「StatefulSet 的序号」或「Pod IP 的 hash」;d) 美团 Leaf-snowflake 用 ZK 分配并做心跳续期。③ 时间戳位数耗尽——41 位从「起始时间(epoch)」起算约 69 年,所以「起始时间要自己设定」(如 2024-01-01),不要照抄 Twitter 的 1970。④ 前端精度问题(真实踩坑)——雪花 ID 是 19 位(如 1891234567890123456),超过 JavaScript Number 的安全整数范围(2^53-1,约 9 千万亿),前端 JSON 反序列化会「精度丢失」(最后几位变成 0)。解法:后端返回给前端时把 long 转成 String(Spring Boot 可全局配置 Jackson 的 WRITE_LONG_AS_STRING,或用 @JsonSerialize(using = ToStringSerializer.class))。
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 坑一:时钟回拨 —— 回拨幅度小则等待,大则直接抛错
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 100) { // 小幅回拨:等时钟追上
Thread.sleep(offset << 1);
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) throw new RuntimeException("时钟回拨");
} else {
throw new RuntimeException("时钟回拨超过阈值: " + offset + "ms");
}
}
// 同一毫秒内:序列号递增;溢出(>4095)则阻塞到下一毫秒
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 12 位序列号掩码
if (sequence == 0) timestamp = tilNextMillis(lastTimestamp);
} else {
sequence = 0;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << 22) // 41 位时间戳
| (workerId << 12) // 10 位机器位
| sequence; // 12 位序列号
}
Q11🔺 高级
🎙️ 面试官:ZooKeeper 的 ZAB 协议与选主过程讲一下?
💬 面试者:
先说 ZooKeeper 的数据模型(这是理解 ZAB 的前提):ZK 的数据是一棵树(类似文件系统),每个节点叫 ZNode,既是「目录」也是「数据载体」(最多存 1MB)。四种 ZNode 类型(必背):① 持久节点(PERSISTENT)——客户端断开后仍存在;② 持久顺序节点(PERSISTENT_SEQUENTIAL)——ZK 自动在名字后追加「单调递增的 10 位序号」(用于分布式锁的排队、生成分布式 ID);③ 临时节点(EPHEMERAL)——生命周期与「会话(Session)」绑定,客户端断开或会话超时后自动删除(服务注册、分布式锁的自动释放都靠它);④ 临时顺序节点(EPHEMERAL_SEQUENTIAL)——②和③的组合,Curator 的公平锁就是用它。Watcher 机制:客户端可以对节点设置监听,节点变化时 ZK 推送通知;关键特性:Watcher 是「一次性的」(触发后失效,要重新注册)、且是「异步、最终一致」的——所以客户端不能依赖 Watcher 做「强一致的状态同步」,通常要「收到通知后再主动拉一次数据」。
ZAB 协议(ZooKeeper Atomic Broadcast)的两个阶段:ZK 集群在运行过程中只有两种模式——① 崩溃恢复模式(选主 + 数据同步):集群启动或 Leader 挂掉时进入,选出新 Leader 并把所有节点的数据对齐;② 消息广播模式(正常工作):所有写请求都由 Leader 处理,Leader 用「类似两阶段提交」的方式把事务广播给所有 Follower——「超过半数」返回 ACK 后就提交并响应客户端(注意:不是全部,所以 ZK 是「顺序一致」而非「严格线性一致」),然后再异步广播 Commit。
选主过程(Fast Leader Election,本题核心,按四步答):① 每个节点进入 LOOKING 状态,先投自己一票,选票内容是 (epoch, zxid, myid)(zxid 是 64 位事务 ID,高 32 位是 epoch 任期、低 32 位是自增计数器;myid 是配置文件里给每个节点编的号)。② 节点互相交换选票,并「按规则更新自己的选票」——比较顺序:先比 epoch(任期大的胜出)→ 再比 zxid(大的胜出,因为它的数据更新)→ 最后比 myid(大的胜出,用于兜底打破平局)。③ 当某个节点收到「超过半数(quorum)」投给同一个节点的选票时,它就认定那个节点为 Leader,自己切换为 FOLLOWING(或 Leader 自己切换为 LEADING)。④ 选出 Leader 后进入「发现 + 同步」阶段:Leader 提出新的 epoch,Follower 上报自己的 zxid,Leader 据此决定「差异同步(只发缺少的事务)」还是「全量快照同步」,数据对齐后才进入消息广播模式。为什么这么设计:「选 zxid 最大的」保证了「新 Leader 拥有最完整的数据」,不会丢已提交的事务——这和 Raft 的「日志至少一样新才能当选」是同一个思想。
几个高频追问点:① 为什么推荐奇数台(3/5/7)——因为「过半派(quorum)」的容错公式是「2n+1 台能容忍 n 台故障」:3 台容忍 1 台、4 台也只容忍 1 台(过半是 3,挂 2 台就不行了),所以 4 台比 3 台贵却没有更多容错,省略。② ZK 的读可以从 Follower 读,可能读到旧数据,要强一致读需先调 sync()(这是「ZooKeeper 是顺序一致而非线性一致」的出处)。③ 脑裂——ZAB 的「epoch + 过半」机制保证「旧 Leader 恢复后发现自己 epoch 过时,会主动降级为 Follower 并同步新 Leader 的数据」,不会出现两个 Leader 同时写入。
Q12🔸 中级
🎙️ 面试官:什么是一致性哈希?解决了什么问题?
💬 面试者:
先说它要解决的问题:传统的「取模哈希」是 hash(key) % N(N 是节点数)。问题在于:当节点数 N 变化时(扩缩容、节点宕机),几乎所有 key 的映射结果都会变——比如从 3 台扩到 4 台,命中率从 1/3 变成 1/4,理论上有约 75% 的 key 需要重新分布。对缓存来说这意味着「缓存雪崩」:扩容一下,绝大部分缓存瞬间失效,流量直接打到数据库。一致性哈希就是为了让「节点变化时,尽可能少的数据需要迁移」。
原理(三步):① 把整个哈希空间看成一个「环」(0 ~ 2^32-1,首尾相接);② 把「节点」也 hash 到环上(通常用节点的 IP 或名字作为 key);③ 把「数据的 key」hash 到环上,然后「顺时针找到遇到的第一个节点」,它就是这条数据的归属节点。这样设计的效果:当「新增一个节点」时,只有「这个新节点逆时针方向、到上一个节点之间」的那一小段数据需要迁移(从原来的后继节点迁到新节点),其余数据完全不受影响;当「删除一个节点」时,只有「它自己那一段」数据需要迁到它的后继节点。迁移量从「几乎全部」降到了「约 1/N」——这就是一致性哈希的核心价值。
一个必须答出的问题:数据倾斜——如果节点数量少(比如 3 台),很可能它们在环上的位置分布不均,导致「某台机器承担了 70% 的数据」。解法:虚拟节点(Virtual Node)——把「一个物理节点」映射成「多个虚拟节点」(如 200 个),把虚拟节点分散地放到环上,数据先落到虚拟节点、再映射到物理节点。虚拟节点越多,分布越均匀。这是所有工业实现(包括 Redis Cluster 前的各种客户端分片、Dubbo 的负载均衡)都会加的优化。
应用与变体(加分项):① Redis Cluster 用的是「哈希槽(Hash Slot)」而不是严格的一致性哈希——它有固定的 16384 个槽,先 CRC16(key) % 16384 算出槽,再把「槽」分配给节点。本质思路一样(槽是「虚拟节点」的一种实现),但好处是「槽和节点的映射关系由集群集中管理、迁移时以槽为单位,非常直观可控」——所以 Redis Cluster 扩容时可以精确地把「某几个槽」从 A 迁到 B。② 分库分表——一致性哈希可以减少「扩容时的数据迁移量」。
面试延伸:一致性哈希还常用于「负载均衡」——Dubbo 的 ConsistentHashLoadBalance 就是「相同参数的请求总是打到同一台机器」(用于「有状态服务」或「本地缓存命中率」优化)。它的局限:不能解决「热点 key」问题(某条数据本身访问量巨大,一致性哈希只会把它固定到一台机器上,反而加剧热点)——热点要靠「本地缓存 + 多副本 + key 加随机后缀打散」来解决。
Q13🔸 中级
🎙️ 面试官:分布式 Session 怎么解决?
💬 面试者:
先说问题来源:HTTP 是无状态的,传统做法是把「登录状态」存在服务端的 Session 里,用 Cookie 里的 JSESSIONID 关联。但一旦上了负载均衡(多台应用服务器),就会出现「第一次请求打到 A 机器建立了 Session,第二次请求被轮询到 B 机器,B 上没有这个 Session,用户被踢下线」。四种解决方案(从原始到现代):
① 粘性会话(Session Sticky / ip_hash)——在 Nginx 上配 ip_hash,让「同一个 IP 的请求永远打到同一台机器」。优点:改动最小(只改 Nginx 配置,应用无感知)。缺点:a) 负载均衡失效(某些 IP 的请求量特别大,会打爆单台机器);b) 机器宕机后,该机器上的 Session 全部丢失,用户依然要重新登录;c) 如果用户用「移动网络」,IP 会变,Session 照样丢。结论:只适合临时过渡,不是真正的解决方案。
② Session 复制(Broadcast)——应用服务器之间通过网络互相同步 Session(Tomcat 有原生支持,用 DeltaManager 广播变更)。优点:应用无感知、机器宕机也不丢。缺点(致命):a) 内存浪费——每台机器都存全量 Session;b) 网络开销随「节点数 × Session 变更频率」呈平方级增长,节点一多(超过 5 台)广播就把带宽打满了。结论:只适合「3~4 台以内的小集群」。
③ 集中存储(Session 外置到 Redis)——生产首选——所有应用服务器都把 Session 写到同一个 Redis 集群,读的时候统一从 Redis 取。优点:a) 彻底无状态(应用服务器可以随意扩缩容、宕机不影响);b) 性能好(Redis 读在毫秒内);c) 便于统一管理和做「强制下线」。实现要点(这些细节是加分项):a) 用 Spring Session 可以「零侵入」接入(加依赖 + @EnableRedisHttpSession 即可,对 HttpSession 的读写自动走 Redis);b) 序列化要选好(默认 JDK 序列化体积大、可读性差,通常换成 JSON 或 Jackson);c) 过期时间要「滑动续期」(每次访问刷新 TTL,如 30 分钟);d) Cookie 里的 SessionId 要设 HttpOnly(防 XSS 窃取)+ Secure(仅 HTTPS)+ SameSite(防 CSRF);e) Redis 要做高可用(哨兵/集群),否则 Redis 挂了等于「全站掉线」。补充:如果用户量极大,可以给 Session 加「本地二级缓存(Caffeine)」,先查本地、再查 Redis,降低 Redis 压力。
④ 无状态方案:JWT(Token)——把用户信息直接「签名后塞进 Token」返回给客户端,服务端不存任何状态,每次请求带上 Token,服务端只做「验签」。优点:服务端彻底无状态、天然支持跨域和微服务、不用 Redis。缺点(必须答出):a) 无法主动失效——Token 签发后,在过期前一直有效(用户「退出登录」或「被封号」,服务端没法让它立刻失效);b) 续期麻烦;c) Token 体积大(每次请求都带着,浪费带宽);d) 安全性依赖「密钥不泄露」,且「不要在 payload 里放敏感信息」(它只是 Base64 编码,不是加密)。实践方案:短过期(如 30 分钟)+ Refresh Token(长过期,存 Redis,用于续期)+ 需要「强制下线」时维护一个「Token 黑名单/版本号」。
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
📝 本篇小结
① 分布式理论:CAP 的 P 是入场券不是选项,分区发生时才在 C/A 间取舍;BASE 是选 AP 后的落地方式(异步 + 重试幂等 + 对账);一致性从强到弱:线性 > 顺序 > 因果 > 最终;Raft 用 term + 连续日志,ZAB 用 epoch + 差异同步;本地时钟不可信,耗时用单调时钟、排序用逻辑时钟/HLC。
② 事务与锁:选型顺序是「MQ 事务/本地表 → Saga → TCC → 2PC」,能用最终一致就别用强一致;2PC 死于同步阻塞与协调者单点,TCC 必答空回滚、幂等、悬挂;分布式锁:要性能选 Redis Redisson、要正确选 ZK/etcd;锁管并发、幂等管重复,两者必须叠加。
③ 分布式组件:ID 首选号段模式或雪花,雪花的四坑是时钟回拨、机器位、时间位耗尽、前端精度丢失;ZAB 选主比 (epoch, zxid, myid),过半即成,数据对齐后才广播;一致性哈希让节点变化时只迁 1/N 数据,虚拟节点解决倾斜;分布式 Session 首选 Redis 集中存储,JWT 要配黑名单才能失效。
关注 + 在看,更新不错过 👀
📌 点赞 · 在看 · 转发,助力更多小伙伴通关

