系统是被打出来的 · 系统设计实战手册
PART 4 · 高可用与多活 · 第 13 章

微服务拆分
以及拆分的代价

单体里,复杂度藏在代码、数据库和发布队列;拆开后,它沿网络、超时、版本与团队边界重新出现。你需要的不是更多服务名,而是一份能证明边界值得存在的账单。

13 · 1 值班现场第十四天,故障群还没有结论

创业第 4 年 8 个月,「秒抢」累计用户约 2000 万,公司 260 人,研发 60 人,其中 30 人的日常改动集中在订单单体主仓。拆分前,发布队列最长排三天;拆成十多个服务半年后,一次「已付款却迟迟不出票」的跨服务故障已经查了两周。

Incident · SEV-1 · 已持续 13d 22h 18m
[CRITICAL] paid-but-not-issued 0.72%(基线 0.03%)
[WARNING]  checkout end-to-end P99 4.8s(基线 820ms)
[INFO]     单服务错误率最高 0.31%,均低于各自 1% 告警阈值
──────────────────────────────────────
故障群第 4,281 条消息:
「订单、支付、库存、出票各自看都正常;请问到底谁在丢请求?」

你把十段调用链摊开。没有哪个服务彻底宕机,每个团队都能拿出自己那段成功率;但用户要的是整条链路完成,不是十份局部健康证明。

同步调用深度
10 hops
● 支付到出票主链
完整 trace 覆盖率
12%
▲ 88% 断在跨队列处
超时配置种类
7 套
● 300ms–5s
最近两周发布
43 次
涉及 9 个服务

抽样 trace 终于露出一条细缝:订单服务调用库存预占平均 90ms,P99 却偶尔超过 1.2s;订单超时后返回失败,库存其实已经成功。补偿任务随后释放库存,而一条迟到的支付成功消息又继续推进出票。每个局部动作都能解释,组合起来却把一个订单撕成两条时间线。

老陈把半年前的发布报表放到旁边:单体时代,30 人改同一个仓库,冲突、全量回归和发布窗口让小改动排三天。拆分确实解开了发布队列,却没有让业务复杂度消失;它把原来编译器和事务能看见的关系,搬到了网络和异步消息里。

A是对的,理由只有一条:把局部证词串成一次用户旅程。用订单业务键连接 HTTP、消息和补偿,才能看见「库存已成功—订单超时—补偿释放—支付迟到」这条跨边界时序。先获取端到端证据,再决定谁修。

B会重复过去两周。局部错误率最高不等于根因;本次每个服务都在阈值内,故障恰好藏在它们之间。让团队继续各看各的大盘,只会得到更多无法拼接的正常截图。

C可能把故障拖得更长。统一长超时会占住线程与连接,把快速失败变成排队;而库存已成功、上游未知的问题并不会因多等几秒自动获得幂等语义。超时必须从端到端预算向下分配。

D方向有价值,但证据仍不完整。冻结发布能缩小变量集,适合作为并行动作;它无法重建已经断掉的调用链,也不能解释历史受影响订单。先补关联标识和关键采样。

13 · 2 原理每多一个边界,就多一份网络账单

「拆分不降低复杂度,它把复杂度从代码里搬到了网络上。」单体里的函数调用失败,通常会抛异常并共享同一事务上下文;跨服务调用则多出超时、重试、部分成功、版本兼容、序列化、认证和可观测性。复杂度的总量未必减少,只是归属改变了。

调用链整体可用率 = ∏(各服务可用率)

假设单个服务可用率都是 99.9%,一条用户请求必须串联 10 个服务,并且其中任意一个失败都会让整条请求失败:

单服务可用率
99.9%
年不可用约 8.8h
串联服务数
10
均为必经路径
整体可用率
约 99.0%
0.999¹⁰ ≈ 0.990
年不可用量级
约 88h
约为单服务 10 倍

