移动互联网高速增长时期,微服务解决了一个非常现实的问题:当业务、代码和团队同时膨胀时,通过业务域拆分、独立部署和团队自治,把集中式协调变成明确的技术契约。
但架构不会脱离组织规模而独立成立。当团队缩小、业务增速趋稳,或者 AI 工具开始显著提高单个工程师的交付能力后,几十个服务带来的注册发现、链路追踪、接口兼容、部署编排和故障排查成本,可能已经超过它们创造的收益。Java 后端此时需要的并不是简单“回到大单体”,而是一次有边界的瘦身:保留模块化,减少不必要的分布式复杂度。
微服务的问题,不只是服务数量多
一个系统是否应该拆成微服务,不能只看代码行数。更关键的是变化速度、团队边界和运行时需求。
假设一次“创建订单”需要依次调用库存、价格、优惠、会员和通知服务,那么代码中的一个业务动作,在运行时会变成多次网络调用。随之而来的问题包括:
- 每个调用都需要超时、重试、熔断和幂等设计;
- 部分成功会引入补偿事务和状态对账;
- 接口升级要维护调用方与提供方的兼容窗口;
- 本地调试依赖多个服务、消息队列和基础设施;
- 一个业务需求可能横跨多个仓库与发布流水线。
这些能力并非没有价值。问题在于,它们是为独立扩缩容、独立发布、故障隔离和团队自治支付的成本。如果一个系统只有十来名开发者,所有服务也总是一起修改、一起测试、一起上线,那么服务虽然在物理上分开,组织和发布过程却没有真正解耦。
可以用三个问题检查拆分是否有效:
- 这个服务能否由一个稳定团队独立负责?
- 它能否在不协调大多数上下游的情况下独立发布?
- 它是否确实需要不同的扩容、可用性或安全策略?
如果多数答案是否定的,进程边界很可能早于业务边界出现了。
AI 提高的是编码速度,不会自动降低系统复杂度
AI 编程工具可以生成控制器、DTO、测试和部署配置,也能帮助工程师快速阅读陌生代码。但它不会消除网络分区、重复消息、跨服务事务和版本兼容问题。
更值得注意的是,AI 工具通常受益于完整、局部且可验证的上下文。一个业务能力如果分散在多个仓库、不同版本的接口定义和若干部署环境中,工具同样需要额外信息才能判断一次修改的真实影响。相反,边界清楚的模块化单体可以在一个仓库内完成搜索、编译、测试和重构,同时继续限制模块之间的依赖。
因此,“瘦身”不应该理解为把所有代码重新塞进同一个包,而应包含以下动作:
- 将高频同步调用的服务合并到同一进程;
- 以业务能力划分 Java 包和模块,而不是按 Controller、Service、DAO 分层堆放;
- 只暴露模块的公开接口,隐藏内部实现;
- 用架构测试阻止跨模块访问内部类;
- 保留消息、审计和指标等边界,为未来重新拆分留下空间;
- 只对确实需要独立扩容或隔离的能力保留微服务。
可以这样实践:做一个有硬边界的模块化单体
下面是一个可直接运行的最小 Spring Boot 项目。订单模块只能依赖库存模块的公开接口,不能直接访问 inventory.internal。它仍然是一个进程、一个制品,但模块边界由 Java 可见性和 ArchUnit 测试共同约束。
运行前需要安装 JDK 17 和 Maven 3.9+。复制整段脚本到 Bash 执行即可:
set -e
mkdir -p lean-monolith/src/main/java/com/example/app/{order,inventory/api,inventory/internal}
mkdir -p lean-monolith/src/test/java/com/example/app
cd lean-monolith
cat > pom.xml <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>lean-monolith</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
EOF
cat > src/main/java/com/example/app/Application.java <<'EOF'
package com.example.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
EOF
cat > src/main/java/com/example/app/inventory/api/Inventory.java <<'EOF'
package com.example.app.inventory.api;
public interface Inventory {
Reservation reserve(String sku, int quantity);
record Reservation(String sku, int quantity, boolean accepted) {}
}
EOF
cat > src/main/java/com/example/app/inventory/internal/InMemoryInventory.java <<'EOF'
package com.example.app.inventory.internal;
import com.example.app.inventory.api.Inventory;
import org.springframework.stereotype.Component;
@Component
class InMemoryInventory implements Inventory {
@Override
public Reservation reserve(String sku, int quantity) {
boolean accepted = sku != null && !sku.isBlank()
&& quantity > 0 && quantity <= 10;
return new Reservation(sku, quantity, accepted);
}
}
EOF
cat > src/main/java/com/example/app/order/OrderController.java <<'EOF'
package com.example.app.order;
import com.example.app.inventory.api.Inventory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final Inventory inventory;
public OrderController(Inventory inventory) {
this.inventory = inventory;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResult create(@RequestBody CreateOrder command) {
Inventory.Reservation reservation =
inventory.reserve(command.sku(), command.quantity());
return new OrderResult(
reservation.accepted() ? "CONFIRMED" : "REJECTED",
reservation.sku(),
reservation.quantity()
);
}
public record CreateOrder(String sku, int quantity) {}
public record OrderResult(String status, String sku, int quantity) {}
}
EOF
cat > src/test/java/com/example/app/ArchitectureTest.java <<'EOF'
package com.example.app;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class ArchitectureTest {
@Test
void orderModuleMustNotAccessInventoryInternals() {
JavaClasses classes = new ClassFileImporter()
.importPackages("com.example.app");
noClasses()
.that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAPackage("..inventory.internal..")
.check(classes);
}
}
EOF
mvn test
mvn spring-boot:run
服务启动后,在另一个终端调用:
curl -i http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"sku":"JAVA-BOOK","quantity":2}'
这个示例使用内存库存,只用于展示边界,不包含生产级持久化与并发控制。实际项目可以把数据库访问留在 inventory.internal,对外继续只暴露 Inventory 接口。订单模块不需要知道库存使用 PostgreSQL、Redis,还是远程服务。
如果未来库存确实需要独立部署,可以保留 Inventory 接口,将当前进程内实现替换为 HTTP 或消息适配器。这样,拆分发生在业务和运行需求成熟之后,而不是在项目开始时提前支付全部分布式成本。
从微服务收拢时,不要做“大爆炸合并”
已有微服务系统不适合一次性重写。更稳妥的做法是从调用密集、团队归属相同、发布节奏一致的服务簇开始:
- 统计真实调用链、故障率、发布频率和资源消耗;
- 找出总是一起变更、同步调用频繁的服务;
- 先统一仓库和测试,再决定是否合并进程;
- 用模块 API 替换内部 HTTP 调用,但暂时保留原有契约测试;
- 合并部署后继续观察延迟、故障影响范围和发布效率;
- 等运行稳定,再逐步移除不再需要的网关规则、服务发现和重复 DTO。
数据所有权是最需要谨慎处理的部分。合并应用不等于立刻合并所有表。可以先让模块继续拥有自己的 schema,并禁止其他模块直接读写其表;只有在事务和查询需求明确时,再调整数据边界。
选择架构时,保留可逆性
模块化单体不是微服务的反义词,而是降低默认复杂度的一种部署选择。对于支付、身份认证、媒体处理等需要强隔离、独立扩缩容或不同安全等级的能力,微服务仍然合理。对于由同一团队维护、总是共同发布、存在大量同步调用的业务模块,一个有自动化边界检查的单体往往更直接。
落地前可以使用这份检查清单:
- 模块是否按业务能力划分,而不是只按技术层划分?
- 内部实现是否默认不可见?
- CI 是否会阻止非法跨模块依赖?
- 数据表是否有明确所有者?
- 外部调用是否设置超时、幂等和可观测性?
- 保留的每个微服务是否都有独立部署的明确理由?
- 合并后是否仍能通过接口或事件重新拆出模块?
AI 可以让团队更快地产生代码,而真正的“瘦身革命”是让工程师少维护一些没有业务价值的边界,把注意力重新放回领域模型、测试和交付结果。