# 分布式面试题
# 分布式理论
# 什么是分布式系统?
我是这样理解的:
简单来说,分布式系统就是把多台独立的计算机通过网络连接起来,让它们协同工作,一起去完成同一个任务。 对于使用这个系统的用户来说,他感觉不到背后有成百上千台机器,他只会觉得自己在用一台超级强大的计算机。
为了方便理解,我们可以打个比方:就像开一家饭店。最开始客人少,店里只有一个大厨(单机系统),洗菜、切菜、炒菜他一个人全包了。但后来生意越来越好,一个大厨根本忙不过来(单机遇到性能瓶颈)。这时候,老板没有去高薪聘请一个世界级的三头六臂神厨(垂直扩容,也就是疯狂升级单台服务器的硬件),而是雇了十个普通厨师,有人专门洗菜,有人专门切菜,有人专门炒菜(水平扩容,也就是分布式)。大家通过大喊或者传纸条来配合(网络通信),共同为大堂里的客人上菜。客人根本不在乎后厨是一个人还是十个人,他只在乎菜上得快不快、好不好吃。
我们在实际开发中之所以要用分布式系统,主要有两个根本原因:

- 第一是为了高并发和高性能。单台服务器的CPU、内存再强也是有天花板的,遇到了双十一这种海量流量,单机肯定扛不住,必须要把压力分散到多台机器上去。
- 第二是为了高可用。也就是容灾能力。如果只有一台机器,它一旦宕机,整个业务就瘫痪了(单点故障)。而在分布式系统里,我们可以通过多副本、故障切换等设计,让部分机器坏了以后,其他机器仍然能接着提供服务。不过,多放几台机器不等于自动高可用,如果它们都依赖同一个没有备份的数据库,数据库一挂,业务还是会一起停下来。
不过,引入分布式并不是百利而无一害的,它引入了极大的复杂性,这也是我们在分布式开发中最头疼的地方。
举个最典型的例子,数据一致性问题。
由于机器变多了,它们之间只能通过网络来沟通,但网络是不稳定的。假设用户下了一笔订单,订单节点的机器记录了「已下单」,然后它通过网络告诉库存节点的机器「扣减库存」,结果这个时候网络卡了,库存节点没收到消息。这就麻烦了,订单生成了,但库存没扣,数据就不一致了。
为了解决这个问题,就会涉及到分布式事务、分布式锁、甚至著名的CAP定理等复杂的技术方案。
总而言之,分布式系统本质上就是用更多的机器和更复杂的软件架构,去换取系统更高的性能、扩展性和可靠性。
# 分布式和微服务有什么区别?
一句话概括它们的核心区别就是:分布式关注的是「多个节点怎么通过网络协同工作」,主要解决扩展性、性能和可靠性的问题;微服务关注的是「业务怎么拆成能独立开发、部署的服务」,主要解决系统复杂后难以维护和交付的问题。
为了方便理解,我分开给你稍微解释一下:
首先看「分布式系统」。 它关注的是节点之间的协同,这里的节点可以部署在不同的物理机、虚拟机或者容器里。当我们的业务流量越来越大,单台服务器的CPU和内存再怎么升级也不起作用时,我们就多买几台机器,把它们连起来一起干活。 打个比方,有1万块砖要搬,一个人搬不动,我们就雇10个人一起搬。只要是多台机器通过网络协同工作来完成一个任务,这就是分布式。它追求的是在这个集群里堆更多的机器,来扛住更高的并发,保证一台机器宕机了别的机器还能顶上(高可用)。
接着看「微服务」。 它是从软件工程和业务逻辑的层面出发的。早期的开发,很多公司习惯把所有的功能(比如订单、用户、支付、积分)全部写在一个庞大的项目里,打包成一个巨大的系统部署。这在业务简单的初期没问题,但如果业务越来越复杂,几百个程序员改同一份代码,牵一发而动全身,系统就会变得极其臃肿且难以维护。 所谓的微服务,就是把这个巨大的单体应用,按照业务边界拆分成一个个独立的小服务。就像一家原本什么都卖的超级大杂货铺,拆分成了专门卖菜的、专门卖肉的、专门卖衣服的独立店铺(订单服务、支付服务等)。各个店铺只管自己的事,互不干扰,相互之间需要什么就通过明确的接口去沟通。
那么,这两者到底是怎么联系在一起的,又有什么本质区别呢? 这里有一个非常核心的例子,也是初学者最容易陷入的误区:有多个机器在跑,不代表它就是微服务。 假设我们有一个传统的、什么功能都塞在一起的巨无霸单体项目,我们把它原封不动地复制了10份,部署在10台服务器上,前面架设一个Nginx做负载均衡。这就叫「分布式系统」(集群部署),因为确实是多台机器在协同工作。但是,这绝对不是微服务,因为它内部依然是一个揉在一起的庞大单体。
反过来,微服务也不要求「一个服务必须占一台机器」。开发环境里,订单、库存、支付几个服务完全可以跑在同一台机器的不同进程里。只要它们能够独立部署、通过网络接口协作,依然是微服务。生产环境为了容量和容灾,通常会把这些服务的多个实例分散到多台机器上,所以微服务通常会带来分布式系统的通信和一致性问题,但服务数并不等于机器数。

总结一下: 分布式的核心是「节点的拆分和协同」,微服务的核心是「业务逻辑和业务边界的拆分」。微服务其实就是分布式架构发展到一定阶段后,在软件工程领域总结出来的一种非常优秀、非常具体的架构风格。
# 介绍一下 CAP 理论?
CAP 理论讲的是分布式系统里三个很重要的性质:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。
面试里经常会听到一句「三者只能选两个」,但我觉得还得补上一个关键前提:当网络分区发生时,系统无法同时保证强一致性和可用性。 网络正常的时候,并不是说一致性和可用性就一定不能兼顾。

我用一个商品库存的例子来解释这三个概念:
- 一致性(C):这里通常指「线性一致性」。比如库存原来是 10,用户扣减后,系统已经返回成功,那么之后再发起的读取,就应该能读到扣减后的值。对外看起来,所有操作像是在同一份数据上按顺序完成的。它不要求所有节点的磁盘在每一瞬间都完全一样,关键是对外提供的读写结果符合这个顺序。
- 可用性(A):每个到达非故障节点的请求,最终都能得到正常的处理结果。这里不能靠一直返回「系统错误」来满足可用性,也不能只说「集群还有一台机器能响应,所以就是 A」。CAP 的定义关注每个非故障节点上的请求,和我们平时说的服务可用率不是完全一回事。
- 分区容错性(P):节点之间可能因为网络故障而无法通信,即使发生这种情况,系统也要按照设计继续运行。注意,这里的分区是网络被隔开,不是数据库分库分表。
假设两个节点 A、B 都保存着库存 10,现在它们之间的网络断了,但用户仍然能分别访问它们。如果这时候还允许用户在 A 上把库存扣成 9,并且返回成功,另一个用户再去 B 上查询,B 根本不知道 A 发生了什么。
这时候就得取舍了:
- 选择 CP:无法确认结果的读写请求要被拒绝或者等待恢复。比如 B 不能确认最新库存,就不能返回旧值;如果写入要求两个副本都确认,那么前面 A 的扣减也不能直接返回成功。这样保住了一致性,但牺牲了这部分请求的可用性。如果集群还能形成多数派,多数派一侧通常仍然可以提供服务。
- 选择 AP:B 先返回自己知道的库存 10,让请求继续得到处理。这样保住了可用性,但暂时放弃了强一致性。网络恢复后,还需要同步数据、处理冲突,不能以为连上网络数据就自动正确了。

所以,CAP 的重点不是给系统贴一个「三选二」的标签,而是想清楚:网络断了以后,这个操作是宁可暂停,也不能返回不一致的结果,还是允许暂时看到旧数据,也要继续服务。 比如库存扣减通常更关注正确性,商品介绍和评论展示往往更能容忍短暂延迟。
# CAP 里的一致性和数据库事务的一致性是一回事吗?
不是,虽然都叫「一致性」,但是它们讨论的问题不一样。
CAP 的 C 关注的是「多个副本对外提供的读写结果,能不能像一份数据一样」。比如我刚修改完收货地址,系统已经告诉我成功了,随后去另一个节点读取,应该能看到这个修改。
ACID 的 C 关注的是「事务执行前后,数据有没有违反业务约束」。比如转账时,A 扣掉 100 元,B 应该增加 100 元,两边的钱加起来不能凭空多了或者少了。这里要靠业务逻辑、数据库约束,以及事务的原子性和隔离性一起保证。
举个更直接的例子:数据库主库上的转账事务完整提交了,金额也没算错,但从库同步慢了一点,用户去从库查询时看到了旧余额。这个本地事务可以满足 ACID,但这次读写并没有满足 CAP 所说的线性一致性。

