1.2 选型对比:单体与拆分的账
选型会上吵得最凶的不是技术,是成本。架构师主张直接上微服务,理由是三个月后大促;我主张单体起步,理由是团队只有六个人、其中两个还是应届。最后老板拍板:先单体,但代码按模块拆好,给将来留口子。回头看这个决定是对的,但中间也付过学费。
场景:团队与业务的真实约束
订单中心上线时日均单量 8 万,大促预估 50 万。团队六个人,Java 经验参差,没有人搞过完整的分布式事务。这个约束下,直接上二十几个微服务是灾难:光配置中心、网关、链路追踪三件套就够折腾两个月,还没写一行业务代码。
单体不是妥协,是阶段最优解。业务上订单、库存、支付虽然逻辑独立,但部署初期流量集中在一台机器上反而省事。真正的问题是代码怎么组织,让单体不至于三个月后变成屎山。
方案:单体里的模块化拆分
我们按领域拆模块,单体内部分成四个 Maven 模块,编译在一个工件里,部署在一个进程上:
| 模块 | 职责 | 对外协议 | 将来拆服务的边界 |
|---|---|---|---|
| order-core | 状态机、创单、关单 | HTTP | 订单服务 |
| stock-core | 冻结、扣减、回滚 | 本地方法调用 | 库存服务 |
| pay-adapter | 渠道接入、验签、回调 | HTTP + 回调 | 支付服务 |
| batch-job | 对账、超时关单、报表 | 定时任务 | 批处理服务 |
关键设计有两条。一是模块间只通过接口调用,不允许直接 AOP 注入对方的 Service 实现类,这一条靠 ArchUnit 单测强制执行。二是数据库按 schema 分开,order_core、stock_core 各一张库,单体内也走不同数据源。这两条让后来的拆服务只改部署方式,几乎不改业务代码。
拆分时机我们也定了量化标准,避免拍脑袋:
| 指标 | 单体可接受 | 触发拆分 |
|---|---|---|
| 单机 QPS 峰值 | 2000 以下 | 持续超过 4000 |
| 发布频率 | 每周一次 | 库存模块要求每天 |
| 团队规模 | 10 人以内 | 超过 15 人 |
| 故障隔离需求 | 可接受全站重启 | 支付回调不能被拖垮 |
代码:模块边界的强制约束
ArchUnit 的规则很短,但救过我们两次。一次是新同事图省事直接注入库存的实现类,一次是订单模块绕过接口直接读库存表,都被单测拦下:
@RunWith(ArchUnitTest.class)
public class ModuleRuleTest {
@ArchTest
static final ArchRule 模块间只走接口 =
classes().that().resideInAPackage("..order..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..order..", "..stock.api..", "java..");
}
踩坑:单体时期的三笔学费
第一笔是本地事务带来的虚假安全感。创单时冻结库存和写订单在同一个事务里,开发觉得很稳,大促时事务时长从 20 毫秒涨到 400 毫秒,行锁排满,整个库都被拖住。单体的事务边界太容易跨模块,这是它最大的陷阱。这点在 Go 微服务实战 里有反面案例,拆开之后事务收敛到单模块反而更稳。
第二笔是全量发布的风险。一个对账批处理的 bug 把支付回调服务的线程池打满,整站重启十分钟。后来我们在单体内也做了线程池隔离,批处理任务单独的池子,线上开关一键降级。
第三笔是监控粒度。单体一个进程, metrics 全糊在一起,分不清是哪个模块的报错。后来给每个模块的日志打统一前缀,MDC 里塞模块名,才恢复可读性。这项工作后来直接平移到了 CLS 的日志检索里。
进阶实战:拆分演练
单体跑稳之后,我们没有原地等待拆分指标触发,而是每个季度做一次「拆分演练」:把 stock-core 模块单独拉出一个进程,用 HTTP 调用替换本地方法调用,跑通全链路再合回去。演练的成本是两个工程师一周,收益是真正拆分时心里有数。
三次演练暴露的问题非常具体。第一,本地方法调用是同步的,拆成 HTTP 后超时怎么定:我们最终统一为连接超时 1 秒、读取超时 3 秒,超时后走幂等重试。第二,事务边界变化:本地调用时冻结库存与创建订单在同一个数据库事务里,跨进程后只能改成本地消息表加最终一致,这部分在「分布式事务」的章节展开。第三,数据访问层:原来 stock-core 直接读 order_core 的表,演练时必须改走 HTTP 接口,ArchUnit 规则这时显出了价值——模块间本来就没有跨表访问,改造极其顺滑。
| 演练项 | 单体现状 | 拆分后要求 |
|---|---|---|
| 调用方式 | 本地方法 | HTTP + 超时 |
| 事务 | 单库事务 | 本地消息表 |
| 配置 | 同一应用配置 | 独立配置中心 |
| 监控 | 进程内 metrics | 链路追踪 |
演练的副产品是接口契约文档被迫提前完善。单体里「调用方直接看实现类」是常态,拆分演练逼着每个模块先写接口文档再写实现,这个顺序一旦养成,文档再也不是滞后产物。
速查清单
单体阶段的四条自律:
| 自律项 | 达标标准 |
|---|---|
| 模块只走接口 | ArchUnit 单测拦截 |
| 数据按库分 | 订单库存不同数据源 |
| 线程池隔离 | 批处理与服务池分开 |
| 模块级监控 | 日志与指标可按模块筛 |
小结
单体与微服务不是先进与落后的选择,是阶段与成本的权衡。单体时期最有价值的做法是「按服务的方式写单体」:接口隔离、数据分库、模块级监控。这样拆分的时候只是搬家,不是重建。下一篇说机器,看 CVM 规格和 JVM 参数怎么配合单体的胃口。
常见误解:分层架构的三个陷阱
第一个误解是「分层就是分包」。团队里一度把 controller、service、dao 当成分层,跨模块调用还是穿堂过户。真正的分层约束是依赖方向:上层依赖下层,下层不许反向引用。订单模块依赖库存的接口包,库存模块对订单一无所知,这才叫分层。约束落地靠 ArchUnit 单测,我们 Review 时也盯 import 语句,跨模块 import 业务实现类的代码直接打回。
| 误解 | 实际做法 |
|---|---|
| 分层等于分包 | 依赖方向才是关键 |
| 微服务必然解耦 | 耦合可能更深 |
| 单体不能做 DDD | 模块化单体同样适用 |
第二个误解是「微服务一定解耦」。恰恰相反,拆分方式不对耦合更深。早期一批接口设计得粒度太细,库存模块一个动作被拆成四个 HTTP 调用,网络开销与排错难度都上去了。合理解耦的标准是「业务内聚」:一次业务动作只调一个接口。我们的库存接口最后收敛到五个:冻结、扣减、释放、查询、修正。
第三个误解是「单体不能落地领域设计」。实际上模块化单体是最适合实践 DDD 的形态: bounded context 之间用接口隔离,领域模型写在模块内部,不用担心分布式带来的妥协。我们订单中心的状态机、库存记账、支付幂等都是按领域建模写的,迁移到分布式时模型基本没动。
团队协作:单体的多人协作规范
单体最大的管理风险是「谁都能改任何代码」。我们为此设了三道门。第一道是模块 owner 制:四个模块各有一个负责人,模块内的 PR 必须有 owner 通过。第二道是代码扫描:ArchUnit 跑在 CI 上,模块越界直接红。第三道是每周一次的架构晨会,讨论新需求落在哪个模块、需不需要新增接口。比起运行时的隔离,组织上的边界往往更重要。
| 规范 | 目的 |
|---|---|
| 模块 owner | 变更有责任人 |
| CI 边界检查 | 依赖方向不跑偏 |
| 架构晨会 | 新接口有归属 |
另外一点经验是单体的发布节奏必须严格。微服务可以按服务独立发布,单体是全量发布,一个模块的 bug 影响全部功能。我们因此形成了「灰度发布 + 快速回滚」的机制:先放 10% 流量观察十五分钟,核心指标(下单成功率、错误率)正常再全量。单体时期把发布纪律养成,拆分之后自然继承。这套流程与 发布治理实践 的灰度策略是同一套方法论。
