2016 年前后,伴随 Spring Cloud 的流行,国内技术圈出现了一种常见的架构图:正中间一个方框写着"业务系统",周围散落着十几个小方框——用户服务、订单服务、支付服务、商品服务、消息服务、日志服务、配置中心、注册中心、API 网关……每个小方框都是一套完整的 Spring Boot 工程,配自己的 CI 流水线、Dockerfile、部署脚本。

Spring Cloud 解决了一组真实的问题——分布式系统的服务发现、配置管理、负载均衡。但工具的成熟也带来了一个副作用:架构决策开始被工具能力驱动。"既然有了这套工具,为什么不拆成微服务"成了默认思路。至于为什么要拆、模块边界划在哪里、运维成本会增加多少——这些问题的优先级被往后放了。

此后几年,Go 在服务端快速普及,Kubernetes 成为容器编排的事实标准。行业对服务拆分的态度明显变得更审慎。不是微服务本身有问题——是分布式系统固有的复杂性被更多人认识到了。从一股脑拆分到先算账再做决定,这个转变本身就是一个重要的行业经验。

好单体本来就是模块化的

讨论单体的时候,很多人脑子里想的是一团乱麻:没有模块边界、随便跨包引用、改一行代码不知道会影响哪里。但这不是单体架构的必然,这是架构纪律缺失的结果。

一个有基本架构纪律的单体,自然会按业务能力做模块划分——订单模块、用户模块、支付模块,每个模块有自己的内部实现,模块之间通过明确的接口通信。这是软件工程里的常规做法。Go 用 internal 目录约束可见性,Java 靠包结构和 access modifier,语言不同,做法一样。

这种约束不是摆设。当订单模块的内部 DAO 被标记为不可导出时,用户模块的开发者就无法直接 import 它。他只能调用订单模块暴露的接口——比如一个 GetOrderStatus 函数。这个限制把模块之间的耦合从"随意访问任何内部实现"压到了"只能依赖对外的 API"。模块内部的数据结构怎么变、数据库表怎么设计,只要对外接口不变,调用方就不受影响。这是封装的基本价值,不新鲜,但在讨论单体的时候经常被忘记。

如果团队连单体里的模块边界都画不清楚,那拆出来的微服务边界大概率也是错的。拆分会把错误的边界固化到网络协议里,修起来代价更大。边界质量不取决于部署单元的数量。

一个正常生长的系统,路径通常是:先在一个进程里把模块边界跑清楚,需要的时候再把某个模块提取为独立服务。对不少系统来说,它们长期停留在这个形态就够了。

拆与不拆:几笔实在账

从几个关键维度看单体与微服务的差异。这些差异不决定"谁更好",但决定了选择之后要承担什么。

延迟。进程内调用和网络调用通常不是同一个数量级。单体里调用链再深,额外延迟也很低;微服务架构下,调用链越深延迟累积越明显。一个用户请求进来,穿过网关、用户服务、订单服务、支付服务、通知服务,五跳下来即使每跳只花 5 毫秒,叠加起来就是 25 毫秒。对大多数系统这不算致命,但它是一个持续增长的税,而且你没有哪一天可以宣布"延迟问题已经解决了"——它只会随着服务数量增长变得更难管理。

事务。单体里跨模块操作就是一个数据库事务,ACID 保证。微服务里跨服务操作需要分布式事务或 Saga——引入补偿、重试、幂等、最终一致性。数据一致性模型从强一致变成最终一致,这个变化对业务逻辑的冲击往往被低估。扣款成功但订单创建失败,退款逻辑触发异常但用户已经收到了退款成功的通知——这类问题在单体里根本不会出现,在微服务里需要额外的对账、补偿和人工介入来兜底。

调试与排障。单体里一个请求从头追到尾。微服务里需要把多份日志拼起来,依赖链路追踪的覆盖率和正确性。链路追踪本身是一套需要维护的基础设施——采集、传输、存储、查询,每个环节都可能出问题。排查一个线上问题时先确认"链路追踪是否正常"本身就是一个额外的排查步骤。

部署与运维。单体是一套 CI/CD、一个镜像、一次发布。微服务每个服务都有独立的流水线和部署配置。自动化能减操作负担,但认知负担不会消失——理解十个部署单元之间的关系、发布顺序、API 兼容矩阵,本身就是一笔开销。当一个服务的 v2 版本需要配合另一个服务的 v3 版本才能工作时,发布就不再是独立的,版本依赖图需要被管理起来。