面试回答时,我会把这两层分开:事务一致性看业务约束,副本一致性看读写的可见顺序。 跨服务的事务原子性,也不能仅仅靠给存储系统选一个 CP 就自动解决。
# 强一致性、最终一致性和读写一致性有什么区别?
我一般会用「用户修改完头像,再去查看」这个例子来区分。
第一种是强一致性,这里按线性一致性来理解。 更新已经返回成功,之后任何用户再读取,都应该看到这个更新,或者在此之后发生的更新。系统不能因为读到了另一个落后的副本,就返回更早的头像。
第二种是最终一致性。 更新刚完成时,有人可能看到新头像,有人可能看到旧头像,但如果没有新的更新,而且同步最终能够完成,各个副本最后会收敛到一致的状态。它本身不承诺「必须在 1 秒内一致」,业务需要的收敛时间还得额外设计和监控。
第三种是读写一致性,也叫「读己之写」。 我自己修改完头像,接下来自己的查询至少能看到这次修改,但其他用户可能暂时还看不到。实际可以在写入后的一段时间内让这个用户读主库,或者让读取携带版本号,等副本追上指定版本再返回。
这三个概念里,读己之写只保证当前用户能看到自己的写入,不能直接当成所有用户都能读到最新数据的强一致性。

实际选型时,我会先问业务:哪些人必须马上看到更新,允许延迟多久,出现旧数据会有什么后果。 像用户资料展示,可以用最终一致性加读己之写;像库存扣减,就得用数据库条件更新、事务等机制守住不能超卖的约束。
# 怎么理解BASE理论?
BASE理论其实是我们在做分布式系统时,一种极其务实的架构设计指导思想。
在解释它之前,不得不稍微提一句大家都很熟悉的CAP定理。我们知道,在发生网络分区时,分布式系统无法同时保证强一致性(C)和可用性(A)。如果一个请求必须确认最新数据,但当前节点联系不上能确认结果的节点,就只能等待或者拒绝请求。在网络正常时,强一致性也通常需要额外的协调,这会增加延迟。
所以,前人们就提出了BASE理论。它的核心思想就是:既然强一致性太难做到,也会拖垮系统性能,那我们能不能妥协一下?允许数据在短时间内不一致,只要保证最后的结果是对的就好了,以此来换取系统极高的可用性。
BASE是三个英文短语的缩写,为了方便理解,我结合实际应用场景给你挨个解释一下:

第一个是 Basically Available,也就是「基本可用」。 它的意思是,当系统遇到机器故障或者前所未有的流量洪峰时,我们允许系统损失掉一部分可用性,来保住核心业务正常运转。 这非常像我们生活中的「丢车保帅」。举个例子,双十一零点,大家都集中冲进去买东西,服务器压力极其巨大。这时候,电商平台可能会把「查看商品历史评价」、「申请退款」这类非核心功能暂时停掉(这就叫服务降级);或者大家觉得页面加载比平时慢了一两秒(响应时间上的妥协)。虽然用户体验打了一点折扣,但大家依然能完成最核心的「下单付款」动作,系统没有直接崩溃,这就是基本可用。
第二个是 Soft State,也就是「软状态」。 它的意思是,系统允许暂时处于没有完全同步的状态,而且即使没有新的用户请求,后台同步、重试等动作也可能让状态继续变化。由于机器之间同步数据需要通过网络,网络是有延迟的,所以中间状态在分布式业务里非常常见。 比如我们在银行APP上跨行转账,钱扣了,但是对方还没收到,这时候其实就处于一个叫「转账处理中」的软状态。
第三个是 Eventually Consistent,也就是「最终一致性」,这也是整个理论的灵魂。 既然我们允许了中间状态(软状态)的存在,那系统总不可能一直错下去吧?最终一致性就是在没有新的更新、故障最终能够恢复的前提下,通过可靠同步、重试和冲突处理,让相关数据最终收敛到一致的状态。它不是「等一等就一定会好」,更不代表错误的业务逻辑也能自动变正确。 就像刚刚那个跨行转账的例子,不用非得要求你点完确定的一瞬间对方立刻收到钱(那叫强一致性),系统先把处理中状态展示清楚,再通过可靠消息、重试和对账推进流程,最后进入「转账成功」或者「转账失败并退回资金」的终态,相关账目对齐,这就体现了业务上的最终一致性。
最后总结一下: 如果你问我怎么一句话概括BASE理论,我会说它就是「对强一致性的一种降级妥协」。在实际业务开发中,消息队列异步处理、跨服务状态同步等场景经常采用这个思路。但不能简单理解成「涉及钱的一律强一致,其他的一律最终一致」。比如支付系统也可能异步通知订单,关键是账务操作不能重复、业务约束不能被破坏,中间状态可追踪,失败能够重试或补偿。允许短暂不同步,是为了减少同步等待,不是允许业务结果出错。
# 分布式锁
# 用 Redis 怎么实现分布式锁?
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
- 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
- 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每一次成功加锁设置的值都是一个唯一值,用于标识这一次持锁操作,不能只拿固定的机器 IP 当标识;
满足这三个条件的分布式命令如下:
SET lock_key unique_value NX PX 10000
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
-- 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
# 还有没有其他方法实现分布式锁?
还可以基于 ZooKeeper 的临时顺序节点来实现。和 Redis 里大家竞争同一个 key 不同,它更像是给每个请求发一张排队号码,排在最前面的请求才能拿到锁。
先看两个关键特性:
- 临时节点:节点跟客户端会话绑定,会话关闭或者过期后,节点会被删除。注意,短暂断网不等于会话立刻过期,所以不能说客户端一断开连接,锁马上就释放了。
- 顺序节点:创建节点时,ZooKeeper 会在名字后面追加递增序号,用来区分排队顺序。
有了这两个特性,加锁过程就比较好理解了:

- 先为要保护的资源准备一个持久节点,比如
/locks/order_123,不同资源使用不同的路径。 - 每个请求在这个路径下创建自己的临时顺序节点,比如
lock-0000000001、lock-0000000002。 - 获取子节点列表,如果自己的序号最小,就拿到了锁;否则只监听排在自己前面的那个节点,等它被删除后再判断自己是不是最小。
- 业务执行完,删除自己的节点,后面的请求就有机会拿到锁。如果持锁客户端宕机,会话过期后,临时节点也会被清理。
为什么只监听前一个节点?因为如果所有等待者都监听最小节点,最小节点一删除,大家就会同时被唤醒,形成「惊群」。只监听前一个,相当于排队时前面的人走了再通知你,压力会小很多。
还有个实现细节:获取列表和注册监听之间,前一个节点可能已经被删了。 所以注册监听时如果发现节点已经不存在,就要立刻重新检查队列,不能一直傻等一个已经错过的通知。普通的一次性 Watch 触发后,也要按需要重新注册。
ZooKeeper 通过多数派确认来保证锁节点的创建顺序,少数派一侧不能继续成功创建锁节点,所以这类方案通常按 CP 来理解。但它同样有一个边界:锁节点被删除,不代表原来的业务线程也被强制停止了。 原持锁者长时间卡顿、会话过期后恢复,仍然可能继续写业务数据,这时候也需要版本校验或者 fencing token 做保护。
除了 ZooKeeper,也可以直接利用数据库的行锁、唯一索引,或者基于 etcd 的租约和事务来做。选型时我会先看资源在哪、并发有多大、能不能容忍锁失效后的重复执行,再看现有系统已经有什么组件。 对同一个数据库里的库存更新,条件更新往往比再引入一把外部分布式锁更直接。
# Redis 锁过期了,但业务还没执行完怎么办?
这是分布式锁里一个非常容易踩的坑:锁过期,只会让 Redis 删除 key,不会让持锁的业务线程自动停下来。
比如 A 拿到了有效期 10 秒的锁,结果业务执行了 15 秒。到了第 10 秒,锁过期,B 又拿到了同一把锁。这时候 A 和 B 都在执行业务,互斥就被破坏了。
通常会用自动续期来减少这个问题。比如持锁期间启动一个定时任务,在锁到期之前检查 key 的值仍然是自己的加锁标识,再延长过期时间。检查和续期也要用 Lua 脚本原子执行,避免给别人的锁续期。Redisson 的看门狗就是类似的思路,具体是否启用要看使用的加锁方式。
但面试里不能回答「加个看门狗就绝对安全了」。如果 A 发生了长时间 GC 暂停,业务线程和续期线程可能一起停住;或者网络断了,续期请求根本发不出去。等 A 恢复时,锁已经在 B 手里了。

对于不能接受旧持锁者继续写入的场景,可以引入 fencing token,也就是递增的持锁凭证。比如 A 的凭证是 100,B 的凭证是 101,业务存储在执行写入时原子校验凭证,已经接受 101 后,就拒绝 A 再带着 100 写入。这个凭证需要可靠地递增,业务存储也必须支持并执行校验,不能只在 Redis 里生成一个数就算完成了。