精确值 0.999^10 ≈ 0.99004,也就是约 99.0%。一年约 8,760 小时,单服务 0.1% 不可用对应约 8.76 小时;整链约 1% 不可用,对应约 87.6 小时。这个估算假设故障相互独立、十个服务都是强依赖;共享基础设施会让故障相关,降级和缓存则可能让部分依赖变成可选,因此真实值要用端到端 SLI 验证。

公式的价值不是劝你拒绝拆分,而是提醒你:每个新同步依赖都在消耗整条链路的可用性预算。如果推荐服务失败不该阻止下单,就不要把它放进强依赖乘积;超时后返回空推荐,整体路径仍可用。

拆分前后的复杂度搬家清单

单体里的问题 拆分后的形态 需要的新护栏 漏交账单的后果
函数调用与共享类型 网络调用与接口版本 超时预算、契约测试、向后兼容 调用方升级节奏不一致,运行时才暴露破坏
本地事务 跨服务部分成功 幂等、状态机、补偿与对账 订单失败但库存成功,业务状态分叉
进程内日志和调试器 跨主机、跨队列调用链 统一 trace id、结构化日志、端到端 SLI 每个服务都正常,用户路径仍失败
一次部署与回滚 多版本并存、独立发布 兼容窗口、灰度、服务目录 旧调用方遇到新响应,回滚顺序互相锁死
一个团队内协调 跨团队依赖与排期 边界归属权、SLO、升级协议 组织问题伪装成接口问题
服务边界不是文件夹边界。它是一次关于数据归属权、发布节奏、故障隔离和团队责任的长期承诺;如果这四件事没有一起变化,拆分很可能只是把函数调用换成 HTTP。

拆分边界要同时满足四个问题

第一,业务能力是否内聚。订单服务应拥有订单生命周期,而不是把「创建订单」「查询订单」「取消订单」按技术动作拆成三个互相聊天的服务。高频一起变化的规则应该留在同一边界内。

第二,数据归属权是否清楚。一个服务写自己的数据,其他服务通过契约读取或订阅事件;若三个服务都直连同一组表,发布虽然分开了,数据耦合仍留在地下。

第三,故障能否被隔离。拆出推荐服务的价值,是推荐挂了仍能下单;如果订单同步等待推荐成功,边界没有购买可用性,反而多乘了一个失败概率。

第四,团队是否能独立交付。康威定律提醒你,系统接口会映照组织沟通结构。一个 3 人团队维护 12 个服务,不会因为仓库变多就获得 12 份自治;一个边界若每次发布仍要六个团队开会,它在组织上尚未独立。

可以用变更记录验证边界:过去三个月哪些文件总是一起改、哪些表总在同一事务里、哪些故障需要同一批人处理。如果两个候选服务 80% 的发布都必须同步,它们也许仍是一个业务能力;先在单体内模块化,等待边界稳定,往往比立刻上网络更便宜。

先过拆分门槛,再申请网络账单

「代码很多」不是充分理由。拆分提案应该给出拆前基线和拆后可验收目标,否则半年后只能用服务数量证明工作发生过。至少从五个维度交账:

门槛 拆前证据 拆后目标 不满足时的替代动作
独立变更 模块与主仓发布耦合率、排队时间 多数发布不要求其他模块同步 先做模块依赖检查与增量测试
独立扩容 该模块资源曲线明显不同 热点只扩目标服务,成本可下降 先在单体内隔离线程池和缓存
故障隔离 模块故障经共享资源拖垮主链 目标服务失败时主流程可降级 先加舱壁、超时和功能开关
数据归属 写表与事务边界可以明确圈定 其他服务不再绕过契约直写 先收敛 DAO 与写入口
团队归属 有长期团队承担需求与值班 发布、SLO、成本由同一团队闭环 保持模块,不创建无人服务

以通知模块为例:它占主仓发布的 18%,峰值资源与订单不一致,失败时可以降级为延迟发送,有独立团队维护,也不需要参与订单事务——五项证据支持拆出。相反,「订单创建」与「订单状态推进」过去三个月 86% 一起修改、共享同一状态表和同一值班人,强行拆开只会把一次事务改成跨服务补偿。

渐进迁移比一次切断更可验证

