目录
- 一、先说结论
- 二、边界怎么定:三个判据
- 三、什么时候才该拆
- 四、六种典型的拆错方式
- 五、第一步永远是模块化单体
- 六、第二步:怎么把模块摘出去
- 七、服务之间怎么通信、数据怎么共享
- 八、服务治理:拆完之后怎么管
- 九、当边界选错了
- 十、自检清单
一、先说结论
微服务拆分最容易踩的坑,是把「拆服务」当成「把代码拆小」。
真正要拆的是变化边界和数据所有权,代码只是这两样东西的副产品。拆错了不会让系统变快,只会把一个单体变成分布式单体(distributed monolith)——比原来更慢、更难查、更难改。
分布式单体是什么样?服务确实拆成了十几个,但:
- 部署时必须一起发,任何一个单独上线都会挂
- 数据库是同一个,或者虽然分了库但仍然互相直接查表
- 一次用户请求要串行穿过五个服务,任何一环抖动都放大到全链路
- 出了问题不知道去哪个服务的日志里找
这时候你付出了全部的分布式代价(网络、序列化、最终一致性、运维复杂度),却没有拿到任何一项收益(独立部署、独立伸缩、故障隔离)。这是最差的一种架构,比单体差得多。
真正的分叉点在于边界验证的时机和成本:
graph TD
S["同一个业务系统"] --> P1["先划逻辑边界<br/>模块化单体"]
S --> P2["直接划物理边界<br/>一步拆成微服务"]
P1 --> V1["边界错了<br/>修正成本:几天到几周"]
V1 --> R1["摘出真正需要的那 10%–30%"]
V1 --> R2["其余继续留在单体<br/>享受单事务的便利"]
P2 --> V2["边界错了<br/>修正成本:数月 + 数据迁移"]
V2 --> R3["分布式单体<br/>付全部代价,零收益"]
style R1 fill:#e6ffe6
style R2 fill:#e6ffe6
style R3 fill:#ffe6e6
下面讲怎么避免它。
二、边界怎么定:三个判据
按可靠性排序,三个判据从实到虚。第一个量化、可验证;第三个是硬约束,不满足就一票否决。
2.1 变更耦合度(最实,可量化)
翻 git 历史。哪些文件总是被同一个 commit 或 PR 一起改动?这些文件应该在同一个服务里。
哪些文件从来不同时改?那里才是天然的边界。
# 看看过去半年里,哪些文件总是成对出现在同一个 commit 中
git log --since="6 months ago" --name-only --pretty=format:'---%H' \
| awk '/^---/{if(f!="")print f; f=""; next} {f=f" "$0} END{print f}' \
| tr -s ' ' '\n' | sort | uniq -c | sort -rn | head -40
这个判据比任何名词分析都准,因为它反映的是真实的业务变化节奏,而不是你脑子里的架构图。两个模块如果每周都要一起改,那它们就是一个模块——不管你给它起了什么名字。
经验数字:如果两个模块的改动重合度超过 30%(同一个 PR 里频繁同时出现),它们大概率不该分家。
2.2 领域语言的分歧点
同一个词在不同场景下含义不同,这个分歧就是边界信号。
以「订单」为例:
| 场景 | 「订单」实际指什么 | 关键属性 |
|---|---|---|
| 交易 | 一笔待支付的意图 | 商品、金额、支付状态 |
| 履约 | 一组要拣货、打包、发货的包裹 | 仓库、物流单号、签收状态 |
| 财务 | 一条应收记录 | 开票信息、账期、对账状态 |
三种「订单」就该是三个不同的东西。如果硬塞进一张 orders 表,你会得到一张有 80 个字段、一半字段永远是 NULL、每次加需求都要改表结构的怪物表。这是单体的经典病症,而拆分正是它的解药。
实践上,这个判据来自 DDD 的限界上下文(Bounded Context)。但注意:不要为了套 DDD 而套。你没有真实的领域专家、没有深入的业务访谈时,硬画出来的上下文地图往往是自欺欺人。这时候回去用判据一。
2.3 数据所有权(硬约束)
每张表(或每个聚合根)有且只有一个服务可以写。
这是不可谈判的硬约束。跨服务的写事务是分布式系统里几乎所有痛苦的源头:事务、锁、时序、重试、幂等,全都是从这里长出来的。
所以正确的顺序是:先看数据,再定边界。
如果两个候选服务需要同时写同一张表,那它们就不是两个服务,无论业务名词上看起来多么独立。硬拆的结果是引入一个分布式事务或一套补偿逻辑,去解决一个本来不存在的问题。
一个快速验证法:把候选边界画出来,看每条写路径。如果任何一条写路径需要跨边界写两张表,这条边界就是错的,或者你还缺一个「聚合服务」。
三、什么时候才该拆
拆分是组织能力的函数,不是技术架构的函数。
这是全篇最重要的一句话。微服务的收益全部来自独立性,而独立性只有在有对应的组织能力时才能变现:
- 没有独立部署流水线,独立部署无从谈起
- 没有服务治理(注册发现、熔断、限流、重试、超时),独立性带来的是级联故障
- 没有可观测性(链路追踪、指标、结构化日志),十几个服务的问题定位成本远超单体
- 没有契约测试,服务之间的接口改动会互相炸
3.1 五条收益,以及它们各自的前置条件
先把收益列全 —— 这五条是拆分的全部正当理由:
| # | 收益 | 前置条件(没有它,收益变成反效果) |
|---|---|---|
| 1 | 独立部署:改一个服务不用重发整个系统 | 独立流水线 + 能回滚 |
| 2 | 技术异构:不同服务用不同语言 / 框架 / 数据库 | 团队真的维护得动第二套技术栈 |
| 3 | 按需扩容:只给压力大的服务加机器 | 负载真的不均衡,且有自动扩缩容 |
| 4 | 故障隔离:一个服务挂了不拖垮全部 | 超时 + 重试 + 熔断 + 降级全部到位 |
| 5 | 团队解耦:小团队各自负责一个服务 | 真的存在多个团队 |
这张表的读法是:左列是大家都会答的,右列才是决定成败的。
逐条说三个最容易想当然的:
第 4 条最反直觉。 “服务拆开就隔离了”是错的。拆开只是把故障面从一个点变成一张网 —— 原来一次函数调用失败就是失败,现在多了一个会挂的网络跳,还多了一个可能被占满的线程池。有治理能力叫隔离,没有就叫放大。
第 3 条最常见的误用是”能独立扩容”和”需要独立扩容”混为一谈。第九节的 Prime Video 案例就是反例:服务拆得很规范,但负载平稳、根本不需要伸缩,于是只剩成本。
第 2 条实践触发得比大家以为的少得多。 多一套语言就多一套构建、依赖、调试、监控、招人和留人的成本。它真正成立只在一种场景:某个环节有非用不可的技术(ML 推理必须 Python 生态、某个计算密集环节必须 Rust/C++)。其余情况下,“技术异构”往往是”想在简历上写微服务”的修辞。
第 5 条最容易自我欺骗。 “小团队各自负责一个服务、边界清晰”是结果,不是原因。康威定律说的是反方向:系统架构会映射组织的沟通结构。组织结构没变(还是一个团队维护所有服务),拆出来的边界不会清晰,只会变成”一个团队维护一堆服务”。
3.2 什么时候真的该拆
出现下面任一情况,才该考虑物理拆分 —— 注意它们和第 3.1 节的收益是一一对应的:
- 独立的伸缩需求。图片处理要 10 倍算力,订单不需要。把它们放一起,就只能整体扩容,为 5% 的流量付 100% 的机器钱。
- 独立的发布节奏。某块代码每天都在改,另一块遵守季度合规窗口。被拖着没法上线,是真实的成本。
- 故障隔离需求。推荐服务挂了不能把支付带崩。注意这条的前置条件是服务治理已经到位,否则隔离只是想象。
- 团队规模超载。一个团队(经典说法是 2 pizza,约 5 到 8 人)已经维护不动整个代码库,沟通成本开始主导。这时候拆分的目的是让多个团队能并行工作,而不是让系统跑得更快。
- 确实需要不同的技术栈。这一条实际触发得比大家以为的少得多。
3.3 什么时候不该拆
反过来,下面这些情况不该拆:
- 团队小(十几个人以内),大家本来就坐在一起
- 领域还没想清楚,业务还在高速试错,边界每周都在变
- 没有自动化运维能力,发个版还要人工登服务器
- 唯一动机是「简历上想写微服务」,或者「别的公司都这么干」
Martin Fowler 有一句被引用很多次的话:微服务是 “you must be this tall to ride this ride” 的架构——有身高限制的项目。CI/CD、可观测性、服务治理没到位,微服务只会把问题放大。
3.4 一个最常见的错误动机:数据库扛不住了
这个动机太常见了,值得单独拎出来 —— 因为它几乎总是判断错了方向。
「数据库扛不住了」和「微服务拆分」解决的是两个完全不同的问题:
| 解决什么 | 拆分依据 | |
|---|---|---|
| 微服务拆分 | 团队与变化的耦合 | 业务边界 |
| 分库分表 | 单表数据量 / 写入量 | 数据量、访问模式 |
你可以需要其中一个而不需要另一个:
- 一个单体应用,数据涨到 10 亿行 —— 该做的是分库分表(或换分布式数据库),而不是拆微服务。拆了服务,单表还是 10 亿行。
- 一个业务边界清晰但数据量很小的系统 —— 拆微服务完全不需要分表。
关键点:拆服务并不解决数据量问题,它只是把一个大库的问题变成若干个小库的问题 —— 而你可能只是需要索引。
数据库扛不住时,正确的处理顺序是:
加索引 / 优化慢查询 → 读写分离 → 加缓存 → 归档冷数据
→ 分库分表 → 最后才问:是不是该拆服务
先穷尽前面的手段,再考虑分表,最后才考虑拆服务。 前面几步的成本比拆服务低一两个数量级,而且它们解决的是真问题。
顺带说:这条也解释了为什么「性能」不该出现在 3.1 节的五条收益里 —— 拆分几乎不提升性能,它把一次进程内调用变成了网络调用。它提升的是独立性和可扩展性,这是两回事。
四、六种典型的拆错方式
4.1 按技术层拆
Controller 一个服务,Service 一个服务,DAO 一个服务。
这是纯灾难。一次请求要跨服务往返,网络开销乘以层数,而且这种拆分完全不产生任何独立性收益——你改一个业务逻辑还是要同时改三层,一个都不能少。
判据:你的边界应该沿业务能力竖切,不是沿技术层横切。
4.2 按实体拆(nano-service)
一张表一个服务。用户服务、地址服务、电话服务、邮箱服务……
结果是完成一个「下单」操作要调用 10 个服务。延迟叠加、失败率叠加,可用性直接相乘:每个服务 99.9% 可用,10 个串起来就是 99%。
一个可用的粗略标准:如果一个用户流程要串超过 5 个服务,大概率拆细了。
4.3 共享数据库
拆了服务没拆数据。所有服务连同一个库,查同一批表。
这等于什么都没拆,还额外多了网络开销和序列化成本。更糟的是,你偷偷获得了分布式的所有缺点,却保留着单体式的耦合——任何一次表结构变更都要所有服务同时上线。
4.4 共享「公共实体类」库
所有服务依赖同一个 common-model 包,里面是所有实体类。
看起来是代码复用,实际是把发布独立性直接归零:任何字段改动都要全体同时发布。这比共享数据库更隐蔽,因为它在编译期就存在,不会在运行时暴露。
替代方案:服务之间只共享契约(接口定义、事件 Schema),不共享实现模型。每个服务在边界处做自己的数据映射。多写一点转换代码,换回独立发布。
4.5 同步调用链过长
为了「解耦」,把原本一个进程内的函数调用改成 HTTP 调用,结果延迟从微秒变成毫秒,还引入了网络故障面。
判断标准很简单:如果两个模块永远在同一次请求里同步调用、永远一起发布、只有同一个团队维护,那它们之间那条网络边界只是在给你自己添堵。
4.6 为了拆而拆
最后这条最普遍:技术选型不是从需求推导出来的,而是从「听说微服务很先进」推导出来的。
五、第一步永远是模块化单体
这是本文最主要的一条操作建议。
模块化单体(Modular Monolith):同一个进程、同一个部署单元,但内部按上面的边界划分模块。三条铁律:
- 模块之间只通过明确的公开接口通信。模块内部的类对外不可见。Java 生态里你可以用包内可见性、或者 ArchUnit 这类工具在 CI 里强制校验依赖方向。
- 每个模块独占自己的数据表。即使物理上在同一个数据库实例里,也用独立的 schema,并且禁止跨模块直接 JOIN。
- 禁止跨模块的事务。如果 A 模块的操作必须和 B 模块的操作原子化,说明边界画错了,回去重画。
5.1 为什么这一步不能跳
这是最省钱的试错方式。 边界画错了怎么办?在模块化单体里,识别出错误到改完,大概是几天到几周的活:移动代码、调整接口、合并模块。而在已经拆成独立服务的系统里,同样一次边界修正意味着:数据迁移、跨服务的接口重设计、多个团队协调排期、灰度上线、回滚预案。这是一个数量级的差距。
换句话说:模块化单体让你用 1% 的成本,验证 100% 的边界假设。
5.2 关于 Java 生态的具体建议
Java 生态里已经不需要手搓这套机制了。Spring Modulith 就是为这个场景设计的:它在 Spring Boot 之上提供模块结构约定,能自动校验模块之间的依赖(禁止内部包的非法引用)、把模块内部事件落成事务性发件箱(Transactional Outbox)、并且生成模块关系图和文档。截至本文写作时,最新稳定版是 2.1.1(2026-08-25 发布)。
它最大的价值不是那些运行时特性,而是把「模块边界」从口头约定变成 CI 里会失败的测试。边界只要没被强制执行,几个月后一定会烂掉——这是人性,不是技术问题。
如果你用别的技术栈,等价的东西是:包可见性 + 依赖校验工具(ArchUnit、Import Linter、dependency-cruiser 等)+ 每个模块独立的数据库 schema 和迁移脚本。
5.3 很多组织应该停在这一步
而且这是对的。
模块化单体能拿到微服务大约 80% 的收益——清晰的边界、可独立开发、可独立理解、将来可拆——只付出大约 20% 的成本。真正需要物理拆分的,是那些有明确伸缩或故障隔离诉求的边界,通常只占总边界的 10% 到 30%。
先全都做成模块,然后只把真正需要的那几个摘出去。剩下的继续待在单体里,享受一个事务搞定的便利。
六、第二步:怎么把模块摘出去
当某个模块确实满足了第三节的触发条件,再动手。用绞杀者模式(Strangler Fig),一次摘一个。
6.1 摘之前,先确认三件事已经就位
- 契约测试。模块的公开接口有测试覆盖,摘出去之后能验证行为没变。没有这个,你不知道自己有没有改坏。
- 可观测性。链路追踪能穿过这条新边界,指标能区分「是新服务慢了还是调用方慢了」,日志能按 trace ID 串起来。
- 部署流水线。新服务能独立构建、独立发布、独立回滚。
这三件没到位就摘,等于在没有任何仪表的情况下拆飞机引擎。
6.2 摘取顺序
按这个顺序挑第一个:
边界最清晰 > 变更最频繁 > 依赖最少 > 业务价值最低
- 边界最清晰:模块接口明确、几乎没有「反向依赖」。
- 变更最频繁:先摘收益最高的,而且高频变更意味着你会很快获得反馈——摘错了能马上发现。
- 依赖最少:摘出去后需要改的调用点越少越好。
- 业务价值最低:第一个一定有坑,别拿核心支付链路练手。
6.3 数据怎么搬
这是整个过程中最容易出事的一环。不要停机搬迁。 标准做法是四步:
graph LR
A["双写<br/>新代码同时写旧表和新库"] --> B["回填<br/>把历史数据同步到新库"]
B --> C["读切换<br/>读走新库,写仍双写"]
C --> D["停双写<br/>确认无异常后移除旧路径"]
style A fill:#fff3e6
style D fill:#e6ffe6
每一步之间都要有足够的观察期,并且每一步都要能单独回滚。读切换那一步风险最高——所有数据不一致的问题都会在这一刻暴露出来。
回填工具的选择取决于数据量:小数据量直接用 SQL,大数据量用 CDC 工具(Debezium 之类)订阅 binlog 做持续同步,避免全量重扫。
6.4 不要重写
这是我最想强调的一条:用绞杀者模式摘模块,不要重写。
重写会同时丢掉两样东西:
- 沉淀的边界知识。现有代码里那些看起来莫名其妙的判断分支、特殊的空值处理、奇怪的超时值——它们大多是踩过坑留下的疤。重写时你会全部丢掉,然后在新系统里把它们重新踩一遍。
- 隐藏的依赖。你以为模块 A 只依赖 B,重写时才发现 A 还依赖了 C 的一个副作用、一个全局变量、一个隐式约定。
正确做法是同一份代码搬家:先让模块的物理位置搬过去,行为保持不变,验证通过之后再重构内部。迁移和重构是两件事,分两次做。
七、服务之间怎么通信、数据怎么共享
服务拆开了,接下来所有问题都收敛到一件事:它们之间怎么说话。
7.1 核心原则:每个服务独占自己的数据库
这是全局最重要的一条,其他所有设计都是它的推论。
不允许任何服务直接连别人的库。 不是”不推荐”,是不允许。理由很简单:一旦你直连了别人的库,那张表的结构就成了跨团队的公共 API,任何一次加字段都要协调所有消费者 —— 发布独立性当场归零。
数据库账号权限本身就是防线:让服务 A 的账号在数据库层面就没有服务 B 那张表的读权限。这样”就这一次”在物理上不可能发生,而不是靠 code review 拦住。
这条原则的推论是:跨服务不能有分布式事务。 你写不了别人的表,就不存在原子提交。后面 7.4 整节都在处理这个推论。
7.2 三种通信方式
| 方式 | 机制 | 适用 |
|---|---|---|
| 同步调用 | HTTP/REST、gRPC、Dubbo | 需要立刻拿到结果 |
| 异步消息 | Kafka、RabbitMQ | 通知状态变更、削峰、解耦 |
| 数据副本 | 订阅事件,在本地存一份 | 读多写少、容忍短暂延迟 |
同步调用最直观,但耦合最强:调用方要等,下游抖动会直接变成上游的延迟。它还有个容易忽略的性质 —— 可用性是相乘的。每个服务 99.9% 可用,串 10 个就是 99%,掉了一个数量级。
异步消息把时间解耦:A 发完就走,不关心谁消费、什么时候消费。代价是复杂度从”调用链”转移到了”消息语义” —— 顺序、重复、积压都是新问题。
数据副本是前两者的折中:B 需要 A 的数据时,订阅 A 的事件在本地维护一份,读的时候走本地,不走网络。它的成本随写增长,收益随读增长 —— 所以只适合读多写少的数据。而且副本必然是最终一致的,读到旧值是常态而不是故障。
7.3 通信方式怎么选
| 场景 | 选择 |
|---|---|
| 强实时、必须拿到结果 | 同步 RPC / HTTP |
| 不要求实时、能接受延迟 | 异步消息 |
| 读多写少、容忍短暂延迟 | 本地副本 + 事件同步 |
一句话概括:查数据用同步,通知状态变更用异步。
7.4 跨服务事务:Saga、TCC 与最终一致
既然不能有分布式事务,那”下单要同时扣库存”这种事怎么做?
用 Saga:把一个大事务拆成一串本地事务,每一步配一个补偿动作。
1. 订单服务:创建订单(本地事务) → 发 OrderCreated
2. 库存服务:扣减库存(本地事务) → 发 InventoryReserved
3. 支付服务:发起扣款(本地事务) → 发 PaymentCompleted
4. 订单服务:订单状态改为「已确认」
任何一步失败,就沿反方向发补偿事件:扣款失败 → 发 PaymentFailed → 库存服务把库存加回去 → 订单服务取消订单。
注意这是补偿,不是回滚。 中间状态对外是可见的(订单确实短暂存在过,库存确实短暂扣过),所以业务上必须能接受这种中间态,每一步的操作也必须幂等 —— 补偿可能执行多次。
TCC(Try-Confirm-Cancel) 是 Saga 的强化版:把每一步显式拆成「预留 / 确认 / 取消」三个阶段。Try 阶段先冻结资源(比如冻结额度而不是直接扣),Confirm 才真正生效。它比 Saga 更强的一致性和更短的中间态窗口,代价是实现复杂度高得多,只在资金类等强一致要求的场景才值得用。
要避免的两件事:分布式锁和两阶段提交(2PC)。前者会在网络分区时把整个系统卡住;后者在跨服务的场景下性能和可用性都很差,实际工程里基本不用。
7.5 从「独占库」到「能用 Saga」,中间少了一环
这是本节最容易被跳过、但生产事故最常出在这里的一步。
订单服务扣完库存要发事件通知别人,代码大概是这样:
@Transactional
void createOrder(Order order) {
orderRepo.save(order);
// 事务提交后才发消息……
}
// 或者更常见的写法
orderRepo.save(order); // 写自己的库
mq.send("OrderCreated", order); // 再发事件 ← 问题在这
最后那两行不在一个事务里。中间挂掉会怎样?
- 第一行成功、第二行失败 → 订单建了,但没人发货。数据不一致,而且没人知道。
- 反过来 → 消息发了但事务回滚 → 库存扣了,订单不存在。
这叫双写问题。它是拆微服务后最早暴露、也最难查的一类 bug,因为它只在故障窗口出现,平时完全正常。
解法是 Transactional Outbox(事务性发件箱):让业务写入和事件投递落在同一个本地事务里。
@Transactional
void createOrder(Order order) {
orderRepo.save(order);
outboxRepo.save(new OutboxEvent("OrderCreated", toJson(order))); // 同一事务
}
// 后台进程轮询,或 CDC 订阅 binlog,把 outbox 里的记录投递到 MQ
这样”数据写了但事件没发”在物理上不可能发生 —— 要么都成功,要么都失败。消费端再做幂等,就同时解决了不丢和不重。
为什么这一步不能省:Saga 解决的是”多步操作怎么补偿”,但它默认事件可靠地发出去了。事件能不能可靠发出,是另一个问题。跳过 Outbox 直接用 Saga,等于在地基没打好的地方盖楼。
Spring Modulith 内置了这个能力(这也是第五节点名它的原因之一),Spring Cloud 生态里也可以用 Debezium 之类的 CDC 工具订阅 binlog 实现。
7.6 幂等:上面所有方案的地基
7.5 结尾提到「消费端再做幂等」,这里展开 —— 因为它是 7.4 和 7.5 共同的实现前提,不懂它,Saga 和 Outbox 都只是名词。
先接受一个事实:消息的投递语义是 at-least-once(至少一次),不是 exactly-once。
「恰好一次」在分布式系统里做不到,原因很朴素:调用超时的时候,你无法区分”对方没收到”和”对方收到了但响应丢了”。这时候重试是唯一的选择,而重试就意味着可能重复。
所以:重复消费是常态,不是异常。幂等不是优化,是必需品。
三种常见实现:
| 手法 | 做法 | 适用 |
|---|---|---|
| 唯一索引 / 去重表 | 每个操作带一个业务唯一键,插去重表,靠数据库唯一约束拦重复 | 最通用,不依赖应用逻辑 |
| 状态机 | 只允许合法跃迁(已支付的订单不能再支付) | 有明确生命周期的实体 |
| 乐观锁(版本号) | 更新时带上版本号,版本不匹配就失败 | 并发更新同一条记录 |
去重表是最常用的一种,核心就一句 SQL:
-- event_id 上有唯一索引
INSERT INTO processed_events (event_id) VALUES (?);
-- 唯一键冲突 → 说明处理过了,直接返回成功
关于「exactly-once」的宣传:有些 MQ 会标称 exactly-once,实际含义通常是”在特定边界内(比如 Kafka 流处理对内部状态)做到恰好一次”。跨系统调用仍然是 at-least-once —— 一旦你要写外部数据库,还是得靠自己幂等。
7.7 读的模式:API 组合与 CQRS
上面讲的是写。读这边还有两种常用模式:
API 组合(扇出) —— 调用方一次调几个服务,在内存里拼出结果。最简单,不需要额外基础设施,但响应时间取决于最慢的那个下游。
CQRS 读模型 —— 订阅事件维护一个专门用于查询的物化视图(比如一张宽表)。适合聚合查询,典型场景是”我的订单列表要同时带商品名、物流状态、售后状态”—— 这种跨多个服务的数据,扇出调用会很难看,读模型一次查完。
顺带澄清一个常见混淆:CQRS ≠ Event Sourcing。 CQRS 只是读写模型分离,底层存储完全可以还是普通的关系库;Event Sourcing 是”把事件本身当作唯一事实来源、状态由事件重放得出”。后者是独立且重得多的决策,不要因为用 CQRS 就顺带上了它。
7.8 代价:诚实的那一面
| 代价 | 具体表现 |
|---|---|
| 没有原子提交 | 中间状态对外可见,补偿逻辑要自己写、要保证幂等 |
| 链路追踪变难 | 有问题要串十几个服务的 trace,MTTR 反而可能变长 |
| 运维成本翻倍 | 服务数 ×(部署、监控、告警、扩容、升级) |
| 组织成本 | 平台团队成为瓶颈;跨服务改动要协调 N 个团队排期 |
最后一条是第九节的重点 —— 大厂回退到单体的主因几乎从来不是性能,是组织成本。
八、服务治理:拆完之后怎么管
8.1 先说个前提
这套管理能力是拆分的前置条件,不是后续工作。
拆之前没有、拆之后补,中间那段就是分布式单体 —— 系统已经承担了分布式的全部复杂度,但你还没获得管理它的手段。Martin Fowler 那句 “you must be this tall to ride this ride”,说的就是这个身高线。
下面四层,从内到外责任范围逐层放大:
第四层 组织 谁负责、什么标准(SLO / 所有权 / 契约)
第三层 系统 出问题怎么查、怎么发版(可观测性 / 发布)
第二层 服务间 流量怎么走、坏了怎么办(网关 / 治理)
第一层 服务自身 我是谁、我在哪、我什么配置(注册发现 / 配置)
8.2 第一层:服务自身
| 能力 | 解决什么 | Java 生态 |
|---|---|---|
| 注册与发现 | 实例动态上下线,调用方怎么找到它 | Nacos、Consul |
| 配置中心 | 配置变更不用重新打包发版 | Nacos Config、Apollo |
| 健康检查 | 实例挂了怎么被摘掉 | Actuator + 心跳 / 主动探测 |
这层的价值是让实例变成可替换的。IP 是动态的、容量是弹性的 —— 这是后面所有管理能力的地基。
一个容易忽略的细节:注册中心的健康检查方式决定了故障发现的延迟。心跳模式(如 Eureka)最慢要几十秒才摘除实例,主动探测模式(Nacos 非临时实例)更快。几十秒乘以调用量,就是故障期间被浪费掉的请求数。
8.3 第二层:服务之间
这是”管理”含义最重的一层,因为它决定了故障会不会蔓延。
网关做统一入口:路由、鉴权、限流、灰度分流。它的价值是把横切关注点从业务服务里拿走,让每个服务只关心自己的业务。
流量治理是核心,五项缺一不可:
| 能力 | 作用 | 没有它会怎样 |
|---|---|---|
| 超时 | 给每个调用设上限 | 线程被慢调用占满,雪崩 |
| 重试 | 临时故障自愈 | —— 但配错会放大故障(见 8.6) |
| 熔断 | 下游持续失败时快速失败 | 一直重试一个已经挂了的服务 |
| 限流 | 保护自己不被压垮 | 突发流量直接打穿 |
| 降级 | 非核心功能可关闭 | 推荐服务挂了导致整个商品页白屏 |
Java 生态:Spring Cloud Gateway(网关)+ Sentinel(流控熔断降级)。
这里有个时代变化值得注意:Netflix 的 Hystrix 早已进入维护模式、停止新功能开发,Spring Cloud 新版本里也不再是默认方案;Ribbon 更是已经被移除。现在国内主流是 Sentinel,或云厂商的对应产品。同理,Zuul 1 已被 Spring Cloud Gateway 取代,链路追踪方面 Sleuth 在 Spring Boot 3 之后被 Micrometer Tracing 取代(底层统一到 OpenTelemetry)。
换句话说:2018 年前后那套”Spring Cloud 五大组件”的教程,组件表已经整体换过一轮了。
8.4 第三层:系统整体
可观测性三支柱,缺一个你就定位不了问题:
- 日志(Logs) —— 结构化 + trace ID 贯穿。这一条是拆微服务后日志可用性的分水岭:没有 trace ID,你手上只是一堆服务各自的日志文件。
- 指标(Metrics) —— RED 方法最实用:Rate(请求量)、Errors(错误率)、Duration(延迟),按服务 / 接口 / 依赖分别看。
- 链路追踪(Traces) —— 一次请求穿过哪些服务、每一跳耗时多少。这是拆微服务后 MTTR 会不会变长的决定性因素。
选追踪方案时,OpenTelemetry 兼容性应该是第一考量。 它现在是跨语言的事实标准,意味着你的 Java、Go、Python 服务可以用同一套协议串在一条链路上。这是少数值得”提前站队”的技术选型。
发布管理 —— “独立部署”是拆分的收益,但它需要配套:灰度 / 金丝雀(先放 1% 流量)、蓝绿(两套环境切换),以及最重要的 —— 能回滚。不能回滚的灰度不是灰度,是赌博。
8.5 第四层:组织
前三层是技术,这层是技术能起作用的前提。
- 服务所有权:每个服务有明确的负责团队。没有 owner 的服务会腐烂 —— 这是必然,不是可能。
- SLO / 错误预算:定义”多好算好”。没有这个,告警会淹没团队,最后所有人开始忽略告警。
- 契约管理:服务间接口是公共 API,改动要讲版本策略(见 8.7)。
- On-call:谁在半夜被叫起来,谁就有动力把系统做好。
8.6 最容易翻车的三件事
① 重试放大。 这是生产事故的经典成因,而且任何人第一次配重试都会配错。
假设 A 调 B 重试 3 次,B 调 C 重试 3 次:
1 个用户请求 → B 收到 3 个 → C 收到 9 个
C 本来就已经过载了(这才是它超时的原因),现在压力乘以 9。这就是重试风暴 —— 你为了自愈配的重试,把一次降级变成了雪崩。
配套的三条纪律:
- 超时必须递减:A 等 B 的超时必须 > B 等 C 的超时。否则 B 还在重试,A 已经超时放弃了 —— 白做功,用户还是拿到失败。
- 退避 + 抖动:固定间隔重试会让所有失败请求同时重来,形成周期性冲击。指数退避加随机抖动打散它。
- 写操作重试必须幂等,否则会重复下单、重复扣款。
② 契约没人管。 拆完之后服务间接口就是跨团队的公共 API,但很多人还按”内部方法”的随意度在改。最小要求:只增不改(加字段安全,改字段语义是破坏性的)、消费者驱动契约测试(让消费方的期望变成提供方的测试用例,否则你根本不知道改坏了谁)、并行版本窗口(新老并存一段时间,而不是一刀切)。
③ 数据所有权松动。 这条不在常规清单里,但它是微服务架构里最容易悄悄烂掉的地方。一开始大家都守规矩,半年后某个紧急需求,某个服务”就这一次”直连了别人的库;再半年后,没人记得哪些跨库查询是合法的。防线就是 7.1 说的那条:在数据库权限层面让它不可能发生。
这三个坑是同一个思路:把约定变成会失败的机制,而不是会忘记的口头共识。
8.7 Java 生态的一张对照表
| 层 | 能力 | 现在的主流选择 | 时代变化 |
|---|---|---|---|
| 一 | 注册发现 | Nacos | Eureka 已停止演进 |
| 一 | 配置中心 | Nacos Config / Apollo | —— |
| 二 | 网关 | Spring Cloud Gateway | Zuul 1 已淘汰 |
| 二 | 负载均衡 | Spring Cloud LoadBalancer | Ribbon 已移除 |
| 二 | 熔断限流 | Sentinel | Hystrix 停更 |
| 三 | 链路追踪 | Micrometer Tracing + OpenTelemetry | Sleuth 已被取代 |
| 三 | 指标 | Micrometer + Prometheus | —— |
| 三 | 日志 | ELK / Loki | —— |
8.8 一句话概括这一节
这四层里,第一、二层是”能用”,第三层是”敢改”,第四层是”能持续用”。
只做前两层,系统能跑,但出了问题查不动、改起来提心吊胆、没人说得清谁该为它负责。拆微服务不是把服务拆开就结束了 —— 是把”管理一个系统”变成了”管理一个系统 + 管理 N 个服务的协作关系”。多出来的这部分成本,必须在拆之前就有能力承担。
九、当边界选错了
这部分在别的文章里很少讲,但它是真实存在的成本。
判断边界选错的两个信号:
- 跨服务事务开始出现。 一旦你开始讨论分布式事务、Saga、补偿逻辑来处理一个日常操作,边界就错了。
- 两个服务永远一起发布。 每次发版都要同时部署 A 和 B,中间还必须有短暂的「不兼容窗口」。这时候那两个服务在架构上是一个服务,只是在物理上分居两地。
代价有多不对称,值得再说一遍:
| 模块化单体 | 已拆分服务 | |
|---|---|---|
| 识别错误到改完 | 几天到几周 | 数月,跨多个团队 |
| 数据调整 | 改 schema 或加视图 | 数据迁移 + 一致性校验 + 双跑 |
| 主要风险 | 代码改动 | 数据丢失、线上不一致 |
| 回滚 | git revert | 可能没有回滚路径 |
9.1 Prime Video:一个被反复误读的案例
2023 年 5 月,亚马逊 Prime Video 的团队发了一篇文章,标题直白得惊人:Scaling up the Prime Video audio/video monitoring service and reducing costs by 90%。他们把一套由 AWS Step Functions 编排、跑在 Lambda 上的微服务管线,合并回了一个单体,基础设施成本降了 90% 以上。
这个故事后来被无数篇文章引用成「微服务已死」的证据。但这恰恰是最典型的误读,而误读的原因正好是本文第三节想说的那件事。
回到当时的具体情况:
- 这个服务负责音视频质量监控——检测画面卡顿、音画不同步之类的异常。
- 它每秒只处理几千个并发流,而且流量相对平稳,不像用户请求那样有剧烈的峰谷。
- 架构是「一个 Step Functions 状态机 + 多个 Lambda 函数」,每个阶段是一个独立的微服务。
成本出在哪?原文明说了:两个最贵的操作是编排工作流和文件传输。Step Functions 按状态转换次数计费,而这个工作流每一步都要走一次状态转换。这套架构为「每个组件独立伸缩」付出了代价——但这个服务根本不怎么需要伸缩。
他们自己写得很清楚:serverless 对 Prime Video 的绝大多数服务来说依然是很好的选择,出问题的只是这一个——低流量、高状态转换次数、每个请求要穿过多个 Lambda 的编排型负载。
所以这个案例的准确含义不是「微服务不好」,而是:
一个为了「可伸缩」而设计的架构,被用在了不需要伸缩的地方;于是它只剩下成本,没有收益。
注意这和我前面说的判据完全咬合:拆分要有收益,收益必须来自真实的伸缩或隔离需求。这个服务没有那个需求,所以它不该拆。而 Prime Video 的其余服务有,所以它们继续是 serverless。
同一家公司、同一时间,两种架构并存且都对。 这才是这个案例真正该被引用的方式。
9.2 更普遍的组织成本
除了这种「架构与负载不匹配」,还有一类更隐蔽的回退动机:服务数量本身变成了负担。
当系统膨胀到几十上百个服务之后,会出现一些单体时代不存在的问题:
- 一个平台团队要为上百个业务团队提供基础设施,它自己成了瓶颈
- 定位一个线上问题要跨十几个服务的日志和链路,MTTR 反而变长
- 一次跨服务的功能改动要协调五个团队的排期,交付周期被组织流程吃掉
- 服务边界在几年间漂移,渐渐和实际的团队边界、业务边界都不再吻合
这种回退的主因几乎从来不是性能,而是组织成本。 它说明不了「微服务是错的」,只说明「服务的数量超出了组织消化它的能力」。
9.3 所以没有普适答案
「微服务好」和「单体好」都是在问错误的问题。
正确的问法是:在我当前的组织规模和能力水平下,哪些边界需要物理隔离,哪些只需要逻辑隔离?
这个答案会随公司成长而变,而且它本来就该变。今天老老实实待在单体里的模块,明年可能正是最该被摘出去的那一个——这不动摇任何东西,因为你的第一步(模块化单体)本来就为这一天做了准备。
十、自检清单
定边界时:
- 我翻过 git 历史,确认了哪些代码真的总是一起改
- 每条写路径我只写一张表,没有跨边界写
- 每个候选服务的名字,在业务语言里指的是同一件事
- 我画出了完整的调用链,最长的那条不超过 5 跳
决定拆分时:
- 我能说出这次拆分对应 3.2 节的哪条触发条件(而不是「因为先进」)
- 3.1 节那五条收益,我能逐条说出它的前置条件已经到位
- 契约测试已覆盖要摘的模块的公开接口
- 链路追踪能穿过这条新边界
- 新服务能独立构建、发布、回滚
- 我已经想清楚了数据迁移方案,包括回滚路径
跨服务写入时:
- 跨服务的事件投递走了 Outbox(而不是「先写库再发消息」)
- 每个消费端都是幂等的
- 涉及多步的写操作,每一步都有对应的补偿动作
- 超时是递减的,重试带退避和抖动
拆完之后定期复查:
- 有没有两个服务永远一起发布?→ 考虑合并
- 有没有日常操作需要跨服务事务?→ 边界错了
- 有没有服务依赖了共享的实体类库?→ 发布独立性已经归零
- 有没有服务在直连别人的库?→ 查数据库账号权限
- 每个服务都有明确的 owner 吗?有没有「无主服务」?
- 链路上最长的一跳是几跳?还在增长吗?
附:延伸阅读
- Amazon Prime Video,Scaling up the Prime Video audio/video monitoring service and reducing costs by 90%(2023-05)。本文第九节讨论的案例原文。读的时候重点看它自己的限定语——哪些服务适合 serverless、哪些不适合,原文的结论比二手转述克制得多。
- Martin Fowler,Microservice Prerequisites、MonolithFirst 与 StranglerFigApplication。第一是本文第三节「身高线」判断的出处;第二是第五节「先模块化单体」思路的经典论述;第三是绞杀者模式的原始定义。
- Spring Modulith 官方文档。如果你用 Java,这是把本文第五节那三条铁律变成 CI 断言的现成方案,同时内置了 7.5 节讲的 Transactional Outbox。
- Chris Richardson,microservices.io 的模式目录。Saga、API Composition、Database per Service、Transactional Outbox 等模式的标准定义,建议在真正需要跨服务事务时再读。
- OpenTelemetry 文档。本文 8.4 节说「选追踪方案时 OTel 兼容性应该是第一考量」,如果只读一份可观测性文档,读这份。
- service-mesh / Istio 相关材料(可选进阶)。当服务数量多到「每个服务都要自己引一套治理 SDK」变成负担时,把治理逻辑下沉到 sidecar 的服务网格方案会开始有吸引力——但它有自己的一套复杂度,不建议在服务数量少时引入。
一句话总结
先把边界做成模块,验证成本很低;确认哪几个模块真的需要独立之后,再把它摘成服务。颠倒这个顺序,是绝大多数微服务失败案例的共同起点。