分布式 面试题


1. 分布式事务——Seata AT 模式原理

题目: 分布式事务有哪些常见的解决方案?请重点介绍 Seata 的 AT 模式是如何工作的,以及它在什么场景下会有局限性。

追问1:Seata AT 模式的全局锁和数据库本地锁有什么区别?为什么需要全局锁? 追问2:TCC 模式和 AT 模式的核心区别是什么?各自适合什么场景?

💡 答案:

主问题: 分布式事务主要有四类方案:两阶段提交(2PC/XA)、TCC(Try-Confirm-Cancel)、Saga、以及 AT 模式(如 Seata)。Seata 的 AT 模式本质上是基于两阶段提交但做了大幅优化,它不需要像 XA 那样长时间锁定数据库资源。AT 模式的工作流程是:一阶段时,各分支事务各自提交并释放本地锁,同时记录回滚日志(undo log);二阶段协调者根据各分支结果决定全局提交还是全局回滚。如果全局提交,只需异步删除 undo log;如果全局回滚,根据 undo log 生成反向 SQL 执行补偿,将数据恢复到修改前的状态。这种设计使得一阶段就释放了本地数据库锁,事务资源占用时间极短,对业务的并发性能影响远小于 XA 方案。

追问1: 数据库本地锁是在事务内持有、提交时释放的行锁,它的作用是保证单个数据库实例上的 ACID。全局锁是 Seata 在多服务间协调的分布式锁,用独立的锁表来管理,作用完全不同:它用于防止在 AT 模式的一阶段和二阶段之间,其他本地事务对同一行数据进行修改。举个例子:服务 A 在本地事务中修改了某行的 money 从 100 改为 80 并提交,本地锁释放。如果此时没有全局锁,服务 B 可以立即修改同一行的 money。然后全局事务决定回滚,A 通过 undo log 将 money 恢复为 100,但 B 在中间做的那次修改就丢失了——产生了脏写。全局锁就是在 A 的一阶段提交后直到二阶段完成前,阻止其他全局事务修改同一行数据,从而避免了脏写问题。如果全局锁获取失败,Seata 会不断重试,直到超时。

追问2: 核心区别在于”谁来写补偿逻辑”。AT 模式是框架自动生成补偿——框架通过解析 SQL 和执行前后数据的快照自动生成 undo log,业务代码无需感知分布式事务的存在,对业务侵入性极低。TCC 则需要开发者自己实现 Try、Confirm、Cancel 三个方法,比如 Try 阶段冻结库存,Confirm 阶段实际扣减,Cancel 阶段解冻库存——每一个资源预留、确认和撤销逻辑都需要手写。从适用场景看,AT 适合大部分基于关系型数据库的业务场景,特别是对现有系统改造分布式事务成本低;TCC 适合对性能要求极高或者需要跨非事务资源(如 Redis、消息队列)的场景,因为它没有全局锁,并发能力更强,但开发成本高、容易出错。如果公司业务团队能力参差不齐,AT 模式通常是更稳妥的选择;如果核心链路对 TPS 有极致要求且团队能力到位,TCC 可以做到更好的性能。

📌 易错点 / 加分项:

  • AT 模式不等于 XA,区别是一阶段就提交释放了本地锁,性能远优于 XA
  • 脏写是 AT 模式最核心的痛点,能把它和全局锁的关系讲清楚说明理解到位了
  • Saga 适合长事务(如订单超过多个系统走审批流程),AT 适合短事务

2. CAP 定理与 BASE 理论

题目: 请详细解释 CAP 定理,并说明为什么”CAP 三者都满足”是不可能的?

追问1:CAP 中 P 的分区容错性和网络分区是两个概念吗?为什么说 P 在分布式系统中是必须选择的? 追问2:BASE 理论是如何在 CAP 的基础上提出折中思路的?最终一致性的”最终”到底要多久?

💡 答案:

主问题: CAP 定理由 Eric Brewer 在 2000 年提出,核心结论是:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)中的两个。一致性的意思是所有节点在同一时刻看到的数据完全一致——写入成功后的读取操作必须读取到新值。可用性的意思是系统始终能够响应客户端的读写请求,不会返回错误或超时。分区容错性的意思是,当网络分区(部分节点之间无法通信)发生时,系统仍能正确运行。