如果资源在数据库里,也可以通过版本号条件更新、库存不小于零的条件更新、唯一约束等手段守住业务规则。
所以我的回答是:续期用来降低锁提前失效的概率,存储侧的校验用来挡住失效持锁者的写入。 解锁时的唯一标识只能防止误删别人的锁,解决不了旧线程继续执行业务的问题。
# Redis 主从切换后,分布式锁还安全吗?
如果只依赖普通 Redis 主从复制,不能保证故障切换时锁一定安全。 原因在于主从复制通常是异步的。
我举个例子:A 在主库加锁成功,但这个 key 还没来得及同步到从库,主库就宕机了。随后从库被提升为新主库,B 来加锁时发现 key 不存在,也成功拿到了锁。这样 A、B 就同时认为自己持有锁。

哨兵或者 Redis Cluster 可以帮我们切换主节点、恢复服务,但不能把异步复制自动变成严格的一致性协议。WAIT 可以等待副本确认,降低写入丢失的风险,但也不能直接当成强一致性保证。
面试官如果继续问 Redlock,我会说明:它是在多个相互独立的 Redis 主节点上,用同一个标识尝试加锁。只有在有效时间内拿到超过半数节点的锁,并扣除加锁耗时等因素后仍有剩余有效期,才认为成功;失败则尝试释放已经拿到的锁。这里不是对一个主库和它的从库分别加锁。

不过,Redlock 仍然依赖锁有效期、时间变化等假设,也不能阻止暂停后恢复的旧持锁者继续写入。所以它不能替代业务侧的版本校验,也不能简单说「节点多了就绝对安全」。
如果锁只是为了减少缓存重建的重复工作,偶尔重复执行通常还能接受;如果关系到资金、库存等正确性,我会优先用事务、条件更新或唯一约束守住底线,再评估是否需要基于一致性协议的协调服务。
# 分布式ID
# 什么情况需要用分布式ID?
首先,分布式ID要解决的核心痛点,就是在「多节点、多数据库环境下,如何生成一个全局唯一且最好是有序的标识」。
在业务发展初期,如果数据集中在一个数据库里,我们往往还不需要专门设计分布式ID。比如存个订单,直接依托MySQL单表主键的「自增属性」(从1,2,3这样往上加)就完美搞定了,既不重复,插入性能又好。
但在系统发展到一定体量后,有几种情况就会逼着我们必须引入分布式ID:
第一种情况,也就是最绝对的刚需,数据库的分库分表。 假设就拿订单业务来说,当数据量和访问压力已经让单库单表遇到瓶颈,索引优化、归档等手段也无法满足需求时,我们可能把订单表拆成比如10张或者100张表(分散在多个库里)。 一旦拆表,原来「每张表各自从1开始自增」的方案,就不能直接保证全局唯一了。为什么呢?因为各个子表之间是不知道对方状态的,表A自己从1开始递增,刚刚生成了ID为「1」的订单;表B也在运行,也从1开始,生成了ID也是「1」的订单。这时候如果你想查这条订单,系统就彻底懵了。

所以,在分库分表场景下,我们迫切需要剥离出这个生成ID的权力,找一个「凌驾于所有子表之上」的独立系统或算法来充当发号器,保证它发出来的ID绝对不会重复。这就必须要用分布式ID了。
第二种情况是高并发的微服务多节点环境,并且对数据库写入性能有极高要求。 这时候你可能会有个疑问:既然多台机器容易生成同样的ID,那我本地自己生成一个UUID不就行了吗? 确实,UUID也是一种分布式ID方案,正确生成时碰撞概率极低。但在实际工程里,特别是Java搭配MySQL的架构里,选它作为主键时还要考虑版本和存储方式。常见的随机UUID(UUIDv4)没有递增趋势,用字符串存储还会占用较多空间。如果把它作为主键存进MySQL,因为存储是按照主键排序的,这种杂乱无章的ID会导致每一次插入新数据,数据库可能都要在底层的B+树索引中间硬塞进去,增加随机页访问和「页分裂」的概率,影响写入性能;而较宽的主键也会让InnoDB的二级索引占用更多空间。

因此,我们需要一种既能在成百上千台机器并发时绝不碰撞(唯一),又能在进入数据库时不乱插队、大致保持「递增趋势」的ID生成的方案(可以考虑雪花算法、数据库号段等方案)。
第三种情况是多系统打通合并数据。 比如咱们公司收购了一个竞品,或者要把原来独立的A系统和B系统的数据汇总到数据仓库里做个报表。如果当初他们各自用的都是自己库里的自增ID,这两套数据一相遇,ID必定有大量重叠冲突,合并起来简直是灾难。这也是为什么多系统协同的核心业务对象,经常会采用全局唯一ID,或者使用「系统标识 + 本地ID」这样的组合来区分数据。

总结一下: 当我们遇到底层数据库必须分库分表,或者是在微服务高并发环境下对数据库插入性能与检索性能有较高要求(希望ID较短且趋势递增)时,各张表独立自增的ID就不能直接满足全局唯一的需求了,需要选择合适的分布式ID方案。
# 分布式ID有什么方案?
我一般会从唯一性、是否有序、生成性能、对外部组件的依赖这几个方面来比较,常见的方案有四种。
第一种是 UUID。 好处是本地就能生成,不需要每次访问一个中心发号器,跨节点使用也方便。它是 128 位,常见的文本形式是 36 个字符,去掉连字符后是 32 个十六进制字符,也可以用 16 字节的二进制形式存储。
这里不能一概说「所有 UUID 都是无序的」。常用的 UUIDv4 是随机的,作为 InnoDB 主键时容易增加随机写入,主键较宽也会增大索引;UUIDv7 则包含时间信息,能改善按时间排序和索引局部性。所以 UUID 能不能做主键,要看版本、存储方式和业务需求,不能只看它叫 UUID 就否定。
第二种是数据库号段模式。 这个可以理解成「批发 ID」。比如数据库里维护当前已经分配到的最大值,业务节点一次申请 1000 个 ID,数据库通过事务或原子更新,把最大值从 1000 推进到 2000,节点就获得了 1001~2000 这个号段。其他节点申请到的是下一段,不会重叠。
拿到号段后,节点就在内存里分配,减少数据库访问。还可以提前申请下一段,做双号段切换,避免当前号段用完时才去请求数据库。但如果数据库故障,本地号段用完后仍然无法继续发号;节点重启时没用完的号段一般直接丢弃,允许 ID 有空洞,不能为了连续而重复使用。
第三种是 Redis 的 INCR。 利用原子递增得到数字 ID,实现很简单,也方便按业务或日期分开计数。但每次发号都有网络开销,而且要考虑持久化和主从切换:如果计数器回退,之前发出的 ID 就可能被再次发出去。原子递增解决的是当前节点上的并发竞争,不代表跨故障也一定不会重复。
第四种是雪花算法(Snowflake)。 它把时间戳、机器号和序列号拼成一个 64 位整数,正常发号过程可以在本地完成,速度快、ID 较短,而且有递增趋势。
雪花算法的前提是机器号不能冲突,同一毫秒的序列号不能重复,还得处理时钟回拨。它是趋势递增,不是多台机器之间严格按生成时间递增:机器时钟可能有偏差,同一毫秒里也会按机器号等位段排序。
实际选型时,普通业务如果已经有可靠的数据库,号段模式通常比较容易落地;需要本地高吞吐发号,可以考虑成熟的雪花算法实现;需要跨系统方便生成标识,也可以评估 UUID。 不是公司规模大就一定要选某一种,而是看约束和运维能力。
# 雪花算法怎么保证唯一?时钟回拨怎么办?
先看常见的位数划分:1 位符号位 + 41 位毫秒时间戳 + 10 位机器标识 + 12 位序列号。不同实现可以调整位数,这只是经典划分。
- 时间戳通常是当前时间减去一个自定义起始时间,41 位大约可以使用 69 年。
- 机器标识用来区分不同发号节点,10 位能表示 1024 个标识,也可以拆成数据中心号和机器号。
- 序列号区分同一台机器在同一毫秒内生成的不同 ID,12 位可以表示 4096 个值。序列用完后,常见实现会等到下一毫秒再生成。
所以它的唯一性来自三个条件:不同机器的标识不重复,同一机器在同一毫秒内的序列不重复,时间不能回到已经使用过的范围还重用相同序列。 同一节点的并发发号,也需要同步保护。

比如节点在 10:00:00.100 已经发过一些 ID,系统时间却回到了 10:00:00.095。如果程序直接把序列号重置,再走到 .100,就可能重新拼出之前的 ID。

