单体、模块化单体与微服务:边界、代价与选择
一个源码仓库可以构建多个制品,一个制品可以启动许多实例;多个模块也可以共享一次部署,却各自拥有明确的数据写入责任。单体、模块化单体与微服务的差异,正是这些边界怎样组合。代码质量并不由名称决定:单体可以模块清晰、测试完善,一组“微服务”也可能锁步发布、共同写表,并在同步调用环中一起故障。
判断架构形态之前,需要先区分几类经常被混为一谈的对象:
源码仓库
└─ 代码模块与依赖图
└─ 构建制品:JAR、WAR、镜像……
└─ 部署 / 扩容 / 回滚单元
└─ 对外提供能力的服务
└─ 某一时刻运行的实例 / 副本
└─ 进程、端口、资源、健康与故障状态
横跨这些对象的另一条边界:
业务事实 → 权威写入者 → schema 与迁移责任 → 纠错与审计责任这些数量没有必然的等号:
仓库数 ≠ Maven / Gradle module 数 ≠ 制品数
制品数 ≠ 服务数 ≠ 实例数 ≠ 容器数 ≠ 主机数“创建订单并预留库存”用来连接模块、事务和部署关系。配套工程采用 Java 25、Maven 3.9.12、Spring Boot 4.1.1、Spring Modulith 2.1.1 与 H2,提供完整工程压缩包,解压后的 README.md 包含运行入口和文件说明。实验验证模块边界、同一资源的本地事务与单部署单元运行;生产数据库、分布式事务和生产容器方案需要各自的运行验证。
三种形态由哪些边界共同决定
先识别模块、制品、部署单元、服务和实例
代码模块是一组具有明确公开接口并隐藏内部实现的代码。Java package、JPMS module、Maven/Gradle 子项目或框架识别的 application module 都可以承载模块,但目录和构建配置只提供结构;只有可见性、依赖方向和自动验证能够长期维护边界。
构建制品是构建过程交付的输出,例如 JAR、WAR 或容器镜像。一个可执行 JAR 可以包含订单、库存、会员等多个业务模块;一个业务服务也可能由共同部署的主应用、sidecar 和初始化工具组成。因此,制品回答“交付什么”,不独立回答“业务能力是否自治”。工程如何形成制品并成为进程,见从代码到进程。
部署单元承载一次发布、扩容、配置变更和回滚。systemd unit、Docker Compose service、Kubernetes Deployment 或 Serverless function 都可能成为载体。资源清单里的名字只是声明;如果两个 Deployment 每次都要在同一窗口升级,单独回滚还会破坏协议或数据,发布耦合依然存在。
服务通过明确契约提供业务能力,并长期负责实现、权威数据和运行结果。它可以运行多个实例,也可以由紧密协作、共同发布的多个进程组成。Kubernetes Service 用稳定入口指向不断变化的多个 Pod,恰好展示了服务身份与实例身份的差异。Kubernetes Service与 Pod分别描述逻辑入口和运行副本。
实例是服务在某一时刻的运行副本。它具有 PID、容器或 Pod 身份、地址、监听端口、资源限制和健康状态。把一个单体从 1 个副本扩到 20 个副本,只是改变容量与可用性,并没有产生 20 个微服务;一个 Pod 内存在两个容器,也不能据此断定它们是两个业务服务。
数据边界由写入权和解释权确定
数据权威回答四个问题:谁有权写入某项业务事实,谁解释其含义,谁执行 schema 迁移,谁在错误时负责纠正。订单服务可以保存库存数量的查询副本,但库存服务仍是库存事实的 source of truth;查询副本允许有多旧、怎样更新和怎样重建,必须成为契约的一部分。
同一物理数据库可以承载多组彼此独立的业务数据:
同一物理数据库服务器
├─ order schema / tables
│ └─ 只允许订单模块或订单服务写入
└─ inventory schema / tables
└─ 只允许库存模块或库存服务写入
仍需分别定义:
账号权限、迁移脚本归属、备份恢复、审计、容量和故障影响在模块化单体中,不同模块可以拥有各自表的写入责任,并在业务需要时加入同一本地数据库事务。在微服务中,也可以暂时共享同一物理数据库服务器,但不能共同依赖同一 schema 或任意读写同一批表。微软的微服务数据设计说明明确区分了共享物理服务器与共享 schema/table:后者会把变化重新耦合起来。
单体:多个能力共享主要应用生命周期
单体把多项业务能力放在同一个主要应用生命周期中构建、测试、发布和回滚。内部可以是清晰的分层或模块,也可能互相穿透;“单体”本身不评价代码质量。
一个主要部署 / 版本生命周期
┌──────────────────────────────────────┐
│ HTTP adapter │
│ └─ order code │
│ └─ inventory code │ 进程内调用
│ │
│ local transaction │
│ ├─ write order_record │
│ └─ write inventory_reservation │
└──────────────────────────────────────┘
│
├─ instance A
├─ instance B
└─ instance C如果订单写入与库存预留确实使用同一个事务管理器、同一支持事务的资源并正确加入同一事务,它们可以原子提交或回滚。这个结论有严格条件:单体若同时调用第三方支付、对象存储或另一个数据库,同样会跨出本地事务,不能把“单体”概括成天然全局 ACID。
单体的直接收益是本地调用、调试和原子重构较简单,发布协调集中。常见代价是所有能力共享发布节奏、进程资源和故障域;只有某个模块是热点时,往往仍需复制整个应用。多副本能降低单实例故障,却不会消除版本、数据和共享依赖的系统性故障。
模块化单体:统一部署,内部边界可验证
模块化单体保留统一部署生命周期与进程内调用,同时按业务能力建立可验证模块。一个有效模块会对其他模块暴露少量稳定 API 或领域事件,隐藏内部类型,保持明确且可检查的依赖方向,并为权威数据、表结构迁移和纠错指定责任。结构测试与模块行为测试使这些约定不只存在于文档中。
同一笔订单在模块化单体中可以表达为:
一个部署 / 回滚单元
┌─────────────────────────────────────────────┐
│ order module │
│ ├─ public: OrderOperations │
│ └─ internal: controller, service, repository│
│ │ │
│ ▼ only public API │
│ inventory module │
│ ├─ public: InventoryOperations │
│ └─ internal: service, repository │
│ │
│ one local transaction when required │
│ ├─ inventory_reservation │
│ └─ order_record │
└─────────────────────────────────────────────┘
禁止:
order → inventory.internal.Repository
order → 直接 UPDATE inventory tables
inventory → order → inventory 依赖环多建几个 Maven module 不会自动形成模块化单体。如果所有类型都公开、依赖任意反向、多个模块共同写表,整齐目录仍掩盖不了穿透。单个 Maven 工程反而可以借助语言可见性、ArchUnit、Spring Modulith 或自定义架构测试守住真实边界。
模块化单体可以长期存在。它尤其适合业务边界仍在学习、跨能力强一致约束较多、团队规模有限或独立部署收益不足的系统。先稳定模块和数据权威,再决定是否跨网络,能够避免把尚未理解的耦合固化成远程协议。
微服务:业务能力拥有独立服务生命周期
微服务把业务能力划分为多个可独立构建、部署、回滚和运行的服务,通过进程外契约协作。经典定义强调围绕业务能力组织、独立部署、去中心化治理与数据管理,同时也明确分布式通信会带来额外成本,参见 James Lewis 与 Martin Fowler 的 Microservices。
order service inventory service
┌──────────────────────┐ ┌──────────────────────┐
│ Order API / state │ │ Inventory API / state│
│ local transaction │ -- remote → │ local transaction │
│ order data authority│ contract │ inventory authority │
└──────────────────────┘ └──────────────────────┘
│ │
└─ success / explicit failure / unknown┘
partial completion possible独立部署要靠运行结果验证。提供者与消费者需要在一段时间内运行不同版本;其他服务不能绕过契约修改内部数据,schema 迁移也不能要求全体同步停机。针对一个能力发布、扩容或回滚时,其余服务应继续工作,负责团队还要独立承担观察、告警、值守和恢复。契约、数据、部署与团队责任同时成立,流水线上的独立按钮才有业务含义。
微服务也不要求“一个服务只有一个进程”。一个服务可以有 API、worker 和专属数据组件,也可以运行多个副本。分类关注的是它们是否构成同一个服务生命周期,而不是肉眼数容器。
分布式单体:跨了网络,却没有得到独立性
分布式单体具有多仓库、多进程或多容器的外观,却仍然依赖锁步发布、共享写表、内部模型共享、同步调用成环或统一停机。它承担了序列化、网络失败、部署对象增加和跨进程调试的成本,又没有获得独立变化能力。
外观:service A ─HTTP→ service B ─HTTP→ service C
实际约束:
├─ A、B、C 必须同批升级和回滚
├─ 三者用同一高权限账号修改同一批表
├─ 公共业务 JAR 变更要求全体升级
├─ B 又同步回调 A,形成调用环
└─ 任一依赖不可用,整条用户路径失败多个服务协作并不自动等于分布式单体。问题在于边界是否与变化、数据和故障责任一致,以及协调成本是否抵消了拆分收益。
正交架构术语回答不同问题
| 术语 | 主要回答的问题 | 与三种形态的关系 |
|---|---|---|
| 分层、六边形、整洁架构 | 一个部署单元内部的依赖方向和适配边界 | 可用于单体、模块化单体和每个微服务内部 |
| DDD bounded context | 一套模型和语言在哪个业务语境内成立 | 可以成为模块或服务候选,但不自动等于微服务 |
| 事件驱动 | 组件怎样通过事件协作 | 可用于同进程模块事件或跨服务消息 |
| CQRS | 命令与查询模型是否分开 | 三种形态都可采用 |
| 容器、Pod、Serverless | 代码怎样被承载和调度 | 不能单独证明业务服务边界 |
| Monorepo / 多仓库 | 源码怎样版本管理 | 单仓可产出多个服务,多仓也可能锁步发布 |
系统也不必拥有唯一的全局标签。一个模块化单体可以与少量独立服务共同组成系统;应对每条具体边界判断部署、调用、数据和责任,而不是为了架构图整齐强行统一命名。
为什么边界位置会改变交付与运行成本
变化边界决定发布是否真的独立
单体允许一次提交原子修改多个模块,跨模块重构、接口调整和统一回归较直接,但部署和回滚覆盖的范围较大。模块化单体通过公开 API、依赖规则和模块测试缩小代码影响面,仍保留统一发布。
微服务只有在契约兼容、数据迁移独立和回滚独立时,才会缩短某项能力等待其他团队或系统的时间。下列外观都不足以证明独立发布:
| 外观 | 仍需确认的事实 |
|---|---|
| 独立 Git 仓库 | 是否仍靠同一版本清单锁步发布 |
| 独立流水线 | 消费者能否与新旧提供者版本共存 |
| 独立镜像 | 数据库迁移是否要求其他服务同时停机 |
| 独立 Deployment | 单独回滚后协议和数据是否仍兼容 |
如果每次修改库存接口都要同时发布订单、促销和报表服务,拆分只把编译期协调改成了发布期协调。Fowler 的微服务权衡把强模块边界和独立部署列为收益,也明确指出分布式、最终一致性和运维成熟度的成本。
进程内调用与远程调用不是同一种失败模型
进程内方法调用共享地址空间、调用栈和通常可直接传播的异常。远程调用要经过序列化、连接、网络、代理、队列和对端调度;它至少需要定义:
caller deadline
├─ 连接建立预算
├─ 请求发送预算
├─ 对端排队与执行预算
└─ 响应读取预算
结果
├─ success:收到并接受成功结果
├─ explicit failure:收到可解释的拒绝或错误
└─ unknown:超时、断连或响应丢失,服务端可能已提交订单服务调用库存预留超时,记录的是 deadline 内没有收到可接受响应,库存结果仍处于未知状态。稳定的 operation ID、幂等约束、状态查询、对账和补偿负责让它最终收敛。完整的重试与恢复机制见重试、幂等与补偿。
服务粒度过细还会产生 chatty calls:完成一个页面需要串行请求许多小服务,或列表中每一项都触发远程查询。链路成功率会随依赖增加而下降,尾延迟受最慢依赖影响,重试还可能放大流量。粗粒度业务 API、批量接口、本地读模型与异步非关键副作用通常比继续拆小更有效。
事务边界决定怎样处理部分完成
在配套模块化单体示例中,订单与库存表使用同一 H2 数据源,OrderApplicationService 的本地事务可以覆盖库存模块调用和订单写入:
@Transactional
public OrderReceipt place(
String orderId, String sku, int quantity, boolean failAfterReservation) {
inventory.reserve(orderId, sku, quantity);
if (failAfterReservation) {
throw new IllegalStateException("controlled failure after reservation");
}
orders.insert(orderId, sku, quantity);
return new OrderReceipt(orderId, sku, quantity, "ORDERED_AND_RESERVED");
}这段代码的原子性来自实际事务资源和调用路径,不来自类名或注解外观。如果 inventory.reserve 改成 HTTP 调用,Spring 的 @Transactional 不会传播为对方数据库事务,也不能撤销对方已经提交的库存记录。
微服务中通常由订单与库存分别提交本地事务。完整流程需要用显式状态机表达,例如:
PENDING_RESERVATION
├─ reservation accepted → RESERVED → CONFIRMED
├─ reservation rejected → REJECTED
└─ outcome unknown
├─ query by operationId
├─ retry idempotently
├─ reconcile asynchronously
└─ compensate when business rules allowSaga 是组织多个本地事务和补偿的一类模式,不是把它们重新变成一个全局数据库事务。补偿也不一定能恢复原状:退款、释放库存、撤销优惠可能有时间窗口和业务副作用。AWS 的 Saga orchestration pattern列出了集中协调方式及其适用条件;具体选择见分布式事务。
数据自治会减少变化耦合,也会增加查询与一致性工作
服务拥有权威数据后,其他服务不能通过共享表 join 获得一切信息。常见选择包括调用拥有者 API、订阅事件构建本地读模型、由组合层聚合,或让分析平台接收经过治理的数据副本。
| 读取方式 | 主要收益 | 必须处理的问题 |
|---|---|---|
| 同步调用拥有者 API | 读到接近当前的权威解释 | deadline、容量、故障传播、结果未知 |
| 事件复制到本地读模型 | 查询快且减少同步依赖 | 延迟、重复、乱序、积压、重建 |
| API 聚合 | 客户端契约集中 | 扇出、尾延迟、部分结果 |
| 离线/流式分析副本 | 适合跨域统计 | 数据时效、血缘、权限与删除传播 |
数据库 per service 不意味着必须购买一台独占服务器,而是其他服务不能绕过拥有者的模型和权限。只把表改到不同 schema,却让所有账号保留全库写权限,仍然只是命名约定。
独立进程提供隔离机会,不自动产生隔离结果
单体扩容会复制整个部署单元,资源开销较集中,热点能力难以单独扩容。微服务可为库存计算、图片处理或报表任务设置不同副本、CPU、内存和调度策略,但也增加基础运行开销、容量模型和调度对象。
进程隔离只是一项机制。同步调用链缺少 deadline、并发隔离和拒绝策略时,故障会沿链传播;所有服务共享数据库、连接上限、消息 Broker 或网络出口时,独立进程仍共享瓶颈;每一层都自动重试会形成指数级重试风暴。readiness 如果继续接收无法完成的请求,或所谓降级结果违反业务契约,也没有形成有效隔离。服务 A 的突发流量还可能耗尽服务 B 的线程、连接或分区。
真正的故障隔离需要容量预算、超时传播、bulkhead、背压、限流、降级和可恢复状态共同成立。单纯增加容器、网关或 service mesh 不能替代这些应用语义。
远程边界扩大了身份、安全与可观测责任
同一进程中的模块通常共享应用身份;跨服务后,每条边都要定义调用方身份、授权范围、传输保护、Secret、审计和最小数据暴露。网络连通后,库存服务仍要判断“这个调用者是否可以预留这个仓库的库存”,网关认证只提供调用者身份的一部分依据。
跨服务后,需要传播 Trace Context,并用稳定业务 operation ID 关联日志、指标、Trace、消息和数据状态。W3C Trace Context定义 traceparent 与 tracestate 的传播格式,但 Trace ID 不是认证凭据,采样也不能替代关键业务审计。
可观测性最终要回答业务问题,而不只是“哪个 span 是红色”:订单是否提交、库存是否预留、哪个步骤未知、谁会重试、何时转人工。服务越多,统一字段语义、时钟、采样、保留和查询成本越高。
组织边界只有与端到端责任一致才有价值
系统结构会受组织沟通方式影响,但“一个团队一个服务”不是服务粒度公式。有效所有权应覆盖需求、代码、数据、部署、容量、告警和恢复。
一个团队维护几个内聚服务可以成立;多个团队共同审批同一服务每次发布,则说明变化责任仍未收敛。反过来,仅因为团队人数增加就拆服务,会制造接口和协调成本,却不一定改善交付。决定因素应是业务内聚、变化模式、数据权威和运行能力。
三种形态的收益都带有成立条件
| 维度 | 单体 | 模块化单体 | 微服务 |
|---|---|---|---|
| 代码协作 | 可直接重构;也可能互相穿透 | API 与依赖规则可自动验证 | 实现隔离,依赖远程契约 |
| 发布 | 统一版本,协调集中 | 统一版本但影响面可收敛 | 契约和数据兼容时才可独立发布 |
| 调用 | 低开销本地调用 | 受控的本地 API | 网络、序列化、deadline 与未知结果 |
| 事务 | 同一资源可用本地事务 | 可跨模块共用本地事务 | 多个本地事务与显式恢复流程 |
| 数据 | 容易统一查询,也容易共同写入 | 逻辑所有权可分开 | 权威写入自治,跨域查询更复杂 |
| 容量 | 通常复制整个应用 | 仍复制整个部署单元 | 可独立扩容,但运行对象增加 |
| 故障 | 共享进程故障域 | 共享进程,代码影响可控 | 可隔离,也可能经依赖级联 |
| 适用前提 | 统一生命周期成本可接受 | 愿意维护内部边界 | 独立收益大于分布式成本且具备运维能力 |
怎样建立模块并判断是否值得拆成服务
先按业务能力建立一个可以被验证的模块
模块边界应围绕稳定的业务语言、规则和权威数据,而不是 Controller、Service、Repository 三个技术层。按技术层分顶级目录会让一次业务修改横跨所有目录,也很难回答谁拥有数据。
dev.example.architecture
├─ order
│ ├─ OrderOperations.java # 模块 API
│ ├─ package-info.java # 模块元数据
│ └─ internal
│ ├─ OrderController.java
│ ├─ OrderApplicationService.java
│ └─ JdbcOrderRepository.java
└─ inventory
├─ InventoryOperations.java # 模块 API
├─ package-info.java
└─ internal
├─ InventoryApplicationService.java
└─ JdbcInventoryRepository.javaSpring Modulith 默认把主应用包的直接子包识别为 application module。模块根包中的公开类型构成默认 API,嵌套包视为内部实现;官方模块基础说明还支持显式依赖与 named interfaces。
示例用 package-info.java 声明订单只允许依赖库存模块:
@org.springframework.modulith.ApplicationModule(
displayName = "Order",
allowedDependencies = "inventory"
)
package dev.example.architecture.order;InventoryOperations 位于库存模块根包,是公开契约;inventory.internal 中的实现即使因为 Spring 装配需要使用 Java public,也不属于模块 API。这个区分正是仅靠 Java 编译器难以完整检查的部分。
用自动化测试固定依赖方向
配套工程的结构测试先断言模型确实识别出两个模块,再执行验证,避免空模型误通过:
@Test
void verifiesTwoApplicationModulesAndTheirBoundaries() {
var modules = ApplicationModules.of(ArchitectureShapesApplication.class);
assertThat(modules.stream()).hasSize(2);
assertThat(modules.getModuleByName("order")).isPresent();
assertThat(modules.getModuleByName("inventory")).isPresent();
modules.verify();
}verify() 检查模块依赖环、只经 API 包访问,以及显式 allowedDependencies。规则来自 Spring Modulith 的模块结构验证说明。其他语言或框架也可以用包可见性、JPMS、ArchUnit、构建依赖图或自定义静态分析实现同样目标;工具不是架构定义本身。
在可重复环境中运行正向与负向实验
下面的命令适用于安装了 Docker Engine 与 Compose v2 的 Linux shell,也可在能够运行 Linux 容器的 Docker Desktop 中执行。命令由拥有本机 Docker 权限的普通开发用户运行,不进入生产主机。一次性 Maven 容器显式使用宿主 UID/GID,应用运行容器使用固定的 10001:10001;Docker daemon 与 BuildKit 的内部身份仍取决于本机安装方式,不能由调用命令的用户身份反推。先解压示例并固定工作目录:
LAB_DIR="$PWD/architecture-shapes-lab"
cd "$LAB_DIR"
test -f pom.xml
BUILD_USER="$(id -u):$(id -g)"
MAVEN_CACHE="$LAB_DIR/.cache/maven"
test "$(id -u)" -ne 0
test "$(id -g)" -ne 0
mkdir -p "$MAVEN_CACHE"
test -w "$MAVEN_CACHE"
docker version --format 'server={{.Server.Version}}'
docker compose version预期文件、缓存写权限检查和两条版本命令均以 0 退出,BUILD_USER 的 UID 与 GID 都不应为 0。如果无法连接 Docker daemon,应先检查当前用户是否有本机 Docker 权限;不要用修改 socket 为全局可写的方式绕过权限。
使用一次性构建容器可以避免宿主机 JDK 差异:
docker run --rm \
--user "$BUILD_USER" \
--env HOME=/var/maven \
--env MAVEN_CONFIG=/var/maven/.m2 \
--env MAVEN_OPTS=-Dmaven.repo.local=/var/maven/.m2/repository \
--volume "$LAB_DIR:/workspace" \
--volume "$MAVEN_CACHE:/var/maven/.m2" \
--workdir /workspace \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp clean verify核心预期输出为:
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS这 3 个测试分别覆盖模块结构、成功下单,以及“库存已写入后故意抛错,订单与库存记录都回滚为 0”。如果测试数不是 3,即使显示成功也要检查测试发现规则;出现模块名缺失,检查主应用包与两个模块的相对位置;出现连接或 schema 错误,则先处理测试数据库,不要误判为模块问题。
负向 profile 会把一个故意越界的类加入主源码,它直接依赖 inventory.internal.JdbcInventoryRepository:
set +e
docker run --rm \
--user "$BUILD_USER" \
--env HOME=/var/maven \
--env MAVEN_CONFIG=/var/maven/.m2 \
--env MAVEN_OPTS=-Dmaven.repo.local=/var/maven/.m2/repository \
--volume "$LAB_DIR:/workspace" \
--volume "$MAVEN_CACHE:/var/maven/.m2" \
--workdir /workspace \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Pboundary-violation clean test \
> boundary-violation.log 2>&1
RC=$?
set -e
printf 'boundary_violation_exit=%s\n' "$RC"
grep -F "depends on non-exposed type" boundary-violation.log
test "$RC" -ne 0实测退出码非 0,核心异常为:
Module 'order' depends on non-exposed type
...inventory.internal.JdbcInventoryRepository within module 'inventory'!这里的失败是预期结果:它证明规则确实阻止订单模块穿透库存内部实现。如果命令以 0 退出,应确认 profile 已启用、负例源码已加入构建且结构测试没有被跳过;如果在 Java 编译阶段就失败,则只能证明语言可见性阻止引用,尚未验证 Modulith 规则。
输出图只节选命令的操作对象与判断字段。复现实验时仍使用上面的完整命令,使构建身份、Maven 缓存、curlrc、代理和 HTTP 错误都受控。