CAP 不能三者都满足的逻辑原因很简单:假设系统发生了网络分区,两个节点之间无法通信,此时一个客户端在一侧写入数据,另一侧的客户端查询数据——如果要保证一致性,则需要拒绝一侧的写入或读取,这就放弃了可用性;如果要保证可用性,则两侧都需要能正常读写,但两侧数据就不一致了,放弃了一致性。CAP 中 P 其实需要重新理解——它不是”要不要分区容错”,而是”当分区发生时,系统选择倾向 C 还是 A”。在网络分区不可达时,如果你选择一致放弃可用就叫 CP 系统,选择可用放弃一致就叫 AP 系统。

追问1: 严格来说,P(Partition Tolerance)准确的理解是:“当一个网络分区存在时,系统选择如何处理”。网络分区是客观物理现象——网线可能被拔、交换机可能故障、丢包可能严重到等同于断连,这不是系统可以选择”不发生”的。所以 CAP 真正表达的选择是:在网络分区已经发生的情况下,系统倾向一致性(CP)还是可用性(AP)。分布式系统根本不可能忽略 P——任何声称 CA 的分布式系统本质上是一个”单机系统”,因为一旦分布式就必然可能发生网络分区。这也是为什么绝大多数分布式系统要么 CP(ZooKeeper、etcd),要么 AP(Eureka、Cassandra),真正要做到 CA 只能是单机。

追问2: BASE 理论是 CAP 定理的工业实践总结——Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致性)。核心思想是:在业务允许的范围内放宽强一致性要求,换取更高的可用性和性能。“最终一致性”并不等于”没有时间上界”——实际上最终一致性也有程度的区分。比如 DNS 的 TTL 是”最终一致”——缓存过期之后才看到新值;MySQL 读写分离中的主从复制延迟通常是秒级的最终一致;而像银行转账这样最终一致通常是分钟级的(比如对账周期)但配合冻结资金等前置操作让用户感知不到延迟。实际工程中”最终”的时效取决于业务容忍度——你可以设定 SLA 比如 99.9% 的更新在 10 秒内同步完成。BASE 不是要放弃一致性,而是”不要求强一致性”,实际上需要结合幂等、异步、补偿、对账等手段来保证业务上的数据最终正确。

📌 易错点 / 加分项:

  • CAP 中的”一致性”特指”线性一致性”(Linearizability),不是 ACID 中的 C
  • 很多系统不是绝对的 CP 或 AP——Kafka 可以在配置层面调整,Paxos/Raft 提供的是可调的一致性
  • 能举出”转账不是一个简单的一致性就能解决的问题”这个观点,说明理解深入到业务层面

3. 分布式 ID 生成方案

题目: 在分布式环境下如何生成全局唯一的 ID?请列举至少四种方案,并对比各自的优缺点。

追问1:雪花算法(Snowflake)的时钟回拨问题如何解决? 追问2:美团 Leaf、百度 UidGenerator 这些方案相比原生的雪花算法做了什么优化?

💡 答案:

主问题: 分布式 ID 有四种主流方案。UUID:简单直接,本地生成无网络开销,但生成的 ID 不递增,用做主键时会导致数据库页分裂严重,写入性能差。数据库自增 ID 或号段模式:利用单张表 REPLACE INTOAUTO_INCREMENT 维护 ID 序列,优点是严格递增,缺点是单点瓶颈,需要借助多台库部署并设置不同自增步长来缓解。Redis 自增:利用 INCR 原子操作,性能好,但需要 Redis 高可用保障,数据持久化也有风险。雪花算法(Snowflake):Twitter 提出的分布式 ID 生成算法,64 bit 长整型,包含 1bit 符号位 + 41bit 毫秒级时间戳 + 10bit 机器标识 + 12bit 序列号,单机每毫秒可生成 4096 个 ID。优点是高性能、趋势递增(时间戳在高位),缺点是依赖系统时钟。综合来看,国内大多数公司用雪花算法或其变种作为首选方案,因为它在性能、有序性和不依赖外部服务三方面取得了最好的平衡。

