很多后端团队的技术讨论,最后都会落到同一张图上:Spring Cloud 的组件选型、Nacos 做注册与配置、Sentinel 做限流、Seata 处理分布式事务、Istio 做流量治理。这些讨论默认了一个前提——服务已经拆了,接下来是「怎么拆得更好」。但真正决定系统未来两三年维护成本的,往往是更前面的那个问题:现在到底该不该拆?
本次提供的参考资料为空,因此本文不引用任何具体站内帖子,也不虚构案例数据。下面只基于可验证的通用工程事实和分布式系统里反复出现的失败模式,讨论一个具体问题:在什么条件下,把单体拆成微服务是划算的;在什么条件下,它只是把编译期的问题推迟成了运行期的问题。
先给结论
拆分的合理触发条件,是组织边界、变更频率、独立伸缩需求这三者中至少两个同时成立,而不是「技术栈是否现成」。 三者都不成立时,拆分的净收益大概率是负的:你付出的是运维复杂度、一致性问题与排障成本,换回来的只是「看起来更现代」的架构图。
核心判断:微服务不是架构升级,而是把代码内部的耦合,换成了网络上的契约。耦合没有消失,只是换了计价方式。
这个结论有边界。当团队规模超过两三个披萨、当某个模块的流量是其他模块的十倍、当合规要求某些数据必须物理隔离,拆分就是合理的,甚至是被迫的。问题不在于「微服务好不好」,而在于你有没有先确认自己站在这些条件里。
为什么「先拆再说」的冲动这么强
第一个原因很朴素:拆的动作变便宜了。Spring Boot 的起步依赖让一个可运行服务只需要几十行代码;Spring Cloud Alibaba 把注册、配置、限流、熔断打包成注解;Kubernetes 让部署和扩缩容变成一份 YAML。十年前拆一个服务要搭一套基础设施,现在一个下午就能跑起来。但变便宜的只是动作,不是后果。
第二个原因在机制层面。本地方法调用只有两种结果:返回或抛异常。远程调用至少有五种:成功、超时、连接被拒、返回了但响应丢失、部分成功。一次下单流程从 3 次本地调用变成 3 次 RPC,失败组合从 1 种变成 2³ 种。这不是框架能消除的,是进程边界本身带来的。Spring Cloud 的 @Retryable、Resilience4j 的熔断器、Sentinel 的流控规则,都是在管理这些失败模式,而不是消除它们。
第三个原因容易被忽略:拆分之后,事务模型被迫改变。Seata 的 AT 模式通过全局锁和 undo log 降低改造成本,但全局锁会带来竞争,隔离级别也弱于本地事务;TCC 需要为每个参与方写 Try、Confirm、Cancel 三个方法,业务侵入明显;Saga 要求每个步骤都有可补偿的反向操作。很多业务原本只需要一个本地事务,拆完之后却被迫引入分布式事务框架,再用消息队列做最终一致,复杂度翻了几倍。
拆分之前,先证明三件事
模块边界:先在单体里划清
如果在一个进程内都划不清模块边界,拆成服务只会把耦合固化成 RPC 调用。可执行的做法是按业务域分包,并且用工具强制约束依赖方向,比如用 ArchUnit 写一条架构测试:
Java
@AnalyzeClasses(packages = "com.example.shop")
class ModuleBoundaryTest {
@ArchTest
static final ArchRule order_should_not_touch_inventory_infra =
noClasses().that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAPackage("..inventory.infrastructure..");
}
这条规则的价值不在于「禁止」,而在于它把「模块边界」从口头约定变成了 CI 里的失败项。Spring Modulith 这类工具还能进一步检查模块之间的依赖是否形成环。能在一个仓库里用测试守住的边界,才有资格变成网络边界。
可观测性:没有统一 traceId 就不要拆
拆分之后,一次用户请求会跨三到五个进程。没有统一 traceId,排障就变成在多个日志文件里按时间戳猜。最小要求其实不高:入口生成 traceId 并透传到下游、日志用结构化格式输出、关键路径打点 RED 指标(请求速率、错误率、耗时分布)。OpenTelemetry 提供了跨语言的语义约定,Spring Boot 3 的 Micrometer Observation 可以把指标和追踪接到同一套观测原语上。
反过来看,如果一个团队在单体里都没有接入过链路追踪,拆分后大概率也不会接。那时每一次线上问题都会变成「先加日志、再发版、再复现」的循环,拆分省下的编译时间会被排障时间全部吃掉。
契约:接口先稳定,再谈远程
进程内调用靠编译器检查,跨进程调用只能靠契约。HTTP 接口用 OpenAPI 描述,gRPC 用 Protobuf 的 .proto 文件描述,两者都能生成客户端和服务端骨架。更进一步是消费者驱动契约测试:由调用方定义期望,提供方在 CI 里验证自己是否满足这些期望,字段改名、类型变更会在合并前暴露,而不是在灰度时暴露。
没有契约的拆分,本质是把编译期错误推迟到了运行期。这类错误的修复成本,通常是编译错误的几十倍。
三种做法的取舍
| 做法 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 模块化单体 | 事务简单、调试直接、部署单一 | 边界靠测试约束,扩缩容粒度粗 | 团队小、领域未稳定 |
| 全面微服务 | 独立部署、独立伸缩、故障隔离 | 运维与一致性成本高 | 多团队、领域清晰、有平台能力 |
| 模块化单体 + 少量服务 | 成本可控,关键路径可独立演进 | 需要明确「哪些拆、哪些不拆」 | 大多数成长型团队 |
表格里最容易被低估的是第一行。模块化单体的代价不是技术代价,而是纪律代价:它依赖 CI 里的架构测试、代码评审时对依赖方向的敏感度,以及团队对领域划分的共识。一旦纪律松动,模块化单体就会退化成「一个大包」。
第三行的混合模式,关键在于判断「拆哪一两个」。一个实用的判断维度是对比模块的变更频率和数据所有权:如果两个模块近半年的提交总是成对出现、共享同一张核心表、事务边界无法切开,那它们就不该被拆开;反过来,如果某个模块的变更频率是其他的五倍,或者在大促时需要独立扩容,它才是候选。
以订单和库存为例:如果库存扣减和订单创建在同一个事务里,且两者总是一起改,拆开之后就必须引入分布式事务或消息补偿,收益很难覆盖成本;但如果库存服务需要支撑多个业务线的读取、且有独立的扩容需求,把它拆出去、用缓存和异步扣减来解耦,就是合理的。
一条可执行的落地路线
流程图
渲染中...
对应的检查清单:
- 用依赖分析工具画出模块之间的调用矩阵,标出双向依赖
- 统计近半年的提交记录,找出变更频率差异最大的模块对
- 为每个候选服务确认「数据所有权」,明确谁写、谁只能读
- 在所有入口接入 traceId,日志与指标能按 traceId 关联
- 为候选接口写 OpenAPI 或 Protobuf 定义,并加消费者驱动契约测试
- 先拆一个读多写少、无强事务依赖的边缘服务做试点
- 准备灰度策略、超时与重试预算、回滚开关
试点选择「读多写少、无强事务依赖」的服务,是因为这类服务的失败模式最简单:读失败可以降级到缓存或返回旧值,不涉及跨服务的数据一致性。用它来验证团队的可观测性、发布流程和值班能力,成本最低。等这套流程跑顺了,再动有事务边界的核心链路。
需要明确不适用的场景:团队不足十人、领域模型还在剧烈变化、没有专职的平台或运维支持时,拆分的收益很难兑现。这时候更值得投入的是模块化、测试和可观测性,而不是服务数量。
总结与讨论
第一,拆分的触发条件应该是组织和变更的现实,而不是技术栈的可得性。工具让拆变便宜,但没让分布式系统的失败模式变少。
第二,拆分前先做三件事:用架构测试守住模块边界、用 traceId 和指标补齐可观测性、用契约把接口稳定下来。这三件事在单体里做,成本低;拆完再补,成本高一个量级。
第三,混合模式对多数成长型团队更现实:保持一个模块化单体,只把真正需要独立伸缩或独立归属的一两个模块拆出去,其余继续用本地调用。
留一个开放问题:你们团队上一次拆分服务,最先出现的信号是组织变化(团队扩了、职责分开了),还是性能瓶颈(某个模块扛不住了)?这两个信号出现时,你们的处理方式有什么不同?