ch03 工程化:Spring Boot 3 + MyBatis-Plus + Flyway 多租户项目骨架

事故现场:上线 3 周做 7 次数据迁移,每次都漏字段
电芯厂 A 二期 MES 项目按 ch02 四层架构搭好之后,进入 mes-core 业务层工程化阶段。第一批 Flyway 迁移脚本 V1(建表)→ V7(加字段),三周内改了 7 次数据库结构。每次改完都漏一两个地方:
- V2 加
dispatch_order.param_set_version字段,DTO 忘了加,前端拿不到,派工时还是按旧参数集跑——又触发了一次"化成截止电流错配"事故险情。 - V5 改
material_consumption表加tenant_id索引,迁移脚本执行了但索引名拼错(idx_tenant_id写成idx_tenantid),生产环境慢查询暴增,但本地测试的 10 万行数据根本暴露不出来。 - V7 给
oee_downtime表加code_level2(二级停机编码),但 MyBatis-Plus 实体类的@TableField注解忘改,写入时这字段一直是 NULL——OEE 报表二级编码全空,业务方吐槽"和没加一样"。
这三次事故的共同根因:多模块项目的工程化没纪律——DTO/Entity/Migration 三处变更要同步、租户拦截器配置要测试、字段改名要走"添加新字段→双写→切读→删旧字段"四步而非一步替换。这一章把项目工程化的"必走流程"全列出来。

排查:MES 工程化的三个高频踩坑点
排查发现 80% 的工程化事故集中在三个地方:
- DTO/Entity/Migration 三层不同步——加字段时只改了其中两层,第三层漏改。Flyway 迁移只在执行那一刻验证 SQL 能跑通,不验证 MyBatis 映射是否对应——本地测试小数据量暴露不出来,生产数据量上去才慢查询。
- 租户拦截器漏网——MyBatis-Plus 的 InnerInterceptor 拦截器对原生 SQL、自定义 XML SQL 的覆盖不完全,部分手写 SQL 漏掉
WHERE tenant_id=?注入,多租户数据互相污染——A 工厂的工单列表里出现 B 工厂的工单。 - Java 21 虚拟线程踩坑——Spring Boot 3.2+ 支持虚拟线程,但 JDBC 连接池(HikariCP)的
synchronized在虚拟线程下会 pin platform 线程,导致连接池假死。OPC UA 长连接和 JDBC 共用一个线程池的配置,曾导致 OPC UA 重连时把数据库连接全占住。