追问1: 时钟回拨是雪花算法最经典的坑——如果机器时钟因为 NTP 同步被往回拨了,哪怕只是几毫秒,就有可能生成重复的 ID。美团的 Leaf 方案处理时钟回拨的方法是:如果检测到回拨量在 5ms 以内,等待到时钟追上为止;如果回拨量超过 5ms,直接报错拒绝服务,等人工介入。另一种常用方案是记录”上次生成 ID 的时间戳”和”该毫秒内使用的最大序列号”——启动时如果发现时钟回拨,且回拨量在可容忍范围内,让序列号从上次最大值继续递增,利用序列号的空间来吸收回拨的时间。如果回拨量太大导致序列号也用完了,仍然拒绝服务。还有一种方案是放弃系统时钟完全改用自增序号——但这就不是雪花算法了。

追问2: 美团 Leaf 的方案经历了从号段模式(Leaf-Segment)到增强雪花(Leaf-Snowflake)的演进。Leaf-Snowflake 的关键优化:workId 的分配不再依赖手动配置,而是通过 ZooKeeper 持久顺序节点自动分配——服务启动时向 ZK 注册获取一个 workId,定期上报心跳保持存活。解决了原生雪花算法”workId 需要手动配置,扩容时容易冲突”的问题。百度 UidGenerator 的两个主要改进:一是”缓存提前生成”——不等到请求来再生成 ID,而是用一个 RingBuffer 提前生成一批 ID 写入队列,请求来的时候直接取,消除掉生成 ID 自身的延迟和锁竞争。二是”序列号借用”——即使同一毫秒内不能生成更多 ID 也可以从未来时间(下一毫秒)借用序列号,减少对外部时钟精度的依赖。不过 41bit 时间戳在 2039 年左右会耗尽——Leaf 和 UidGenerator 都提供了更长位数的扩展方案。

📌 易错点 / 加分项:

  • 雪花算法生成的 ID 是”趋势递增”不是”全局严格递增”,这两者要区分
  • workId 管理是生产环境雪花算法的核心痛点——怎么保证 1024 个 workId 够用、不冲突?
  • 号段模式+双 buffer 是另一个思路——不是每个请求都去数据库取,而是一次取一个号段(如 1000 个)缓存在本地

4. 分布式锁方案对比

题目: 实现分布式锁有哪些主流方案?对比 Redis、ZooKeeper、etcd 三种方案在一致性、性能、可用性上的差异。

追问1:ZooKeeper 的临时顺序节点如何实现分布式锁?和 Redis 的 SET NX 方案在生产可靠性上谁更优? 追问2:在 Kubernetes 环境下,如果没有 etcd,用什么实现分布式锁和选主?

💡 答案:

主问题: 三种方案的核心差异。Redis 方案:基于 SET NX + 唯一值 + Lua 脚本解锁,性能最高(单机 10w+ QPS),部署运维最简单,但一致性问题最大——因为 Redis 的主从复制是异步的,主库拿到锁后宕机未同步到从库可能导致锁丢失,RedLock 尝试缓解但业界争议很大。ZooKeeper 方案:基于临时顺序节点——每个竞争者在该 lock 节点下创建一个临时顺序节点,序号最小的获取锁,其他节点 watch 前一个节点等待。临时节点的特性是:客户端 session 断开后节点自动删除,天然解决死锁问题。ZK 基于 ZAB 协议保证强一致性(锁状态不会丢失),但吞吐较低(千级别 QPS),而且锁竞争激烈时大量的 watch 通知会触发”惊群效应”——排在前面的节点释放后所有等待节点都被唤醒抢锁。etcd 方案:基于 Lease 机制 + 事务操作——TXN 操作原子地判断 key 是否存在,不存在则创建并绑定 Lease。etcd 基于 Raft 协议,一致性和 ZK 同级,性能在 ZK 和 Redis 之间,与 K8s 天然契合。

