2026 年的 Spring Boot 长什么样:JDK 21 虚拟线程 + 模块化单体重建业务后端

0 阅读8分钟

系列:《从零重建 AI-native ERP/MES》· 第 02 篇 对应代码:Forge 仓库 M0、M1 两个里程碑

TL;DR

  • 不拆微服务,做模块化单体。 用 Spring Modulith 把「模块边界」变成一条会失败的测试,而不是一份没人看的文档。
  • 模块之间只通过 shared 里的接口通信。 数据权限、元数据、字段校验,都是业务模块声明需求、别的模块提供实现。
  • 虚拟线程一行配置打开,但要知道它在 JDK 21 上的限制。
  • 前后端契约只有一份:后端 OpenAPI 生成前端 TS 类型,接口一改,前端编译不过。
  • Spring Boot 4 的坑,我踩过的都列在后面。

一、为什么是模块化单体,而不是微服务

先说上一代系统的结构:Spring Boot 2 时代的 Maven 多模块,十几个 xxx-module-*,看起来边界很清楚。但实际上:

  • 模块之间可以随便互相依赖,Maven 只管「能不能编译」,不管「该不该依赖」;
  • 报表模块直接查工单模块的表,工单改个字段,报表就挂;
  • 想拆出去单独部署?拆不动,因为依赖已经织成网了。

有人会说那就上微服务。对一个几个人、甚至一个人的团队来说,微服务意味着:分布式事务、服务发现、链路追踪、N 套部署流水线。而制造业系统里,「下单 → 生成工单 → 扣减库存」天然就是一个事务。为了架构好看把它拆成三个服务,是在给自己找麻烦。

我的选择是 模块化单体:一个进程、一个数据库、一次部署,但模块边界像微服务一样严格。Spring Modulith 就是干这个的。

二、用 Spring Modulith 把边界变成测试

Modulith 的规则很简单:

  1. dev.forge 下的每个一级包是一个模块(iam、meta、basedata、shared);
  2. 模块根包里的类型是公开 API,子包(如 iam.domain)默认是内部实现;
  3. 其他模块只能依赖公开 API。

然后写一个测试:

class ModularityTests {

    private final ApplicationModules modules = ApplicationModules.of(ForgeServerApplication.class);

    @Test
    void verifiesModuleBoundaries() {
        modules.verify();
    }

    @Test
    void writesModuleDocumentation() {
        new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml();
    }
}

为了写这篇文章,我故意在物料模块里注入了 iam 模块内部的 UserRepository,跑测试的结果是:

org.springframework.modulith.core.Violations:
- Module 'basedata' depends on non-exposed type dev.forge.iam.domain.UserRepository within module 'iam'!

这就是和「多模块目录」最本质的区别:越界依赖会让 CI 变红。 边界不再靠 Code Review 时有人眼尖,而是靠机器。

顺带,Documenter 会在每次测试时生成模块关系图(PlantUML),文档永远和代码一致。

三、模块之间怎么说话:依赖倒置,而不是互相调用

既然 basedata 不能直接调 iam,那物料模块需要「当前用户的数据权限」怎么办?需要「校验自定义字段」怎么办?

答案是:需求方在 shared 里声明接口,提供方去实现它。

                 ┌──────────────────────────────┐
                 │ shared(OPEN 模块,只放契约) │
                 │  DataScopeProvider           │
                 │  ExtensionFields             │
                 │  EntityDefinitionContributor │
                 └──────────────────────────────┘
                   ▲             ▲             ▲
          实现 │             │ 实现          │ 实现 & 调用
             iam           meta         basedata

shared 被标记为 OPEN 模块,所有子包都对外可见:

@ApplicationModule(type = ApplicationModule.Type.OPEN)
package dev.forge.shared;

以「自定义字段」为例,三个模块各司其职:

// shared:契约
public interface EntityDefinitionContributor {
    EntityDefinition definition();
}

public interface ExtensionFields {
    Map<String, Object> validate(String entityCode, Map<String, Object> values);
}
// basedata:声明「物料有哪些内置字段」
@Component
class MaterialDefinition implements EntityDefinitionContributor {

