模块化单体
模块化单体是拆微服务前的中间态
微服务解决的是两个问题:团队规模与独立部署。两个需求都没到就拆,等于用真实的业务复杂度去换网络延迟 (函数调用微秒级变网络调用毫秒级)、分布式一致性(一个数据库事务变成补偿与最终一致)和 N 倍运维 (一套流水线变十几套),而且合并回单体比拆分出去更痛,回头路代价不对称。模块化单体(Modular Monolith) 是拆分之前被跳过的中间态:部署形态仍是单体,一个进程、一个数据库、一次发布,但代码内部按业务能力 划分高内聚模块,模块内怎么写随便,模块间怎么通信有严格规矩。这套边界现在立好,将来真要拆微服务时 成本最低:该断的边界代码里已经断好,物理拆分只是搬迁。Martin Fowler 的 MonolithFirst 说的就是这件事: 新项目几乎都从单体开始,即使最后会走向微服务,单体期换来的信息也值得。
高内聚低耦合落在目录上就是 Package-by-Feature
高内聚指强相关的代码放在一起,改动被限制在一个模块内;低耦合指模块间依赖最少、只走窄接口。 落到工程上,切法只有两种:Package-by-Layer 按技术职责切顶层目录(controllers、services、repositories), 一个业务功能的代码散落在多个文件夹;Package-by-Feature 按业务能力切(order、inventory、payment), 一个业务从接口到数据访问全部收在自己的文件夹里。两种切法代码量几乎一样,判据一句话:改同一个需求, 你要不要跨文件夹跳来跳去。Package-by-Feature 的改动散布半径小,新人看懂一个业务只需读一个文件夹, 团队分工也能对齐到模块。
见:Modular Monolith Architecture
边界强度三档:目录约定、编译器强制与 CI 架构测试
微服务的边界是进程边界、网络边界,越界直接报网络错误,躲不掉;模块化单体的边界只是一层代码约定, 必须自己购买防御,强度分三档。第一档目录约定,零强制,靠自觉。第二档编译器强制:Java 模块系统(JPMS) 用 requires 与 exports 控制包可见性,.NET 用 internal 关键字让实现类出了程序集就不可见,越界直接编译报错, IDE 在写 import 那一刻就标红;评审和架构测试都是事后拦截(代码写完、PR 已开),编译错误是事前阻止, 成本结构完全不同。第三档 CI 架构测试兜编译器管不到的结构规则,比如循环依赖、跨模块只许依赖接口层, ArchUnit、Spring Modulith 的 verification、前端的 dependency-cruiser 与 ts-arch 都属于这一档, 规则写成可执行断言跑在 CI 里,违反边界的提交几秒内红灯。三档并存而非互斥:架构测试不会心软, 比代码评审可靠,因为边界从「请大家自觉」变成一道能拦人的闸门。破坏通常是慢性的:每次赶工期多加一次 跨模块调用,半年后模块间重新纠缠成大泥球,而没有物理反馈的腐化最难被看见。
见:ArchUnit
模块间通信三道闸:窄接口、领域事件与数据所有权
模块要打交道时,代码闸是只 import 对方模块公开的接口或门面,实现类对模块外不可见;通信闸是同步调用 走窄接口(如下单前调库存模块的 hasStock 问一句有没有货),异步走领域事件;数据闸是不碰对方的表。 数据闸最常被击穿:某个「紧急报表」让订单模块直接 join 库存表,架构测试只查 import 关系不查 SQL, 没人拦得住,从那天起库存表任何一列改动都会在订单模块炸出事故,而两个模块在代码层看起来毫无关系。 其机制在于,模块间集成方式的耦合强度有档位(文件传输、共享数据库、直接调用、事件),共享数据库是最强 的一档:代码接口只是窄门,表结构才是承重墙。落地做法是每个模块拥有自己的 schema(独立 schema 或严格 表前缀),并在数据库账号权限层面禁止跨 schema 访问,让数据闸和代码闸一样从「不该」变成「不能」。
见:Modular Monolith: Integration Styles
单体内事件解耦的事务陷阱:提交后才投递
进程内事件不是网络消息,没有「发出去就与你无关」的保证。典型事故:下单模块发布 OrderCreated 事件, 库存模块监听后扣减并回查订单表校验,结果查到空,因为 Spring 默认的 @EventListener 在事件发布瞬间同步 投递,此刻下单事务尚未提交,监听器读到的永远是旧快照;更糟的是监听器抛异常会连带回滚主事务, 以为解耦了,实际还在同一个事务里。机制在于进程内事件总线的投递时机默认绑定在发布调用点,而不是事务 边界。Spring Modulith 的 Event Publication Registry 是官方解法:事件先落库、事务提交后才投递、 监听器失败记录在案可重放,相当于免费拿到一个事务性发件箱(transactional outbox)。单体内做事件解耦, 要么用提交后投递加落库注册表,要么明确接受同步事务语义;裸用事件总线假装异步是最隐蔽的假解耦。
拆分前算账与回单体案例
做扎实的模块化单体拿到三个明确好处:部署简单(永远只有一个进程上线,不协调发布顺序)、事务简单 (一个数据库连接保证强一致性,不引入分布式事务)、调用性能高(模块间是函数调用)。代价是越界没有 物理反馈,腐化慢且不可见,没有架构测试兜底就会在看不见的地方烂掉。适用边界也要认:团队已到规模且 某模块流量特征不同需要独立伸缩时,直接微服务;组织按团队边界切(康威定律),多团队并行改同一模块 必然互相踩;一周生命周期的原型不必投资边界。演化路径已被一线验证:Istio 控制面 2022 年合并为 istiod 单二进制,Amazon Prime Video 监控服务 2023 年从微服务回到单体,加上 Segment 与 InVision,一篇多来源 文献综述把回退原因归纳为五类:成本、复杂度、伸缩性、性能与组织。拆分是手段不是勋章, 规模到了再拆,边界先立好随时可拆。
见:From Microservice to Monolith: A Multivocal Literature Review