追问1: 临时顺序节点实现分布式锁的核心流程:所有竞争者在同一个锁节点路径下(如 /locks/mylock)create 临时顺序节点,返回值如 lock-000000001lock-000000002;每个节点调用 getChildren("/locks/mylock", false) 获取所有子节点;序号最小的节点获得锁,其他节点对前一个序号节点设置 watch;释放锁时删除临时节点,Watch 通知下一个节点轮到了。对比 Redis 方案,ZK 的核心优势是强一致性——只要拿到锁,不会由于主从复制导致锁丢失,且 session 断开自动释放锁,从根本上规避了 Redis 方案中”业务挂了锁还在”的问题。Redis 的核心优势是性能——10w+ QPS vs ZK 的千级别,如果你的业务每秒几千次甚至万次抢锁,ZK 可能会成为瓶颈。生产建议:对一致性要求高(扣库存、扣余额)选 ZK/etcd,对性能要求高且能容忍极小概率的锁冲突(如防重复提交)选 Redis。

追问2: Kubernetes 自身的 etcd 集群一般不会暴露给业务直接使用。K8s 环境下的选主和锁通常通过 Kubernetes API 实现——使用 ConfigMap 或 Endpoints 资源的注解和 ResourceVersion 实现乐观锁,配合 Lease API(K8s 1.14+ 引入的 coordination.k8s.io/v1 Lease 资源)做 Heartbeat 续约。具体做法是:创建一个 Lease 对象,只有当前此 Lease 的 holderIdentity 是当前实例、且 renewTime 超时的实例才可竞争;竞争过程就是去 Update 这个 Lease 资源,利用 ResourceVersion 做乐观并发控制(类似 CAS)。另外 Leader Election 也是通过先到先得 + 定时续约实现。这本质上是利用 etcd 的一致性(K8s API Server 背后是 etcd)而非直接访问 etcd,适合微服务在 K8s 中部署的场景。

📌 易错点 / 加分项:

  • ZK 的”惊群效应”是其分布式锁性能差的主因——Apache Curator 通过 InterProcessMutex 做了一些优化
  • Redis Redisson 的看门狗 + ZK 的临时节点机制在解决”业务挂后锁泄漏”上有思路上的共通
  • etcd 的 watch 是基于流式 gRPC,比 ZK 的一次性 watch(触发后需要重新注册)更高效

5. 分布式一致性协议:Raft vs Paxos

题目: 请简述 Raft 共识算法的核心流程,解释 Leader 选举和日志复制分别是如何工作的。

追问1:Raft 和 Paxos 最大的区别是什么?为什么 Raft 在工程界更流行? 追问2:Raft 真的保证了强一致性吗?“读请求”是否也走 Raft 协议?

💡 答案:

主问题: Raft 将共识问题拆分为三个独立的子问题:Leader 选举(Leader Election)、日志复制(Log Replication)、安全性保证(Safety)。Leader 选举流程:集群中的节点有三种角色——Leader、Follower、Candidate。初始时所有节点都是 Follower,每个节点有一个随机的 election timeout(150-300ms)。下 Follower 在 election timeout 时间内没有收到 Leader 的心跳,就转为 Candidate,给自己投一票,Term 号 +1,向其他节点发送 RequestVote 请求。如果收到过半节点的投票,Candidate 升级为 Leader,开始发送心跳。如果多个 Candidate 同时选举(分裂投票),没有节点获得过半票,term 增加重新选举。

日志复制流程:Client 的写请求发送到 Leader,Leader 将操作封装为 Log Entry 追加到自己的日志中,然后向所有 Follower 发送 AppendEntries 请求。Follower 收到后比较 prevLogIndex 和 prevLogTerm 是否匹配,如果匹配则追加日志并返回成功。当 Leader 确认该 Log Entry 被过半节点复制后,将状态机应用到这条 Entry(commit),然后将 commitIndex 通过后续的心跳消息同步给 Follower。