    @Override
    public EntityDefinition definition() {
        return new EntityDefinition("basedata.material", "物料", List.of(
                FieldDefinition.builtin("code", "物料编码", FieldType.TEXT).required().sortable().build(),
                FieldDefinition.builtin("name", "物料名称", FieldType.TEXT).required().sortable().build(),
                // ...
        ));
    }
}
// meta:收集所有模块的声明,合并租户自定义字段
@Service
class MetaService implements ExtensionFields {

    MetaService(CustomFieldRepository customFieldRepository, List<EntityDefinitionContributor> contributors) {
        this.definitions = contributors.stream()
                .map(EntityDefinitionContributor::definition)
                .collect(Collectors.toUnmodifiableMap(EntityDefinition::code, Function.identity()));
    }
    // ...
}

meta 模块不知道「物料」的存在,basedata 也不知道自定义字段存在哪张表。以后 M2 加「销售订单」,只需要再写一个 EntityDefinitionContributor,订单就自动拥有了自定义字段能力。

什么时候用接口,什么时候用事件?需要同步拿到结果(权限范围、校验结果)用接口;只是通知别人「我这边发生了什么」(工单完工 → 库存入账)用 Modulith 的事件,M2 会用到。

四、虚拟线程:一行配置,三个注意点

spring:
  threads:
    virtual:
      enabled: true

打开后,Tomcat 的请求线程和 @Async 都跑在虚拟线程上。对 ERP 这类「大量请求在等数据库」的 IO 密集型系统,这基本是白捡的吞吐量。但有三件事要知道:

  1. ThreadLocal 照常可用。 我的租户上下文 TenantContext 就是 ThreadLocal,虚拟线程下行为不变。只是不要往里塞大对象,虚拟线程可能有几万个。

  2. JDK 21 上 synchronized 会钉住载体线程(pinning)。 阻塞 IO 发生在 synchronized 块里时,虚拟线程无法让出。好在主流依赖已经处理过:pgjdbc 42.6+ 把内部的 synchronized 换成了 ReentrantLock。这个问题在 JDK 24(JEP 491)才从根本上解决,所以业务代码里别在锁里做 IO。

  3. 连接池才是真正的上限。 虚拟线程让「等待」变便宜了,但数据库连接数没变。并发一上来,瓶颈会从线程池转移到 HikariCP,池大小要按数据库能承受的来配,而不是按线程数。

    这一点我在后面做库存时真实踩到过:15 个并发请求同时扣库存,每个请求在外层事务里已经占着一个连接,取单据号时又用 REQUIRES_NEW 开了个新事务、再要一个连接。连接池只有 10 个,10 个请求各握一个、都在等第二个,全部超时。线程再便宜也没用,卡住的是连接。改成取号加入外层事务后问题消失(细节在第 03 篇)。结论是:用了虚拟线程之后,要格外留意「一个请求同时需要几个连接」。

五、前后端只有一份契约

上一代系统里,前端的接口类型是照着后端 DTO 手抄的。字段一改,前端要到运行时才发现。

这次的做法:后端用 springdoc 生成 OpenAPI,前端用 openapi-typescript 生成类型,再用 openapi-fetch 调接口:

const page = await unwrap(api.GET('/api/basedata/materials', {
  params: { query: { keyword, category, page: 1, size: 20 } },
}))
// page.items 的类型是 MaterialView[],路径、参数、返回值全部有类型

后端改了字段名,pnpm gen:api 一跑,所有用到的地方直接编译失败。

但默认生成的类型有个问题:所有字段都是可选的。

RoleView: {
  id?: number;
  code?: string;
  // ...
}

原因是 Java 的 record 在 OpenAPI 里没有标 required。前端于是满屏的 ?. 和 !,类型形同虚设。我的处理是在后端加一个 OpenApiCustomizer:非 nullable 的字段一律标为 required,真正可空的字段显式标注:

@Bean
OpenApiCustomizer requireNonNullableProperties() {
    return openApi -> openApi.getComponents().getSchemas().values().forEach(schema -> {
        Map<String, Schema> properties = schema.getProperties();
        if (properties == null) {
            return;
        }
        schema.setRequired(properties.entrySet().stream()
                .filter(entry -> !isNullable(entry.getValue()))
                .map(Map.Entry::getKey)
                .toList());
    });
}
public record UserView(long id, String username, String nickname,
                       @Schema(nullable = true) Long deptId,
                       @Schema(nullable = true) String deptName,
                       boolean enabled, Set<Long> roleIds, Instant createdAt) {
}

生成结果变成:

UserView: {
  id: number;
  deptId?: number | null;
  deptName?: string | null;
  enabled: boolean;
  // ...
}

可空性从此是后端的一个显式决定,而不是前端的猜测。

六、测试:真实数据库,只测会出事故的地方

所有集成测试都用 Testcontainers 起真实的 PostgreSQL,Flyway 迁移和演示数据跟生产一模一样:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgresContainer() {
        return new PostgreSQLContainer(DockerImageName.parse("postgres:17-alpine"));
    }
}

@ServiceConnection 会自动把容器的连接信息注入数据源,不用写任何 URL。测试基类封装了登录,用例读起来像业务描述:

@Test
void managerSeesOwnDepartmentTree() {
    assertThat(usernames("demo", "zhang"))
            .contains("zhang", "li", "zhao")
            .doesNotContain("admin", "wang");
}

目前后端 43 个测试,所有测试类共用一个 Spring 上下文和一个容器,第一个类约 12 秒(启动容器 + 上下文),后面的类大多在 2 秒内。

七、Spring Boot 4 踩坑清单

都是这次真实遇到的:

现象原因处理
Initializr 生成的 pom 拉不到父 POM生成的版本号是 4.1.1.RELEASE,Maven Central 上是 4.1.1手动改掉版本号
启动报 missing table [event_publication]引入 Modulith 的 JPA 事件发布后,Hibernate ddl-auto: validate 要求这张表存在在 Flyway 里补上建表迁移
@AutoConfigureMockMvc 找不到Boot 4 按技术拆分了自动配置模块,包名变了改为 org.springframework.boot.webmvc.test.autoconfigure
HibernatePropertiesCustomizer 找不到同上,移到了 org.springframework.boot.hibernate.autoconfigure改 import
Testcontainers 找不到 DockermacOS 上 /var/run/docker.sock 指向未运行的 Docker Desktop,实际用的是 OrbStack设置 DOCKER_HOST 指向 OrbStack 的 socket
JSONB 字段映射Boot 4 默认是 Jackson 3Hibernate 7.4 自带 Jackson3JsonFormatMapper,@JdbcTypeCode(SqlTypes.JSON) 直接可用

另外,Boot 4 的 starter 也按技术拆分了(例如 spring-boot-starter-webmvc、spring-boot-starter-flyway),建议直接用 Initializr 生成依赖,而不是从老项目复制。

八、从老项目升级过来的人,注意这些

我之前把一个十几个模块的老项目从 Spring Boot 2 升到 3.5 + JDK 21,印象最深的两件事:

  1. javax.* → jakarta.* 只是开始。 真正花时间的是传递依赖:某个库还依赖老的 javax,编译能过,运行时才炸。
  2. 依赖版本要成组升级。 当时 Apache POI 升到 5.5.1 后运行时报 NoSuchMethodError,查下来是 commons-io 还停在 2.12.0,升到 2.21.0 才好。这类问题编译期完全看不出来,只能靠启动加冒烟测试兜底。

所以这次新项目的原则是:依赖版本交给 Spring Boot 的 BOM 管,不手写版本号,唯一的例外是 springdoc。

小结

2026 年的 Spring Boot,已经不是那个「要配一堆 XML、启动一分钟」的框架了。在这个项目里:

  • Spring Boot 4 + JDK 21,本地启动约 5 秒;
  • 模块边界由测试强制,文档自动生成;
  • 虚拟线程一行打开;
  • 前后端类型从同一份 OpenAPI 生成。

它对 AI 协作开发也更友好:边界清楚、约定一致、测试能兜底,AI 写的代码是对是错,跑一遍测试就知道。

下一篇(04)讲权限:多租户、RBAC、数据权限,以及一个 AI 审查帮我找到的提权漏洞。