单体、模块化单体与微服务
架构形态不是团队规模的勋章。把一个进程切成十个服务,并不会自动得到清晰边界;它只会把原来能靠方法调用和本地事务处理的问题,改写成网络超时、重复消息、数据副本、版本兼容和跨团队协作问题。
真正值得问的是:某条业务边界是否已经清楚到能独立变化、独立承担数据责任和故障责任,而且拆分收益足以支付分布式成本?这篇用“创建订单并预留库存”贯穿三种形态。
比较的是责任边界,不是框架名称
下面沿部署单元、模块依赖、调用方式、数据所有权、事务、故障传播、性能、安全、测试和团队治理,比较单体、模块化单体和微服务,并给出从简单形态渐进演进与回滚的方法。
DDD 全套方法论、服务发现、网关、消息中间件、分布式事务和 Kubernetes 部署各有独立专题;任何框架名称都不能替代这里要判断的责任边界。
比较三种形态前先分清四种“边界”
已理解一次请求会跨越协议、线程、连接和事务边界。能区分源码模块、运行进程、部署单元、数据库 schema 和团队所有权。建议先完成“从代码到进程”,避免把“多模块工程”误认为“多进程服务”。
它在知识树里的位置
工程入口
├─ 从请求到响应
├─ 从代码到进程
├─ 架构形态(本文)
│ ├─ 单体:一个部署单元
│ ├─ 模块化单体:进程内强边界
│ ├─ 微服务:独立部署与数据责任
│ ├─ 调用、事务、故障与安全代价
│ └─ 拆分证据、迁移和回滚
└─ 环境边界先统一三个定义
单体是一个主要部署单元。它可以有很多包、模块和数据库表,也可能有多个副本;“单体”不等于“代码混乱”或“只能部署一个进程”。
模块化单体仍作为一个部署单元运行,但按业务能力建立可验证的进程内模块。模块只公开少量 API,内部类型和数据访问不允许被其他模块随意穿透。它减少耦合,却保留本地调用、单进程调试和本地事务的简单性。
微服务把部分业务能力变成独立部署和运行的服务。服务通过网络或消息协作,并对自己的数据、契约、容量、可用性和发布负责。仅仅把同一个数据库前面的 Controller 分到多个进程,通常只是“分布式单体”。
图中最重要的变化不是方框数量,而是远程边界出现后,调用可能超时、结果可能未知、消息可能重复、两侧版本可能不同步,本地 ACID 事务也不再天然覆盖全链。
同一笔下单在三种形态中怎样运行
单体:最短的正确链路
请求进入订单入口后,订单组件直接调用库存组件;两者可以共享同一数据库连接和本地事务。任一写入失败,事务回滚,调用方获得明确失败。优势是链路短、调试简单、序列化少、发布原子;代价是如果代码没有边界,任何模块都可能读取或修改任何表,变化会逐渐扩散。
模块化单体:先把边界做实
订单模块只能调用库存模块公开的 reserve 能力,不能直接使用库存 repository 或更新库存表。两者仍可在同一进程中调用,并在明确需要时共享本地事务。模块边界通过包可见性、构建依赖和架构测试约束。
这一步经常已经解决真正的问题:团队能看见依赖方向,测试能隔离模块,变更影响范围缩小,而运行和交付仍然简单。Spring Modulith 官方模型也将应用模块描述为提供接口、内部实现和对其他模块提供接口的引用,并支持结构验证与模块级集成测试。
微服务:远程边界必须承认不确定性
订单服务调用库存服务时,至少有三种结果:成功;明确失败;调用方超时但库存其实已经成功。第三种状态迫使系统引入请求 ID、幂等、超时预算、重试策略、补偿或异步确认。两份数据不能再由一个本地事务自然提交。
如果没有幂等键,重试可能重复扣减;如果完全不重试,暂时性网络故障会降低成功率;如果无限重试,下游故障会被放大。微服务设计的核心工作正是管理这些新状态,而不是画服务框图。
一个可审查的模块化最小实验
下面的目录足以表达两个模块,不需要先引入微服务:
src/main/java/example
├─ Application.java
├─ order
│ ├─ OrderService.java # 对外 API
│ └─ internal
│ └─ DefaultOrderService.java
└─ inventory
├─ InventoryService.java # 对外 API
└─ internal
└─ JdbcInventoryRepository.java关键约束是订单模块只能依赖 inventory.InventoryService,不能依赖 inventory.internal。最小代码可写成:
package example.inventory;
public interface InventoryService {
Reservation reserve(String requestId, String sku, int quantity);
}package example.order.internal;
import example.inventory.InventoryService;
final class DefaultOrderService {
private final InventoryService inventory;
DefaultOrderService(InventoryService inventory) {
this.inventory = inventory;
}
void create(String requestId, String sku, int quantity) {
inventory.reserve(requestId, sku, quantity);
// 只根据公开结果推进订单,不读取库存内部表或内部类型
}
}静态审查时搜索越界依赖:
rg "inventory\.internal|JdbcInventoryRepository" src/main/java/example/order预期无输出。但搜索只能发现命名已知的违规,不能替代编译期或架构测试。Java 模块系统、ArchUnit 或 Spring Modulith 都可以把“不能穿透内部实现”变成自动门禁。使用 Spring Modulith 时,可建立模块模型并执行验证:
@Test
void module_boundaries_are_valid() {
ApplicationModules.of(Application.class).verify();
}官方验证规则包含模块之间不得形成循环依赖。实验真正要证明的是依赖方向可执行,而不是证明选中了某个工具。
失败验证:边界坏掉时要看见什么
反例一:订单直接改库存表
如果 DefaultOrderService 注入 JdbcInventoryRepository,短期少写一层接口,长期却让库存表结构成为订单模块的隐式契约。库存迁移时无法只测试库存模块,权限也无法收敛。让架构测试失败,并把例外写成有期限的决策记录;不要用 public 暂时绕过后永久遗忘。
反例二:模块形成循环
订单调用库存,库存又同步调用订单查询状态,会形成 order → inventory → order。在单进程里它可能还能启动,拆服务后却容易形成调用环、级联超时和发布锁步。修复通常不是引入第三个“公共模块”塞入所有逻辑,而是重新判断谁拥有该决策,或把完成通知改为事件。
反例三:过早远程化
把库存拆成服务后做故障注入:让它延迟超过订单超时,但仍提交预留。观察订单是否把“未知结果”误判为失败并再次扣减。若系统没有 request ID、幂等记录、超时预算和恢复流程,就还没有承担远程边界的基本能力。
故障证据应按“现象 → 影响 → 关联 ID/指标/日志 → 根因 → 修复 → 重放验证 → 预防门禁”闭环,不能只写“增加重试”。
用证据判断要不要拆
值得拆分的信号通常同时出现,而不是单项达标:
业务边界稳定,输入、输出、数据所有权和失败语义能写成契约。两部分发布节奏长期不同,单体发布窗口已成为可量化瓶颈。资源曲线显著不同,例如一个能力 CPU 密集、另一个主要等待 I/O,独立扩缩容确实节省成本。
故障需要隔离,且团队已经有超时、幂等、观测、容量和恢复能力。安全或合规要求独立权限和审计,进程/数据隔离能实质降低风险。团队能端到端拥有服务,而不是把开发、数据库和事故责任切成更多交接。
不应作为拆分理由的信号包括“代码行数多”“团队人数到了某个数字”“大家都在用微服务”或“未来可能有流量”。先做模块化、索引优化、异步任务或垂直扩容,常常成本更低。
性能、可靠性、安全和成本账
单体内调用通常没有网络序列化,事务简单,但共享进程意味着内存泄漏、线程耗尽或错误配置可能影响全部模块。模块化单体能限制代码耦合,却不能提供进程级故障隔离。
微服务可以独立扩缩容和限制故障域,但每个远程 hop 都增加连接、排队、序列化和尾延迟。服务越多,证书、身份、授权、秘密、镜像、日志、指标、追踪、告警和漏洞修复的对象也越多。安全并不会因为“内网调用”自动成立;NIST SP 800-204 将身份、访问管理、安全通信、监控和韧性列为微服务安全策略的重要部分。
数据边界尤其不能含糊。两个服务共享一组可任意写入的表,会让独立发布成为假象;立即强拆数据库又可能制造跨服务查询和一致性问题。可先明确表所有权、禁止跨模块写入、通过 API 或事件复制只读视图,再根据访问证据迁移物理存储。
渐进演进与可回滚迁移
推荐先在单体内建立清晰模块,因为它同时是拆分前的准备和“不拆”的可长期形态。真正迁移时可采用绞杀方式:旧入口仍负责路由,新服务先处理低风险或影子流量;对比结果、延迟和错误率;设置明确停止条件;切换后保留有限时间的回退路径。
双写不是免费保险。若必须双写,要定义主记录、失败补偿、对账频率和终止日期。回滚也不能只回滚代码:契约、数据库 schema、消息格式和已写入数据都要向后兼容,或者准备可验证的数据恢复步骤。
团队门禁
架构评审不该只收一张组件图。至少提交:边界与数据所有权;正常及超时/重复/部分失败时序;容量基线和 SLO;鉴权与审计路径;部署、灰度、回滚和数据恢复方案;模块/契约测试;拆分前后的成本估算。
上线门禁要验证旧版本与新版本能否共存,故障注入是否会级联,仪表盘能否按服务和业务关联 ID 定位问题。运行一段时间后复盘拆分是否真的降低发布等待、故障影响或资源成本。如果收益没有出现,应允许合并服务;架构演进不是只能向更多进程前进。
架构取舍继续查这些一手资料
NIST SP 800-204:微服务架构安全策略:微服务安全、身份、通信、监控与韧性的一手公共标准入口。Spring Modulith Fundamentals:应用模块、提供/需要接口与内部实现的官方模型。
Spring Modulith 模块结构验证:循环依赖和模块依赖验证规则。
最后的选择原则
默认从能正确交付的最简单形态开始;先在代码和数据上证明边界,再考虑把边界变成网络。只有当独立发布、容量、故障隔离、安全或团队所有权的收益已有持续证据,并且团队能承担不确定结果、契约兼容和运行治理时,微服务边界才真正值得拆。