验证一个部署单元中确实存在两个模块
构建并启动容器:
docker compose up --build --detach
for attempt in $(seq 1 30); do
HEALTH="$(docker inspect \
--format '{{.State.Health.Status}}' \
architecture-shapes-lab-app-1 2>/dev/null || true)"
[ "$HEALTH" = healthy ] && break
sleep 2
done
test "$HEALTH" = healthy
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--request POST \
'http://127.0.0.1:18081/orders?orderId=order-1001&sku=SKU-1&quantity=2'
docker compose ps
docker exec architecture-shapes-lab-app-1 sh -c 'id && ps -o pid,user,args -p 1'预期业务响应和关键运行状态为:
{"orderId":"order-1001","sku":"SKU-1","quantity":2,"state":"ORDERED_AND_RESERVED"}
STATUS: Up ... (healthy)
PORTS: 127.0.0.1:18081->8080/tcp
uid=10001(app) gid=10001(app)
1 app java -jar /app/application.jar这组证据证明一个容器、一个 Java 主进程和一个对外端口中运行着两个经过结构验证的业务模块。它不证明数据库具备生产持久性、应用高可用或模块可以独立扩容。若 health 长期为 starting 或变为 unhealthy,先执行 docker compose logs app 检查启动与 readiness;若端口占用,修改 Compose 的宿主端口,不要修改容器内端口来掩盖问题。
回环请求把 -q 放在 curl 的第一个选项,阻止读取用户级 .curlrc;--noproxy '*' 阻止代理环境改变连接目标;--fail-with-body 让 HTTP 4xx/5xx 保留响应体并以非零状态退出。三项共同保证这里验证的是宿主到本机发布端口的成功路径。
实验结束必须清理运行对象和负向日志:
docker compose down
rm -f boundary-violation.log预期 Compose 删除本示例容器和网络,随后 docker compose ps 为空。若仍有对象,先确认当前目录的项目名,避免删除其他 Compose 项目。命令不会删除镜像和 $MAVEN_CACHE;需要清理缓存时,先用 test "$MAVEN_CACHE" = "$LAB_DIR/.cache/maven" 锁定路径,再执行 rm -rf -- "$MAVEN_CACHE"。需要回收镜像时可精确执行 docker image rm local/architecture-shapes-lab:1.0.0。
模块事件是否解耦取决于交付语义
模块之间可以调用方法 API,也可以发布应用事件。事件能够降低发布者对消费者类型的直接依赖;它究竟同步还是异步、是否可靠,要看监听与交付机制。
Spring 的普通应用事件默认可在发布线程同步处理,监听器异常会影响调用方;此时它仍可能加入同一本地事务。改为异步监听后,发布者不再等待消费者,但失败不再沿原调用栈返回,也必须决定事件是否持久、怎样重试、怎样防重和怎样观察积压。Spring Modulith 的事件支持提供事件发布登记等能力,但业务仍需明确交付与恢复契约。
需要立即获得结果并共同提交时,使用明确的同步 API;已经发生的事实需要触发可独立完成的后续动作时,事件更自然。不要为了将来可能拆服务,提前把所有本地调用变成复杂消息流。
用当前系统的证据完成分类
架构图记录设计意图,运行与发布记录呈现系统此刻的事实。下面的采集入口以只读操作为主,结果还要与发布记录、数据库权限和团队职责互相印证。
需要确认的事实
├─ 版本与回滚单元:CI/CD 产物、发布单、镜像 tag、回滚目标
├─ 运行与网络边界:systemd / Compose / Kubernetes、端口、Trace
├─ 代码依赖:编译依赖、模块 API、架构测试、依赖环
├─ 数据权威:数据库角色、迁移归属、SQL 审计、纠错责任
├─ 事务边界:transaction manager、连接/会话、消息与远程提交
└─ 变化和故障责任:共同变更、发布等待、回滚、事故与值守记录Docker Compose 环境可以先查看声明与运行对象:
docker compose config --services
docker compose images
docker compose psKubernetes 环境由具有只读权限的身份执行:
kubectl auth can-i get deployments.apps --all-namespaces
kubectl get deployment,statefulset --all-namespaces
kubectl get service --all-namespaces第一条应返回 yes 后再继续;返回 no 时申请最小只读权限,不要改用集群管理员凭据。多个 Deployment 表示声明了多个工作负载,多个 Pod 也可能只是同一服务的副本,sidecar 通常仍属于宿主服务。分类还要检查发布能否独立、调用是否跨进程以及数据由谁写入。
数据边界应检查数据库角色授权、迁移仓库和真实 SQL 审计。文档声称“库存服务拥有库存表”,但订单服务账号仍可直接 UPDATE 时,应以运行权限为准。生产检查优先使用只读元数据与审计系统,查询语句要按数据库产品编写并先评估权限与负载,不能把教学 SQL 直接粘贴到生产执行。
保持、抽取和合并都是正常结果
业务边界仍快速变化、团队规模有限,或跨能力强一致约束较多时,单体和模块化单体便于在进程内继续调整。如果当前发布已经足够快,各模块容量曲线接近,独立故障域的收益又不明显,跨进程只会提前增加运行负担。先建立模块 API、数据归属和自动验证,日后仍可按真实收益抽取服务。
评估抽取服务需要持续、可观察的收益信号。某项能力可能长期阻塞其他能力发布,或具有明显不同的容量曲线和计算资源;事故记录可能要求独立故障域,安全与合规也可能要求独立身份、数据访问和审计。与此同时,业务模型需要足够稳定并拥有明确 owner,团队也要具备独立构建、发布、观测、值守和恢复的能力。
这些信号用于启动拆分评估,单独出现一项还不足以决定抽取。接口需要足够粗粒度,跨边界状态可以建模,数据迁移与回滚有路径,新旧契约能够共存,未知结果也能查询或对账。
合并服务同样是正常架构决策。两个服务长期同步互调、版本总是一起发布、数据事实无法分开、同一团队共同值守、独立扩容从未使用且基础设施成本显著时,应评估合并。边界的价值在于匹配真实变化和责任,不在于维持服务数量。
| 决策 | 必须提交的核心证据 | 复验结果 |
|---|---|---|
| 保持模块化 | 当前发布、容量和故障成本可接受;边界仍在学习 | 模块违规下降,交付未因统一部署受阻 |
| 抽取服务 | 独立发布/容量/故障/合规收益持续且边界稳定 | 能独立升级回滚,跨边界失败可恢复 |
| 合并服务 | 独立收益长期未出现,协调与运行成本更高 | 调用链缩短,发布与数据责任更清楚 |
Martin Fowler 的 Monolith First讨论了早期边界难以判断以及微服务额外成本。其重点在于:业务边界仍模糊时,先在进程内学习和调整,通常比用网络协议固化猜测更容易撤销。这条建议并不要求所有系统永久保持单体。
怎样识别分布式单体并修复错误边界
从没有出现的独立收益开始诊断
分布式单体表现为一组可验证的耦合:系统已经承担跨进程成本,独立发布、容量隔离或故障隔离等收益却没有出现。
系统已经拆为多个运行单元
├─ 发布仍锁步
│ ├─ 破坏性 API / event schema
│ ├─ 公共业务库必须全量升级
│ └─ 数据库迁移要求全局停机
├─ 数据仍共享写入
│ ├─ 多服务共用高权限账号
│ ├─ 跨服务直接 JOIN / UPDATE
│ └─ schema 与纠错责任不明
├─ 调用仍强耦合
│ ├─ 同步环与过长调用链
│ ├─ chatty API / N+1 remote calls
│ └─ deadline、重试与容量预算不一致
├─ 故障仍级联
│ ├─ 共享数据库、连接池、Broker 或网络出口
│ ├─ 无隔离的重试风暴
│ └─ readiness 或降级契约不成立
└─ 运行责任仍集中
├─ 统一发布团队和统一值守入口
├─ 没有服务级 SLO 与 owner
└─ Trace、日志和 operation ID 无法关联不要用仓库数量、容器数量或组织名称证明微服务成功。发布共现记录、跨服务调用图、数据库审计、权限、事故传播与值守记录更接近真实边界。
锁步发布先处理契约和迁移兼容
如果服务有独立流水线却必须同批上线,先找出协调点:破坏性字段变更、共享 DTO JAR、枚举语义变化,还是数据库迁移。
接口演进通常需要扩展—迁移—收缩:提供者先增加兼容字段或新版本,消费者在新旧形态并存时迁移,确认旧消费者消失后再删除旧字段。数据库迁移也应允许新旧应用版本共存,例如先新增可空字段或新表、双路径读取和回填,再在观察窗口后收缩。每一步都要有回滚边界;已经不可逆转换的数据不能假设应用镜像回滚即可恢复。
共享库应收缩到协议生成物、基础观测和极少稳定原语。把数据库实体、业务服务、错误枚举与所有 DTO 放入 common 包,会在编译期强迫全体升级。业务规则应通过服务契约演进,不通过升级一个公共 JAR 传播。
复验不只是“流水线能够单独点击”,而是选择一个真实变更,让提供者新旧版本与消费者新旧版本按支持矩阵共存,分别验证单独部署和回滚。
共享写表先恢复唯一权威写入
跨服务共同写表时,先确定每项事实的唯一权威写入者和纠错者,再收紧运行账号权限。直接删权限可能中断业务,应先从 SQL 审计识别真实写路径,把越界写入改为拥有者 API、命令或事件驱动流程,完成数据迁移和回退演练后再撤销权限。
order service account
├─ write order-owned tables
└─ no write on inventory-owned tables
inventory service account
├─ write inventory-owned tables
└─ publish/query inventory facts through contract跨服务只读访问同样会形成耦合。消费者开始依赖拥有者的表结构,还可能制造高负载查询和隐私泄漏。长期跨域读取应转为版本化 API、受治理的数据产品或可重建读模型;临时迁移查询要记录负责人和下线日期。
复验同时查看数据库授权、SQL 审计和迁移归属;代码中删掉某个 repository 类,只完成了其中一层。
同步环和 chatty calls 要重画业务责任
A → B → C → A 的同步环通常说明责任被按处理步骤切碎,任何节点都无法独立完成一项业务结果。先用 Trace 与接口清单画出调用方向、请求次数、deadline、失败语义和数据需求,再判断同一业务不变量是否应该回到一个模块或服务,细小查询是否能变成粗粒度命令,非权威查询数据是否适合形成本地读模型,以及通知、审计或索引能否在事实提交后异步处理。如果两个边界始终不可分,合并比继续维持远程外观更诚实。
引入消息队列不会自动消除耦合。事件 schema、交付保证、顺序、幂等、积压和所有权仍然需要治理;同步环改成消息环但没有稳定状态机,只会让故障更难观察。
复验应比较用户路径的同步深度、远程调用次数、尾延迟和部分失败率,并做下游不可用实验,确认核心业务能按契约拒绝、排队或降级。
级联故障先限制传播,再调整结构
线上级联故障发生时,先停止放大:限制入口流量,关闭无幂等保障的自动重试,保护数据库与关键依赖,必要时降级非核心能力。不要在事故中直接进行服务合并或大规模契约改造。
稳定后按依赖边设置总体 deadline、并发上限、连接池和队列上限、重试责任、幂等条件与降级语义。只有调用方能够处理拒绝,限流才不是随机错误;只有操作可安全重复,重试才是恢复机制;只有就绪检查反映实例能承担必要依赖,摘流才有意义。
若多个服务仍共享一个容量瓶颈,应把共享数据库、Broker、缓存和网络出口纳入容量与故障域,而不是只增加应用副本。用压测和故障注入验证传播被限制在预算内,不能以组件面板绿色作为完成标准。
可观测断裂要同时建立技术关联和业务状态
先统一稳定的 service.name、环境、版本、实例和错误分类,再传播符合 W3C 规范的 Trace Context。跨消息处理还要保留生产、存储、投递和消费的因果关系,并控制 baggage 中的敏感信息。
业务操作使用独立 operation ID。Trace 可能采样、截断或超过保留期,而订单和库存状态需要长期对账。一个可恢复流程至少能回答:
operationId = order-1001
├─ order state = PENDING / CONFIRMED / REJECTED
├─ inventory reservation = NONE / HELD / RELEASED
├─ last accepted command / event
├─ retry or compensation owner
└─ next reconciliation time修复后,应能从用户请求定位相关服务,再依据权威状态判断业务最终结果。所有服务接入同一日志平台,只提供了其中一种查询手段。
按边界根因选择修复方向
发现耦合
├─ 边界内聚,但契约与运行机制不足
│ └─ 保留服务边界,补兼容、权限、恢复和观测
├─ 业务边界仍不稳定
│ └─ 收回到模块化单体,继续验证模型
├─ 两个服务长期共同变化、共同数据、共同值守
│ └─ 评估合并,消除无收益远程边界
└─ 单体某个稳定能力存在持续独立收益
└─ 评估抽取,先建立模块与数据权威再迁移修复顺序通常从业务和数据权威开始,然后处理调用方向与兼容契约,再建立独立发布、恢复、容量、安全和观测。网关、注册中心、消息队列或 service mesh 可以提供机制,但不能替代边界判断。
需要阻止代码越界时,继续阅读分层、依赖规则与架构测试;需要完整设计模块化单体时,阅读从单体到模块化单体;需要评估具体拆分点时,阅读服务边界与拆分证据;需要执行数据迁移、切流和回退时,阅读从模块化单体到微服务。
架构名称应与可观察事实一致:代码能否守住模块边界,版本能否独立共存,数据由谁写入,远程失败怎样恢复,容量与故障是否真正隔离,团队是否承担端到端结果。系统可能继续保持模块化单体,也可能抽取或重新合并服务;选择取决于业务边界和运行能力,而非成熟度排名。
权威资料与规范地址
以下资料可用于查阅微服务定义与权衡、模块边界验证、数据与事务,以及运行实例与可观测性;应用其中结论时还需结合所用版本与平台限制。
微服务定义、选择与演进
| 资料 | 地址 |
|---|---|
| Microservices(James Lewis、Martin Fowler) | https://martinfowler.com/articles/microservices.html |
| Microservice Trade-Offs | https://martinfowler.com/articles/microservice-trade-offs.html |
| Monolith First | https://martinfowler.com/bliki/MonolithFirst.html |
Spring Modulith 模块建模与验证
| 资料 | 地址 |
|---|---|
| Fundamentals | https://docs.spring.io/spring-modulith/reference/fundamentals.html |
| Verifying Application Module Structure | https://docs.spring.io/spring-modulith/reference/verification.html |
| Working with Application Events | https://docs.spring.io/spring-modulith/reference/events.html |
数据所有权与跨服务事务
| 资料 | 地址 |
|---|---|
| Azure — Data considerations for microservices | https://learn.microsoft.com/en-us/azure/architecture/microservices/design/data-considerations |
| AWS — Saga orchestration pattern | https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/saga-orchestration.html |
运行实例与可观测上下文
| 资料 | 地址 |
|---|---|
| Kubernetes — Pods | https://kubernetes.io/docs/concepts/workloads/pods/ |
| Kubernetes — Services | https://kubernetes.io/docs/concepts/services-networking/service/ |
| W3C Trace Context | https://www.w3.org/TR/trace-context/ |