下面我们按"模块拆分 → 数据迁移纪律 → 租户拦截器 → 虚拟线程配置"四步讲工程化骨架。
底层原理:MES 多模块项目骨架
Maven 多模块分层
cell-mes-practice/
├── pom.xml # parent
├── cell-mes-common/ # 共享 DTO/常量/异常
├── cell-mes-domain/ # 领域模型 + Repository 接口
├── cell-mes-infra/ # 基础设施:DB/Redis/Kafka/IoTDB 客户端
├── cell-mes-collector/ # 采集层(独立进程)
├── cell-mes-core/ # 业务层(独立进程)
│ ├── cell-mes-core-routing # 工艺路线
│ ├── cell-mes-core-dispatch # 派工单
│ ├── cell-mes-core-trace # 批次追溯
│ ├── cell-mes-core-oee # OEE
│ ├── cell-mes-core-spc # SPC
│ └── cell-mes-core-andon # Andon
├── cell-mes-web-bff/ # BFF
└── docs/ # 文档+连载源稿
为什么按业务领域而不是按技术分层(Controller/Service/Dao):MES 业务逻辑复杂,跨模块调用多(追溯调派工、SPC 调追溯、Andon 调 OEE);按业务领域分模块后,模块内自包含 Controller+Service+Dao,模块间靠接口暴露,符合 DDD 限界上下文原则。
Flyway 迁移纪律
每次加字段走"四步迁移法",禁止单步替换:
| 步骤 | 迁移脚本 | 行为 | 兼容期 |
|---|---|---|---|
| 1 | V8.1: ALTER ADD new_col | 加字段,DEFAULT NULL | 1 周双写 |
| 2 | V8.2: 应用层双写(旧+新) | 代码改 `setOld + setNew` | - |
| 3 | V8.3: 应用层切读 + 数据回填 | UPDATE t SET new=old WHERE new IS NULL | 切读验证 |
| 4 | V8.4: ALTER DROP old_col | 删旧字段 | - |
强制纪律:禁止 "ALTER RENAME COLUMN"(一步替换)——应用进程在迁移过程中可能还在用旧字段,会报 column not found。
多租户拦截器完整覆盖
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 多租户拦截器(必须最先,否则权限/分页拦截器会拼到错位置)
TenantLineInnerInterceptor tenant = new TenantLineInnerInterceptor();
tenant.setTenantLineHandler(new TenantLineHandler() {
@Override public Expression getTenantId() {
Long t = TenantContext.get();
if (t == null) throw new IllegalStateException("tenant_id required");
return new LongValue(t);
}
@Override public String getTenantIdColumn() { return "tenant_id"; }
@Override public boolean ignoreTable(String tableName) {
// 全局表(如 tenant/sys_dict)不注入
return IGNORE_TABLES.contains(tableName.toLowerCase());
}
});
interceptor.addInnerInterceptor(tenant);
// 2. 权限拦截器
interceptor.addInnerInterceptor(new DataPermissionInterceptor());
// 3. 分页拦截器(必须最后)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}
手写 SQL 必须验证:所有 @Select / XML SQL 在 CI 上跑 TenantInterceptorCoverageTest,断言每条 SQL 执行后都带 tenant_id 条件——这是事故 2 的根因防线。
Java 21 虚拟线程配置
Spring Boot 3.2+ 开虚拟线程:
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 50
# 关键:连接借出使用 FastPath,避免 synchronized pin platform 线程
# 虚拟线程下 HikariCP 的 synchronized 在 pool entry 上会 pin
# 解决方案:HikariCP 5.1+ 已优化,配置 minimumIdle=maximumPoolSize 消除借出抖动
minimum-idle: 50
为什么 OPC UA 采集层不开虚拟线程:Milo OPC UA SDK 的 CompleteFuture 内部用 synchronized,开虚拟线程会被 pin 反而性能差。采集层保留 platform 线程,业务层开虚拟线程——这是 ch04 的话题。
正确姿势:项目骨架的工程化清单
pom.xml 父项目
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.cellmes</groupId>
<artifactId>cell-mes-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.11</version>
</parent>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<iotdb.version>1.3.4</iotdb.version>
<kafka.version>3.7.0</kafka.version>
</properties>
<modules>
<module>cell-mes-common</module>
<module>cell-mes-domain</module>
<module>cell-mes-infra</module>
<module>cell-mes-collector</module>
<module>cell-mes-core</module>
<module>cell-mes-web-bff</module>
</modules>
</project>
Flyway 迁移目录结构
cell-mes-core/src/main/resources/db/migration/
├── V1.0.0__init_schema.sql # 基础表
├── V1.0.1__seed_routing.sql # 工艺路线种子
├── V1.0.2__seed_bom.sql # BOM 种子
├── V1.0.3__add_dispatch_param_version.sql # 加字段(四步第 1 步)
├── V1.0.4__backfill_dispatch_param_version.sql # 回填(第 3 步)
├── V1.0.5__add_tenant_index.sql # 加索引
├── V1.0.6__add_oee_downtime_code_level2.sql # 加二级编码字段
└── V1.0.7__drop_legacy_oee_code.sql # 删旧字段(四步第 4 步)
命名规则:V<大版本>.<小版本>.<补丁>__<动词>_<对象>.sql,禁止 V1__xxx.sql 这种无小版本号写法(合并冲突难处理)。
Entity 与 DTO 分离
// cell-mes-domain: 领域模型(DB 映射)
@TableName("dispatch_order")
public class DispatchOrder {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId; // 必带,租户拦截器用
private String erpWorkOrderNo;
private Long routingId;
private Long bomId;
private Integer operationSeq;
private String workCellCode;
private LocalDate shiftDate;
private String shiftCode;
private Integer plannedQty;
private Integer actualQty; // 实际产出(ch04 实时更新)
private LocalDateTime plannedStart;
private LocalDateTime plannedEnd;
private LocalDateTime actualStart; // ch04 推送设备启动事件时回写
private LocalDateTime actualEnd;
private Integer paramSetVersion; // 改派校验用
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// cell-mes-common: 对外 DTO(不暴露 tenantId)
public record DispatchOrderDTO(
Long id,
String erpWorkOrderNo,
String operationCode,
String workCellCode,
Integer plannedQty,
Integer actualQty,
String status,
String shiftLabel
) {}
// 严禁:DispatchOrder 实体直接返回前端(泄漏 tenantId)
纪律:Controller 永远返回 DTO,不返回 Entity——避免字段误泄漏(如 tenant_id、内部审计字段)。Mapper 用 MapStruct 显式声明映射,禁止反射拷贝。
异步任务显式传租户
public class TraceService {
@Async("traceExecutor")
public void buildTraceIndexAsync(Long cellSn) {
Long tid = TenantContext.get(); // 关键:从主线程取
CompletableFuture.runAsync(() -> {
try (TenantContext.Scope scope = TenantContext.with(tid)) {
// 异步线程内显式设置租户
doBuildTrace(cellSn);
}
}, traceExecutor);
}
}
// TenantContext.Scope 是 AutoCloseable,try-with-resources 自动清理
public class TenantContext {
private static final ThreadLocal<Long> CTX = new ThreadLocal<>();
public static Long get() { return CTX.get(); }
public static Scope with(Long tid) {
Long old = CTX.get();
CTX.set(tid);
return () -> { if (old == null) CTX.remove(); else CTX.set(old); };
}
public interface Scope extends AutoCloseable { default void close() {} }
}
踩坑警告:Java 21 虚拟线程下 ThreadLocal 仍可用,但 InheritableThreadLocal 在虚拟线程下行为不一致(虚拟线程可能复用 carrier),禁止用 InheritableThreadLocal,必须显式传递。
数据说话:多模块骨架的工程化指标
电芯厂 A 二期重构后的工程化指标:
| 指标 | 一期(单模块) | 二期(多模块+纪律) | 改善 |
|---|---|---|---|
| Flyway 迁移脚本数 | 23 个(混在一起) | 87 个(按版本分) | - |
| 迁移冲突次数 | 7 次/月 | 0 次/月 | - |
| 多租户拦截器漏 SQL | 4 处 | 0 处(CI 强校验) | - |
| 字段同步漏改 | 3 次/月 | 0 次/月 | - |
| 单元测试覆盖率 | 18% | 62% | 3.4x |
| 编译时间 | 12s | 28s(多模块) | -2.3x(接受) |
| 部署包大小 | 92 MB(fat jar) | 38 MB(mes-core)+ 26 MB(mes-collector) | - |
| 虚拟线程下 HikariCP pin 次数 | 4 次/天 | 0 次/天 | - |
关键数字:单元测试覆盖率从 18% 到 62%——这是工程化纪律最大的收益,事故数从 7 次/月降到 0 次/月。
面试怎么答:MES 工程化三个高频问题
问:MyBatis-Plus 多租户拦截器为什么有 SQL 漏注入?
答:① 手写 XML SQL不在拦截范围——TenantLineInnerInterceptor 默认只拦截 BaseMapper 方法,自定义 @Select / XML SQL 需要显式调用 InterceptorIgnoreHelper;② 复杂 SQL 的子查询——某些嵌套 SQL 的 inner table 拦截器解析失败漏注入;③ DDL 语句——CREATE TABLE / ALTER 不拦截但插入数据时要带 tenant_id。强制纪律:CI 跑 TenantInterceptorCoverageTest,对每条 SQL 断言执行计划带 tenant_id 过滤条件。
问:Java 21 虚拟线程下 HikariCP 为什么会 pin?
答:HikariCP 在 pool entry 借出/归还连接时用了 synchronized,Java 21 虚拟线程在 synchronized 块内会被 pin 到 platform 线程(carrier thread),导致虚拟线程"退化"为 platform 线程,失去轻量级优势。解决方案:① HikariCP 5.1+ 已针对虚拟线程优化(用 ReentrantLock 替代 synchronized);② 配置 minimumIdle=maximumPoolSize 消除借出抖动;③ OPC UA 长连接和 JDBC 不共用线程池。JDK 24+ 将支持 synchronized 不 pin,届时可彻底解决。
问:MES 多模块项目按业务领域分还是按技术分层?
答:按业务领域分(DDD 限界上下文)。理由:① MES 业务跨模块调用多(追溯调派工、SPC 调追溯),按业务分模块后模块内自包含,跨模块走接口暴露,限界清晰;② 团队按业务组分工(追溯组、SPC 组),按业务模块对齐团队结构;③ 单模块可以独立打 jar 部署(采集层/业务层分进程),按技术分层无法独立部署。代价是模块多编译慢(28s vs 12s),可接受。
落地清单:工程化骨架动作
- 多模块拆分:parent + common + domain + infra + collector + core + web-bff;core 内部再按业务子模块(routing/dispatch/trace/oee/spc/andon)。
- Java 21 + Spring Boot 3.2.11:开虚拟线程 + HikariCP 5.1+ + minimumIdle=maximumPoolSize。
- MyBatis-Plus 3.5.7+:TenantLineInnerInterceptor 必须最先添加(拦截器顺序敏感),最后加 PaginationInnerInterceptor。
- Flyway 命名
V<大>.<小>.<补丁>__<动词>_<对象>.sql,禁止无小版本号;改字段走四步迁移法(加→双写→切读回填→删),禁止单步 RENAME。 - Entity / DTO 严格分离:Controller 返回 DTO,MapStruct 显式映射,禁止反射拷贝;Entity 必带
tenantId。 - TenantContext 用 ThreadLocal,禁用 InheritableThreadLocal;异步任务显式
try (TenantContext.Scope scope = TenantContext.with(tid))。 - CI 强制 TenantInterceptorCoverageTest:每条 SQL 执行计划断言带
tenant_id条件,跑 EXPLAIN 解析。 - 黄金回归:① 迁移脚本回滚测试(V8.4 删字段后能用 V8.3 数据回滚);② 多租户测试(A 租户看不到 B 租户的工单);③ 虚拟线程下 HikariCP pin 计数(0 次/天)。
工程化是项目的"骨架肌肉",下一章我们深入 L2 采集层,看 OPC UA、Modbus、SECS/GEM 三协议怎么适配。
