从单体到模块化单体:怎样在不分布式化时获得真实边界
一个订单应用可以只有一个可执行 JAR,同时包含商品、订购、支付和通知等业务模块。模块内部保存自己的业务规则和数据访问实现;其他模块通过公开接口调用它,或者订阅它发布的事件。应用仍然整体启动和部署,模块之间通常使用进程内调用。
商品内部实现可以调整,订单代码继续调用稳定的商品接口。要保持这种关系,需要限制可引用的类型,指定业务数据的修改模块,并明确哪些变化必须共同提交。这些约定落在代码和数据访问中,再由自动化测试持续检查。
业务模块怎样落到代码结构
模块、包、构建单元与进程
“模块”出现在不同技术层次,每一层限制的对象不同。
| 层次 | 组织或限制什么 | 常见实现 |
|---|---|---|
| 业务模块 | 一组业务规则、公开能力、数据修改职责 | 商品、订购、支付 |
| Java 包 | 类的命名与包级访问权限 | orders、catalog.internal |
| Maven 模块 | 源码编译、依赖声明和制品生成 | 一个聚合工程中的多个子项目 |
| JPMS 模块 | 模块读取关系、包导出与反射开放 | module-info.java 中的 requires、exports、opens |
| 进程与部署单元 | 内存、线程、资源限制及启动发布 | 一个 JVM、一个容器或服务实例 |
业务模块可以先放在同一个 Maven 项目中,用包结构与架构测试约束引用。拆成多个 Maven 子项目后,编译依赖更显式,构建也更容易按子项目组织;但只要它们最终装进同一个应用,部署和进程资源仍然共享。
JPMS 提供 Java 模块系统的语言与运行时约束。在普通 classpath 工程中,仅仅命名一个 internal 子包没有特殊访问控制效果。需要限制代码引用时,可以组合使用包级可见性、构建依赖、JPMS 或架构检查。各种方式对反射、框架扫描和发布制品的要求不同;JPMS 的导出与开放规则见 Java 模块声明规范。
按业务能力划分,再安排技术分层
按全局 controller/service/repository 分目录,容易找到所有 Controller,却很难看出一项业务功能需要修改哪些地方。业务模块优先把共同变化的代码放在一起,内部再安排接口、用例、领域规则与持久化实现。
lab.evolution
├── ModulesApplication 应用启动与组装
├── catalog 商品模块
│ ├── CatalogService 公开能力:预留库存
│ ├── pricing 命名接口:查询价格
│ │ ├── package-info.java
│ │ └── ProductPrices
│ └── internal
│ └── StockRepository 模块内部的库存访问
├── orders 订购模块
│ ├── package-info.java 允许依赖商品接口
│ ├── OrderService 创建订单的事务用例
│ └── OrderPlaced 已创建订单的事件
└── notifications 通知模块
├── package-info.java 允许订阅订购事件
└── internal
└── OrderNotification 通知监听与持久化划分时先观察业务语言和修改原因。商品定价、库存预留、订单确认具有不同规则,可以形成独立模块;如果每次修改促销都必须同时修改价格计算的多个“模块”,则需要重新检查这条划分。历史上放在不同目录中的代码,也可能属于同一个业务概念。
模块大小没有通用的类数阈值。一个模块应该能说清自己提供什么能力、保存什么业务状态,以及哪些变化需要共同完成。划得过细会让一个普通用例穿过大量接口;划得过粗则会使无关业务持续联合修改。对于支付记账、库存扣减等强约束,先判断业务规则需要哪种事务,再确定调用形式。
领域模型也不必跨模块统一。商品模块中的商品可能包含定价规则;订单需要保存的是成交时的商品说明、数量与价格快照。让订单引用商品的 JPA Entity,会把持久化映射、懒加载与可变字段一起暴露出去。使用明确的 ID、不可变结果或事件载荷,调用方依赖的内容更容易控制。
公开接口包括类型,也包括调用约定
公开接口应表达业务动作,例如 reserve(sku, quantity),而非把 Repository 的通用增删改查全部转发出去。除了参数和返回值,还要明确:哪些异常代表业务拒绝、是否修改状态、是否需要已有事务、调用能否重复,以及返回时哪些结果已经提交。
同进程调用没有 HTTP 序列化开销,但仍可能暴露共享的可变对象。如果接口返回内部集合,调用方可以绕开模块方法修改集合内容。返回不可变值、复制必要字段和限制可见性,都属于模块 API 的设计工作。
Spring Modulith 默认把应用基础包下的直接子包识别为模块,把模块根包中的公开类型作为可用接口;更深的包默认视为内部实现。子包可以通过 @NamedInterface 显式导出。完整识别规则及开放模块的差异见 Spring Modulith 模块模型。
商品模块的价格接口使用命名接口:
@org.springframework.modulith.NamedInterface("pricing")
package lab.evolution.catalog.pricing;订购模块声明它允许使用的接口:
@org.springframework.modulith.ApplicationModule(
allowedDependencies = {"catalog", "catalog::pricing"}
)
package lab.evolution.orders;catalog 指商品模块的默认接口,catalog::pricing 指价格命名接口。这样的声明可以把“订购不能直接调用通知内部组件”变成可检查的约束。新增一个确有必要的模块依赖时,应同时审查接口内容与依赖方向,而非把所有模块统一设为开放。
数据库也需要同样清晰的约定。多个模块共用一个数据库时,每张业务表应有主要修改者;跨模块读取可以通过查询接口、明确维护的只读模型,或确有必要且受兼容管理的报表查询完成。架构工具检查 Java 引用,不会解析所有动态 SQL。一个 Repository 直接写了另一个模块的表,即使模块测试全绿,也仍然越过了数据访问约定。
用模块检查和集成测试固定依赖
构建一个有真实业务代码的工程
下载模块实验工程,解压得到 evolution-modules-lab。工程使用 Spring Boot 4.1.1、Spring Modulith 2.1.1 和 JDBC;数据库采用 H2 文件模式,事件恢复时会复用同一文件。Java 编译目标为 17,可用 Java 17 或 25 运行。
在 Linux Bash 中,以有权使用 Docker 的普通宿主用户操作。该用户需要对解压目录有写权限。Docker daemon 负责创建容器;下面的 Maven 进程通过 --user 使用宿主 UID/GID,依赖缓存位于当前工程的 .m2。首次构建需要访问 Maven Central;企业内网可在组织批准的 Maven 配置中设置制品代理,下载失败时先处理网络或仓库访问,再重跑构建。
cd evolution-modules-lab
mkdir -p .m2 work
docker run --rm --user "$(id -u):$(id -g)" \
--memory 2g --cpus 2 \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$PWD:/lab" -v "$PWD/.m2:/m2" -w /lab \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify构建会编译业务代码,运行模块规则、订购模块集成与持久事件恢复测试,最后生成 target/evolution-modules-lab-1.0.0.jar。出现依赖下载失败、没有测试被执行或任一断言失败时,不继续运行后面的恢复步骤。
该工程的测试汇总为 Tests run: 6, Failures: 0, Errors: 0, Skipped: 0。Maven 构建镜像在此基线中使用 Temurin 17.0.18+8;后面的独立应用进程使用明确版本的 Temurin 17.0.20+8 镜像。镜像标签与其中实际 JDK 补丁应分别检查,不能从 Maven 版本推断运行时版本。
正确引用、内部访问与依赖环
正常模块规则由实际的 ApplicationModules 检查:
ApplicationModules.of(ModulesApplication.class).verify();检查涉及模块依赖环、对内部类型的访问和显式声明的允许依赖。它分析的是代码形成的模块关系,不会自动发现所有反射调用、字符串类名、SQL 和外部配置耦合。规则的确切范围见 Spring Modulith 结构验证。
实验的 ModuleRulesTest 还带有两组独立的错误包结构。第一组让 B 模块字段直接引用 A 模块的 internal.Secret。Secret 使用 Java public,因此 javac 能编译;模块检查仍会报告 non-exposed type。第二组让 A 引用 B、B 又引用 A,检查会报告 Cycle detected。
这两项测试的通过条件是捕获并核对对应违规,正常应用本身没有这些错误依赖。测试文件放在独立的 fixtures 包中,不装进可执行 JAR,也不会通过宽泛地忽略所有异常来判定成功。
实际诊断包含以下关键内容:
Module 'b' depends on non-exposed type fixtures.hidden.a.internal.Secret within module 'a'!
Cycle detected: Slice a -> Slice b -> Slice a测试中的负例通过 ImportOption 显式导入测试目录。默认生产模块扫描会排除 test-classes;没有找到任何类属于实验装配错误,不能算作“依赖违规已被发现”。
依赖环意味着两边都需要了解对方,修改和测试容易再次绑定。常见修复包括把双方共同使用的稳定值类型移到一个明确命名的小接口包,把组合用例放到更上层的协调模块,或者把不需要同步结果的后续动作改为事件订阅。简单地把互相依赖的类搬到 common,往往只是改变环所在的位置。
模块集成测试实际装配哪些对象
单元测试可以验证一个价格函数;模块集成测试需要证明模块的 Bean、配置和数据访问能够共同运行。订购模块依赖商品模块,因此测试使用直接依赖模式:
@ApplicationModuleTest(
mode = ApplicationModuleTest.BootstrapMode.DIRECT_DEPENDENCIES
)
class OrderModuleTest {
// 调用真实 OrderService,检查订单、库存与发布的事件。
}这种模式加载被测模块及其直接依赖。STANDALONE 只加载当前模块,适合用替身隔离外部模块;ALL_DEPENDENCIES 会继续加载传递依赖。具体加载范围及事件测试接口见 Spring Modulith 集成测试。
订购测试检查 OrderPlaced 已发布,通知的异步消费则留给后面的完整应用恢复实验。事件发布成功时,通知尚可能没有处理完。
模块测试用较少的启动依赖定位局部问题;整应用测试继续检查实际组装、数据库迁移和各模块交互。工程需要同时保留这两种测试范围。
同步事务与持久事件承担不同交互
一个业务用例可以调用多个进程内模块
创建订单时,订单写入与库存预留需要共同成功。实验将事务放在订购用例的公开方法上,使用同一个 JDBC 数据源与事务管理器:
@Transactional
public void place(UUID eventId, String sku, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("POSITIVE_QUANTITY_REQUIRED");
long total = Math.multiplyExact(prices.unitCents(sku), quantity);
jdbc.update("INSERT INTO placed_order VALUES(?,?,?,?)",
eventId, sku, quantity, total);
catalog.reserve(sku, quantity);
events.publishEvent(new OrderPlaced(eventId, sku, quantity, total));
}库存模块通过条件更新执行预留,避免先读余额、再写余额之间被并发请求插入:
UPDATE catalog_stock
SET available = available - ?
WHERE sku = ? AND available >= ?;更新行数为零时,CatalogService 抛出 INSUFFICIENT_STOCK。异常沿调用栈离开被代理的事务方法,已经插入的订单也被回滚。正常返回时,订单、库存修改及对应的事件发布记录在同一个事务中提交。
这里的原子性来自数据库事务及正确的调用方式。Spring 默认代理模式只拦截经代理进入的方法;同一个对象内部的自调用不会重新应用方法上的事务注解。默认回滚规则、方法可见性与代理行为见 Spring 声明式事务注解。
模块间的同步调用有几种常见用途:读取当前操作必需的信息,立即执行业务校验,或在同一事务内共同修改状态。若把每次跨模块调用都改成异步,原先“库存不足则订单不成立”的规则就要变成待确认订单、库存回复、失败取消等后续流程,复杂度也随之增加。
事务传播还会改变失败影响。REQUIRED 在已有事务中参与共同提交;REQUIRES_NEW 使用独立事务,内层提交后不会因外层随后回滚而自动撤销。独立事务可能额外占用数据库连接,连接池容量也需考虑。传播机制见 Spring 事务传播。
事件发布、事务后处理与异步执行
订单创建后发送通知,通常允许稍后完成。订单确认与通知发送可以采用不同事务,但必须给失败通知留下重新处理的入口。
先区分几个容易混淆的机制:
| 交互形式 | 执行时机 | 失败对源业务的影响 |
|---|---|---|
| 普通同步方法调用 | 调用栈立即执行 | 同一事务中抛出的未处理回滚异常可以使源事务回滚 |
| 默认同步 Spring 事件监听 | 发布时在调用线程执行 | 监听器执行仍处于当前调用过程,可能影响源事务 |
@TransactionalEventListener(AFTER_COMMIT) | 成功提交后回调 | 源事务已经提交,回调失败需要单独处理 |
@ApplicationModuleListener | 事务提交后异步调用,并在新事务中处理 | 监听失败不撤销源业务;使用发布登记保存可重投记录 |
@ApplicationModuleListener 的默认组合包括 @Async、事务事件监听和 REQUIRES_NEW。它需要异步支持,实验通过 @EnableAsync 启用。注解的传播方式、监听器 ID 等可配置项见 2.1.1 注解定义。
通知模块的实际监听方法如下。故障开关只用于后面的恢复实验;正常消费把通知结果写进自己的表。
@ApplicationModuleListener(id = "order-notification-v1")
public void accept(OrderPlaced event) {
if (environment.getProperty("lab.notification.fail", Boolean.class, false)) {
throw new IllegalStateException("INJECTED_NOTIFICATION_FAILURE");
}
jdbc.update("MERGE INTO notification(event_id,message) KEY(event_id) VALUES(?,?)",
event.eventId(), "order=" + event.eventId() + ",cents=" + event.totalCents());
}这里的 MERGE ... KEY 是 H2 语法,配合 event_id 主键,在重复接收内容不变的同一事件时保持一条通知行。换用其他数据库,应按其事务和冲突处理语义实现,再测试重复消费;不要直接复制 H2 SQL。
普通应用事件首先解决发布方和订阅方的代码依赖:订单模块只发布订单事实,不需要持有每个通知组件的引用。事件是否持久化、是否异步、在哪个事务执行,是另外的配置选择。异步执行器的内存队列在进程退出后消失,因此不能只靠 @Async 保存待办业务。
发布登记保存什么
Spring Modulith JDBC 发布登记为符合条件的事务监听器记录待处理事件,并将这些记录写入源业务事务。事件序列化内容和监听器身份让应用可以在重新启动后找到相应消费入口。默认完成模式会保留已完成记录;其他持久化实现和完成模式见 应用事件与发布登记。
在 2.1.1 的当前 JDBC 表结构中,诊断时主要查看:
| 字段 | 作用 |
|---|---|
ID | 一条事件投递记录的标识;同一业务事件面向不同监听器时有不同记录 |
EVENT_TYPE、SERIALIZED_EVENT | 重建事件对象需要的类型与内容 |
LISTENER_ID | 目标监听器的稳定身份 |
STATUS | 已登记、处理中、失败、重新提交或完成 |
COMPLETION_ATTEMPTS | 进入处理的尝试次数 |
PUBLICATION_DATE、LAST_RESUBMISSION_DATE、COMPLETION_DATE | 判断等待、重投和完成时点 |
业务事件自身的 eventId 与发布表的 ID 分别标识业务事实和一次监听器投递,不应混用。通知幂等使用业务 eventId,这样即使相同业务事件再次发布、生成了另一条投递记录,也能识别已经处理过的业务操作。JDBC 字段、初始化与旧表结构的差异见 发布登记配置与表结构。