阶段 动作 放行门槛 回滚点
1 · 模块化 在单体内建立 API、依赖方向和表写入边界 架构测试抓住越界调用 仍是同进程,无远程回滚
2 · 影子验证 新服务读取复制流量,结果只比较不生效 关键字段差异率低于约定阈值 停止影子流量,不影响用户
3 · 小流量切读 按租户或业务键让 1% 查询走新服务 P99、错误率、结果一致性均达标 路由开关切回单体
4 · 单写迁移 写入口只保留一处,事件同步给旧读模型 对账持续闭合,补偿可演练 在明确停写窗口恢复旧入口
5 · 扩大与退役 逐级放量,确认无旧调用方后移除旧代码 服务目录和依赖扫描无遗留边 每一级保留有限时间回切能力

最危险的捷径是长期双写:单体与新服务都修改同一业务事实,看似方便回滚,实际制造了两个持有者。更安全的迁移始终保留一个写入裁决点,通过事件构建另一侧读模型;需要回切时先停写、对账,再迁移裁决权。

端到端超时预算不能层层照抄

假设用户请求预算 2 秒,入口调用 5 层,每层都配置 2 秒超时,最坏路径不会神奇地保持 2 秒:上游可能在下游等待耗尽后继续重试,线程和连接被层层占住。更合理的方式是从端到端预算倒推:预留网络与响应开销,越靠下游的超时越短,并确保重试仍落在剩余预算内。

例如 2 秒预算中,网关留 200ms 返回,订单编排预算 1.8s;库存 400ms、支付 800ms、出票异步受理 300ms,其余留给抖动。支付已耗 750ms 后,订单服务不应再发起一次完整 800ms 重试。预算是一张逐层递减的支票,不是每层重新领一张。

第 7 章的 No.09 级联故障会在微服务里获得更多传播边;第 14 章要解决的,就是怎样用 metrics、traces 和 logs 把这些边重新画回一张可诊断的图。

13 · 3 病历卡拆分没有新病,却让旧病有了更多路径

本章不新增病历卡。最重要的是回收一张已经见过的卡,再预告一张只收录在附录里的隐蔽病:一个负责传播,一个负责伪装健康。

病历卡 No.09级联故障███▇▅▃▁ 微服务让调用边增多,一个下游的延迟会沿超时、重试和资源占用向上游传播;局部降级与舱壁必须跟着边界一起设计。第 7 章定义 →
病历卡 No.15灰色故障▇▇▇▇ 实例探活正常、平均指标正常,只有特定请求、分区或调用组合失败;服务越多,部分坏而整体不红的机会越多。附录病历卡图鉴 →

两张卡叠在一起,就是这次两周排障:库存调用偶发长尾,没有触发单服务阈值;订单超时和补偿却把局部灰色故障传播成业务级联。诊断不能只找一块红色面板,要沿用户路径寻找哪一条边先改变了形状。

13 · 5 权衡拆,还是不拆

发布排队三天是真成本,跨服务排障两周也是真成本。架构选择不是「先进」与「落后」,而是决定复杂度由谁、在哪一层支付。每一个都是对的,每一个都有账单。

决策点 D2

订单单体还要继续拆吗?

A · 继续拆:团队可按业务能力独立发布与扩容,热点和故障有机会隔离。账单:网络失败、跨服务一致性、可观测性与平台治理成本全面上升;需要与服务数量匹配的团队归属。适合边界稳定、发布冲突高且平台能力成熟的组织。

B · 暂停拆分:本地事务、调试器和一次回滚仍然便宜,可以集中治理测试与发布。账单:30 人继续共享仓库和数据库,发布协调会随团队增长变重,热点模块也难独立扩容。适合团队较小、边界仍频繁变化或分布式治理尚未就绪的阶段。

C · 模块化单体:先用代码依赖、表归属权和内部契约验证边界,保留进程内调用;拆出时迁移路径更清楚。账单:需要纪律和架构测试,不能只建几个目录就宣布模块化;独立扩容与故障隔离要等真正拆出。适合边界需要验证、又已出现协作摩擦的团队。

