想象你是一家街角小餐馆的老板:一个人身兼切菜、炒菜、收银、传菜,所有菜谱和食材都在你脑子里。生意越来越好,你发现就算加班到凌晨也忙不过来——因为改一道菜谱,要重新排练整个后厨流程;想多接几桌客人,却发现灶台、冰箱、收银台全都绑在一起,动弹不得。于是你决定拆分:前台专门点单,蒸菜、炒菜、甜品各设一个档口,每个档口有自己的厨师和食材。软件系统的成长过程一模一样:业务变大后,一个“单体应用”装不下所有功能,就要按业务边界拆成多个独立服务,这就是系统拆分。 但拆,远不是把代码分几个文件夹那么简单。拆完之后:档口之间怎么传菜单(服务通信)?各档口的食材账本怎么对得上(数据一致性)?新档口开张客人怎么知道(服务发现)?某个档口着火怎么不殃及邻居(故障隔离)?这篇文章就用这家小餐馆的故事,带你完整走一遍系统拆分的思考路径——从“为什么要拆”,到“按什么拆”,再到“拆完怎么管”。
系统拆分是分布式系统设计的核心能力。学会它,你在面试中可以把“微服务”讲出真正的设计深度,而不是停留在背概念、数组件。
可视化演示
展示单体架构到微服务的拆分过程,通过滑块控制服务拆分粒度,实时观察耦合度、通信开销与一致性指标变化。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统拆分权衡 单体:高耦合低开销 服务化:解耦但通信增加 协调节点保障一致性 耦合度 = 100 - 拆分粒度×10 按业务边界拆分是核心策略
单体架构困境
小餐馆最初只有一位全能厨师:所有的菜谱、食材、收银都在他脑子里。顾客再多也只能一桌一桌做——不是因为他能力不行,而是所有事情缠在一起,任何一步都依赖其他步骤。软件世界的“全能厨师”就是单体架构(Monolith):一个应用打包了所有业务模块,启动时全量加载,模块之间互相调用没有边界,专业说法叫“高耦合”。
单体的问题可以归结为三件事。第一,代码耦合:改一个支付模块,可能会影响到下单流程,回归测试必须全量跑一遍。第二,发布风险:任何小改动都要把整个系统重新打包上线,就像为了换一口锅要把整个厨房停业一天。第三,资源扩缩:只有下单功能流量大,却必须把整个系统一起扩容,白白浪费计算资源。
更本质的是,模块越多,彼此之间潜在的影响关系呈平方级增长。这个规律可以用沟通渠道数来量化:
就像餐馆老板每加一道新菜,都要重新核对它与所有旧菜的搭配禁忌——工作量逐年膨胀,直到某一天,你不得不按下“拆分”的开关。这就是单体架构在增长中必然走向拆分的根本原因。
- n 个模块n 代表系统里的模块或团队数量,n 越大,系统越庞大。
- 每个模块要面对 n-1 个伙伴每个模块都可能与其他模块发生调用或影响,所以要乘以 n-1。
- 两两配对去重模块 A 和 B 之间的相互影响只算一对,除以 2 去除重复计数。
拆分维度选择
餐馆扩店后,老板面临两个组织方案:按工种分(切菜组、炒菜组、甜品组),或者按菜系分(川菜档口、粤菜档口)。按工种分,人力可以复用,但一道菜从切配到上桌要跨组协作,沟通成本极高;按菜系分,一个团队能完整负责一道菜的备料、烹饪和出品,边界清晰。
软件拆分也有这两条主线:按功能拆和按领域拆(即 DDD 中的“限界上下文”)。按功能拆,就像把所有“切菜的”放在一起——代码结构好看了,业务上却是撕裂的,改一个订单功能要同时改好几个服务。按领域拆则是让订单域、支付域、库存域各自闭环,一个域内的修改不扩散到其他域。
业界更推荐按领域拆,因为领域边界就是业务边界。识别边界有一个很实用的量化手段——看“变更耦合度”:如果两个模块总是被同时修改,说明它们其实是同一件事,不该被拆开。
这里还藏着一个深刻的规律:康威定律——组织的沟通结构决定了系统的架构形状。就像餐馆老板如果把川菜和粤菜分给两个团队管,后厨自然会长出两个菜系档口。所以,拆分维度不是纯技术问题,先回答“团队怎么组织”,再回答“系统怎么拆”。
- 共同变更次数一段时间内,两个模块在同一次提交中被一起修改的次数,记为 C_co。
- 总变更次数同一时间段内所有提交的总数,记为 C_total。
- 比值接近 1 就是一家人CC 越接近 1,说明它们应该留在同一个服务里;CC 很低,才适合拆到不同服务。
数据一致性保障
拆成多个档口后,最头疼的是食材账本。以前一个账本大家共用,谁都看得见;现在每个档口都有自己的小库房。如果川菜档口取走了冰箱里最后一块牛肉,粤菜档口不知道,就可能在同一天把同一块牛肉卖给两桌客人——这就是“数据不一致”。
软件里对应的是分布式事务难题:下单、扣库存、支付分属三个服务,原来单体里的本地数据库事务失效了。有人会想到“两阶段提交”(2PC)来强制一致,但它的代价就像要求所有档口同时举手确认才能开炒——只要一个档口网络抖动,整桌菜就要停摆,可用性大打折扣。
所以大多数业务场景改用最终一致性方案,最经典的是“本地消息表 + 消息队列 + 幂等消费”:订单服务把业务操作和消息写进同一个本地事务,然后异步把消息搬给库存、支付服务,发送失败就反复重试,消费端对同一个订单号去重。为什么我们要尽量避免跨服务的强一致?看这个连乘公式就明白了:
回到餐馆:账本不需要实时同步,每晚对一次总账完全可以接受——高峰期“库存先扣、支付稍后确认”,偶尔短时间对不上没关系。用“最终一致”换“高可用”,这才是分布式时代的账房哲学。
- 单个环节成功率 p_i比如库存扣减、支付、通知,每个局部事务单次成功的概率。
- 连乘全部环节所有环节都必须成功,整体事务才算成功,所以用连乘符号 Π。
- 环节越多整体越低每个 p_i 都小于 1,乘数越多乘积越小,所以强一致的跨服务操作要尽量少。
服务间通信协议
档口之间怎么协作?有两条路:让服务员跑腿传单,当面等对方回复——这叫同步调用,稳但慢;或者把菜单往传菜口一放,后厨自己来取——这叫异步消息,快但没法当场确认结果。两种方式各有各的用武之地。
软件里有三类主流通信方式。REST/HTTP + JSON:可读性好、跨语言通用,适合对外部开放的 API。RPC(如 gRPC):走二进制序列化 + 长连接,体积小、性能高,适合内部服务间高频调用。消息队列(如 Kafka、RocketMQ):生产者把消息扔进队列就走,消费者异步处理,适合削峰填谷和跨服务解耦。
选择的关键是理解同步与异步的权衡:同步链路实时性强,但延迟会逐跳叠加,适合查询类操作;异步吞吐高、不阻塞,但结果滞后、排查困难,适合事件通知和跨服务的写操作。调用链越长,越应该考虑异步化。网络延迟的叠加规律可以用一个简单的公式描述:
就像高峰期客人催菜,服务员跑腿传单的人越多,走廊越拥挤——这时候不如把点单往传菜口一放,后厨按优先级自己消化掉,前台也不用干等。异步消息牺牲一点实时性,换回的是整个系统的吞吐和平滑。
- m 次串行往返请求在调用链上经过 m 跳,每一跳都要经历一次网络往返 RTT。
- 传输数据耗时报文体积 D 除以网络带宽 B,得到物理传输花费的时间。
- 加总得到总延迟链路越长、报文越大,总延迟越明显,所以长链路要么改异步、要么压缩数据。
运维复杂度管理
档口多起来之后,老板发现管理成本反而涨了:新档口开张,客人怎么知道?今日菜单改了,怎么让所有档口同步知道?一道菜从下单到上桌,出了问题怎么知道是哪一步的锅?这三个问题,对应微服务的三大基础设施。
第一个问题靠服务注册与发现:新档口开张时,把自己的招牌挂到公告栏(注册中心),客人点餐时先看公告栏找到档口位置,档口歇业了自动摘牌。第二个问题靠配置中心:菜单统一由总厨下发,档口不用改火候设置、不用重启,配置热更新即时生效。第三个问题靠链路追踪:一张订单从下单到上桌,每个环节都盖一个带 traceId 的章,出了问题顺着章就能查到是哪一步。
此外还有故障隔离:一个档口着火了,要保证其他档口还能正常营业。实现手段是熔断、限流和舱壁隔离,调用某服务失败率达到阈值就熔断,不让故障像多米诺骨牌一样传遍全链路。为什么要这么较真?因为整体可用性是连乘的:
档口多了,老板必须把“挂招牌、发菜单、查流程”变成制度化工具,否则拆完的系统比单体还难伺候。这也是为什么成熟的微服务实践都强调:先建好可观测性和容灾能力,再动手拆分。
- 单个服务可用性 A_i比如 99.9%,意味着一年只允许约 8.76 小时不可用。
- 连乘得到链路可用性一次请求要经过 n 个服务,所有服务都可用请求才成功。
- 服务越多越难保证10 个 99.9% 的服务连乘后只剩约 99%,相当于一年要增加 4 天多的不可用时间。
拆分评估与演进
是不是所有餐馆都该拆成十个档口?当然不是。一家只有三张桌子的小店,拆了反而亏钱——多出来的传菜流程和档口间的扯皮,比省下的那点人力还贵。老板要算一笔账:拆了之后,上菜速度真的快了吗?交付一道新菜的成本真的降了吗?
评估拆分的核心指标是交付周期(CT):一个需求从提出到上线要多久。如果单体里发布一次要全量回归两周,CT 就极长;拆成独立服务后,每个团队只对自己的模块负责,CT 大幅缩短。这里有一个通用的定量工具——Little's Law:
具体怎么拆?业界推荐绞杀者模式:在旧系统旁边建一条新走廊,把单体的一条条业务按“领域走廊”逐步迁出,每迁出一个域就灰度验证,验证不过随时回滚。反过来,两个经典反模式要避开:一是“一步到位的大爆炸重构”,好比把厨房整个拆了重建,期间只能停业;二是“为了拆而拆”,把服务拆到十几个微服务,每个却只有几百行代码,纯粹给自己增加运维负担。
- 在制品 WIP同时进行中的需求/任务数量,越多说明积压越严重。
- 交付速率 λ单位时间内完成并上线的需求数。
- 交付周期 CT一个需求从提出到上线所花的平均时间。
- 定位瓶颈如果 WIP 不断上涨而 λ 不变,CT 必然被拉长,说明团队或系统被耦合拖住了,这才值得拆。
电商案例实战
把餐厅搬上线,就是电商系统。用户下单就像客人点菜:先看菜单(商品校验),再锁定食材(扣库存),最后结账(支付)。在单体时代,这一步用一个数据库事务就搞定了;拆成订单、库存、支付三个服务后,同一个流程要在网络上跑好几趟,任何一环失败都会造成“订单下了但没扣库存”或“库存扣了但没付钱”的尴尬。
拆分后的典型分工是这样的:订单服务创建订单,把“待扣库存”消息写进本地消息表;库存服务消费消息扣减库存,扣减失败就回滚订单并发起补偿;支付服务收到支付回调后幂等入账,同一个订单号重复回调只记一次。失败的流程用 SAGA 补偿事务处理:按逆序执行补偿操作,比如先取消订单、再释放库存,让系统回到一致状态。
很多人担心“重试会不会永远失败”?不会。只要单次调用的成功率稳定,重试足够多次后,最终成功率以指数级速度逼近 100%,这就是补偿和重试的底气和数学依据:
拆分前后的对比很直观:拆分前,大促时单体 CPU 打满,为了撑住流量只能把整个系统翻倍扩容,一次发布要全量停服半小时;拆分后,大促只需单独扩容订单和库存服务,发布互不干扰,某个服务出故障也有熔断和降级兜底。从“一个账本包打天下”到“本地消息表 + 重试 + 幂等 + 补偿”四件套兜底,看似绕了远路,实则每个服务都轻装上阵,系统在高并发下依然稳如老狗。
- 单次成功率 p一次支付或库存扣减调用成功的概率,比如 0.9。
- k 次全部失败单次失败概率是 1-p,k 次独立尝试全部失败的概率是它的 k 次方。
- 至少一次成功用 1 减去全部失败的情况,就得到重试 k 次后至少成功一次的概率。
AI 应用场景
电商订单-库存一致性保障
阶段:系统设计阶段(服务边界划分与数据一致性方案设计)
解决的问题:单体时代扣库存和下单在同一个事务里;拆成订单服务、库存服务后,本地事务失效。传统做法是硬编码同步调用 + 超时重试,导致超卖和丢单频发。AI 平台中订单系统要对接推荐、搜索、营销等多个上游服务,同样需要这种跨服务的一致性保障。
具体案例:某电商平台大促期间,订单服务将业务和消息同库写入本地消息表,异步投递到库存服务扣减;库存不足时反向补偿回滚订单,支付回调做幂等处理,最终一致地完成全部订单。高峰期系统可用性从 99.5% 提升到 99.99%。
微服务全链路监控与故障定位
阶段:上线运维与可观测性建设(监控、告警、链路追踪)
解决的问题:服务拆分后一次请求要跨 5-8 个服务,报错后不知道卡在哪一环。传统方式是挨个查日志,效率极低。借助注册中心做服务发现、traceId 穿透全链路做追踪、监控大盘统一告警,可以把定位时间从小时级压缩到分钟级。
具体案例:某推荐服务线上响应变慢,链路追踪发现日志集中在消息队列消费环节大量堆积,定位到消费者实例不足,扩容消费者组后秒级恢复。
存量单体渐进式拆分
阶段:架构演进与重构(绞杀者模式落地)
解决的问题:老系统不敢动、又不得不动。传统做法是大版本重写,风险极高;用绞杀者模式按领域逐条迁出,每个域独立灰度,出问题可回滚,业务几乎无感知。
具体案例:某金融平台把用户中心作为第一个拆分试点,拆出后独立发布、独立扩缩,验证收益后逐步拆出订单域、支付域,一年内完成 60% 流量的服务化迁移。
Python 演示
import random # 模拟"本地消息表 + 定时重试"方案:# 业务操作与消息在同一个本地事务中写入, 发送失败后持续重试,# 配合消费端幂等, 最终实现跨服务的最终一致性。class Outbox: def __init__(self, success_rate): self.rows = [] self.success_rate = success_rate self.delivered = set() def add(self, msg_id, business): # 业务与消息同事务写入本地消息表 self.rows.append({"id": msg_id, "biz": business, "status": "pending"}) def deliver_once(self): # 一轮发送尝试: 网络失败的消息留在表里等下一轮 for row in self.rows: if row["status"] == "pending" and random.random() < self.success_rate: row["status"] = "delivered" self.delivered.add(row["id"]) def done(self): return len(self.delivered) == len(self.rows) def simulate(order_count, success_rate): outbox = Outbox(success_rate) for order_id in range(order_count): outbox.add(order_id, "deduct_stock_and_pay") rounds = 0 while not outbox.done() and rounds < 10000: outbox.deliver_once() rounds += 1 return rounds, len(outbox.delivered) if __name__ == "__main__": print("单次成功率\t重试轮数\t最终投递数") for rate in (0.3, 0.6, 0.9): rounds, delivered = simulate(50, rate) print(f"{rate:.0%}\t\t{rounds}\t\t{delivered} / 50")
outbox.add(order_id, "deduct_stock_and_pay")模拟订单创建时,在同一“事务”里同时写入业务数据和待发消息,这是本地消息表的精髓。def deliver_once(self)每一轮扫描消息表做发送尝试,失败的消息状态不变,留在表里等待下一轮。while not outbox.done() and rounds < 10000无限重试直到全部投递完成,用轮数上限做安全阀,体现最终一致性。for rate in (0.3, 0.6, 0.9)对比不同单次成功率,你会发现即使只有 30% 成功率,重试几十轮后 50 条消息依然全部送达。
常见误区
误区微服务嘛,当然拆得越细越灵活。最好一个功能一个服务,甚至一个类一个服务,这样每个服务都超小、超独立。
正解服务越多,网络延迟、数据一致性、监控联调的成本呈平方级增长,整体可用性反而下降。拆分的粒度应该跟随业务边界和团队规模:一个服务由一个团队长期负责,团队能舒适地维护它,就是合适的粒度。小餐馆拆出十个档口,客人没多,传菜员倒是先忙不过来。
为何常见:看到微服务成功案例,误把“细”当“好”,忽略了成功案例背后的组织规模和组件成本。
误区业务跨了服务,就必须用两阶段提交(2PC)这类分布式事务来保证强一致,否则数据就错了。
正解2PC 加锁时间长、协调者单点风险高,可用性极低,基本只适合强一致要求极高的极少数场景。绝大多数业务用最终一致性:本地消息表 + 消息队列 + 幂等消费 + SAGA 补偿,短时间数据不一致可接受,最终一定收敛。就像各档口账本不用实时同步,每晚对一次总账就行。
为何常见:习惯了单机数据库的本地事务思维,不理解分布式环境下无法全局加锁,把“正确性”等同于“强一致”。
误区REST + JSON 最通用、最简单,内部服务之间也全用 REST,没必要引 RPC 那套复杂的东西。
正解REST 适合对外 API 和跨团队网关,可读性好;但内部高频低延迟调用用 gRPC 这类二进制 RPC 更合适——传输体积小一个量级、长连接免去反复握手。长调用链和削峰场景还要改异步消息,固定一种协议打天下是性能浪费。
为何常见:只对比了协议的可读性和上手难度,没评估序列化体积、连接复用与延迟差异在万亿次调用下的放大效应。
误区消息队列保证可靠投递,所以生产端把消息往 MQ 里一丢就完事,剩下的交给消费者。
正解MQ 的“可靠”不等于“不丢不重”,它通常只保证“至少一次投递”——可能重复、可能延迟。生产端要用本地消息表 + 定时补偿防止消息丢失,消费端必须做幂等防止重复处理,否则一个订单被重复扣两次库存。厨房里广播铃响了不代表菜就做好了,还要有人确认这道菜确实送到了客人桌上。
为何常见:把消息队列想象成“不会出错的数据库事务”,忽略了网络分区和消费者崩溃的真实场景。
误区反正要拆,干脆花几个月把单体一次性重写成微服务,一步到位最干净。
正解大爆炸重构风险极高——重构期间业务停滞、新旧系统难以对齐、出现问题无法回滚。更稳的是渐进式拆分(绞杀者模式):先搭新骨架,再按领域一条条把业务从单体里迁出,每迁一条就灰度验证、监控对比,出问题立即回滚。聪明的老板不会一夜之间拆掉厨房,而是先开一个甜品档口试水。
为何常见:想快速见效,低估了拆分期间双轨运行、数据迁移、接口对齐的复杂度和长期成本。
面试题
Q1. 单体架构最典型的问题是什么?
- A.容器镜像体积偏大
- B.模块紧密耦合相互影响← 正确答案
- C.数据库单表数据量大
- D.前端页面加载速度偏慢
答案:B。单体最核心的痛点是模块间紧密耦合,改一处要全量回归、发布互相牵制。就像小餐馆一道菜改动要重新排练整个后厨。镜像大、数据量大、加载慢都只是表象,不是本质。
Q2. 康威定律的核心观点是什么?
- A.架构由组织沟通结构塑造← 正确答案
- B.团队越大交付效率越高
- C.架构必须由资深工程师拍板
- D.数据库设计决定系统边界
答案:A。康威定律说:系统的架构会最终长得像设计它的组织的沟通结构。餐馆老板把川菜粤菜分给两个团队,后厨自然长出两个档口。所以拆分维度要先想清楚团队怎么组织,而不是反过来。
Q3. 订单服务和支付服务需要最终一致性,最推荐的做法是什么?
- A.两阶段提交保证强一致性
- B.同步调用并设置超时重试
- C.本地消息表加异步消息加幂等← 正确答案
- D.订单支付共享同一个数据库
答案:C。跨服务强一致用 2PC 会拖垮可用性;同步超时重试会造成重复扣款;共享数据库等于把拆开的服务又缝回去。本地消息表 + 异步消息 + 消费幂等,是兼顾可用性和一致性的标准答案,相当于各档口先记账后对账。
Q4. 服务注册与发现的核心作用是什么?
- A.动态感知服务实例的地址← 正确答案
- B.缓存热点数据以提升性能
- C.对请求报文进行压缩处理
- D.统一收集并归档应用日志
答案:A。服务实例的 IP 和端口是动态变化的,注册中心让服务之间能动态感知彼此地址,实例挂了自动摘除。就像新档口开张在公告栏挂招牌,客人照着招牌就能找到位置。
Q5. 为什么服务数量增加后,整体可用性会下降?
- A.服务数量增加带宽消耗变大
- B.存储与网络的成本随之上升
- C.可用性是各服务可用性的乘积← 正确答案
- D.容器镜像数量增多难以管理
答案:C。一次请求经过 n 个服务,所有服务都可用请求才成功,整体可用性是连乘关系。每个服务 99.9%,10 个连乘后就只有 99%。所以服务越多,越需要监控、容灾和快速恢复来对冲风险。
Q6. 本地消息表方案中,业务操作与消息写入必须满足什么条件?
- A.在同一个本地事务中原子提交← 正确答案
- B.先写业务库再异步同步消息
- C.依赖消息队列回执确认投递
- D.借助分布式锁保证消息顺序
答案:A。本地消息表的核心是“要么业务和消息一起成功,要么一起失败”,这要求二者在同一个本地事务里原子提交,绝不能先写业务再发消息——否则业务成功而消息丢失,就出现数据不一致。
Q7. 同步调用与异步消息的主要权衡是什么?
- A.同步调用链路越长延迟越高
- B.异步消息吞吐高且实时性强
- C.同步模式下不存在任何故障风险
- D.异步解耦但一致性滞后难排查← 正确答案
答案:D。同步调用实时性强,但延迟逐跳叠加;异步消息解耦、吞吐高,但消息落地有延迟,结果滞后且排查链路难。用哪类要看业务场景,长链路、削峰场景优先异步,查询类场景保持同步。
Q8. 以下哪些属于“按领域拆分”时的业务边界?
- A.商品域与订单域之间的边界← 正确答案
- B.页面展示层与接口层的边界
- C.支付域与库存域之间的边界← 正确答案
- D.数据库读写分离的存储边界
答案:AC。商品域、订单域、支付域、库存域都是业务领域,它们之间的边界是领域边界(限界上下文)。页面层与接口层是技术分层,读写分离是存储优化,都不属于按领域的业务边界。
Q9. 服务拆分后,运维需要哪些基础设施支撑?
- A.注册中心实现服务发现← 正确答案
- B.配置中心统一下发配置← 正确答案
- C.链路追踪串联调用日志← 正确答案
- D.物理服务器硬件的采购
答案:ABC。拆分后服务实例动态变化需要注册中心;配置频繁调整需要配置中心热下发;一次请求跨多服务需要链路追踪定位问题。这三件套是微服务运维的地基,而物理硬件采购不属于软件基础设施。
Q10. 关于 SAGA 补偿事务,下列说法正确的是?
- A.所有局部事务同步一次性提交
- B.每个局部事务配有补偿事务← 正确答案
- C.失败后按逆序执行补偿操作← 正确答案
- D.补偿操作本身必须具备幂等← 正确答案
答案:BCD。SAGA 把长事务拆成多个局部事务,每个局部事务都配一个补偿操作;失败时按逆序(反向)逐个执行补偿来撤销已生效的操作,且补偿操作必须幂等,防止重试造成二次影响。A 是 2PC 的思路,不是 SAGA。