分布式存储系统面试问题解析
共识算法剖析
raft共识算法
1.在你的 C++ 实现中,如果 Leader 发送 AppendEntries 给 Follower,但 Follower 的日志落后太多(比如中间缺了 100 条),Raft 论文建议是一个一个往前找(nextIndex--)。这种做法在高性能存储中显然是不可接受的,你会如何优化这个“回溯”过程?
在工程实现中,我会从两个维度优化日志冲突后的同步效率:
1. 快照分发 (InstallSnapshot):如果 Follower 缺失的日志已经被 Leader 做了 Snapshot(快照) 并截断了,Leader 无法通过日志比对找到一致点,此时直接发送 InstallSnapshot RPC 让 Follower 快速同步到最新状态 。
2. 冲突优化 (Term-based Optimization):不采用论文基础版的 nextIndex--(步长为1),而是利用 AppendEntries 拒绝回传信息。Follower 拒绝时返回冲突条目的 Term 以及该 Term 的 第一个 Index。Leader 收到后直接跳过该 Term 的所有冲突日志 。
[这样优化是否是安全的:
因为同一任期内的日志都是连续的,如果在某个位置的日志不一致,那么可以知道在该任期中的所有日志都是不一致的,因此Leader可以直接跳过整个任期的所有日志,这并不会影响一致性。]
结论:这样能将通信次数从『冲突日志数』降低到『冲突任期数』,极大减少了网络 RTT 损耗 。”
2.场景:节点 A 是 Leader,它收到客户端的一个请求并写入了本地日志,随后它把这个日志发给了节点 B 和 C(满足 Quorum 多数派)。就在它准备告诉大家“这条日志已提交”之前,A 突然宕机了。
问题:当 A 重启并重新竞争成为 Leader 后,它能直接根据“该日志已在多数派存在”而将其标记为 Committed 吗?如果不可以,为什么?(这是 Raft 论文 5.4.2 节最精妙的地方)。
结论: 不可以。Raft 规定:Leader 只能通过提交“当前任期”的日志,来间接覆盖并提交“之前任期”的日志。
面试官问:为什么?(核心逻辑):
覆盖风险:如果 Leader A 仅仅因为旧日志存到了多数派就提交它,在极端情况下(如论文 Figure 8 所示),该日志仍可能被后续拥有更高 Term 的新 Leader 覆盖掉。
唯一性保证:为了彻底杜绝日志被覆盖的可能,Leader 必须先提交一条自己任期内的日志。一旦当前任期的日志达到多数派,根据 Log Matching Property(日志匹配特性),之前所有的日志都会自动被视为已提交且不可修改。
记忆金句: “Raft 永远不会通过计算副本数来提交旧任期的日志;它只通过提交当前任期的日志,来为之前的日志提供『安全性背书』。”
Raft 不允许 Leader 直接提交旧任期日志,因为未提交的日志可能已被后续任期覆盖。只有当前任期 Leader 提交新日志时,才能通过日志匹配特性安全地“背书”所有历史日志。 核心逻辑: “提交是当前任期 Leader 的行为,它隐含证明了该日志在多数节点上是最终一致的。旧日志的安全性依赖于当前任期日志的提交,而非其副本数量。”
3.如果 Leader 上任后一直没有新数据进来,那之前的旧日志不就永远无法提交了吗?你的 C++ 项目是怎么处理的?
为了避免旧日志因没有新请求而无法提交,工程上通常采用 No-op 日志 方案。Leader 刚上任时,立刻发送一条不带任何数据的空日志(No-op)。一旦这条 No-op 日志达到多数派,就能顺便把之前任期的所有日志全部提交,从而保证了系统的活跃性。
为什么不能直接提交旧日志?
为了防止日志覆盖。旧任期的日志即使占多数派,也可能被更高任期的 Leader 用不同的日志覆盖。
Raft 怎么解决的?
Leader 只提交当前任期的日志,通过 Log Matching 特性间接提交旧日志。在工程中,我们会通过发送一条 No-op 空日志 来强行触发这个提交动作。
4.成员变更时的脑裂问题』(即 3 台服务器变 5 台时,怎么保证不出错)?
🛠️ 剖析修正:成员变更的两种方案
方案一:单节点变更 (One-at-a-time)
做法:每次只增加或删除一个节点。
优点:非常简单。数学上可以证明,在旧集群($N$)和新集群(\(N+1\))之间,它们的多数派(Majority)必然存在重叠。
结论:因此,永远不会同时诞生两个 Leader,不需要额外的复杂逻辑。
方案二:联合共识 (Joint Consensus)
做法:即你提到的 \(Majority_{old} \ \&\& \ Majority_{new}\)。
优点:支持一次性变动任意数量的节点(比如从 3 台直接扩容到 10 台)。
过程:Leader 先下发一个包含
C-old和C-new的特殊日志。在这一阶段,所有的选主和日志同步都必须同时获得“新旧两个群体”的多数派同意。结论:这是 Raft 论文(Extended 版)中详细描述的方法,虽然复杂,但功能最强。
5.如何防止离线节点(或由于成员变更被移除的节点)回归时干扰集群?
核心矛盾:在 Raft 基础算法中,任何节点只要收到更高的
term就会自动降级并更新任期。离线节点因收不到心跳会不断自增term,回归后会导致原 Leader 频繁退化,引发集群震荡。解决方案(Pre-Vote):在正式发起选主并自增
term之前,节点先进入 Pre-Candidate 状态,发送 Pre-Vote RPC。关键逻辑:其他节点只有在以下两种情况都满足时,才会对 Pre-Vote 投赞成票:
日志足够新:满足 Raft 的安全性要求。
心跳超时:这是关键!如果节点在租约时间内(Lease Time)收到了当前 Leader 的心跳,它会拒绝 Pre-Vote 请求。
结论:因为离线节点无法获得多数派的 Pre-Vote 认可,它的
term永远不会增加,也就无法干扰正常的集群。
6.在不写日志的情况下,为了保证“线性一致性读”(即读到的永远是最新的提交),工程上常用的 ReadIndex 优化和 Lease Read(租约读)分别是怎么做的?它们有什么区别?
1. 为什么需要 ReadIndex?
在 Raft 中,如果 Leader 直接从内存读数据,可能会发生**“脑裂读”**:
此时 Leader 已经不是真 Leader 了(被网络分区),新的 Leader 已经产生并提交了新数据。
旧 Leader 如果直接读内存,会返回旧数据,违反了线性一致性。
2. ReadIndex 的执行流程(简洁版)
ReadIndex 的核心思想是:“我不写日志,但我得确认一下我还是不是 Leader。”
记录 CommitIndex:Leader 收到读请求时,记录下当前的
commitIndex作为readIndex。确认身份(核心步):Leader 向多数派发送一次 心跳。
等待应用:一旦多数派回复了心跳,证明 Leader 地位合法。Leader 等待自己的状态机(ApplyIndex)追上这个
readIndex。返回结果:达到或超过
readIndex后,直接从内存读取数据并返回给客户端。
3. ReadIndex vs 租约读 (Lease Read)
这是面试官最喜欢问的对比题:
ReadIndex(更安全):
原理:靠 RPC(心跳)确认地位。
优点:不依赖物理时钟,绝对安全。
缺点:读请求会有一次网络 RTT(心跳)的开销。
租约读(更高效):
原理:靠时间(Lease)确认地位。Leader 只要在过去一小段时间内收到了多数派心跳,就认为自己合法。
避免频繁的对raft集群发送共识请求,这里我们需要减少频次,即设置一个租约时间,在这个租约时间内,Leader可以无条件的相信自己的日志是正确的,可以直接发送给客户端,只是这个租约时间的设置应该是根据心跳时间与Follower的超时时间来共同决定
优点:本地读,零网络开销,吞吐极高。
缺点:强依赖 CPU 时钟。如果 Leader 的时钟突然由于 NTP 偏差或者飘移,导致它认为租约没过期,但实际上已经过期了,就会读到旧数据(Stale Read)。
🛠️ 面试陈述金句(背诵版)
“在我的项目中,为了保证线性一致性读且兼顾性能,我推荐使用 ReadIndex 机制。它的核心逻辑是:在执行读操作前,先记录当前的
commitIndex,并通过一次多数派心跳确认自己的 Leader 地位。确认后,等状态机应用到该进度再返回数据。相比写日志,它减少了磁盘 IO 负载;相比租约读,它避免了物理时钟漂移带来的安全性风险。”
7.场景: 读请求压力很大,我想分摊给 Follower。但是 Follower 的日志通常比 Leader 慢。 问题: 如果客户端发一个读请求给 Follower,Follower 怎么保证它返回的数据不是过时的?它需要去问 Leader 吗?
🛠️ 剖析总结:Follower Read 优化
你的思路纠正: 虽然 Follower 可以发心跳,但如果每个 Follower 都去发心跳,网络请求会瞬间翻倍。工程上更优雅的做法是:Follower 询问 Leader。
标准执行流程:
获取 ReadIndex:Follower 收到读请求,不拒绝,而是向 Leader 发一个请求:“老大,现在的 ReadIndex 是多少?”
Leader 确认:Leader 执行一遍我们刚才说的 ReadIndex 流程(确认自己是 Leader),然后把最新的
commitIndex回传给 Follower。Follower 等待:Follower 拿到这个 index 后,对比自己的
applyIndex。如果自己还没同步到这个位置,就等一会儿。本地读取:一旦 Follower 自己的日志也追到了这个 index,它就直接从本地内存读数据返给客户端。
结论(记忆金句):
“Follower Read 的本质是 ‘ReadIndex 的代理模式’。它通过向 Leader 获取当前的 ReadIndex,确保 Follower 在返回数据前,其本地状态机至少已经更新到了请求发起时的共识状态。这样既分担了 Leader 的读带宽压力,又保证了线性一致性。”
2PC提交
2PC(Two-Phase Commit,两阶段提交) 是一种经典的分布式事务协议,用于 确保分布式系统中多个节点之间的事务一致性。
核心流程:
2PC将事务提交过程分为两个明确阶段:
准备阶段:
协调者(Coordinator)向所有参与者(Participant)发送"准备请求"
参与者执行本地事务操作,写入日志但不提交,返回"同意"或"中止"
提交阶段:
如果所有参与者都返回"同意",协调者发送"提交请求",参与者正式提交事务
如果有任何参与者返回"中止"或超时,协调者发送"回滚请求",参与者撤销事务
2PC阶段提交的几个致命伤
⚠️ 2PC 的三个致命伤(面试必背)
在 C++ 工程实现中,2PC 被诟病主要是因为:
同步阻塞:在参与者等待协调者指令时,它占用的数据库锁是一直不释放的。如果协调者卡了,整个系统的并发能力会断崖式下跌。
单点故障:如果协调者在发出
Commit之前宕机了,参与者们会陷入“等死”状态,它们不知道该提交还是回滚,资源会被永久锁定。数据不一致:如果协调者发出
Commit后,只有一部分参与者收到了,剩下的没收到就挂了,那么集群内部数据就不统一了。
8.问题: 既然 2PC 怕协调者(Coordinator)挂掉,也怕参与者(Participant)挂掉。那么在现代分布式系统(如腾讯的 TDSQL)中,如果我们把“协调者”和“每个参与者分片”都做成一个 Raft 组,是不是就能解决 2PC 的单点故障问题了?如果能,它是怎么做到的?
🛠️ 深度剖析:2PC + Raft 的联动逻辑
1. 解决单点故障(Raft 化协调者)
做法:协调者不再是一个单机进程,而是一个 Raft 组。
效果:如果协调者 Leader 挂了,Raft 组会迅速选出新 Leader。由于之前的事务状态(Prepare、Commit 等日志)已经同步到了 Raft 多数派,新 Leader 能够立即接管未完成的事务,继续下达指令。事务不会“悬挂”。
2. 解决数据不一致(Raft 化参与者)
做法:每个参与者(数据分片)也是一个 Raft 组。
效果:当协调者发送
Commit指令时,只要发给了参与者的 Raft Leader,这个指令就会被 Raft 保证同步到该分片的多数派节点。即便某个从节点掉线,它恢复后也会通过 Raft 补齐这个Commit日志。数据不会“分裂”。
3. 缓解同步阻塞
做法:引入 Raft 的超时检测。
效果:如果参与者长时间收不到协调者的指令,它不再傻等,而是可以询问协调者 Raft 组的其他成员。
3PC提交
⚠️ 现实中的“骨感”:3PC 为何出现?
虽然 Raft 解决了 2PC 的可用性问题,但在纯粹的协议层面,2PC 还有一个致命逻辑缺陷:它是强同步的,任何一个节点不响应,整个事务就卡死。
为了进一步优化“阻塞”问题,学术界提出了 3PC(三阶段提交)。
3PC 的两个关键改进(面试必考点):
引入 CanCommit 阶段:在正式锁资源之前,先问一句“你能不能干?(比如检查网络、检查版本号)”。如果这时候就有人不行,直接取消,成本极低。
引入超时机制(双向超时):
在 2PC 中,只有协调者等参与者会超时。
在 3PC 中,参与者等协调者也会超时。如果参与者在第三阶段(DoCommit)迟迟等不到协调者的指令,它会自动提交(基于一种“大概率协调者已经发了指令”的假设)。
9..问题: “既然 3PC 引入了‘超时自动提交’来解决阻塞,那如果网络发生了分区,协调者想回滚(Abort),但参与者因为没收到通知而自动提交(Commit)了,这不就导致数据彻底不一致了吗?那为什么大家还说 3PC 比 2PC 好?”
**我的回答:**对于你的这个问题 我觉得是这样的 这里其实是涉及到一个概率性的问题 因为三阶段算法我认为他在设计的时候是默认网络不会发生分区 或者是 发生网络分区的情况很少 而这种极少情况导致的不一致的情况是能够忍受的 才会出现这个3PC 我是这样认为的 而为什么在真实的工业界为什么还是倾向于使用2PC+raft 我想大概可能有这部分原因 就是3PC如果造成的数据不一致对于真实的工业环境的体量来说其恢复一致性是不太能接受的 因此才退而求其次来选择2PC+raft这个结合的方案
参考:
🛠️ 剖析总结:为什么 3PC 是“美好的理想”,而 2PC+Raft 是“硬核的现实”?
1. 3PC 的致命逻辑缺陷(你提到的不一致性) 正如你所说,3PC 假设“概率上大家都能达成一致”,但分布式系统最怕的是 网络分区(Network Partition)。
致命场景:在第三阶段(DoCommit),如果协调者发出了
Abort(回滚),但部分参与者因为网络分区没收到,它们超时后会自动提交。后果:这导致了一部分节点回滚,另一部分节点提交。这种**“脑裂”级的数据不一致**在金融级(如腾讯支付、数据库)场景下是灾难性的,人工修复成本极高。
2. 为什么工业界偏爱 2PC + Raft? 你的理解非常正确,2PC 虽然有阻塞风险,但它保住了“一致性”的底线。
高可用靠 Raft:2PC 的单点故障通过 Raft 解决(Leader 挂了新 Leader 顶上,日志不丢)。
原子性靠 2PC:跨分片的事务通过 2PC 保证。
结论:2PC + Raft 实现了“即使在最极端网络下,数据也不会错”。对于大厂来说,“宁可系统暂时不可用(阻塞),也绝不能把钱算错”。
paxos系列共识算法
1. Paxos 的核心角色:这是一场“议会表决”
论文将参与者分为三种角色(在 C++ 工程实现中,一个节点通常同时扮演这三种角色):
Proposer(提议者):负责接收客户端请求,发起提议。
Acceptor(决策者):负责对提议进行投票。只有获得**多数派(Majority)**通过的提议才被选中。
Learner(学习者):不参与投票,只负责学习被选中的结果。
2. 深度剖析:Basic Paxos 的两个阶段(Phase)
面试官最喜欢让你现场推演这两阶段。记住:Paxos 的本质是抢占式的锁定机制。
第一阶段:Prepare(准备/锁定阶段)
Proposer:选一个全球唯一的编号 $n$,发给多数派 Acceptors。
Acceptor:收到后做出两个承诺:
不再理会编号小于 $n$ 的任何提议。
告知现状:如果之前我已经通过了某个值,我会把那个值(及其编号)告诉你。
工程逻辑: 这一步相当于在分布式环境下“申请锁”。但我申请的不仅是锁,还要问问“前任”留下了什么没干完的活。
第二阶段:Accept(批准/执行阶段)
Proposer:
如果大家都没见过旧值,那我就提议我自己想写的值。
如果大家回传了旧值,我必须“认命”,必须用收到的旧值去发起请求(即便我想写别的)。
Acceptor:只要我没承诺过不理你(即没有更大的 $n'$ 出现),我就通过你的请求。
我的理解:但是如果ALice提议的这个值并没有达成多数票的决定 那是不是Bob就可以进行正常的提议呢 这个决策是不是就是类似于Raft集群变更一个节点可以直接变更不会发生脑裂的原因一样 就是如果两个Candidate当选Leader那么必然会存在交叉 因此不会发生脑裂
任何两个“成功”的决策集合(无论是 Paxos 的批准集合,还是 Raft 的选举集合),只要它们都是多数派,它们就一定共享至少一个节点。这个共享节点就像是一个“守门员”,它保存着过去的状态,阻止了相互冲突的新决策同时成功。
1.为什么 Paxos 比 Raft 难懂?(面试核心区别)
这是一个非常经典的问题。在分布式系统的面试中,面试官不仅想听你背诵它们的定义,更想看你是否理解了**“工业实现”与“理论模型”之间的鸿沟**。
1. 为什么 Paxos 比 Raft 难懂?
简单来说:Paxos 是数学家的推演,而 Raft 是工程师的蓝图。
核心区别:抽象维度的不同
| 维度 | Basic Paxos | Raft |
|---|---|---|
| 设计目标 | 解决“共识”这一个数学命题。 | 解决“如何构建可理解、可实现的复制状态机”。 |
| 角色划分 | 角色重叠且模糊(Proposer, Acceptor, Learner),可以多主共存。 | 角色分明(Leader, Follower, Candidate),严格单主。 |
| 日志顺序 | 允许空洞。允许乱序达成共识,最后再拼凑日志。 | 严格连续。日志必须按顺序提交,不允许空洞。 |
| 成员变更 | 极其复杂,需要通过 Paxos 协议本身来达成协议。 | 内置了成员变更机制(Joint Consensus)。 |
Paxos 的“难”在于它的非线性
Paxos(指 Basic Paxos)并不关心日志的顺序,它只关心“在某一个槽位(Slot)上达成一致”。如果你有 100 个槽位,Paxos 可以并行地对这 100 个槽位发起决议。这种无序性和并发性虽然在数学上很美,但违反了人类习惯的“线性增长”思维,导致代码实现极其困难(如 Multi-Paxos 并没有标准论文,各家大厂实现各异)。
2. 深度剖析:Paxos 的并行选举与并行共识
你提到的“并行选举”其实是 Paxos 核心优势的一部分。在 Paxos 体系中,尤其是 Multi-Paxos 或 EPaxos,其并行能力体现在以下两个层面:
A. 实例级别的并行 (Instance Parallelism)
在 Raft 中,如果 Leader 挂了,整个集群必须先选出新 Leader 才能继续工作。但在 Paxos 中:
每个日志槽位(Instance)是独立的:只要不违反 Overlapping Majority(多数派重叠)原则,理论上可以同时对多个 Slot 发起提案。
无强制序列化:不需要等待 Slot 1 确认,就可以开始 Slot 2 的预案。
B. EPaxos 的并行选举机制 (Egalitarian Paxos)
这是 Paxos “并行”思想的巅峰。为了解决 Multi-Paxos 中单 Leader 的性能瓶颈和跨地域延迟,EPaxos 引入了**无主(Leaderless)**的概念:
节点对等:任何一个节点都可以直接处理客户端请求并成为该请求的“临时 Leader”。
依赖追踪:如果两个并行请求操作的是不同的数据,它们可以直接并行提交,无需协商顺序。
快速路径(Fast Path):在没有冲突的情况下,EPaxos 只需要一轮 RTT 即可达成共识,而不需要像 Raft 那样必须通过中心化的 Leader。
3. 面试官想听的“核心差异”总结
如果面试官问你“两者的核心区别”,你可以从这三个深度视角回答:
强领袖 vs 弱领袖:
Raft 依赖强领袖,数据流向是单向的(Leader -> Follower),简化了冲突处理。
Paxos 可以是多主或无主,数据流向是网状的,通过版本号(Proposal Number)来解决冲突。
日志完整性限制:
Raft 规定:只有包含全部已提交日志的节点才能当选 Leader。
Paxos 规定:任何节点都能发起提案,达成共识后再补全缺失的日志(允许先达成共识,后同步数据)。
确定性 vs 活性(Liveness):
Raft 通过随机选举超时规避了“活锁”问题。
Basic Paxos 在极端情况下会发生两个 Proposer 互相递增编号导致的活锁(Livelock),需要通过选主或退避算法解决。
总结:
Raft 是通过限制(限制日志必须连续、限制只能 Leader 写入)来换取简单;而 Paxos 是通过放权(允许乱序、允许并发)来换取性能上限。
总结版:Paxos 难懂的核心原因在于它为了追求极致的理论性能,牺牲了人类直觉的线性思维。 具体来说,Paxos 允许日志乱序和空洞达成,将‘选主’和‘提交’解耦为并行的数学实例,这导致工程实现时必须处理极其复杂的状态回补和冲突合并逻辑; 而 Raft 则是‘以性能换简单’的工程妥协:它强制引入强 Leader和严格连续的日志,把复杂的分布式并发问题,降维成了简单的‘主从复制’问题。 一句话总结:Paxos 是数学家追求的‘无序并发之美’,而 Raft 是工程师选择的‘有序约束之简’。
为什么现在大家多用Raft?因为在工业界,可维护性(Maintainability)往往比极致的理论吞吐量更重要。Raft 的代码任何人都能看懂并修改,而 Paxos 一旦出 Bug,只有作者本人能修。
2.为什么微信选择 Paxos 而不是 Raft?
微信底层(PaxosStore/PhxPaxos)坚持用 Paxos 的核心理由:
容灾的灵活性(多地多活):
Raft:必须有 Leader 才能写。如果 Leader 在深圳,上海节点想写,必须走一个跨城的 RTT(往返时延)传给深圳。如果深圳机房炸了,整个集群要停摆几秒选新主。
Paxos:它不需要一个“常驻”的 Leader。任何节点理论上都能发起提议。在微信这种千亿级流量下,Multi-Paxos 允许不同机房的节点在本地达成共识后再同步,极大地降低了长途网络对写性能的影响。
解决“日志空洞”(High Concurrency):
在 Raft 中,如果 Leader 发送日志 1、2、3,日志 1 因为网络丢包迟迟未到,日志 2 和 3 即使到了多数派也不能提交,这叫“队头阻塞(Head-of-Line Blocking)”。
在 Paxos 中,日志 1 丢了?没关系,日志 2 和 3 可以先达成共识并持久化。等到日志 1 补齐后,状态机再按顺序执行。这种乱序到达、乱序共识的能力,在高并发网络抖动时,吞吐量远超 Raft。
工程演进的历史:
- 微信做 PaxosStore 的时候,Raft 论文还没出(或者还没火)。腾讯在 Paxos 上积累了极其深厚的工程经验(比如如何解决活锁、如何做 Lease 优化)。对于腾讯这种体量,稳定高于一切,没必要为了“好懂”去换掉一个已经证明成功的架构。
微信选择 Paxos,是因为它在共识层放开了顺序限制,允许日志乱序到达和持久化。在跨机房的长距离网络下,这能最大限度利用带宽,避免因为单条日志丢失导致整个流水线停滞。虽然在应用层我们依然要按序执行,但 Paxos 提供的‘乱序并行共识’能力,让它在高并发和不稳定网络下的吞吐上限和抗抖动能力优于 Raft。
3.在 C++ 实现 Multi-Paxos 时,如果允许日志“乱序共识”(即 Slot 5 还没定,Slot 6 已经定了),那么在状态机(State Machine)把结果应用到数据库之前,我们需要做哪些处理?
你的思考非常深入,但这里涉及到一个分布式系统中最核心的原则:共识(Consensus)与 状态机(State Machine)的解耦。
你的直觉里“先到的先执行”是为了效率,但在分布式存储(如数据库、文件系统)中,顺序(Order)就是生命。如果 Slot 5 是 set A = 10,Slot 6 是 set A = 20,如果上海节点先执行了 Slot 6,后来才补上 Slot 5,那 A 的值就变成了错误的 10。
所以,针对你的疑问,我们来硬核拆解一下 Paxos 的执行逻辑:
1. 你的理解对了一半:共识是并行的,但应用是顺序的
你说“等 Slot 5 到了再执行 Slot 6 就成了 Raft”,这其实只对了一部分。
Raft 的限制:它的共识过程就是串行的。如果日志 5 没达成多数派确认,Leader 根本不会去发起日志 6 的共识。这就叫“队头阻塞”。
Paxos 的优势:它的共识过程是并行的。节点可以同时发起 Slot 5、6、7 的表决。即使 Slot 5 因为网络丢包卡住了,Slot 6 和 7 可以先在各自的槽位里达成共识(即大家都同意了 Slot 6 的内容是
set A = 20)。
2. 状态机必须“按序提交”
虽然 Slot 6 先达成了共识,但为了保证一致性,状态机必须等待 Slot 5 补齐后,按照 5 -> 6 的顺序执行。
那么 Paxos 强在哪? 在 Raft 中,如果 Slot 5 卡住,网络带宽就浪费了。而在 Paxos 中,我们可以先把 Slot 6、7、8、9 都填满并持久化到磁盘。等到 Slot 5 通过重传一到,状态机可以瞬间“起飞”,一口气把 5 到 9 全部执行完。这种能力叫 “Out-of-order log replication”(乱序日志复制)。
3. 针对你说的“先到的先执行”:极少数场景
你提到的“先到先执行”在一种情况下是成立的:如果 Slot 5 和 Slot 6 操作的是完全无关的数据(比如一个是修改账户 A,一个是修改账户 B)。 在一些极高性能的 Paxos 实现中,会引入**“多流状态机”**,只要数据不冲突,确实可以并排执行。但这大大增加了工程复杂度。
4.Paxos的活锁问题
既然聊到了 Paxos 的并行能力,就必须面对它的致命弱点——活锁(Livelock)。
场景:
Proposer A 带着编号 1 去 Prepare,成功。
Proposer B 突然冒出来,带着编号 2 去 Prepare,成功。此时 Acceptor 承诺不再理会编号 1。
Proposer A 进入第二阶段(Accept)被拒绝,气急败坏,带着编号 3 去 Prepare……
两人无限循环,互相踢馆。
工程解法(简洁有力):
方案一:随机退避。像以太网冲突检测一样,失败后随机等一会儿再试。
方案二:选出主提议者(Leader/Distinguished Proposer)。
这是微信底层做法:虽然协议是 Paxos,但我们在工程上会选出一个“临时老大”来负责提议。
区别点:Paxos 的这个 Leader 只是为了优化性能和避免活锁。即使同时出现两个 Leader,Paxos 协议也能保证安全性,不会出乱账;而 Raft 如果出现两个 Leader 且没有分区隔离,直接就崩了。
5.如果你是面试官,现在你要招一个做分布式存储的工程师,你会更看重他在 C++ 实现中对哪个模块的优化?(是网络 I/O?磁盘 fsync 频率?还是状态机的并发执行?)
1. 网络 I/O 的深度优化:不仅仅是“换线路”
你提到的“假长链接”和“换线路”,在工程上其实叫 “多路径并行同步” 或 “多流并行”。
面试标准表述:
“在处理微信这种千亿级流量时,网络 I/O 的瓶颈在于单 TCP 连接的吞吐上限和网络抖动带来的队头阻塞。在 C++ 实现中,我会引入 用户态网络协议栈(如 DPDK) 或者是 多连接并行复制。
针对 Paxos,我可以利用其‘允许日志空洞’的特性,在多个 TCP 连接上并行分发不同的 Slot。即使其中一条链路发生丢包,也不会像 Raft 那样导致整个流水线停滞,从而极大地提升了极端网络环境下的请求成功率。”
2. 状态机并发执行:这才是真正的“深水区”
你观察到“消息乱序”,这在分布式存储中是一个顺序一致性的问题。你提到的“状态机并发执行”是目前分布式数据库(如 TiDB、TDSQL)最前沿的优化点。
面试标准表述:
“传统的 Paxos/Raft 状态机是单线程串行执行的,这在多核 CPU 时代是巨大的浪费。为了实现状态机并行化,我会引入 基于依赖分析的并行执行引擎。
核心逻辑是:状态机在执行前,先解析 Slot 中的指令。如果两个指令操作的是不同的 Key(例如用户 A 转账和用户 B 发朋友圈),它们之间没有数据依赖,那么我就将它们分发到不同的线程池中并行执行。只有当两个操作涉及同一个资源时,才强制串行。这样能将系统的单机吞吐量提升数倍。”
面试问题集锦
1.Raft算法的基本原理
回答要点: 解释Raft算法的基本工作原理,包括领导者选举、日志复制和安全性保障。
示例回答:
Raft算法是一种分布式算法,旨在解决分布式系统中的一致性问题,相对于Paxos算法而言更易于理解和实现。
Raft算法将系统中的所有节点分为三类角色:领导者(leader)、跟随者(follower)和候选人(candidate)。其选举机制确保系统中的一个节点被选为领导者(leader),领导者负责处理客户端的请求,并将更新复制到其他节点。
Raft算法的基本原理包括以下几个关键步骤:
领导者选举(Leader Election):在系统启动时或者当前领导者失效时,节点会发起选举过程。节点会在一个随机的超时时间内等待收到来自其他节点的心跳消息。如果在超时时间内没有收到心跳消息,节点就会成为候选人并发起选举。候选人向其他节点发送投票请求,并在得到大多数节点的投票后成为新的领导者。
日志复制(Log Replication):一旦领导者选举完成,新的领导者就会接收客户端的请求,并将更新的日志条目复制到其他节点。当大多数节点都成功复制了这些日志条目时,更新被认为是提交的,并且可以应用到节点的状态机中。
安全性(Safety):Raft算法通过确保在选举中只有一个领导者(单一领导者)、大多数节点的一致性以及只有领导者可以处理客户端请求等方式保证分布式系统的安全性。
通过以上机制,Raft算法确保了分布式系统中的一致性、可用性和分区容错性。
注意: 如果这么回答如果面试官懂一些分布式算法的话,那么后续可能会提问Raft与其他分布式算法的关系。
1. 定位:从 Paxos 说起
“首先,在分布式共识算法的家族里,Raft 和 Paxos 是‘近亲’。它们都属于 CFT(Crash Fault Tolerance),也就是只解决节点宕机故障、不处理恶意作恶节点的算法。
大家都知道 Paxos 是共识算法的鼻祖,但它有两个痛点:一是极其晦涩难懂,二是工程实现非常困难。Raft 的出现就是为了‘可理解性’而生的。它把 Paxos 抽象且复杂的共识过程分解成了几个清晰的模块:领导者选举、日志复制和安全性保障。”
2. 核心对比:Raft vs. Multi-Paxos
“如果面试官追问 Raft 和 Multi-Paxos 的区别,我会强调这两点:
强领导者模型(Strong Leader): Raft 的权力更集中。所有的日志都必须从 Leader 流向 Follower。而在 Multi-Paxos 中,虽然也有 Leader,但在某些实现里它允许更灵活的写入。
日志连续性: Raft 要求日志必须是连续的,Leader 永远拥有最新的、完整的日志。而 Paxos 允许日志中有‘空洞’,随后再通过同步来补齐。
总结来说: Raft 是通过限制灵活性,换取了极大的工程简单性。”
3. 横向对比:Raft vs. ZAB (ZooKeeper)
“在实际中间件中,Raft 常被拿来和 ZooKeeper 使用的 ZAB 协议做对比:
相似性: 它们都依赖 Leader 节点,都有纪元(Epoch/Term)的概念,且都要求‘大多数’节点同意。
区别: ZAB 的设计更倾向于主备模式的恢复,它的状态机模型和 Raft 在处理 Leader 切换时的日志衔接细节上有所不同。不过在现代架构中,Raft 已经成为了事实上的工业标准(比如 ETCD、TiDB、Consul 都在用)。”
4. 边界区分:CFT vs. BFT
“最后,需要跳出这个圈子看一下。Raft、Paxos、ZAB 解决的都是非拜占庭故障。
如果是在区块链或者不信任的网络环境下,我们需要的是 BFT(拜占庭容错) 算法,比如 PBFT 或者 HotStuff。这些算法比 Raft 复杂得多,因为它们要应对节点‘说谎’的情况,而 Raft 默认集群内的节点都是诚实但可能掉线的‘好人’。”
💡 总结一句话:
“Raft 是对 Paxos 的一种工程化重构,它通过强化 Leader 的地位和日志的连续性,把共识算法从‘数学难题’变成了‘工程组件’。”
2.如何进行raft中的领导人选举?
什么时候触发选举?
当 Follower 在选举超时时间内(本项目设置为 300-500ms 随机)没有收到 Leader 的心跳(AppendEntries),就认为 Leader 已经宕机或网络隔离,于是发起选举。
超时时间随机化是为了避免多个节点同时发起选举导致"选票瓜分"(split vote)。
选举流程
状态转换:Follower → Candidate,将自己的
currentTerm + 1,并先投自己一票。广播 RequestVote RPC:向集群中所有其他节点发送投票请求,携带
(term, candidateId, lastLogIndex, lastLogTerm)。投票规则(接收方判断):
请求的
term不小于自己的currentTerm自己在该 term 尚未投票(或已经投给该 Candidate)
Candidate 的日志至少和自己一样新(先比较 lastLogTerm,再比较 lastLogIndex)
三个条件都满足才投赞成票
选举结果(三种情况):
结果 条件 处理 当选 Leader 获得多数派投票(3 节点需 ≥2 票) 立即发送心跳宣告权威,阻止其他选举 发现更高 term 收到 term 更大的回复 立即退回 Follower,更新 term 选举超时 超时内未获多数票 增加 term,重新发起选举
结合项目实际
在本项目的 3 节点集群中:
心跳间隔约 25ms,选举超时 300-500ms 随机
容忍 1 个节点故障,剩余 2 个节点仍可完成选举
客户端遇到
ErrWrongLeader时会销毁连接、重新查询 ZooKeeper,通常 1-2 次重试即可找到新 Leader
一句话总结
Raft 选举的核心是**"谁的日志最新谁当选"**——通过 term 机制保证同一任期只有一个 Leader,通过日志比较保证新 Leader 一定包含所有已提交的日志,从而保证数据不丢失。
第一层:问题引出(为什么做)
"在测试过程中,我发现如果手动 kill 掉一个节点模拟网络分区,一段时间后再恢复,这个节点的 term 已经涨得很高了,重新加入集群后直接把正常工作的 Leader 给拉下来了,整个集群出现了一次不必要的选举抖动。这个问题让我意识到原始 Raft 在网络分区场景下有缺陷。"
第二层:方案设计(怎么做)
"我的解决方案是引入 PreVote 预选举。具体来说,在 Raft 的选举超时触发时,不再直接自增 term 发起 RequestVote,而是分两步走:"
步骤 1:发送 PreVote RPC
- 本地 term 不变
- RPC 中携带 (currentTerm + 1) 作为试探性 term
- 其他节点用同样的投票规则判断:日志是否足够新、是否已有合法 Leader
步骤 2:统计预投票结果
- 获得多数预投票 → 说明自己有资格,正式 term++ 发起 RequestVote
- 未获得多数 → 说明自己可能被隔离了,放弃选举,term 保持不变
第三层:实现细节(追问时展开)
"具体代码层面,我做了这几件事:"
① 新增 RPC 类型
"我在 protobuf 中新增了
PreRequestVote的 Request 和 Response,字段和 RequestVote 完全一致,只是语义不同——不会导致接收方更新自己的 term 和 votedFor。"
② 接收方判断逻辑
"接收 PreVote 的节点判断条件有两个:一是候选者的日志至少和自己一样新(lastLogTerm 和 lastLogIndex 的比较逻辑和正式投票一样);二是自己当前没有收到合法 Leader 的心跳(即自己也处于选举超时状态)。第二个条件很关键——如果我正常收着 Leader 心跳,说明集群是健康的,我就拒绝这个 PreVote,防止一个小分区的节点骚扰正常集群。"
③ 状态机变化
"原来是
Follower → Candidate,现在变成Follower → PreCandidate → Candidate。PreCandidate 不改变任何持久化状态(term、votedFor 都不变),只有预投票通过后才进入真正的 Candidate 状态。"
④ 关键区别总结
| 对比项 | PreVote | 正式 RequestVote |
|---|---|---|
| 本地 term | 不自增 | 自增 |
| 接收方更新 votedFor | 不更新 | 更新 |
| 接收方降级为 Follower | 不降级 | 发现更高 term 会降级 |
| 失败后果 | 无副作用 | term 已被污染 |
第四层:效果验证(有数据说话)可以不说 还没有测试
"加了 PreVote 之后,我再做同样的测试——kill 一个节点 30 秒后恢复,这个节点的 term 和集群其他节点保持一致,重新加入后直接作为 Follower 平滑接入,集群没有发生任何不必要的选举,Leader 也没有中断。客户端完全无感知。"
应对追问
| 面试官可能追问 | 回答要点 |
|---|---|
| "PreVote 会不会影响正常选举速度?" | "只多了一轮 RPC,在正常选举场景下增加约一个 RTT(几毫秒),可以忽略。" |
| "如果所有节点同时分区呢?" | "那所有节点都无法获得多数预投票,都不会自增 term,网络恢复后所有节点 term 一致,正常选举即可。" |
| "这个方案是你自己想的吗?" | "我是在测试中发现 term 膨胀问题后,查阅了 Raft 论文的后续改进和 etcd 的实现,然后在自己的项目中实现了这个机制。" |
| "和 etcd 的实现有什么区别?" | "核心思路一致。etcd 中还考虑了 learner 节点不参与投票等场景,我的实现聚焦在 3 节点集群的核心逻辑上。" |
3.在什么情况下会触发领导者选举
在raft算法中,领导者选举会在以下情况下触发:
当系统启动时,所有节点都处于初始状态,没有领导者
当领导者节点因网络分区、宕机或者其他原因失效时,导致系统中没有活跃的领导者
当节点故障或者被网络分区时,他可能会检查到当前没有领导者,因此会成为候选人并发起选举
4.为什么需要设置随机超时选举时间?
“在 Raft 中,随机化选举超时(Randomized Election Timeouts)是为了解决分布式系统中的 ‘瓜分选票(Split Vote)’ 问题。
具体原因可以分为以下三点来陈述:”
1. 打破对称性(Breaking the Symmetry)
“如果所有节点的超时时间都是固定的(比如都是 150ms),那么当 Leader 宕机后,所有 Follower 都会在同一时刻发现 Leader 失踪,并同时转变为 Candidate 发起选举。 这时,每个节点都会投自己一票,导致没有任何一个节点能获得‘超过半数’的选票。如果没有随机化,这种‘全员竞争且无人胜出’的僵局可能会无限循环下去,导致集群迟迟选不出 Leader。”
2. 确保单一获胜者
“通过将超时时间设置为一个区间内的随机值(例如 150ms - 300ms),我们可以确保在大多数情况下,总有一个节点会比其他节点早几毫秒醒来。 这个‘先觉醒’的节点会率先发出投票请求(RequestVote RPC),在其他节点还没来得及超时之前,就抢先拿到大部分选票,从而迅速达成共识,让系统恢复可用。”
3. 提高系统的活锁抗性(Liveness)
“在分布式理论中,这是一种通过‘随机化’来规避‘确定性活锁’的经典手段。它极大地降低了多轮选举失败的概率,保证了集群在 Leader 故障后能以极快的速度(通常只需一轮选举)重新选举成功。”
💡 进阶补充(加分项):
“除了选举超时(Election Timeout)是随机的,Raft 建议在选举失败后重新发起选举前的等待时间也应该是随机的。这样双重保险,可以彻底避免多个 Candidate 陷入同步竞争的死循环。”
总结成面试金句:
“随机超时的目的就是为了错开竞选时间,防止瓜分选票,从而保证集群能快速且唯一地选出 Leader。”
5.你的RPC是怎样设计的?
我这套RPC用的是stub/channel 代理模型:业务方只调用Stub Channel 统一处理编解码、连接管理和容错;服务端用Muduo做网络层并把请求投递到线程池异步执行;注册中心使用Zookeeper做服务发现与注册,客户端对同一方法的多实例做本地缓存+轮询负载,结合临时节点实现故障自动摘除;同时保留直连模式用于联调压测和应急旁路,一般默认走服务发现,并通过长短链接切换、超时重试和自定义报头来解决性能与可靠性问题。
当然在基于raft的分布式存储系统中,由于是强Leader控制的共识系统,都是一个Leader与其他Follower建立RPC链接,因此在这里我的轮询负载这些优化是关了的,只是为了降低Leader与Follower之间的RPC建连开销 依旧保持了长短链接切换这个方案。
追问1:为什么不直接用gRPC?
回答模板:
从项目定位角度:
我这个项目的核心是分布式KV存储 + Raft共识算法,RPC只是其中一个基础组件。我自己实现RPC主要有三个考虑:
学习目的:深入理解RPC的完整链路(序列化、网络传输、服务发现、负载均衡),而不是只会调API
定制化需求:我的Raft节点间通信需要低延迟、高频短连接复用,gRPC的HTTP/2开销在内网场景下不一定最优,我可以针对Raft的心跳、日志复制等场景做专门优化
轻量级部署:gRPC依赖较重(HTTP/2、大量proto插件),我这套基于Protobuf + Muduo的方案编译产物更小,适合嵌入式或资源受限环境
从技术选型角度:
gRPC确实功能更全(流式RPC、拦截器、丰富的语言绑定),但我的场景主要是简单的请求-响应模型,用不到那么多特性
我已有Muduo作为网络库基础,直接在其上封装RPC比引入整个gRPC栈更可控,遇到问题调试也更直接
(加分项)如果生产化会怎么做:
如果这个项目要生产化或对外提供服务,我会优先考虑gRPC(生态成熟、跨语言、工具链完善)
但保留当前这套作为内网高性能通信的备选方案,或者在gRPC基础上做混合:外部用gRPC,Raft集群内部用自定义RPC
追问2:你这套RPC最大的短板是什么?
回答模板:
诚实态度 + 深度思考:
(2)性能优化空间:
序列化性能:Protobuf本身不算最快(相比FlatBuffers、Cap'n Proto),而且我当前每次都完整序列化,没做零拷贝或增量序列化
连接池管理简单:长连接模式下只是复用单个socket,没有做连接池、连接预热、慢启动等精细化管理
负载均衡策略单一:只实现了轮询,缺少加权轮询、一致性哈希、最少连接数等自适应策略
(关键)如何规避/改进:
当前项目语境下的规避:我的Raft-KV场景主要是简单的Get/Put + Raft日志同步,这些短板不是瓶颈;通过合理的超时、重试配置和ZooKeeper故障摘除,已经能满足基本可用性
如果继续迭代:
引入连接池 + 更智能的负载均衡(比如根据Raft角色做路由:读请求优先打Follower,写请求打Leader)
考虑混合架构:保留当前自研RPC做高性能内核通信,对外服务用gRPC做网关
额外加分项:反问面试官
如果氛围合适,你可以在回答完后反问:
"我现在这套RPC在项目里主要解决Raft集群内部的通信和客户端-服务端的简单交互,如果是贵司的业务场景,您觉得服务治理这块(比如熔断降级、灰度发布)会是优先要补齐的..吗?我之前考虑过对接类似Sentinel或者Istio这样的方案。"
作用:
展示你有全局视角(知道RPC只是分布式系统的一环)
引导话题到面试官公司的技术栈,让对方多说(减轻你的压力)
体现你有持续学习和工程化思维
6.你这个RPC框架的序列化和反序列中protobuf细节有没有了解
“在我的 RPC 框架中,关于 Protobuf 的细节主要体现在 高效序列化 和 协助处理 TCP 边界 两个方面:
解决粘包与半包(利用 Varint 变长头部): Protobuf 本身不解决粘包,但我基于它设计了协议头。我使用 Protobuf 的
varint32变长编码来记录 Header 的长度。程序先读取并解析出 Header(包含服务名、方法名和 Body 参数长度),以此精准定位后续参数的位置,这就解决了粘包问题。如果底层缓冲区读取的字节总数,还不满足我们解析出的Header长度 + Body长度,程序就会挂起等待,这就解决了半包问题。 (对应你原本的思路,但表述更严谨)底层的压缩与编码细节: 除了变长头部,我还了解到 Protobuf 内部摒弃了 JSON 那种冗余的 Key-Value 文本结构,采用了
Tag-Type-Value的紧凑内存布局,配合 ZigZag 编码和varint,对整型等数据进行了极大地压缩,这就相当于 Google 实现的一种高效数据压缩算法,大幅降低了 RPC 的网络 I/O 开销。 (对应图片中要求掌握的“压缩算法”知识点,拔高技术深度)”
7.除了简单轮询之外,你还了解什么复杂均衡策略》
1.静态策略:
加权轮询
给每个节点分配一个权重,性能好的机器权重高,轮询时获得的请求也就越多
这适合用在后端服务器硬件配置参差不齐时
随机/加权随机
利用随机数生成器选择一个节点。在并发请求量非常大的情况下,由概率论可知,其效果无限近似于轮询。
场景:实现简单,适合节点无状态且绝对均匀度要求不高的系统
原地址/请求哈希
据客户端的 IP 地址或请求中的某个特定字段(比如 UserID)进行 Hash 计算,然后对当前节点总数取模,将请求固定路由到同一台机器。
场景:需要保持Session会话状态或者带有本地缓存的业务。
2.高级与动态策略:
一致性哈希:
将服务器节点和请求的 Key 映射到一个首尾相接的“哈希环”上(通常是 0 到 2^32 - 1)。请求会顺时针寻找环上的第一个节点。为了解决节点少时导致的数据倾斜,通常会为每个物理节点分配多个“虚拟节点”。
场景:分布式 KV 存储或分布式缓存的核心标配。当节点宕机或扩容时,只会影响环上的一小段数据,避免了取模算法中节点数变化导致的全局数据重新洗牌(缓存雪崩)。
最少活跃连接数
动态记录每个节点的当前活跃连接数,把新的请求分配给当前连接数最少的节点。
场景:适合长连接业务或处理时间长短不一的 RPC 调用。能有效防止某台机器因为碰巧处理了几个极度耗时的请求而被“堆死”。
最短响应时间
结合了“最少活跃连接”和“历史平均响应延迟”。不仅看哪个节点现在最闲,还看哪个节点处理速度最快。
场景:对 QPS 和 P99 延迟指标要求极高的核心后端服务,能够实现真正的动态自适应调度。
8.什么是QPS 以及P99指标以及P95指标?
这两个概念是评估任何后端系统(尤其是分布式系统和高并发框架)性能的核心标尺,也是大厂后端开发面试的必考点。
以下是精炼的解释:
1. QPS (Queries Per Second) - 系统的“吞吐量”
定义: 每秒查询率,即系统在一秒钟内能够成功处理的请求数量。
业务意义: 它直接反映了系统的承载能力。QPS 越高,说明系统单位时间内能服务的请求越多。
工程视角: 将一个基础架构(例如一个底层的 RPC 框架)的 QPS 从几百压榨、优化到突破 10,000 级别,通常需要跨越多个性能瓶颈,包括引入高效的序列化协议、优化网络 I/O(如 Epoll)、管理线程池以及减少不必要的内存拷贝和锁竞争。
2. P99 与 P95 指标 - 系统的“长尾延迟”
定义: P 代表 Percentile(百分位数)。
P95 延迟为 50ms: 意味着系统中有 95% 的请求都在 50 毫秒内处理完毕,只有 5% 的请求超过了这个时间。
P99 延迟为 100ms: 意味着系统中有 99% 的请求都在 100 毫秒内处理完毕,只有 1% 的请求是最慢的,超过了 100ms。
此外还有 P99.9(千个九)甚至 P99.99(万个九),要求极其严苛。
为什么不看“平均响应时间”?
- 平均值极具欺骗性。假设 99 个请求都是 1ms,1 个请求是 1000ms(比如因为网络抖动或发生了垃圾回收),平均延迟是 10.99ms,看起来系统依然很快。但对于那个遭遇 1000ms 延迟的用户来说,体验是灾难性的。
业务意义: 现代分布式架构通常包含长长的调用链。如果底层的分布式 KV 存储节点出现偶尔的耗时抖动(哪怕只是磁盘 I/O 慢了一下或者 Raft 选举卡顿),这种“长尾效应”会被上层服务急剧放大。因此,大厂不仅要求高 QPS,更要求死死压住 P99 甚至 P99.9 的延迟,以保证绝大多数用户的体验都是丝滑的。
总结:
QPS 看的是系统“能吃多少”(并发处理能力),而 P95/P99 看的是系统“吃得稳不稳”(服务质量与稳定性)。
9.TCP的三次握手和四次挥手的状态是怎么样的?为什么挥手需要四次 而握手只需要三次?
TCP(传输控制协议)是一个全双工的可靠通信协议。建立连接需要确认双方的收发能力,而断开连接需要确保双方的数据都已经发送完毕。
这里为您模拟整个流程,并配以关键状态的流转说明。
1. TCP 三次握手:建立连接
三次握手的核心目的是:确认客户端和服务端各自的发送能力和接收能力都是正常的。
假设客户端(Client)主动发起连接,服务端(Server)被动接收连接。
准备阶段: 服务端处于
LISTEN(监听)状态,等待客户端的连接请求。第一次握手 (Client -> Server):
动作: 客户端向服务端发送一个 SYN(同步)报文段,并带上一个随机生成的初始序列号
seq = x。意义: 客户端说:“我要和你建立连接,我的初始序列号是 x。”
状态转变: 客户端进入
SYN_SENT(同步已发送)状态。
第二次握手 (Server -> Client):
动作: 服务端收到 SYN 报文后,如果同意连接,会发送一个 SYN + ACK(同步+确认)报文段。它确认了客户端的序列号(
ack = x + 1),同时自己也生成一个初始序列号seq = y。意义: 服务端说:“收到你的请求了(ACK),我同意连接;同时我也要和你建立连接(SYN),我的序列号是 y。”
状态转变: 服务端进入
SYN_RCVD(同步收到)状态。
第三次握手 (Client -> Server):
动作: 客户端收到服务端的 SYN+ACK 后,发送一个 ACK(确认)报文段,确认服务端的序列号(
ack = y + 1,seq = x + 1)。意义: 客户端说:“收到你的确认和连接请求了,连接正式建立!”
状态转变: 客户端进入
ESTABLISHED(已建立)状态。服务端收到此报文后,也进入ESTABLISHED状态。
2. TCP 四次挥手:释放连接
TCP 是全双工的(双方可以同时发数据),因此每个方向的连接必须单独关闭。这就是为什么建立连接只需三次,而断开需要四次(即所谓的“半关闭”状态)。
假设这次是客户端(Client)主动要求断开连接。
第一次挥手 (Client -> Server):
动作: 客户端数据发送完毕,向服务端发送一个 FIN(结束)报文段(
seq = u)。意义: 客户端说:“我的数据发完了,我不再发送数据了,请求关闭连接。”
状态转变: 客户端进入
FIN_WAIT_1(终止等待1)状态。
第二次挥手 (Server -> Client):
动作: 服务端收到 FIN 报文,立刻回复一个 ACK 报文段(
ack = u + 1,seq = v)。意义: 服务端说:“收到你的关闭请求了。但我可能还有数据没发完,你等我发完。”
状态转变: 服务端进入
CLOSE_WAIT(关闭等待)状态;客户端收到后进入FIN_WAIT_2(终止等待2)状态。(此时连接处于半关闭状态:客户端不能发数据了,但还能收数据;服务端还能发数据)。
第三次挥手 (Server -> Client):
动作: 当服务端的数据也全部发送完毕后,向客户端发送一个 FIN 报文段(
seq = w,ack = u + 1)。意义: 服务端说:“我的数据也发完了,我同意彻底关闭连接。”
状态转变: 服务端进入
LAST_ACK(最后确认)状态。
第四次挥手 (Client -> Server):
动作: 客户端收到服务端的 FIN 后,回复一个 ACK 报文段(
ack = w + 1,seq = u + 1)。意义: 客户端说:“收到你的最后确认,连接彻底关闭。”
状态转变: 客户端进入
TIME_WAIT(时间等待)状态。服务端收到此 ACK 后,立刻进入CLOSED(已关闭)状态。
关键注意点:
在四次挥手结束后,客户端并没有立刻进入 CLOSED 状态,而是要在 TIME_WAIT 状态强制等待 2MSL(最大报文段生存时间,通常是 1-4 分钟)后才会真正关闭。
10.TCP的滑动窗口机制是什么?他在流量控制和拥塞控制中起到了什么作用?
1. 什么是滑动窗口 (Sliding Window)?
滑动窗口本质上是操作系统在内存中开辟的一个数据缓冲区,用来管理和追踪数据包的发送与接收状态。
痛点与目的: 如果不使用滑动窗口,TCP 就只能退化成“停等协议”(发一个包,等一个 ACK 确认,再发下一个),这在长距离网络传输中效率极低。滑动窗口的目的就是允许在没有收到全部确认(ACK)的情况下,连续发送多个数据包,极大提升了吞吐量。
如何“滑动”: 窗口内的字节状态被分为几类:已发送未确认、允许发送未发送。当发送方收到了窗口最左侧数据的 ACK 确认时,整个窗口就会“向右滑动”,把旧数据移出窗口,把新数据纳入窗口的“允许发送”范围内。
2. 它在“流量控制”中的作用(保护接收方)
核心变量:接收窗口 (
rwnd, Receiver Window)机制: 接收方的应用层程序读取数据的速度是波动的。接收方会根据自己当前接收缓冲区剩余的可用物理空间,在 TCP 报文头中将
rwnd的大小动态告诉发送方。作用: 如果接收方处理不过来了,它就会通知发送方缩小
rwnd,甚至告诉发送方rwnd = 0(零窗口)。发送方会严格按照这个大小调整自己的发送窗口,从而绝对防止发送方发得太快,把接收方的内存缓冲区撑爆。
3. 它在“拥塞控制”中的作用(保护整个网络)
核心变量:拥塞窗口 (
cwnd, Congestion Window)机制: 这就是我们刚刚聊过的慢启动、拥塞避免等算法。发送方自己通过感知丢包和延迟,推测当前网络链路的拥堵程度,维护的另一个窗口大小。
作用: 防止发送方注入过多的数据到网络中,避免沿途的路由器或光纤链路发生崩溃。
一句话总结(面试绝杀句):
滑动窗口是 TCP 实现可靠传输和控制速率的基础机制。在实际运行中,为了同时不撑死接收方、也不堵死网络,发送方的实际滑动窗口大小 = min(rwnd, cwnd)(即接收窗口和拥塞窗口中的较小值)。
11.消息队列
消息队列(Message Queue,简称 MQ)本质上是一个用于跨进程、跨系统进行异步通信的中间件。
你可以把它直观地想象成一个邮局:寄信人(生产者)把信扔进邮筒就可以转身去干别的事了,邮局(消息队列)负责把信安全地暂存起来,并最终按顺序交到收信人(消费者)手上。
1. 核心运转机制(三个关键角色)
生产者 (Producer): 负责产生数据并把消息发送到 MQ。它不需要关心谁会去处理这条消息,发完就结束了。
消息代理 (Broker): 就是 MQ 服务器本身。它负责接收生产者发来的消息,将其持久化(通常存在磁盘或内存中),并管理消息的队列和分发。
消费者 (Consumer): 持续监听 MQ。一旦队列里有了消息,它就主动拉取(Pull)或等待被推送(Push)过来,然后执行具体的业务逻辑。
2. 为什么需要消息队列?(三大杀手锏)
在复杂的架构中,引入 MQ 主要为了解决以下三个痛点:
异步处理 (提升响应速度):
场景: 用户注册。以前系统需要依次执行:写入数据库 -> 发送注册邮件 -> 发送欢迎短信,全部做完才告诉用户注册成功,可能耗时 2 秒。
MQ 机制: 引入 MQ 后,系统写完数据库,直接往 MQ 里扔一条“新用户注册”的消息,立刻就告诉用户注册成功(耗时 50 毫秒)。发邮件和发短信的服务在后台慢慢从 MQ 里拿消息去处理。
系统解耦 (降低依赖,提升稳定性):
场景: 电商下单。订单系统原本需要直接调用库存、支付、物流等多个系统的接口。如果物流系统正在升级重启,用户的订单就会创建失败。
MQ 机制: 订单系统不再直接调用其他系统,而是往 MQ 发送一条“订单已创建”的消息。库存、物流等系统作为消费者自行订阅这个消息。这样各个系统相互独立,即使物流系统挂了半小时,等它恢复后继续去 MQ 里消费消息即可,完全不影响用户下单。
削峰填谷 (保护脆弱的后端资源):
场景: 秒杀活动。平时 QPS 只有 500,秒杀瞬间飙升到 50,000。如果这 5 万个并发直接打到数据库,数据库会瞬间崩溃。
MQ 机制: 把这 5 万个请求全部先挡在 MQ 里排队。后端的订单处理程序依然按照自己最大的处理能力(比如每秒处理 2000 个)平稳地从 MQ 中按顺序拉取请求进行处理。虽然有些用户会排队等待一会儿,但保证了整个系统不会宕机。
小结:
消息队列是用系统复杂度换取高性能与高稳定性的经典空间换时间策略。