追问1: 最大的区别在于”可理解性”——这是 Raft 设计时明确的工程目标。Paxos 的理论虽然早在 1990 年就提出,但其 Lamport 原版论文(The Part-Time Parliament)对于工程实现来说非常晦涩,导致后来的 Paxos 实现很多各自为政、互相不兼容。Paxos 定义了单次提案(Single-Decree Paxos)的算法,但应用到实际系统(Multi-Paxos)后需要大量的工程补充,且没有规范化一致性读、集群变更等外围问题的标准做法。Raft 直接给出了完整的、清晰的 Multi-Raft 方案,每个步骤都有明确的 RPC 定义和状态机转换图,大学生也可以自己实现一个基本可用的 Raft 库。所以在工程界 Raft 更容易被接受和实现——etcd、TiKV、Consul、Nacos(2.x 版本引入)、Apache Ratis 等都是基于 Raft。

追问2: 标准 Raft 算法中”读请求”不需要走日志复制——Leader 直接读自己的状态机返回即可。但这里有一个一致性问题:网络分区后可能出现”旧 Leader”(它还不知道自己已经失联了,term 已经落后),旧 Leader 如果直接返回读取结果,该结果可能已经是过期数据。所以 etcd 实现了 ReadIndex 机制来处理一致读:Leader 先向 Follower 发送心跳确认自己仍是过半 Leader(未落后于集群状态),记录当前的 commitIndex 为 readIndex;然后等待自己的状态机 apply 到 readIndex 后再返回读取结果。这个机制类似”隐式的 Raft 确认”,确保了即使 Leader 发生了切换,读到的也是已 commit 的数据。如果对一致性要求低于线性一致性读,也可以读 Follower(但可能读到旧数据)。

📌 易错点 / 加分项:

  • Raft 保证的是”日志顺序一致”而非”已被应用到状态机的数据一致”——有 apply lag
  • Leader 变更过程中有一小段不可用时间,所以 Raft 是”CAP 中的 CP”系统
  • PreVote 扩展是 Raft 实际部署中的经典优化——避免隔离节点回群后破坏集群

6. 服务注册与发现

题目: 微服务架构下的服务注册与发现是如何工作的?为什么需要健康检查机制?

追问1:Nacos 的 AP 模式和 CP 模式可以切换,底层如何实现的?你的服务什么情况选 AP、什么情况选 CP? 追问2:服务下线了但注册中心还有它的节点信息,调用这个服务的请求会持续失败——如何优雅下线?

💡 答案:

主问题: 服务注册与发现是微服务的基础设施。核心组件包括注册中心(Registry)、服务提供者(Provider)、服务消费者(Consumer)。工作流程分两步:注册——Provider 启动时将自己的 IP、端口、服务名等元信息注册到注册中心,并且通过心跳定期续约,注册中心维护一个实时更新的服务列表。发现——Consumer 启动时从注册中心拉取它所依赖的服务列表,缓存在本地,之后可以通过定时轮询或长轮询更新变化的部分,也可以用注册中心的推送机制收到变化通知。健康检查机制的必要性:Provider 可能因为服务崩溃、OOM、网络中断、僵尸实例等原因无法服务,如果不做健康检查,注册中心会保留这些无效节点,Consumer 的负载均衡策略可能将请求路由到这些死节点上,导致请求大面积失败。健康检查一般通过心跳(客户端上报)或主动探测(注册中心发起 HTTP/TCP 探活)两种模式实现。

追问1: Nacos 通过 Raft 协议(CP 模式)和 Distro 协议(AP 模式)实现了注册中心的可切换一致性模式——临时实例走 Distro(AP),持久实例走 Raft(CP)。Distro 的设计非常直接——每个 Nacos 节点拥有全量数据,节点之间异步同步,不要求过半确认;而 Raft 模式下所有写入必须由 Leader 发起且过半确认,保证强一致性。选 AP 还是 CP,取决于你的服务对一致性的要求:如果只是做负载均衡和请求路由(如 Spring Cloud Gateway 查 Nacos 拿到实例列表),暂时的不一致不会造成业务错误,只是少数请求失败然后重试即可——选 AP 即可,性能更好。如果注册中心存的是”数据分片路由表”等影响数据正确性的信息——选 CP,即使性能略低但保证数据正确。