测试。单体的端到端测试直接启动整个应用即可。微服务的端到端测试要么维护 staging 集群,要么在 CI 里靠 docker-compose 拉起所有服务(不稳定),要么大量依赖 contract testing 和 mock。三种方案各有各的成本,没有哪种是"配置一次就再也不用管了"的。

团队协作。微服务最实在的优势在这里:独立开发、独立部署、互不阻塞。这个优势成立的前提是模块边界已经足够清晰和稳定,且团队结构和系统边界能够对齐。如果在单体里已经把边界跑清楚了、团队也按模块划分了,提取一个模块变成独立服务的成本反而很低。微服务的团队协作优势,通常是在已经做好模块化设计之后才能兑现的——而一旦做好了模块化设计,微服务就变成了一个可选的额外步骤,不是必须的。

六个维度看下来,微服务最难被单体直接替代的优势包括:独立扩缩容、独立故障隔离、独立部署节奏,以及在大团队里把系统边界和组织边界对齐。其余的都是取舍。如果系统没有这些需求,拆成微服务带来的主要是额外成本。

什么时候拆

判断要不要把某个模块提取为独立服务,实际发生的信号比"规划"更可靠:

负载分化。某个模块的负载特征与其他模块显著不同,且已经体现在监控数据上——不是"未来可能会"的预估。比如订单服务的 CPU 和内存曲线跟用户服务在高峰期完全脱钩,单独扩缩容的收益是可以在监控图上算出来的。

独立交付节奏。某个模块的变更频率远超其他模块,且已经被整体发布节奏拖慢——已经发生,不是计划中的加速。比如支付模块每周要发版,但因为跟其他模块绑在一起,每次发布都要等整个系统的回归测试跑完。

故障隔离。某个模块的故障不能拖垮整个系统。不过如果这个模块是核心业务引擎,它挂了整个系统本来也不能用——拆出来并不能增加可用性,反而引入网络通信这个新的故障点。故障隔离的价值取决于这个模块的可用性要求是否明显高于系统的其他部分,以及它的故障模式是否真的能通过独立部署来隔离。

团队规模。团队大到在单一代码库中高效协作变得困难。这个阈值比很多人想象的高——模块边界清晰的单体可以支撑相当规模的团队。10 个人 10 个微服务,意味着每个服务 bus factor 接近 1,任何一个人离开,对应的服务就成了无人区。

这些条件不是绝对的。但如果信号一个都没出现,把单体拆成微服务大概率是在为尚未发生的需求预支复杂度。

物理隔离不是免费的

支持拆分的常见理由是:物理隔离让边界不可逾越。没有物理隔离,边界靠纪律,守不住。

物理隔离确实能堵住"不小心 import 了不该 import 的包"。但换到微服务架构下,边界破坏不会消失——它会换一种形式:接口语义悄悄变了但下游不知道,字段含义改了,异常不再抛出了。这些问题更难被静态检查发现,因为它们在协议层面,不在类型层面。在单体里,你改了某个函数的签名,编译器在几秒钟内就能告诉你所有受影响的地方。在微服务里,你改了 REST API 的响应字段,下游可能在几天后的集成测试里才发现,或者更糟——在生产环境里通过报警发现。

反过来,单体里的编译期检查和静态分析规则,在每次构建时强制执行模块访问限制。这个机制是可重复的、确定性的。像 Go 的 internal 包、Java 的 module system、ArchUnit 这类静态分析工具,可以在 CI 里拦住违规的依赖。一次配置之后,零人力成本。

有些场景下网络隔离是必需的——比如合规要求某些数据必须物理隔离。这些情况下拆成微服务是合理的选择。但对大多数系统而言,核心需求只是防止订单模块直接用用户模块的内部实现,编译期检查就能做到。把边界质量寄托在编译器和静态分析上,比寄托在"别人没法通过网络访问我的数据库"上,至少在可验证性上更强。

小结

要不要拆成微服务,不是一个"是否现代化"的问题。它首先是一笔账:用网络通信的成本换物理隔离的收益,在当前阶段值不值得。

对很多系统来说,当前阶段不需要付这笔账。不是因为微服务不好,而是因为约束条件还没有到需要承担分布式复杂性的程度。把模块边界在单体里跑清楚,把接口设计稳定,把团队按模块组织好——这些做好之后,某一天确实需要独立扩缩容或独立交付节奏的时候,提取一个模块出去,成本很低。因为你已经知道接口长什么样、数据归谁管、依赖方向是什么。

在此之前,把一个模块化良好的单体继续跑下去,不是技术保守,是不为未发生的需求预支成本。