处理办法通常有几种:
- 小幅回拨:暂停发号,等时间追上上次发号时间。实现简单,但这段时间会增加延迟。
- 大幅回拨:报警并拒绝发号,或者按设计切换到备用发号方案,不能悄悄继续生成可能重复的 ID。
- 使用逻辑时间:让内部时间不小于上次使用的时间,在逻辑时间内继续增加序列。这个方案仍然要处理序列耗尽、进程重启,以及时间状态的可靠保存,不能只写一个
max就觉得万事大吉了。
还有一个容易遗漏的点:容器扩缩容、节点重启时,机器号的分配和回收也要可靠。旧节点还在运行,新节点就拿到同一个机器号,同样会重复;重启后丢了上次时间和序列,也要防止重新进入已用过的组合。
我会优先用成熟组件,并检查它怎么管理机器号、怎么处理回拨和重启。雪花算法的位运算并不难,难的是在故障和扩缩容时把这些前提守住。
# 分布式事务
# 什么情况下需要使用分布式事务?
简单地说,当一次业务操作跨越了多个独立的事务资源,比如不同数据库,或者数据库和消息队列,而业务又要求这些操作协调成功或失败时,就需要考虑分布式事务或者业务上的一致性方案。关键是本地事务能不能覆盖这些操作,不是方法里调了几个服务、用了几条连接。
在实际开发中,通常有以下两种最典型的场景会逼着我们使用分布式事务:
第一种情况最常见:微服务架构下的跨服务协同。

在微服务时代,我们推崇「一个服务专属一个自己的数据库」。我给您举个特别经典的电商下单例子:用户点击支付后,后台其实需要做三件事:订单服务去更新订单状态,库存服务去扣减库存,账户服务去扣减余额。 这三个动作发生在三个独立的系统和三个独立的数据库里。假设前两步都成功了,但在调用账户服务扣钱时,账户所在的服务器宕机了。这时候,传统的本地事务根本无能为力,它只能回滚当前账户库中尚未提交的操作,完全没办法隔空把已经成功提交的「订单」和「库存」撤销掉。这就会导致库存已经扣了,但用户的钱没扣。 为了让这分散在各个地方的三个操作绑在一条战壕里,做到一损俱损、一荣俱荣,我们可以引入像Seata这样的分布式事务组件做全局调度,也可以设计可靠消息、补偿等业务流程来保证最终结果正确。
第二种情况是底层由于数据量大,做了「分库分表」。

这可能发生在单体应用里。假如咱们没有做微服务架构,所有的代码都在一起,但是底层由于数据太多,把一个大库拆成了库A和库B。 如果我们在一个方法里,同时往库A里插入账单,又往库B里插入流水。由于它们在物理上已经是两个独立的数据库实例、走的是两条独立的网络连接了,Spring自带的本地事务就无法再确保这两个连接能同时完美提交了,一旦一边成功一边挂掉,也会产生脏数据。这种情况同样也得靠分布式事务来解决。
不过,站在实际架构面试者的视角,我还想补充非常重要的一点:权衡与妥协。 虽然分布式事务能解决跨库的数据一致性,但业界有个共识,能绕开不用,就尽量不用。 因为传统的两阶段提交通常需要让多个资源保留锁、等待协调结果,会增加延迟和资源占用,故障时还可能阻塞。高并发场景里,这些代价必须评估,不能只看它能统一提交就直接引入。
如果业务要求跨资源的操作整体提交或回滚,而且能接受锁等待和协调成本,可以评估XA、两阶段提交等方案。但不是所有涉及钱的业务都必须让多个系统在同一瞬间更新完,支付成功后异步更新订单就是很常见的例子,关键是资金账务准确、处理状态明确,最终结果能够对齐。
而对于大多数微服务场景,我们通常会退而求其次,利用 消息队列(MQ)+ 本地消息表,或者重试机制,来实现业务的「最终一致性」。也就是说:允许订单、库存等系统暂时处于不同的处理状态,但不能允许余额凭空变化、库存扣成负数。我们通过可靠记录、幂等、重试和补偿推进到正确终态,减少同步等待带来的性能开销。

总结一下: 当一个业务确实要协调多个独立事务资源时,才需要评估分布式事务方案;跨服务调用如果只是读取数据,并不因此就需要分布式事务。实际落地时,还要结合原子提交、隔离、补偿需求以及允许的处理延迟选择方案,不能把所有分布式事务框架都当成同一种「强一致性」方案。
# 分布式事务的解决方案你知道哪些?
我一般分成两类来理解:一类是在资源层协调提交,比如 XA/2PC;另一类是在业务层通过预留、补偿或者可靠消息,让流程最终走到正确状态,比如 TCC、Saga 和本地消息表。
先用一个表把主要区别说清楚:
| 方案 | 核心做法 | 主要代价 | 常见场景 |
|---|---|---|---|
| XA/2PC | 多个事务资源先准备,再统一提交或回滚 | 锁持有时间长,故障时可能阻塞 | 跨数据库原子提交 |
| 3PC | 在准备、提交之间增加阶段和超时处理 | 消息更多,依赖故障模型,分区下仍有风险 | 理论讨论较多,业务中相对少见 |
| TCC | 先预留业务资源,再确认或取消 | 三套业务逻辑,需处理幂等和异常状态 | 库存预留、余额冻结 |
| Saga | 每一步先提交本地事务,失败再业务补偿 | 缺少全局隔离,补偿也可能失败 | 订单、机票、酒店等长流程 |
| 可靠消息/本地消息表 | 可靠记录事件,异步通知下游推进业务 | 状态延迟、消息重复,需重试和对账 | 支付后更新订单、订单完成后发积分 |
第一种是两阶段提交,也就是 2PC。 可以把协调者理解成一个组织者,参与者是各个数据库。第一阶段问大家「能不能提交」,参与者完成操作,写好准备日志、保留必要的锁,回复准备成功;第二阶段,只有都准备成功,协调者才决定提交,否则决定回滚。
关键是第一阶段的「准备成功」不等于事务已经提交。参与者做了承诺后,不能因为等得不耐烦就随便回滚,否则可能和协调者的提交决定冲突。所以协调者故障或网络异常时,参与者可能长时间等待。可靠实现还需要持久化决定并在恢复后继续处理,不是发两轮消息,就能忽略所有故障自动得到强一致性。2PC 主要解决原子提交,全局隔离还要由具体系统保证。

第二种是三阶段提交,也就是 3PC。 它在 2PC 的基础上增加了中间阶段,并设计超时后的处理规则,希望减少阻塞。但它的非阻塞效果依赖对网络和故障的假设,遇到网络分区仍可能出现不一致,所以不能把它理解成「2PC 的问题全解决了」。实际业务里并没有因为多了一阶段就普遍改用 3PC。
第三种是 TCC。 比如扣库存,Try 先把可用库存转成预留库存,Confirm 真正消耗预留库存,Cancel 把预留库存释放。它不需要在整个业务期间一直占着数据库行锁,但预留的业务资源仍然会被占用。优点是资源怎么预留由业务控制,代价是开发者得把三个阶段和异常处理都写好。
第四种是 Saga。 比如订机票、订酒店、租车,每一步都先提交自己的本地事务。如果租车失败,就按业务需要取消酒店和机票。这里的补偿是一个新的业务动作,不是数据库把历史抹掉:退款可能有手续费,邮件发出后也不能让对方没看过。补偿失败还需要持续重试、告警,必要时人工处理。
第五种是可靠消息最终一致性。 比如支付服务先完成支付,然后可靠地通知订单服务更新状态。这里最容易遗漏的是「写数据库」和「发 MQ」之间的空隙,如果数据库提交了,消息却没发出去,单靠消费者重试也没用。因此要配合本地消息表、事务消息等机制,先保证事件能可靠发出去,再让下游幂等消费。
我在选型时会先问:是否必须跨资源原子提交,能否先预留,已经完成的动作能不能补偿,允许多长的处理中状态。 这些答案比单纯背「哪个性能高、哪个性能低」更能决定方案。
# 阿里的 Seata 框架了解过吗?
了解,Seata 是一个分布式事务框架,它把全局事务的管理和各个服务里的分支事务协调起来。它支持 AT、TCC、Saga、XA 等模式,不同模式的实现方式和业务代价不一样。
先说三个核心角色:
- TM(事务管理器):负责发起全局事务,告诉协调者什么时候提交或回滚。
- TC(事务协调者):维护全局事务和分支事务的状态,协调各个分支提交或回滚。
- RM(资源管理器):管理本地资源,注册分支事务,并执行协调者下发的提交或回滚。
全局事务有一个标识叫 XID,跨服务调用时要把它传下去,下游的分支才能加入同一个全局事务。只有入口加了注解,但下游没有正确传递 XID、接入资源管理器,并不能自动覆盖整条链路。
最常被问的是 AT 模式。我用「扣库存」举个例子:第一阶段,Seata 通过数据源代理,记录 SQL 执行前后的数据镜像,把库存更新和 undo_log 放在同一个本地事务里,并在本地提交前申请全局锁,申请失败则按规则重试或回滚。这样本地数据库锁不用一直等到整个全局事务结束。
如果全局事务成功,第二阶段主要清理回滚日志和释放相关资源;如果失败,就根据 undo_log 恢复数据。恢复前会比较当前数据和执行后的镜像,防止直接覆盖别人的修改。所以 AT 不是简单地「记个旧值,失败就改回去」,它还依赖全局锁、脏写检查等机制。

