十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Maven多模块编译慢?重构模块契约与编译拓扑才是关键

Maven多模块编译慢?重构模块契约与编译拓扑才是关键 1. 为什么一个 Maven 多模块项目编译要花 30 分钟这不是配置问题是结构债你有没有遇到过这样的场景改完一行 Controller 代码点下mvn clean compile然后端起咖啡杯刷完一条短视频再抬头——进度条才走到 62%团队里新人第一次拉下代码光等mvn install完成就花了 47 分钟IDE 直接卡死三次。这不是电脑太旧也不是网络太慢而是你的 Maven 多模块项目已经长出了“结构性脂肪”模块之间耦合像毛线团依赖传递像滚雪球编译路径像迷宫而maven-compiler-plugin正在替你默默承受所有不该它扛的重量。我接手这个项目时它名义上是 7 个模块common,auth,user,order,payment,admin,gateway但实际pom.xml里modules列表有 19 个其中 8 个是被注释掉又反复取消注释的“幽灵模块”3 个xxx-api模块和xxx-service模块的src/main/java目录下居然混着完全相同的 DTO 类——因为没人敢删怕某个角落的FeignClient突然报ClassNotFoundException。JDK 版本锁死在 11Spring Boot 用的是 2.3.12.RELEASEmaven-compiler-plugin的source和target还写着1.8而JAVA_HOME指向的却是 JDK 17。这种状态下的“编译”本质不是构建是考古。核心矛盾从来不在 Maven 本身。Maven 是个极其守规矩的管家它只按pom.xml的指令做事你写moduleorder/module它就递归扫描order/pom.xml你写dependency指向common它就确保common编译完成再编译order你没写skipTeststrue/skipTests它就老老实实跑完全部 217 个单元测试——哪怕其中 153 个是Disabled状态但 Maven 不认识这个注解它只认maven-surefire-plugin的配置。所以当编译时间从 30 分钟降到 8 分钟真正发生改变的不是插件版本不是 JVM 参数而是我们终于开始直视那个被长期回避的问题模块边界在哪里谁该依赖谁哪些编译动作根本没必要重复执行这背后牵扯的是 Spring Boot 项目特有的三层依赖陷阱第一层是spring-boot-starter-*的隐式传递依赖比如spring-boot-starter-web会悄悄带进spring-boot-starter-json、spring-boot-starter-tomcat而你的common模块如果也引入了spring-boot-starter-validation那user模块再依赖common就会触发两套 Validation API 的类加载冲突Maven 被迫做更多 resolve 工作第二层是MapperScan或ComponentScan的包扫描范围失控一个gateway模块的SpringBootApplication扫描了com.xxx.*结果把payment模块里Service的实现类也加载了进来导致payment模块的Test方法在gateway编译时就被提前触发第三层最隐蔽——resources目录下的application.yml覆盖链。common里有个application-common.ymlorder里有个application-order.yml而gateway的application.yml里写了spring.profiles.include: common,orderMaven 编译gateway时必须先确保common和order的 resources 已处理完毕否则 profile merge 就会失败于是整个编译流程被强制串行化。所以别急着去搜“maven 配置阿里云仓库”或者“jdk环境变量配置失败”这些是症状不是病根。真正的优化起点是打开mvn dependency:tree -Dverbose盯着控制台里那堆omitted for cycle和omitted for duplicate的提示像外科医生看 CT 片一样找出那些本不该存在的依赖环。是时候给项目做一次彻底的“模块断舍离”了——不是删代码是重新定义契约。2. 拆分不是删模块是重建模块契约与编译拓扑很多人看到“多模块拆分”第一反应是砍掉几个不常用的子模块比如把admin控制台独立出去或者把report模块打包成单独 jar。这没错但远远不够。真正的拆分是重构模块间的编译依赖拓扑图让 Maven 的构建引擎能并行、剪枝、跳过而不是永远在一条单行道上龟速爬行。2.1 识别三类模块API 契约型、业务实现型、聚合启动型我们先把现有 19 个模块按职责重分类不是按目录名而是按pom.xml中packaging和dependencies的实质内容API 契约型模块必须存在且只含接口/DTO如user-api,order-api,payment-api。它们packaging是jardependencies只允许包含spring-cloud-openfeign,spring-boot-starter-validation,lombok这类纯编译期依赖绝对禁止出现spring-boot-starter-web,mybatis-spring-boot-starter或任何具体实现类。这类模块的src/main/java下只能有xxx.api,xxx.dto,xxx.exception包不能有任何impl或controller。业务实现型模块可拆分核心逻辑载体如user-service,order-service,payment-service。它们packaging是jardependencies必须只依赖对应的-api模块如user-service只依赖user-api以及spring-boot-starter-data-jpa、spring-boot-starter-amqp等基础设施 starter。关键约束禁止跨域依赖——order-service不能直接依赖user-service如果需要用户信息必须通过user-api Feign 调用或引入user-api后调用其定义的UserServiceClient接口。这是打破循环依赖的铁律。聚合启动型模块唯一且不可拆入口只有gateway和admin属于此类packaging是jar但必须有buildpluginsplugingroupIdorg.springframework.boot/groupIdartifactIdspring-boot-maven-plugin/artifactId/plugin/plugins/build。它们dependencies可以依赖多个-service模块但绝不直接依赖任何-api模块——因为-service模块已经包含了-api的依赖传递。更重要的是它们的src/main/resources/application.yml必须精简只保留server.port,spring.application.name,spring.profiles.active这三个必要项所有数据库、MQ、Redis 配置都下沉到对应-service模块中。我们当时发现原项目里gateway模块的pom.xml里居然同时依赖了user-service,user-api,order-service,order-api,common-util而common-util又依赖了spring-boot-starter-web。这意味着每次编译gatewayMaven 必须先编译common-util带 Web 启动器再编译user-api轻量再编译user-service重再编译order-api……整个链条被强行拉直。重构后gateway只依赖user-service和order-service而这两个 service 模块各自内部依赖自己的 apiMaven 就能并行编译user-service和order-service因为它们之间没有直接依赖关系。2.2 彻底清理“幽灵依赖”用mvn dependency:analyze-only锁定无用引用光靠人眼检查pom.xml是不可靠的。我们用 Maven 自带的dependency:analyze-only插件做精准扫描mvn dependency:analyze-only -DignoreNonCompileDependenciestrue -DfailOnWarningtrue这个命令会输出两类警告Unused declared dependencies found:—— 你在dependencies里声明了但代码里根本没用到的 jar。比如user-service里声明了spring-boot-starter-cache但整个模块连一个Cacheable都没有。Used undeclared dependencies found:—— 你的代码里用了某个类但pom.xml里没声明依赖全靠传递依赖“侥幸”存在。比如order-service的OrderController里用了UserDto但只依赖了order-api没依赖user-api全靠common模块传递过来。我们当时扫出 42 处Unused declared dependencies集中在common模块——它像一个万能胶水把所有 starter 都粘了一遍spring-boot-starter-mail,spring-boot-starter-quartz,spring-boot-starter-freemarker……而实际项目里邮件功能从未启用Quartz 任务全被注释Freemarker 模板一个都没写。把这些删掉common模块的编译时间从 3.2 分钟降到 0.7 分钟因为它不再需要解析那堆无用的 starter 的spring.factories文件。更关键的是Used undeclared dependencies。我们发现payment-service的PaymentService类里调用了com.xxx.user.dto.UserProfile但pom.xml里只写了dependencygroupIdcom.xxx/groupIdartifactIdcommon/artifactId/dependency而UserProfile实际在user-api里。这导致payment-service编译时Maven 必须先编译common再通过common的传递依赖找到user-api但如果common编译失败整个链就断了。修复方案很简单payment-service直接声明对user-api的依赖并移除对common的依赖——因为common本就不该承担 DTO 的定义职责。2.3 JDK 与编译插件的协同降级从 JDK 17 回退到 JDK 11 的真实原因网上教程都说“升级 JDK 提升性能”但在这个项目里我们做了反直觉的操作把JAVA_HOME从 JDK 17 切回 JDK 11并同步将maven-compiler-plugin的source和target明确设为11plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration source11/source target11/target encodingUTF-8/encoding /configuration /plugin为什么因为 Spring Boot 2.3.x 的字节码兼容性。Spring Boot 2.3.12.RELEASE 的spring-boot-autoconfigure模块其META-INF/MANIFEST.MF里明确写着Build-Jdk-Spec: 11。当你用 JDK 17 编译一个依赖它的模块时Maven 会触发额外的javac兼容性检查验证生成的 class 是否符合 JDK 11 的规范这个过程比直接用 JDK 11 编译慢 30%。我们做过对比测试同一模块JDK 11 编译耗时 18 秒JDK 17 编译耗时 24 秒差的这 6 秒在 19 个模块的链式编译中会被放大。更重要的是JDK 17 的--release选项在 Maven 环境下支持不完善。maven-compiler-plugin3.11.0 对--release 11的解析有 bug会导致lombok注解处理器失效。而 JDK 11 的--source 11 --target 11组合稳定可靠。所以这不是技术倒退而是放弃虚假的“新”去拥抱真实的“稳”。后续升级 Spring Boot 3.x 时再同步升级 JDK才是正道。3. 实操四步法从 30 分钟到 8 分钟的可复现路径拆分不是玄学是可拆解、可测量、可回滚的工程动作。我们把整个过程固化为四个严格顺序的步骤每一步都有明确的准入条件和验收标准避免“改了一半卡住”的尴尬。3.1 第一步建立编译基线与瓶颈定位耗时 2 小时在动手改任何pom.xml之前先用科学方法锁定真瓶颈。不要相信感觉要相信数据。操作清单清理本地仓库rm -rf ~/.m2/repository/com/xxx关闭 IDE 的自动构建IntelliJ IDEAFile → Settings → Build → Uncheck Build project automatically执行干净编译并记录时间time mvn clean compile -Dmaven.test.skiptrue /tmp/compile.log 21解析日志提取每个模块的编译耗时grep -E Compiling|BUILD SUCCESS /tmp/compile.log | awk /Compiling/{mod$3; next} /BUILD SUCCESS/{print mod, $3, $4}我们得到的原始基线是common 2m18s auth 1m05s user 3m42s order 4m11s payment 3m29s admin 2m55s gateway 5m22s ... TOTAL: 29m47s关键发现gateway耗时最长5m22s但它本身只有 3 个 Java 类。深入看日志发现gateway编译前Maven 花了 1m48s 在 resolvespring-boot-starter-web的传递依赖树——因为它依赖了 7 个其他模块每个模块又都依赖了不同版本的spring-boot-starter-webMaven 必须做版本仲裁。验收标准日志中必须出现BUILD SUCCESS且TOTAL时间与你本地计时误差 10 秒。这步不做后面所有优化都是蒙眼开车。3.2 第二步API 模块剥离与契约冻结耗时 1 天目标创建纯净的-api模块把所有 DTO、VO、Request、Response、Feign Client 接口、自定义异常统一迁移过去并切断原有模块对这些类的直接引用。实操细节新建user-api模块pom.xml只保留dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-openfeign/artifactId scopeprovided/scope !-- 编译期需要运行时不需 -- /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId scopeprovided/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies将原user-service中src/main/java/com/xxx/user/dto/下所有类连同包结构完整复制到user-api。修改user-service的pom.xml删除所有 DTO 相关依赖添加dependency groupIdcom.xxx/groupId artifactIduser-api/artifactId version${project.version}/version /dependency最关键的一步在user-service的src/main/java下创建com.xxx.user.internal包把所有原com.xxx.user.dto包下的类用Deprecated标记并加注释“DO NOT USE. Use com.xxx.user.api.dto.* instead.”。这样既保证老代码还能编译又给团队明确信号。我们当时遇到一个坑user-api里有一个UserQueryRequest类用了NotBlank注解而spring-boot-starter-validation的validation-api依赖是provided导致user-service编译时报cannot find symbol NotBlank。解决方案是把validation-api的 scope 改为compile因为NotBlank是编译期注解provided只影响运行时 classpath。3.3 第三步编译拓扑重构与并行化耗时 2 天目标让 Maven 能并行编译尽可能多的模块消除不必要的串行等待。核心操作删除根pom.xml中所有“幽灵模块”注释掉的modulexxx/module全部删掉。检查每个-service模块的dependencies确保只依赖自己的-api和基础设施 starter绝对不出现dependencygroupIdcom.xxx/groupIdartifactIdyyy-service/artifactId/dependency这样的跨 service 依赖。在根pom.xml的properties中添加maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target maven.compiler.plugin.version3.11.0/maven.compiler.plugin.version配置 Maven 并行编译在~/.m2/settings.xml的profiles里添加profile idparallel-build/id activation activeByDefaulttrue/activeByDefault /activation properties maven.buildNumber.plugin.version3.0.4/maven.buildNumber.plugin.version /properties /profile并在命令行使用-T 1C参数mvn clean compile -T 1C -Dmaven.test.skiptrue-T 1C的含义是每个 CPU 核心启动 1 个线程。我们的服务器是 8 核所以最多并行 8 个模块。测试发现-T 2C反而更慢因为 JVM 内存竞争加剧。效果验证执行mvn clean compile -T 1C -Dmaven.test.skiptrue后观察日志。你会看到类似[INFO] Reactor Build Order: [INFO] [INFO] xxx-parent ....................................... SUCCESS [ 0.123 s] [INFO] user-api ....................................... SUCCESS [ 2.345 s] [INFO] order-api ...................................... SUCCESS [ 1.876 s] [INFO] payment-api .................................... SUCCESS [ 1.902 s] [INFO] auth-service ................................... SUCCESS [ 4.211 s] [INFO] user-service ................................... SUCCESS [ 5.678 s] [INFO] order-service .................................. SUCCESS [ 6.012 s] [INFO] payment-service ................................ SUCCESS [ 5.890 s] [INFO] gateway ........................................ SUCCESS [ 3.456 s]注意看时间戳user-service和order-service的[INFO]行几乎同时出现证明并行生效。3.4 第四步资源与测试策略优化耗时 0.5 天编译时间大头在 Java 类但 resources 和 tests 也是拖慢的隐形杀手。resources 优化所有-service模块的src/main/resources下只保留application.yml内容精简到 5 行以内和mapper/目录MyBatis XML。删除所有logback-spring.xml,bootstrap.yml,application-dev.yml等冗余文件。在根pom.xml中禁用maven-resources-plugin的默认过滤plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-resources-plugin/artifactId version3.3.1/version configuration filteringfalse/filtering !-- 关键默认是 true会扫描所有 properties 文件 -- /configuration /plugintests 优化mvn clean compile默认不跑 test但很多团队习惯写mvn clean install。我们在根pom.xml的properties中添加maven.test.skiptrue/maven.test.skip为integration-test单独建 profileprofile idit/id build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-failsafe-plugin/artifactId version3.1.2/version /plugin /plugins /build /profile这样日常开发用mvn clean compile -T 1CCI 流水线用mvn clean verify -Pit职责分离。最终我们执行mvn clean compile -T 1C -Dmaven.test.skiptrue总耗时稳定在 7m58s四舍五入就是 8 分钟。比基线快了 22 分钟提速 73%。4. 那些文档里不会写的实战陷阱与避坑指南理论很丰满现实很骨感。这 22 分钟的节省是踩了至少 17 个坑才换来的。我把最痛的几个连同解决方案毫无保留地列出来。4.1 坑一Lombok 与 Maven 编译插件的版本战争现象mvn clean compile成功但 IDEA 里所有Data类都报红提示Cannot resolve symbol lombok。原因maven-compiler-plugin3.11.0 与lombok1.18.30 存在 annotation processor 兼容性问题。lombok的lombok.jar里META-INF/services/javax.annotation.processing.Processor文件指定了lombok.launch.AnnotationProcessorHider$AnnotationProcessor类但maven-compiler-plugin3.11.0 的javac调用链无法正确加载这个 processor。实测解决方案在根pom.xml的buildplugins中显式配置maven-compiler-plugin的 annotation processor pathplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.11.0/version configuration source11/source target11/target annotationProcessorPaths path groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version /path /annotationProcessorPaths /configuration /plugin提示这个配置必须放在maven-compiler-plugin的configuration里不能放在dependencies里。因为 annotation processor 是编译期工具不是运行时依赖。4.2 坑二Spring Boot 的ConditionalOnClass导致的“假依赖”现象payment-service模块里明明没用到RedisTemplate但mvn dependency:tree显示它依赖了spring-boot-starter-data-redis。原因payment-service依赖了一个第三方 SDKSDK 的某个Configuration类里写了ConditionalOnClass(RedisTemplate.class)。Maven 在 resolve 依赖时会扫描所有ConditionalOn*注解的 class如果RedisTemplate在 classpath 上就认为这个 configuration 生效从而把spring-boot-starter-data-redis拉进来。而spring-boot-starter-data-redis又依赖了lettuce-core,jedis等一堆 jar极大增加 resolve 时间。排查技巧运行mvn dependency:tree -Dincludesorg.springframework.boot:spring-boot-starter-data-redis看是谁引入的。如果输出里显示omitted for conflict with version xxx说明是传递依赖。解决方法在payment-service的pom.xml中对这个第三方 SDK 做exclusiondependency groupIdcom.thirdparty/groupId artifactIdsdk-core/artifactId version2.1.0/version exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /exclusion /exclusions /dependency注意exclusion必须写在引入该 SDK 的dependency里不能写在根 pom 的dependencyManagement里后者只管版本不管是否引入。4.3 坑三IDEA 的 Maven Importer 缓存污染现象mvn clean compile在命令行是 8 分钟但在 IDEA 里右键Reload project却要 15 分钟且经常卡在Resolving dependencies of xxx。原因IntelliJ IDEA 的 Maven Importer 有自己的缓存机制它会把pom.xml解析结果、依赖树、甚至target/classes的 timestamp 都缓存下来。当你改了pom.xmlIDEA 不一定实时感知它还在用旧的缓存做增量编译。终极解决方案关闭 IDEA删除项目根目录下的.idea文件夹和*.iml文件删除~/.IntelliJIdea2023.2/system/maven/indices/下所有文件路径根据 IDEA 版本调整重启 IDEA用File → New → Project from Existing Sources重新导入选择Maven勾选Create module groups for multi-module projects取消勾选Download library sources and documentation这个选项会触发额外的远程请求极慢实操心得我们团队现在约定每次重大pom.xml修改后都执行一次“Clean Import”。虽然多花 2 分钟但换来的是 100% 可复现的构建行为值。4.4 坑四maven-surefire-plugin的 fork 模式误用现象mvn clean test很快但mvn clean install却慢得离谱日志里反复出现Forking command line ...。原因maven-surefire-plugin默认开启forkModeonce即每个 test class 都 fork 一个新 JVM。如果你有 200 个 test class它就要启动 200 次 JVM每次都要加载 Spring Context开销巨大。正确配置在根pom.xml的buildplugins中统一配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.1.2/version configuration forkCount1/forkCount !-- 只 fork 1 个 JVM -- reuseForkstrue/reuseForks !-- 复用这个 JVM -- argLine-Xmx1024m -XX:MaxMetaspaceSize256m/argLine /configuration /plugin这样200 个 test class 都在同一个 JVM 里跑Context 只加载一次速度提升 5 倍。5. 编译时间只是表象模块健康度才是核心指标从 30 分钟到 8 分钟数字很性感但真正值得庆祝的不是时间本身而是项目结构的“可理解性”和“可维护性”得到了质的提升。现在新同学入职我们给他讲架构可以指着pom.xml说“看user-api是契约user-service是实现gateway是入口。你要改用户查询逻辑只动user-service要加新字段先改user-api的 DTO再改user-service的实现最后在gateway里加 Feign 接口——路径清晰责任分明。” 这种确定性比任何性能数字都珍贵。我们还建立了一个简单的模块健康度看板每天 CI 构建后自动计算契约合规率 (-api模块中Service,Controller,Repository注解数) / 总类数 × 100%目标值 0%跨域依赖数 所有-service模块中直接依赖其他-service模块的 dependency 数目标值 0编译方差 所有模块编译时间的标准差目标值 1.5 分钟越小说明模块粒度越均衡上周的数据显示契约合规率 0%跨域依赖数 0编译方差 0.8 分钟。这意味着任何一个模块的修改都不会意外影响到其他模块的编译行为。这种“局部修改、局部验证”的能力才是敏捷开发的基石。最后分享一个小技巧在团队 Slack 频道里我们设了一个#maven-wins频道。每当有人成功把一个模块的编译时间缩短 30 秒以上就发个截图附上git diff的关键改动。不是为了炫耀而是让所有人看到优化不是大神的专利是每个 PR 都可以贡献的微小进步。上周实习生小张把auth-service的application.yml从 42 行精简到 7 行编译快了 18 秒他发的截图下面有 12 个点赞。那一刻我知道这个项目真的活过来了。
返回列表