追问2: 优雅下线是一个完整的流程。第一步:服务在收到下线信号(kill -15)后,向注册中心发起主动注销(deregister),让所有 Consumer 在第一时间感知到节点下线的通知。第二步:如果 Consumer 是轮询更新,还需要等待一轮更新周期(通常数秒),为了让这个等待期间没有请求进来,可以先标记为”不可用”,触发 Consumer 的熔断策略。第三步:正在处理的请求等待其完成(Graceful Shutdown——Spring Boot 3.x 支持通过 server.shutdown=graceful 配置优雅关闭时间),新请求不再接受。第四步:确保已处理完成的请求完成资源释放后退出进程。如果直接 kill -9,注册中心只能在心跳超时后(如 15 秒)才发现节点不可用,这 15 秒内所有调用此节点的请求都失败,在在线系统上会直接造成用户可见的错误。一个好的优雅下线方案一定要覆盖”注册中心→负载均衡→服务本身”三个环节。

📌 易错点 / 加分项:

  • 客户端负载均衡(Ribbon、Spring Cloud LoadBalancer)和服务端负载均衡的区别——前者在 Consumer 侧维护服务列表,后者在代理层(如 Nginx)
  • Eureka 的自我保护模式是经典坑——网络抖动时注册表膨胀,但服务可能已经死掉
  • 优雅下线上线(蓝绿发布、滚动更新)中,预热也是一个容易被忽略的环节——刚启动的服务还没预热好就被打满流量

7. 微服务网关设计与限流

题目: 微服务网关在架构中扮演什么角色?如果让你设计一个网关的限流功能,你会如何实现?

追问1:令牌桶和漏桶的区别是什么?Sentinel 和 Hystrix 的限流和熔断在概念和实现上有什么不同?

💡 答案:

主问题: 微服务网关是整个微服务集群的统一入口,核心职责包括:路由转发——根据请求路径、Header 等信息将请求路由到对应的后端服务;安全认证——统一校验 Token/OAuth,避免每个微服务各自做鉴权;限流——防止下游服务被突发流量冲垮;熔断降级——某个服务不可用时快速失败;日志监控——统一记录请求链路便于排查和统计。网关的限流可以按几个维度做:全局限流(整个集群 QPS 上限)、用户/IP 限流(单个用户的请求频率)、服务限流(某个具体接口的 QPS 上限)。实现方案通常是”Redis + Lua 脚本”:将每秒的时间窗口作为 key,用 INCR 计数并设过期时间,Lua 脚本原子地判断计数是否超过阈值。如果需要更精确的限流,可以用”滑动窗口”——Redis 的 Sorted Set,key 为 counter,score 为时间戳,每次请求插入当前时间戳同时清理窗口外的旧数据,统计窗口内的 count。更进一步是 Sentinel 这种专业框架——把限流规则配置化、支持动态调整、整合了监控大盘。

追问1: 令牌桶的核心思路是”匀速产生令牌”——以一个固定速率往桶中放入令牌,桶满后多余的令牌丢弃。请求来时先取令牌,拿到的放行,拿不到的被拒绝。令牌桶的特点是允许”突发流量”——如果桶里积攒了 maxToken 个令牌,可以一次被突发请求耗尽,刚好模拟了”系统可以承受短时间内的流量高峰”的现实。漏桶的核心思路是”匀速流出”——不管入水多快,出水速度是恒定的,类似队列。漏桶彻底不允许突发流量,请求超过出水速率就得排队或丢弃。Sentinel 和 Hystrix 的核心区别:Hystrix 的默认思路是熔断为主、限流为辅——主要通过线程池隔离和信号量限制实现资源隔离和快速熔断,解决了级联故障问题。Sentinel 更注重流量控制——支持 QPS 限流、调用关系限流、热点参数限流等丰富的流量整形策略,限流是 Sentinel 的设计原点。Hystrix 已进入维护模式,Sentinel 已经成为国内微服务限流的事实标准。

📌 易错点 / 加分项:

  • 网关不应该有重量级业务逻辑,它要尽量轻量——JWT 鉴权可以,复杂的权限判断下放到服务层
  • 网关层的限流是”粗粒度”的,业务敏感接口的限流最好在业务服务层再做一层”细粒度”限流
  • 能提到基于滑动窗口的限流算法以及 Redis 实现中的坑(Sorted Set 的空间膨胀)说明有实战经验