其他几个模式可以这样记:
- TCC:开发者自己写资源预留、确认和取消逻辑,适合能明确表达预留资源的业务。
- Saga:把长流程拆成多个本地事务,失败时执行补偿,适合跨服务的长事务。
- XA:借助数据库原生 XA 能力协调分支,准备后通常要继续持有数据库锁,一致性要求和性能代价都要评估。
还有一点,AT 的全局锁并不自动拦住所有普通 SQL。要保证预期的隔离效果,相关读写也要按要求接入 Seata 的机制,不能让其他程序随意绕过全局锁修改同一份数据。普通查询也不会因为启用了 AT 就自动变成全局隔离的查询。
我会根据业务选择模式:关系型数据库、希望减少业务改造,可以评估 AT;业务能够预留资源,可以评估 TCC;流程很长、需要业务补偿,可以评估 Saga。 引入框架以后,超时、故障恢复、幂等和监控仍然需要设计。
# TCC 的幂等、空回滚和悬挂怎么处理?
这三个问题都来自同一件事:网络调用可能超时、重试,也可能晚到,不能假设 Try、Confirm、Cancel 永远只执行一次,并且严格按顺序到达。
我拿「冻结 100 元余额」来解释:Try 冻结 100 元,Confirm 把冻结金额真正扣掉,Cancel 解冻。
第一个是幂等。 Confirm 扣款成功了,但响应丢了,协调者会重试。如果第二次又扣一次,就出问题了。所以要记录这笔分支事务的状态,比如「已预留、已确认、已取消」,同一笔事务重复 Confirm 或 Cancel 时,按已完成状态返回,不能再次改变金额。状态判断和资金操作要在同一个本地事务里完成。
第二个是空回滚。 Try 请求还没执行,协调者就已经因为超时发来了 Cancel。这时候没有资金被冻结,Cancel 不能凭空给用户加回 100 元。它应该识别到没有预留操作,记录已经取消这个事实,再安全结束。
第三个是悬挂。 就接着刚才的情况:Cancel 已经执行完,迟到的 Try 又来了。如果这时再冻结 100 元,后面可能再也没有 Confirm 或 Cancel 来处理它,资金就一直被冻结。所以 Try 发现这笔分支已经取消,就要拒绝执行。
实际可以用事务控制表,以 全局事务 ID + 分支事务 ID作为唯一键,配合数据库事务、行锁或条件更新,让状态只按合法方向变化。比如预留后可以确认或取消,但取消后不能再重新预留,确认后也不能再执行释放预留资源的 Cancel。

所以,TCC 不只是写三个方法,而是要写一套能应对重复、缺失和乱序调用的状态流转。
# 本地消息表怎么保证最终一致性?
本地消息表的关键是:把「业务已经完成」和「这件事还需要通知下游」放在同一个本地事务里记录下来。 这样就不会业务成功了,却完全丢掉通知。
比如用户支付成功后要给订单服务发消息,可以分成几步:
- 支付服务在一个数据库事务里更新支付状态,同时插入一条待发送消息,消息带上唯一 ID、订单号和事件内容。
- 事务提交后,后台任务扫描消息表,发送到 MQ,也可以通过 CDC 等方式把这些记录投递出去。
- 按 MQ 的可靠性配置确认消息已经被可靠接收后,更新消息的发送状态;失败就按退避策略重试,不能刚写进客户端发送缓冲区就标记成功。
- 订单服务收到消息,在自己的本地事务里做去重和订单状态更新,成功后再确认消费。

最容易被追问的是:消息已经发到 MQ 了,但支付服务还没来得及把消息标记为已发送就宕机了,怎么办?
恢复后这条消息还会再发一次,所以本地消息表保证的是消息不轻易丢失,不是绝对只发送一次。消费者必须幂等,同一条支付成功消息来两遍,订单只能完成一次有效的状态更新。

另外,标记「已发送」只代表 MQ 接收了,不代表下游业务已经成功。消费失败的重试、死信处理、长期卡住的业务状态都要监控,超过阈值要告警和对账。如果同一个订单的多个事件有顺序要求,还需要按订单有序处理,或者通过版本号、状态条件挡住过期事件。
总结一下:本地事务保证业务和事件记录一起落库,可靠投递保证事件有机会到下游,幂等消费保证重复消息不会重复改变业务结果,重试和对账负责把异常流程收尾。
# 分布式系统里的接口幂等怎么做?
幂等说白了就是:同一个业务请求执行一次,和重复执行多次,对业务产生的效果一样。 它不是要求每次返回的响应时间都一样,而是不能重复创建订单、重复扣款。
为什么分布式里特别需要它?因为用户会重复点击,客户端会重试,MQ 也可能重复投递。最麻烦的是请求已经成功了,但响应丢了,调用方根本不知道该不该再试。
我通常会从这几种方式里选:
- 业务唯一键:比如创建订单时传入稳定的请求号,在数据库上加唯一约束。重复请求插不进去,再查询第一次的处理结果。注意同一笔请求重试要沿用同一个号,不能每重试一次就换一个。
- 状态条件更新:比如支付通知只能把订单从「待支付」改成「已支付」,SQL 带上旧状态条件。第一次更新成功,后面的重复通知就不能再触发同样的业务副作用。
- 去重表:比如消费消息时,以消息 ID 或业务操作号做唯一键,把「插入去重记录」和「更新业务数据」放在同一个本地事务里提交。
这里有个典型错误:先用 Redis 记下「请求处理过了」,然后再去数据库扣款。如果 Redis 成功、扣款失败,重试却被拦住,业务就丢了;如果先扣款再记 Redis,中间宕机又可能重复扣款。去重记录和业务动作之间的原子性,才是关键。

对于需要保存请求状态的接口,还要区分「处理中」「成功」「失败」,重复请求可以返回已有结果,或者明确告诉调用方还在处理中。同一个幂等键带着不同的金额、商品参数再次请求,也应该拒绝,不能直接复用另一个请求的结果。
我会优先让数据库的唯一约束、事务和条件更新守住业务结果,再根据流量加缓存优化。分布式锁能减少同时执行的请求,但不能代替幂等,因为重复请求还可能在第一次释放锁以后才到达。
# 分布式组件
# RPC的概念是什么?
RPC 即远程过程调用,允许程序调用运行在另一台计算机上的程序中的过程或函数,就像调用本地程序中的过程或函数一样,而无需了解底层网络细节。
一个典型的 RPC 调用过程通常包含以下几个步骤:

- 客户端调用:客户端程序调用本地的一个 「伪函数」(也称为存根,Stub),并传入所需的参数。这个 「伪函数」 看起来和普通的本地函数一样,但实际上它会负责处理远程调用的相关事宜。
- 请求发送:客户端存根将调用信息(包括函数名、参数等)进行序列化,然后通过网络将请求发送到服务器端。
- 服务器接收与处理:服务器端接收到请求后,将请求信息进行反序列化,然后找到对应的函数并执行。
- 结果返回:服务器端将函数的执行结果进行序列化,通过网络发送回客户端。
- 客户端接收结果:客户端接收到服务器返回的结果后,将其反序列化,并将结果返回给调用者。
常见的 RPC 框架:
- gRPC:由 Google 开发的高性能、开源的 RPC 框架,支持多种编程语言,使用 Protocol Buffers 作为序列化协议,具有高效、灵活等特点。
- Thrift:由 Facebook 开发的跨语言的 RPC 框架,支持多种数据传输协议和序列化格式,具有良好的可扩展性和性能。
- Dubbo:阿里巴巴开源的高性能 Java RPC 框架,提供了服务治理、集群容错、负载均衡等功能,广泛应用于国内的互联网企业。
# RPC 调用超时了,就代表服务端执行失败了吗?
不代表。超时只能说明调用方在规定时间内没拿到结果,不能说明服务端一定没执行。
比如订单服务调用支付服务扣款,支付服务已经把钱扣了,但返回结果时网络断了。订单服务看到超时,如果立刻再发一个全新的扣款请求,就可能扣两次。