上图保留 Spring Modulith 官方事件文档的原始示意,蓝色文档符号表示各监听器对应的发布记录;图源来自 Spring 项目,按 Apache 2.0 许可证保留,未修改。它强调提交与监听器登记的关系,下面进一步展开源事务回滚、异步消费和失败重投。
监听器抛出异常时,应用有机会登记 FAILED。进程突然消失则可能留下 PUBLISHED、PROCESSING 或 RESUBMITTED。过期监测可以把符合配置条件的旧记录标为失败,但判定窗口必须覆盖合法处理时长。仍在执行的慢监听器若被过早重投,可能与新一轮消费并行。
完成记录更新也有独立的提交时点。通知已写入,而进程在登记完成前退出时,恢复程序仍可能再次调用监听器。因此,监听器必须控制重复副作用。实验通知是数据库中的一行,以业务 eventId 为主键;发送邮件、短信或扣款时,还需要外部服务支持幂等键,或由持久任务表保存可查询的业务操作结果。数据库中只有一行通知,不代表外部服务一定只收到一次请求。
停止一个进程后,在新进程中恢复
保持在解压工程目录中,先创建一个空的 work 目录。下面的运行容器没有外部网络,应用 JAR 只读,只有 /lab/work 挂载可持久写入。运行身份仍是宿主 UID/GID,内存上限为 512 MiB,Java 堆上限为 256 MiB。当前 H2 嵌入式配置只允许一个进程打开该数据库,各阶段顺序执行。
首先启动通知故障实验:
docker run --rm --network none --read-only \
--user "$(id -u):$(id -g)" --memory 512m --cpus 2 \
--tmpfs /tmp:rw,nosuid,nodev,size=64m \
-v "$PWD/target/evolution-modules-lab-1.0.0.jar:/lab/app.jar:ro" \
-v "$PWD/work:/lab/work" -w /lab \
eclipse-temurin:17.0.20_8-jdk java -Xmx256m -jar app.jar \
--lab.mode=seed-failure --lab.notification.fail=true程序先以超过库存的数量创建订单,验证订单插入被回滚;随后创建一个合法订单,并在通知监听器中抛出指定异常。关键输出为:
rollback: orders=0 stock=10
INJECTED_NOTIFICATION_FAILURE
state: orders=1 stock=9 notifications=0 failed=1 completed=0 attempts=1完整日志还会包含异步监听器的异常栈。INJECTED_NOTIFICATION_FAILURE 是这里主动注入的故障;表中确实出现一条失败投递,程序核对所有断言后才正常退出。其他异常、错误状态或非零退出需要先处理。若得到 FRESH_DATABASE_REQUIRED,表示这个实验目录已有历史数据,应保留它供检查,改用新的空目录重新实验。
此时第一个 JVM 和容器都已退出,订单和失败投递仍在宿主 work 中。重新运行相同容器命令,把末尾参数换成 --lab.mode=inspect,可以看到相同的订单、库存及失败数。inspect 根据数据库查询生成输出,没有重新创建订单。
恢复时去掉故障开关,使用 recover:
docker run --rm --network none --read-only \
--user "$(id -u):$(id -g)" --memory 512m --cpus 2 \
--tmpfs /tmp:rw,nosuid,nodev,size=64m \
-v "$PWD/target/evolution-modules-lab-1.0.0.jar:/lab/app.jar:ro" \
-v "$PWD/work:/lab/work" -w /lab \
eclipse-temurin:17.0.20_8-jdk java -Xmx256m -jar app.jar \
--lab.mode=recover恢复入口调用真实的失败投递接口:
failedPublications.resubmit(
ResubmissionOptions.defaults()
.withBatchSize(1)
.withMaxInFlight(1)
);随后等待数据库中的完成状态,并检查通知数量。重投调用只是启动处理,不负责同步等待异步监听器结束;相关选项定义见 2.1.1 重投配置。
recovered: notifications=1 completion_attempts=2
state: orders=2 stock=8 notifications=2 failed=0 completed=2 attempts=3第一行表示旧事件经过第二次处理完成通知。第二行来自随后创建的一笔新订单:库存继续减少,新的通知也完成。旧事件已恢复,新的业务处理也能继续。
还可以再次启动容器,把模式改为 --lab.mode=duplicate。该模式重新发布同一业务 eventId,生成另一条投递记录;通知主键仍然只有两条:
state: orders=2 stock=8 notifications=2 failed=0 completed=3 attempts=4重复实验使用内容不变的同一业务事件。接收外部事件时,如果相同 ID 可以携带不同载荷,应保存摘要并拒绝内容冲突,不能任由后到内容覆盖既有通知。当前实验没有模拟任意进程崩溃点,也没有发送外部邮件或短信。
四个模式的连续运行入口是 bash run.sh。它使用 set -euo pipefail,任一步失败就停止,不清空旧数据库。需要重新开始时,用 LAB_DATA="$PWD/work-second" bash run.sh 指定新的实验目录。结束后没有常驻容器;work、.m2 与 target 分别保存实验数据、依赖缓存和构建产物,按实际保留需要管理。
渐进迁移已有工程并保持可维护性
一次迁移一组业务行为
已有应用通常不能停下所有开发,重新安排全部目录。可以选择一个业务规则相对完整、调用关系清楚的功能,沿下面的顺序迁移:
- 为现有公开行为补充回归测试,固定输入、业务结果、拒绝条件与需要共同回滚的数据。
- 建立新的业务模块包,先提供一个能够包住原实现的公开接口。调用方逐个改用这个入口,原实现暂时保留在适配层后面。
- 将这项业务内部使用的规则、Repository 和数据映射逐步迁入模块,限制内部类型的可见性。
- 对新的模块执行依赖检查和模块集成测试;确认一个正常用例与一个失败回滚用例均保持原语义。
- 检查动态查询、定时任务、批处理和后台管理入口,确认它们也通过对应模块修改数据。
- 删除无人再调用的旧入口,再处理下一组业务;尚未迁移的代码继续通过兼容适配接口运行。
第一阶段保留旧实现,可以把“改变调用方式”和“改变业务算法”分开。若同时搬目录、改表结构、修改价格计算并引入异步,回归失败时很难定位是哪一类变化改变了结果。模块化调整也应保留足够小的提交与可比较的行为,而不只检查最终目录看起来整齐。
历史违规可以逐项迁移,但例外要指向明确的调用关系。把全包设为开放、关闭自动验证或把所有代码加入忽略名单,会让新的违规与旧问题混在一起。临时适配层应有可查的使用者;当使用者归零后删除,而不是永久变成第二套公共 API。
控制共享库与横切能力
日志、认证、数据库访问框架等技术能力可以由公共基础设施提供。业务共享则更谨慎:把金额、租户 ID 等稳定值类型放在小而明确的模块中,比建立一个不断增长的 common 包更容易维护。类型字段一旦改变会影响谁,是判断共享范围的重要依据。
跨模块安全校验应放在能够取得可信业务身份的位置。外部 HTTP 层完成登录校验后,模块仍需检查用户能否操作目标订单;否则定时任务、内部调用或另一个入口可能绕过原先只写在 Controller 中的授权逻辑。模块之间共用进程,无法提供与独立进程相同的内存隔离,也不适合运行不受信任的插件代码。
观测可以沿模块公开接口和事件监听器记录操作名、业务事件 ID、耗时与结果。事件 ID 适合关联日志;把每个订单 ID 都变成指标标签会产生高基数。查看失败投递时,将 LISTENER_ID、状态、尝试次数与最早未处理记录一起观察,才能区分单个坏事件和整个监听器持续失败。
事件兼容、停机与记录清理
持久事件会跨越应用版本存活。直接移动事件类的包名、删除旧字段解释或更换监听器默认身份,可能让旧记录无法反序列化或找不到目标。实验给监听器设置了稳定的 order-notification-v1,但稳定 ID 仍要求新代码保持对应的业务处理能力。
升级可以采用兼容读取:新版本先能够读取旧事件格式,新增字段提供合理的缺省或升级转换;确认旧记录处理完成后,再移除旧类型或适配代码。对包含敏感数据的载荷,序列化持久化会增加一份数据副本,字段选择、访问权限与保存期限需要和业务表一起管理。
停止应用时,应先停止接收新的业务,再等待合理范围内的事务与异步任务退出。未完成记录保留给恢复流程;强制终止后,需要检查处理中记录,而不是假定关闭钩子已经完成全部通知。多实例运行时,不应不加判断地在每个实例启动时重发全部未完成记录,其他实例可能仍在处理它们。
生产恢复通常需要限定监听器、事件类型、尝试次数、批量和执行频率。失败原因没有解除前反复重投,会增加数据库与外部服务负担。UPDATE 完成模式会保留完成记录,应依据业务恢复与审计需要定期清理;切换 DELETE 后,不能继续依靠完成表查询历史通知。应用的业务通知记录与框架的投递记录,也应分别管理保存周期。
从现象定位模块问题
| 现象 | 首先确认什么 | 处理与复测 |
|---|---|---|
| javac 成功,模块检查失败 | 报告中的来源模块、目标类型、命名接口和允许依赖 | 改用公开能力或调整有业务依据的接口;重新运行结构检查和原调用用例 |
| 模块测试找不到 Bean | 被测模块的装配范围和真实依赖 | 显式加入所需依赖或提供受控替身;不把测试直接扩大为全应用来掩盖模块耦合 |
| 订单已回滚,库存却改变 | 是否使用同一数据源、事务管理器,是否绕过代理或用了独立事务 | 恢复共同事务或重设业务补偿;再次触发不足库存,检查两张业务表 |
| 订单已提交,通知失败 | 对应事件的 LISTENER_ID、STATUS、异常与通知结果 | 解除监听器故障,选择失败事件重投;检查旧通知完成后,再创建新订单 |
| 长期处于处理中 | 对应实例是否存活,线程是否阻塞,下游是否仍执行 | 先确认真实运行情况,再决定是否标记过期和重投;同时检查重复副作用 |
| 升级后旧事件无法恢复 | 事件类型、序列化字段、监听器 ID 与旧版本差异 | 恢复兼容类型或转换入口;先用一条旧记录验证,再逐批处理 |
| 静态依赖无环,修改仍牵动全系统 | 共享可变对象、公共业务模型、跨表 SQL 与配置依赖 | 缩小共享接口,明确数据修改者,增加对应行为回归;代码依赖图之外继续检查运行交互 |
模块化单体仍然共同发布,共享堆、连接池和线程资源。在进程内整理模块,可以先减少业务修改的牵连。某项能力确实需要独立发布、独立扩缩容或独立故障处置时,再利用已有模块 API 与数据修改职责准备服务提取。远程调用、写入交接与回切的具体处理见从模块化单体到微服务。
权威资料与规范地址
模块识别与测试
- Java 模块声明、导出与开放:https://docs.oracle.com/javase/specs/jls/se17/html/jls-7.html#jls-7.7
- Spring Modulith 模块模型与命名接口:https://docs.spring.io/spring-modulith/reference/fundamentals.html
- 模块结构验证:https://docs.spring.io/spring-modulith/reference/verification.html
- 模块集成测试与事件测试:https://docs.spring.io/spring-modulith/reference/testing.html
事务、事件与恢复
- Spring 声明式事务与代理:https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
- 事务传播机制:https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html
- Spring Modulith 应用事件与持久发布登记:https://docs.spring.io/spring-modulith/reference/events.html
- 事件配置与 JDBC 表结构:https://docs.spring.io/spring-modulith/reference/appendix.html
- 2.1.1
ApplicationModuleListener定义:https://github.com/spring-projects/spring-modulith/blob/2.1.1/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ApplicationModuleListener.java - 2.1.1 重投参数定义:https://github.com/spring-projects/spring-modulith/blob/2.1.1/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/ResubmissionOptions.java
