别急着拆微服务:AI 时代 Java 后端的模块化单体瘦身法

2026-07-27 24 预计阅读时间: 1 分钟
来源: my.oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:13 分钟

移动互联网高速增长时期,微服务解决了一个非常现实的问题:当业务、代码和团队同时膨胀时,通过业务域拆分、独立部署和团队自治,把集中式协调变成明确的技术契约。

但架构不会脱离组织规模而独立成立。当团队缩小、业务增速趋稳,或者 AI 工具开始显著提高单个工程师的交付能力后,几十个服务带来的注册发现、链路追踪、接口兼容、部署编排和故障排查成本,可能已经超过它们创造的收益。Java 后端此时需要的并不是简单“回到大单体”,而是一次有边界的瘦身:保留模块化,减少不必要的分布式复杂度。

微服务的问题,不只是服务数量多

一个系统是否应该拆成微服务,不能只看代码行数。更关键的是变化速度、团队边界和运行时需求。

假设一次“创建订单”需要依次调用库存、价格、优惠、会员和通知服务,那么代码中的一个业务动作,在运行时会变成多次网络调用。随之而来的问题包括:

  • 每个调用都需要超时、重试、熔断和幂等设计;
  • 部分成功会引入补偿事务和状态对账;
  • 接口升级要维护调用方与提供方的兼容窗口;
  • 本地调试依赖多个服务、消息队列和基础设施;
  • 一个业务需求可能横跨多个仓库与发布流水线。

这些能力并非没有价值。问题在于,它们是为独立扩缩容、独立发布、故障隔离和团队自治支付的成本。如果一个系统只有十来名开发者,所有服务也总是一起修改、一起测试、一起上线,那么服务虽然在物理上分开,组织和发布过程却没有真正解耦。

可以用三个问题检查拆分是否有效:

  1. 这个服务能否由一个稳定团队独立负责?
  2. 它能否在不协调大多数上下游的情况下独立发布?
  3. 它是否确实需要不同的扩容、可用性或安全策略?

如果多数答案是否定的,进程边界很可能早于业务边界出现了。

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 或消息适配器。这样,拆分发生在业务和运行需求成熟之后,而不是在项目开始时提前支付全部分布式成本。

从微服务收拢时,不要做“大爆炸合并”

已有微服务系统不适合一次性重写。更稳妥的做法是从调用密集、团队归属相同、发布节奏一致的服务簇开始:

  1. 统计真实调用链、故障率、发布频率和资源消耗;
  2. 找出总是一起变更、同步调用频繁的服务;
  3. 先统一仓库和测试,再决定是否合并进程;
  4. 用模块 API 替换内部 HTTP 调用,但暂时保留原有契约测试;
  5. 合并部署后继续观察延迟、故障影响范围和发布效率;
  6. 等运行稳定,再逐步移除不再需要的网关规则、服务发现和重复 DTO。

数据所有权是最需要谨慎处理的部分。合并应用不等于立刻合并所有表。可以先让模块继续拥有自己的 schema,并禁止其他模块直接读写其表;只有在事务和查询需求明确时,再调整数据边界。

选择架构时,保留可逆性

模块化单体不是微服务的反义词,而是降低默认复杂度的一种部署选择。对于支付、身份认证、媒体处理等需要强隔离、独立扩缩容或不同安全等级的能力,微服务仍然合理。对于由同一团队维护、总是共同发布、存在大量同步调用的业务模块,一个有自动化边界检查的单体往往更直接。

落地前可以使用这份检查清单:

  • 模块是否按业务能力划分,而不是只按技术层划分?
  • 内部实现是否默认不可见?
  • CI 是否会阻止非法跨模块依赖?
  • 数据表是否有明确所有者?
  • 外部调用是否设置超时、幂等和可观测性?
  • 保留的每个微服务是否都有独立部署的明确理由?
  • 合并后是否仍能通过接口或事件重新拆出模块?

AI 可以让团队更快地产生代码,而真正的“瘦身革命”是让工程师少维护一些没有业务价值的边界,把注意力重新放回领域模型、测试和交付结果。


相关推荐