所以处理超时,第一步不是无脑重试,而是先判断操作类型:查询类调用通常比较容易重试,扣款、创建订单这种有副作用的操作,必须先有稳定的业务请求号和幂等设计。
对扣款这种操作,我会让所有重试沿用同一个支付请求号,支付服务保存处理状态和结果。调用方超时后,可以按请求号查询结果,不能马上把订单改成「支付失败」,因为它可能只是结果暂时未知。需要取消时,也要有明确的取消协议,不能假设客户端超时就把服务端停了。
重试本身还要有次数上限、总时间预算、指数退避和随机抖动,而且尽量统一放在一层做。否则 A 重试 3 次,B 也重试 3 次,到了 C 就可能放大成 9 次,原本就慢的下游会被压得更厉害。
面试里我会强调:远程调用有「结果未知」这个状态,要靠幂等、查询和补偿去确认最终结果,不能只分成成功和失败。
# zookeeper拿来做什么?核心的原理是什么?
ZooKeeper 是一个分布式协调服务,适合管理配置、服务地址、锁节点等小规模协调数据。它通过 ZAB 为写事务建立统一的提交顺序,但普通读取可以由客户端连接的节点本地响应,可能读到落后的数据,不能把「写事务有统一顺序」理解成「所有读写都是线性一致的」。以下是其常见的应用场景:
- 配置管理:在分布式系统中,不同节点往往需要相同的配置信息,如数据库连接参数、服务端口等。ZooKeeper 可以将这些配置信息集中存储,当配置发生变更时,能及时通知到各个节点。例如,一个由多个微服务组成的系统,各个服务实例可以从 ZooKeeper 中获取统一的配置,当配置更新时,ZooKeeper 会通知所有相关服务重新加载配置。
- 服务注册与发现:服务注册与发现是微服务架构中的关键环节。服务提供者在启动时将自己的服务信息(如服务名称、地址、端口等)注册到 ZooKeeper 中,服务消费者通过 ZooKeeper 查找并获取服务提供者的信息。当服务提供者发生变化(如上线、下线、故障等)时,ZooKeeper 会实时更新服务列表并通知服务消费者。像 Dubbo 框架就可以利用 ZooKeeper 实现服务的注册与发现。
- 分布式锁:在分布式环境下,多个进程或线程可能会竞争同一资源,为了避免数据不一致等问题,需要实现分布式锁。ZooKeeper 可以通过创建临时顺序节点来实现分布式锁。当一个客户端需要获取锁时,它会在 ZooKeeper 中创建一个临时顺序节点,然后检查自己创建的节点是否是序号最小的节点,如果是,则表示获取到了锁;如果不是,则等待前一个节点释放锁。
ZooKeeper 的数据模型类似于文件系统的树形结构,每个节点称为 Znode。

Znode 可以存储数据,持久节点也可以有子节点,但临时节点不能有子节点。Znode 有不同的类型,包括持久节点(PERSISTENT)、临时节点(EPHEMERAL)和顺序节点(SEQUENTIAL)。
- 持久节点:一旦创建,除非主动删除,否则会一直存在。
- 临时节点:与客户端会话绑定,当客户端会话结束时,临时节点会自动被删除。
- 顺序节点:在创建时,ZooKeeper 会为其名称添加一个单调递增的序号,保证节点创建的顺序性。
ZooKeeper 使用 ZAB协议来保证集群中数据的一致性。ZAB 协议基于主从架构,有一个领导者(Leader)和多个跟随者(Follower)。

- 消息广播:当客户端发起写请求时,请求会先到达领导者。领导者将写操作封装成一个事务提案,并广播给所有跟随者。跟随者收到提案后,将其写入本地日志,并向领导者发送确认消息。当提案获得超过半数投票节点的确认后,领导者会发送提交消息,跟随者收到后按顺序应用到本地状态机。这个多数派包含领导者自己,不是要求超过半数的跟随者回复;Observer 不计入投票节点。
- 崩溃恢复:当领导者出现故障时,ZooKeeper 会进入崩溃恢复阶段。在这个阶段,集群会选举出新的领导者,并确保在新领导者产生之前,不会处理新的写请求。选举过程基于节点的事务 ID 和节点 ID 等信息,保证新选举出的领导者包含了所有已提交的事务。
# 服务注册与发现是怎么实现的?注册中心挂了怎么办?
可以把注册中心理解成一份动态的「服务通讯录」。比如订单服务有 10 个实例,地址会随着扩容、缩容变化,调用方不能把这 10 个 IP 永远写死在代码里。
整体过程一般分成三步:
- 注册:服务实例启动后,把服务名、地址、端口等信息写到注册中心。
- 健康检查:通过心跳、租约或者主动探测判断实例是否还可用,失效后从服务列表移除。
- 发现:调用方查询或订阅服务列表,把地址缓存在本地,再从中选择一个实例发起调用。列表发生变化时,通过推送或者定期拉取更新。
ZooKeeper 可以用临时节点跟踪会话,etcd 可以用租约管理注册信息,其他注册中心也会有各自的心跳和健康检查机制。但故障检测都不是瞬间完成的,通讯录里还有地址,不代表这台实例此刻一定健康。调用方仍然要处理连接失败和调用超时。
如果注册中心挂了,已有调用方通常还能使用本地缓存的地址继续调用,所以不一定会导致整条业务立刻停止。但新实例可能无法注册,新启动的调用方可能拿不到地址,旧列表也无法及时更新。随着实例上下线,缓存会越来越不准确。

所以我会从两层做保护:注册中心本身做合理的集群和容灾;客户端保留可用的地址缓存,配合失败实例的暂时剔除、合理重试和监控。是否允许继续使用旧列表,还要看业务能否接受它过期。
注册中心负责告诉我们「去哪里找服务」,负载均衡负责「这次选谁」,超时和熔断负责「选中的实例出问题后怎么办」。这几个环节得配合起来。
# 分布式场景
# 常见的限流算法你知道哪些?
- 固定窗口限流算法按固定时间段计数,实现简单,但窗口交界处容易放过突发流量。
- 滑动窗口限流算法统计最近一段时间的请求量,缓解固定窗口的边界突刺,精度取决于具体实现。
- 漏桶限流算法能够对流量起到整流的作用,让随机不稳定的流量以固定的速率流出,但是不能解决流量突发的问题。
- 令牌桶算法通过控制令牌生成速率和桶容量,限制长期平均流量,同时允许一定程度的流量突发。
固定窗口限流算法
固定窗口限流算法就是对一段固定时间窗口内的请求进行计数,如果计数已经达到阈值,就拒绝新请求;如果没有达到阈值,则接受请求,并把计数加1。当时间窗口结束时,重置计数器为0。

固定窗口限流优点是实现简单,但是会有「流量突刺」的问题,假设窗口大小为1s,限流大小为100,然后恰好在某个窗口的第999ms来了100个请求,窗口前期没有请求,所以这100个请求都会通过。再恰好,下一个窗口的第1ms又来了100个请求,也全部通过了,那也就是在2ms之内通过了200个请求,而我们设定的阈值是100,通过的请求达到了阈值的两倍,这样可能会给系统造成巨大的负载压力。

滑动窗口限流算法
改进固定窗口缺陷的方法是采用滑动窗口限流算法,滑动窗口就是将限流窗口内部切分成一些更小的时间片,然后在时间轴上滑动,每次滑动,滑过一个小时间片,就形成一个新的限流窗口,即滑动窗口。然后在这个滑动窗口内执行固定窗口算法即可。
这里要分清两种实现:滑动日志保存窗口内每个请求的时间戳,可以精确统计任意滚动时间范围,但占用空间较多;按小时间片计数的滑动窗口实现成本较低,可以缓解固定窗口的边界突刺,但时间片边界上仍然可能存在统计误差,粒度越细通常越准确。
另外,滑动窗口限制的是一段时间内的总量,不等于把请求均匀摊开。比如过去1秒没有请求,现在瞬间来了100个,在阈值是100的情况下仍然可能全部通过。如果下游连这种突发也不能承受,还需要排队整流或者更严格的并发限制。

漏桶限流算法
漏桶限流算法是模拟水流过一个有漏洞的桶进而限流的思路,如图。

水龙头的水先流入漏桶,再通过漏桶底部的孔流出。如果流入的水量太大,底部的孔来不及流出,就会导致水桶太满溢出去。
从系统的角度来看,我们不知道什么时候会有请求来,也不知道请求会以多大的速率来,这就给系统的安全性埋下了隐患。但是如果加了一层漏斗算法限流之后,就能够保证请求以恒定的速率流出。在系统看来,请求永远是以平滑的传输速率过来,从而起到了保护系统的作用。
使用漏桶限流算法,缺点有两个:
- 即使系统资源很空闲,多个请求同时到达时,漏桶也是慢慢地一个接一个地去处理请求,这其实并不符合人们的期望,因为这样就是在浪费计算资源。
- 不能解决流量突发的问题,假设漏斗速率是2个/秒,然后突然来了10个请求,受限于漏斗的容量,只有5个请求被接受,另外5个被拒绝。你可能会说,漏斗速率是2个/秒,然后瞬间接受了5个请求,这不就解决了流量突发的问题吗?不,这5个请求只是被接受了,但是没有马上被处理,处理的速度仍然是我们设定的2个/秒,所以没有解决流量突发的问题
令牌桶限流算法
令牌桶是另一种桶限流算法,模拟一个特定大小的桶,然后向桶中以特定的速度放入令牌(token),请求到达后,必须从桶中取出一个令牌才能继续处理。如果桶中已经没有令牌了,那么当前请求就被限流。如果桶中的令牌放满了,令牌桶也会溢出。
放令牌的动作是持续不断进行的,如果桶中令牌数达到上限,则丢弃令牌,因此桶中可能一直持有大量的可用令牌。此时请求进来可以直接拿到令牌执行。比如令牌生成速率为每秒100个、桶容量也是100,且初始为空,那么1秒后没有请求消耗的话,桶里就有100个令牌。此时突然来了100个请求,都可以拿到令牌。实际允许多大的突发由桶容量决定,不能只看令牌生成速率。由此可见,桶中没有令牌时,可以直接拒绝,也可以在允许的超时范围内等待,这取决于限流器的实现。它控制的是长期平均速率,并不保证短时间内请求严格匀速。令牌桶的示意图如下:

令牌桶限流算法综合效果比较好,能在最大程度利用系统资源处理请求的基础上,实现限流的目标,建议通常场景中优先使用该算法。
# 多台机器部署时,怎么做全局限流?
单机限流限制的是一台机器的流量,全局限流限制的是整个服务或者某个用户在所有机器上的总流量。 这两个不能混用。
比如接口整体只能承受每秒 1000 个请求,现在部署了 10 个实例。如果每个实例都配置 1000,整体就可能放进来 10000。简单地给每台配置 100,也会遇到流量不均、实例数量变化的问题。
需要较准确的全局限制时,可以在网关统一限流,或者把限流状态放在 Redis 等共享存储里。比如每个用户维护一个令牌桶,用 Lua 脚本把「补充令牌、判断数量、扣减令牌」原子执行,所有实例都用同一份状态。时间计算也要统一,避免不同机器时钟偏差影响额度。

代价是每次判断都可能增加一次网络访问,共享限流组件也会成为依赖。流量很大时,可以批量分配额度到各实例,在本地消耗,或者把全局限制和本地保护组合起来,但额度预分配会带来利用率和精度上的取舍。
还有个问题必须提前想好:限流组件挂了怎么办?如果选择放行,要靠本地限流、并发隔离等机制保护下游;如果选择拒绝,可以保住额度约束,但业务可用性会下降。选择取决于限流是在保护系统容量,还是在执行必须严格遵守的业务配额。
所以全局限流不只是把计数器搬到 Redis,还要考虑原子性、热点 key、延迟、故障策略,以及共享状态能提供多严格的保证。
# 限流、熔断和降级有什么区别?
我一般用三个问题来区分:请求太多了怎么办?下游持续出问题怎么办?部分功能暂时做不了怎么办?
限流解决的是请求太多。 比如接口每秒只允许 1000 个请求,超过的就拒绝或者进入有界队列,避免系统被流量压垮。它可以在系统还没出故障时就生效。
熔断解决的是下游持续变慢或失败。 比如订单服务调用积分服务,发现一段时间内超时比例很高,就暂时停止调用,快速返回,避免线程和连接全部卡在积分服务上。熔断器通常有关闭、打开、半开三个状态:正常调用是关闭;触发阈值后打开;等待一段时间再半开,放少量请求探测,成功则恢复,失败则再次打开。
降级解决的是功能无法正常完成时,提供什么替代结果。 比如推荐服务不可用,就显示默认商品列表;积分服务暂时不可用,就记录待处理事件,告诉用户积分稍后到账。
三者经常一起使用:先限流控制总量,下游异常时熔断,熔断后按业务规则返回降级结果。但降级不能乱做,扣款失败不能返回支付成功,库存查询失败也不能当成库存无限。

还要配合超时和资源隔离。比如推荐服务慢了,不能让它占满下单服务的全部线程;重试也要受总时间和次数限制,否则熔断还没生效,系统已经被重复请求压垮了。
# 常见的负载均衡策略有哪些?怎么选?
负载均衡要解决的是:同一个服务有多个实例,这次请求该发给哪一个? 常见策略有这么几种:
- 轮询和加权轮询:按顺序分配,机器配置不同可以设置不同权重。实现简单,适合处理能力和请求耗时相对稳定的场景。
- 随机和加权随机:随机选择实例,大量请求下通常能比较均匀,权重也可以体现不同实例的容量。
- 最少连接或最少在途请求:优先选当前负担较小的实例。但连接数不一定等于请求数,比如一个 HTTP/2 连接可以同时承载多个请求,需要看具体协议和统计指标。
- 一致性哈希:按用户 ID、缓存 key 等把请求映射到固定实例,实例变化时只重新映射一部分 key,适合需要提高缓存命中率、保持同一类请求落点的场景。
一致性哈希也不是天然均匀,通常要用虚拟节点等方法改善分布,遇到热点 key 仍然可能让一个实例压力特别大。而且固定落到同一台机器,不代表数据自动有备份,节点宕机后的数据恢复还得单独设计。
实际我会先看实例是否无状态、机器容量是否一致、请求耗时是否差别很大,再选择策略。 无论用哪一种,都要配合健康检查和失败处理,把已经不可用的实例从候选列表里排除。
# 分布式一致性算法
# 说下 Raft 算法?
Raft 是一种分布式共识算法,主要解决多个节点如何对一串操作的顺序达成一致,再按同样的顺序执行,让复制出来的状态机保持一致。它把问题拆成了三个部分:选主、日志复制、安全性。
先看节点角色:Leader(领导者)、Follower(跟随者)、Candidate(候选人)。正常情况下由 Leader 接收写请求,Follower 复制日志;Follower 等不到有效心跳时,就会变成 Candidate 发起选举。

选主的时候,有一个很重要的概念叫任期(Term),可以理解成第几轮领导周期。候选人发起选举时会增加任期,先投自己一票,再请求其他节点投票。每个投票节点在同一个任期里最多投一票,候选人获得超过半数的票才当选。随机的选举超时可以减少大家同时参选、一直分票的情况。
不过,不是先来拉票就一定能拿到票。投票节点还要检查候选人的日志是否至少和自己一样新:先比较最后一条日志的任期,再比较日志索引。这个限制是为了不让缺少已提交日志的节点当选。
接着是日志复制。 比如客户端要求把库存减 1,Leader 先把这条命令写入自己的日志,再通过 AppendEntries 消息复制给 Follower。Follower 会校验前一条日志的索引和任期,前缀对不上就不能直接追加,需要由 Leader 找到一致的位置再修复未提交的冲突日志。

当当前任期的日志已经被超过半数投票节点持久化后,Leader 可以推进提交位置,把已提交日志应用到状态机,再把提交位置通知 Follower。这里的多数派包含 Leader 自己,比如 3 个投票节点,Leader 加 1 个 Follower 就够了,不用等全部节点。
「当前任期」这个条件不能漏掉。Leader 不能仅仅因为某条旧任期日志已经复制到多数派,就直接用这个条件提交它;通常要先提交一条当前任期的日志,再间接提交它前面的日志。

最后是安全性。 在协议假设和持久化正确的前提下,已经提交的日志不会在后续选主时被覆盖,同一个日志位置也不会让不同节点执行不同的命令。落后的节点可以稍后追上,不代表所有节点必须在同一瞬间完成更新。
所以我会把 Raft 概括成:用任期和投票规则选出合适的 Leader,由它复制有序日志,再通过多数派确认和日志匹配规则保护已经提交的结果。 etcd 等组件用它来实现复制状态机,但业务请求的幂等、读取方式等,还需要在协议之外正确实现。
# 说一下 Paxos 协议?
Paxos 是一个经典的共识协议。先按 Basic Paxos 来讲,它要解决的是:多个节点对一个值达成共识,一旦这个值被选定,后面就不能再选出另一个不同的值。 要连续处理一串操作,通常需要 Multi-Paxos 这类扩展。
它有三个核心角色:
- Proposer(提议者):提出自己希望大家采用的值。
- Acceptor(接受者):记录承诺和已经接受的提案,参与多数派确认。
- Learner(学习者):获知最终被选定的值。
这三个是逻辑角色,同一台机器可以同时承担多个角色。Acceptor 本身就参与投票,不是它后面还有一个独立的「Voter」再投一轮。