「秒抢」的选择:采用 C,停止按名词继续切服务。订单核心状态机先回收成明确模块,推荐、通知等可降级能力保持独立;以三个月变更耦合和故障数据证明边界后,再拆出具备独立团队与 SLO 的模块。康威定律的账单也写进方案:没有长期 owner 的服务不再新增。

13 · 6 复盘两周之后,问题终于有了完整时间线

正确复盘不是找一个团队背锅,而是重建复杂度如何跨过边界:

周三 16:40

补齐业务关联。按订单键连接 HTTP、库存事件、支付回调和补偿记录,完整 trace 覆盖从 12% 提升到 86%,首次看见两条分叉时间线。

周三 18:10

止住部分成功。订单超时后先按幂等键查询库存预占结果,不再立即判失败;补偿释放增加版本条件,paid-but-not-issued 从 0.72% 降到 0.09%。

周四 11:00

重写超时预算。从 2 秒端到端 SLO 向下分配各段预算,取消中间层重复重试;库存 P99 长尾仍可见,但不再占满上游线程。

周五 15:30

收敛边界。冻结两个高耦合服务的继续拆分,把状态机归属权交给一个订单域团队;发布清单加入契约兼容、trace 覆盖和降级演练。

壹
局部健康不能证明用户路径健康。服务指标必须向端到端 SLI 汇总;否则十张绿面板可以围着一个失败用户。
贰
拆分前先写数据与故障边界。谁写哪张表、谁能独立降级、谁承担补偿,比先决定仓库名和通信协议更重要。
叁
服务数量必须匹配组织能力。没有 owner、SLO、契约测试和可观测性的服务,只是把发布冲突换成运行时冲突。

13 · 7 自测你拆的是边界,还是文件夹

Q1 · 看形状诊断

调用链各服务探活正常、平均错误率平稳,只有特定分区请求持续失败,整体面板呈 ▇▇▇▇。最值得优先怀疑什么?

B。「部分坏 + 探活正常 + 特定维度失败」是灰色故障。A 是最诱人的错误:探活只覆盖一个探针路径,不能代表每个用户分区;C 与 D 都有明显的时间形状,不会保持这种局部、持续的伪健康。

Q2 · 可用性算术

10 个强依赖服务串联,每个可用率 99.9%,故障相互独立。整体可用率最接近哪个值?

B。0.999^10 ≈ 0.99004,约 99.0%,年不可用量级约 88 小时。A 忽略了十个必经依赖的乘积;C 把 0.1% 错当成每个服务损失 1 个百分点后直接相减。

Q3 · 边界判断

两个候选服务 80% 的发布都要同步、共享同一批表,并由同一个 4 人团队维护。下一步更稳妥的是?

B。高变更耦合、共享数据和同一团队说明业务边界尚未独立。A 与 D 会增加网络和发布对象,却不改变协作事实;C 只换通信工具,也没有解决归属权。先在进程内证明边界更便宜。

Q4 · 超时预算

端到端请求预算 2 秒,五层调用各自都配置 2 秒超时并允许重试。主要风险是什么?

B。端到端预算应向下递减分配,重试还要消耗剩余时间。A 把每层超时当成同一张支票,实际却可能串行等待;C 忽略资源占用和用户截止时间,长超时会把故障传播得更远。

🎯 面试映射 · 本章直接覆盖的高频追问

「单体和微服务怎么选?」 答题骨架:团队规模与发布冲突 → 业务边界稳定度 → 独立扩容/故障隔离收益 → 网络与平台成本 → 端到端 SLO → 给出模块化单体作为可逆中间态。
「微服务拆分边界怎么定?」 答题骨架:业务能力内聚 → 数据单一归属 → 独立变更频率 → 故障可降级 → 长期团队 owner → 用历史共变更和事故数据验证,而不是按表或技术层切。
「什么时候不该拆?」 答题骨架:团队小、边界频繁变化、强事务密集、平台能力不足、端到端观测缺失时先不拆。反直觉加分点:部署单元少不等于代码无边界,模块化单体也可以有严格归属约束。