核心分成 Prepare(准备)和 Accept(接受)两个阶段。虽然也是两阶段,但它不是分布式事务的 2PC:Paxos 要选定一个值,2PC 要协调多个事务资源是否共同提交,目标和故障处理都不一样。
第一阶段,提议者发 Prepare。 提案带一个全局唯一、可比较的编号 n,可以用递增计数加节点标识等方式生成。接受者如果没有承诺过更大的编号,就承诺不再接受小于 n 的提案,并返回自己之前接受过的最高编号提案和值。已经承诺了更大编号,就拒绝这次请求,提议者可以换更大的编号重试。
第二阶段,提议者发 Accept。 收到多数派的准备响应后,不能马上随便提出自己喜欢的值,而是先检查响应:
- 如果有人已经接受过提案,必须从这些响应中选出编号最高的已接受提案的值,作为这一轮要提议的值。
- 如果大家都没有接受过提案,才可以使用自己的值。
然后发送 (n, value)。接受者只要没有承诺过比 n 更大的编号,就可以接受并持久化这个提案。当同一个提案被多数派接受,这个值就被选定了,学习者再获知结果。
为什么「沿用最高编号的已接受值」这么关键?假设 3 个接受者里,A、B 已经接受了同一个提案 (n, X),X 就已经被选定。后来新的提议者联系任何 2 个接受者,都至少会遇到 A 或 B 中的一个,再配合承诺和选值规则,就不能另选一个与 X 冲突的值。多数派相交加上这些规则,才一起守住结果,不是只要超过半数就够了。

还有个边界:多个提议者不断拿更大的编号抢占,可能一直没人完成,所以实际通常会选一个稳定的 Leader,减少竞争。协议能保证安全性,不代表在任意网络故障下都能一直顺利推进。
我会这样记:先用 Prepare 了解已有承诺,再按规则决定值,用 Accept 获得多数派接受;一旦选定,就不允许后续提案推翻这个值。
# Raft 和 Paxos 有什么区别?
它们都在解决共识问题,但比较时要先说清楚:Basic Paxos 是对单个值达成共识,Raft 是一套复制日志的协议;和 Raft 更接近的比较对象是 Multi-Paxos。
Basic Paxos 每次通过 Prepare 和 Accept 选定一个值。Multi-Paxos 则把很多这样的决定串成日志,并在 Leader 稳定时复用准备阶段的结果,让后续操作主要走接受阶段,减少消息往返。因此不能说「Paxos 完全没有 Leader,而 Raft 才有 Leader」,实际的 Multi-Paxos 经常也由稳定的 Leader 推进。
Raft 的特点是把规则拆得比较清楚:通过任期和日志新旧条件选主,日志主要由 Leader 向 Follower 复制,发生冲突时按日志匹配规则修复。选出的 Leader 必须包含所有已提交日志,这样恢复流程比较容易理解。
Multi-Paxos 对 Leader 的恢复、日志槽位等实现选择相对更灵活,但工程落地时也需要把这些细节补齐。不能因为 Basic Paxos 的核心规则篇幅短,就认为实现一个完整、可靠的系统很简单。
两者都需要多数派来保证安全,也都不能在失去多数派时继续随意提交新结果。 它们能应对的是协议模型下的宕机、丢包、延迟等故障,不能直接拿来解决恶意节点伪造消息这种拜占庭问题。
如果面试官问怎么选,我会回答:先看已有组件和团队经验,通常直接使用成熟的 etcd、ZooKeeper 等协调服务,或者成熟的协议库;理解协议是为了正确使用和排查问题,不是业务一上分布式就要自己写一套共识算法。
# Raft 为什么通常部署 3 个或 5 个节点?网络分区会出现两个 Leader 吗?
这里说的是投票节点。核心不是奇数本身,而是提交和选主需要超过半数。 3 个节点需要 2 个,能容忍 1 个不可用;5 个节点需要 3 个,能容忍 2 个不可用,前提是剩下的节点能形成通信正常的多数派。
4 个节点需要 3 个,仍然只能容忍 1 个不可用;和 3 个节点相比,多了一个投票节点,却没有提高可容忍的故障数,所以通常更常用 3 个或 5 个。节点越多,复制和协调的成本也可能越高,不是越多越好。
假设 5 个节点发生网络分区,被分成 3 个和 2 个。3 个节点这一侧可以形成多数派,必要时选出新 Leader;2 个节点的一侧无法形成多数派,不能提交新的写入。
如果旧 Leader 正好留在这 2 个节点里,它可能还没意识到自己已经失去领导权;另一侧已经选出更高任期的新 Leader。于是短时间内可以有两个节点各自认为自己是 Leader,但它们处于不同任期,旧 Leader 无法得到多数派来提交新日志。遇到更高任期后,旧 Leader 会退回 Follower。

所以不能简单回答「永远不可能有两个 Leader」。Raft 保证的是同一任期最多有一个 Leader,以及不会提交互相冲突的日志,而不是每台机器在网络隔离时都立刻知道全局发生了什么。
# 使用 Raft 的系统,读取一定是强一致的吗?
不一定。Raft 保证提交日志的安全,不代表从任何节点随便读一次,都能读到最新的已提交状态。
比如 Leader 已经提交了一次库存更新,但某个 Follower 还没有把日志应用到状态机,你直接从这个 Follower 读,就可能拿到旧库存。
甚至直接读 Leader 也不能省掉所有检查。网络分区后,旧 Leader 可能还不知道多数派已经选出了新 Leader,这时候它把自己的旧状态返回给客户端,也可能破坏线性一致性。
一种常见做法是 ReadIndex:Leader 先确保当前任期至少有一条日志已经提交,再通过与多数派通信确认当前领导权,确定一个安全的读取位置,再等待状态机应用到这个位置,最后返回结果。Follower 如果要做这样的读取,也需要获取安全读取位置并等自己的状态机追上。

另一种是租约读取,在有效租约内减少每次确认的开销,但需要正确实现租约,并依赖相应的时钟和时间假设,不能因为「最近刚发过心跳」就随意认为读取安全。
所以使用组件时,我会看它提供的读模式:允许落后数据的本地读可以更快,需要线性一致性的查询则必须走相应的读取协议。共识日志、一致性读取和业务事务,是相关但不同的几件事。
# 有什么框架或技术用了 Raft 协议?
常见的有 etcd 和 Consul。
- etcd:用 Raft 复制键值数据和相关状态。Kubernetes 会把集群状态存到 etcd,所以经常会说 Kubernetes 底层依赖 Raft,但更准确地说,是它依赖的 etcd 使用了 Raft,不是每个 Kubernetes 组件都自己跑一套 Raft。
- Consul:Server 节点通过 Raft 维护服务目录、配置等需要共识的集群状态。Client Agent 并不会都成为 Raft 投票节点,Consul 还使用 Gossip 等机制处理成员信息,不能把它的所有通信都归到 Raft 上。
这些组件采用 Raft,是因为相关状态需要可靠地复制和恢复;使用它们时,仍然要关注投票节点数、部署故障域、磁盘延迟,以及提供给业务的读写语义。
# 描述一下 ZAB 协议?
ZAB 是 ZooKeeper 使用的原子广播协议。简单来说,它让集群对写事务的顺序达成一致,并且在 Leader 故障后恢复出安全的事务历史。它主要分成两种模式:正常工作的广播模式,以及故障后的恢复模式。
先看三个角色:Leader、Follower、Observer。Leader 负责组织写事务;Follower 复制事务并参与投票;Observer 也同步数据、可以服务客户端,但不参与选举和提交所需的多数派确认。

广播模式可以理解成「先把操作按顺序记下来,再决定哪些可以提交」。 客户端的写请求即使先到 Follower 或 Observer,也会转交给 Leader。Leader 为事务分配 zxid,然后发送提案;Follower 把提案持久化到事务日志,返回 ACK。提案得到超过半数投票节点的确认后,Leader 再广播提交,节点按顺序应用事务。
这个多数派包含 Leader 自己,不包含 Observer。比如 3 个投票节点,Leader 自己加 1 个 Follower 的确认就能形成多数派,不是需要 2 个 Follower 都回复。
zxid 是 64 位事务 ID,通常高 32 位表示 Leader 周期的 epoch,低 32 位表示这个周期内的事务计数。这样既能区分不同 Leader 周期,也能给事务建立顺序。
恢复模式解决的是 Leader 挂了以后怎么办。 集群先选出新的 Leader,再通过恢复和同步流程建立新的周期,让足够的投票节点确认并同步安全的事务历史,然后恢复对外写服务。已经提交的事务必须保留,冲突的历史需要按协议修复。
这里不需要等所有节点都上线并同步完,能形成满足协议要求的多数派就可以继续推进,落后的节点之后再追上。否则只坏一台机器就永远不能恢复,集群容错也就没意义了。
最后有一个很容易说错的点:ZAB 让写事务按统一顺序提交,但 ZooKeeper 的普通读通常直接读本地状态,可能落后于其他节点。 所以不能说「采用 ZAB,任何节点的任意读取都能拿到最新值」,也不能把统一提交顺序理解成所有副本在同一时刻完成应用。
我会把它概括成:Leader 组织有序写入,多数派确认提交,恢复流程保护事务历史。它是 ZooKeeper 协调能力的基础,但客户端还需要正确处理会话失效、通知和读取语义。
最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

