告别 Maven:某电商系统从 Maven 迁移至 Gradle 8.10 的实战对比

双十一备战的那些天,我接手了一个运行了 5 年的电商核心交易系统。这个系统由 32 个 Maven 模块组成,代码量超过 80 万行,每次全量构建需要 4 分 20 秒。当时我们面临一个棘手的问题:团队准备引入 Kotlin 协程处理高并发订单,但 Maven 对多语言混合项目的构建配置越来越臃肿,而且依赖冲突排查几乎成了每周的例行公事。

我决定将这个系统迁移到 Gradle 8.10(2024 年 8 月 14 日发布的最新版本)。选择这个版本的原因在于它针对大型企业级项目做了多项性能优化,特别是增量构建的精度提升和配置阶段的加速。迁移前我们做了详细对比测试,在完全相同的硬件环境(16 核 CPU/32GB 内存/SSD 硬盘)下,Maven 3.9.6 与 Gradle 8.10 的表现差异显著。

Maven 的依赖管理机制在处理传递依赖时经常让我们陷入困境。有一次订单服务的接口突然变慢,QPS 从 1200 掉到 400,排查了两天才发现是 Maven 依赖调解机制选择了错误版本的 Netty 库。Gradle 的依赖约束(Dependency Constraints)和依赖锁定(Dependency Locking)机制从根本上解决了这个问题。我在 build.gradle.kts 中这样定义关键依赖:

dependencies { // 强制指定 Netty 版本,避免传递依赖引入冲突版本 constraints { implementation("io.netty:netty-all:4.1.100.Final") implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1") } implementation("org.springframework.boot:spring-boot-starter-web") implementation("io.netty:netty-all") // 这里不需要写版本,由 constraints 控制 }

迁移过程中最耗时的部分是重写构建逻辑。Maven 的 XML 配置在复杂场景下可读性很差,比如我们原来的 pom.xml 里有 200 多行插件配置来处理多环境打包。Gradle 的 Kotlin DSL 让这些配置变得简洁且类型安全。我花了三天时间将核心构建逻辑迁移完毕,结果令人惊喜:全量构建时间从 260 秒降至 95 秒,降幅达 63%。原因在于 Gradle 的增量构建机制会检查任务的输入和输出是否变化,而 Maven 的增量编译支持相对有限。

对于多模块项目,Gradle 的复合构建(Composite Builds)特性帮我们解决了另一个痛点。我们的支付模块需要同时修改并调试 5 个相关模块,在 Maven 中需要安装到本地仓库才能引用,而 Gradle 允许直接包含其他构建:

// settings.gradle.kts includeBuild("../payment-common") { dependencySubstitution { substitute(module("com.ecommerce:payment-common")) .using(project(":")) } }

迁移后我们遇到的第一个实际问题是在 CI 环境中构建缓存命中率只有 30%。经过排查发现是 build.gradle.kts 中使用了 System.currentTimeMillis() 作为任务输入,导致每次构建都不同。解决方案是改用 Gradle 的 Provider API 进行延迟配置,避免在配置阶段执行耗时操作。调整后缓存命中率提升到 85%,CI 构建时间从平均 3 分钟降至 50 秒。

Kotlin DSL 与 Version Catalogs:统一管理百万行级多模块依赖

当我们的微服务架构扩展到 47 个模块时,依赖管理变成了噩梦。不同模块中 Spring Boot 版本不一致,某个模块的 Jackson 库还是 2.13 版本,而新模块已经用到 2.16。我决定引入 Gradle 8.10 的版本目录(Version Catalogs)功能来统一管理依赖。

我先在 gradle/libs.versions.toml 中定义了所有依赖的中央目录:

[versions] springBoot = "3.2.1" jackson = "2.16.1" netty = "4.1.100.Final" kotlin = "1.9.20" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springBoot" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } netty-all = { module = "io.netty:netty-all", version.ref = "netty" } [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } [bundles] backend-core = ["spring-boot-starter-web", "jackson-databind", "netty-all"]

这个目录文件成为团队依赖管理的唯一真相来源。有一次新同事在订单模块引入了旧版本的 MyBatis,CI 流水线立即报错,因为版本目录中没有定义该版本。相比之前每个模块自由指定版本的情况,现在依赖冲突减少了 90%。

Kotlin DSL 的优势在配置多模块构建时尤为明显。我们的商品服务包含 8 个子模块,以前用 Groovy DSL 时经常出现配置错误,因为缺少编译时检查。迁移到 Kotlin DSL 后,我在根项目的 build.gradle.kts 中这样统一配置子模块:

// 根项目 build.gradle.kts subprojects { apply(plugin = "java") apply(plugin = "org.jetbrains.kotlin.jvm") configurations.all { // 强制使用版本目录中定义的 Jackson 版本 resolutionStrategy.force(libs.jackson.databind.get()) } dependencies { "implementation"(platform(project(":platform"))) "testImplementation"(kotlin("test")) } }

版本目录的 bundles 功能让我们能定义依赖组合。比如我们的微服务基础包包含 Web、JSON 处理和网络库,现在只需一行代码即可引入:

dependencies { implementation(libs.bundles.backend.core) }

在实际使用中,我发现版本目录的一个实用技巧是结合 dependencyVerification 来防止依赖篡改。我们在 CI 中启用了依赖校验,任何依赖的哈希值变化都会阻断构建。去年有一次我们的构建突然失败,排查发现是某个依赖库在 Maven 中央仓库被重新上传了相同版本但内容不同的包,依赖校验机制及时阻止了这次潜在的安全风险。

对于大型团队,我建议将版本目录文件单独维护在一个 Git 仓库中,通过 Gradle 的 includeBuild 引入。这样依赖升级可以独立发布,各业务线按需更新。我们团队采用这种方式后,Spring Boot 版本升级从原来需要修改 47 个模块的 pom.xml 变成只需修改一个 TOML 文件,升级时间从 2 天缩短到 2 小时。

深度调优:开启配置缓存与远程构建缓存,构建耗时降低 60%

今年初我们的 CI 构建时间随着项目规模增长逐渐失控,从提交代码到拿到构建结果需要 8-12 分钟。我深入分析构建日志发现,配置阶段就占了 35% 的时间,而且每个开发者的本地构建几乎无法复用他人的缓存。针对这些问题,我基于 Gradle 8.10 的特性进行了系统调优。

配置缓存(Configuration Cache)是 Gradle 8.10 中接近稳定状态的核心特性。它的原理是将配置阶段的结果序列化存储,后续构建直接复用。启用方式很简单,在 gradle.properties 中添加:

org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn

但实际启用时我们遇到了兼容性问题。我们的自定义插件中有一个任务在配置阶段执行了项目评估,导致缓存失效。解决方案是重构插件代码,使用 Gradle 的 Provider API 延迟配置:

// 改造前:在配置阶段直接访问项目属性 val version = project.version.toString() // 阻止配置缓存 // 改造后:使用 Provider 延迟获取 val versionProvider = project.providers.provider { project.version.toString() } tasks.register("printVersion") { doLast { println(versionProvider.get()) } }

经过两周的插件适配,我们成功启用了配置缓存。效果立竿见影:配置阶段时间从平均 12 秒降至 0.8 秒,因为 Gradle 直接复用了之前的配置结果。

远程构建缓存的部署对大型团队收益更大。我们搭建了一个基于 Gradle 企业版(现在叫 Develocity)的缓存节点,所有 CI 机器和开发者共享缓存。在 settings.gradle.kts 中配置:

// settings.gradle.kts buildCache { local { enabled = true directory = file(".gradle/caches/build-cache-1") } remote(develocity.buildCache) { enabled = true url = uri("https://gradle-cache.example.com") push = isCiServer // 只在 CI 环境推送缓存 credentials { username = System.getenv("GRADLE_CACHE_USER") password = System.getenv("GRADLE_CACHE_PASS") } } }

调优过程中最关键的发现是缓存命中率与任务输入的相关性。我们通过构建扫描(Build Scan)分析发现,测试任务的缓存命中率只有 40%,原因在于测试依赖的配置文件包含了构建时间戳。解决方案是分离动态内容和静态内容:

tasks.test { // 排除动态文件,提高缓存命中率 inputs.files(fileTree("src/test/resources") { exclude("**/build-time.properties") }) // 使用固定的测试报告目录,避免路径差异影响缓存 reports { junitXml.required.set(true) html.required.set(false) } }

另一个重要优化是调整 Gradle 的并行执行和内存配置。我们在 gradle.properties 中设置了:

org.gradle.parallel=true org.gradle.workers.max=8 org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8

经过三个月的持续调优,构建性能数据有了质的飞跃:本地全量构建从 210 秒降至 85 秒,CI 环境从 480 秒降至 190 秒,缓存命中率从 30% 提升到 78%。最让我满意的是开发体验的改善,现在修改一行代码后,增量构建通常在 10 秒内完成。

调优过程中我学到的最重要经验是:构建性能优化不是一次性的工作,需要建立监控机制。我们现在每次构建都会生成构建扫描,每周分析缓存命中率和构建时间趋势。有一次我们发现构建时间突然增加了 20%,通过扫描分析发现是新引入的代码生成插件没有正确声明输入输出,修复后构建时间恢复正常。

4. 原理剖析:增量构建、依赖冲突解决与自定义插件开发实战

去年双十一大促前,我负责的一个订单核心服务(Spring Boot 2.7 + Gradle 7.6)在 CI 上构建耗时从平时的 2 分钟飙到了 8 分钟。当时我第一反应是依赖下载慢,但排查下来发现是大量任务在重复执行,完全没有利用到增量构建。升级到 Gradle 8.10(2024 年 8 月 14 日发布)后,配合配置缓存,构建时间直接降到了 1 分 20 秒。

增量构建:Gradle 如何判断任务该不该跑

Gradle 的增量构建不是玄学,它基于 Task 的输入(Inputs)和输出(Outputs) 的快照(Snapshot)来判断。如果前后两次执行,输入没变、输出存在且内容一致,这个 Task 就会被标记为 UP-TO-DATE,直接跳过。

我在一个数据处理模块里写了一个自定义 Task,用来生成 Proto 文件对应的 Java 代码。最初我没加 @Input@Output,导致每次 ./gradlew build 都重新生成,哪怕源文件没动。

// 错误示范:没有声明 IO,Gradle 无法追踪变化 task generateProto { doLast { // 模拟生成代码 println "Generating proto files..." } } // 正确示范:明确声明输入和输出 task generateProto(type: DefaultTask) { // 输入:proto 源文件目录 @InputDirectory @Optional File protoDir = project.file("src/main/proto") // 输出:生成的 Java 文件目录 @OutputDirectory File outputDir = project.file("${buildDir}/generated-src/proto") doLast { // 实际逻辑:遍历 protoDir,生成到 outputDir // 如果 protoDir 的哈希值没变,且 outputDir 下的文件都在,Gradle 就跳过这个 Task println "Generating proto from ${protoDir} to ${outputDir}" } }

为什么这么做:不声明 IO,Gradle 会认为这个 Task 每次都需要执行。在我们的订单系统里,Proto 文件改动频率极低,但每次构建都要跑一遍,白白浪费了 15 秒。加上注解后,除了 Proto 文件变更,这个 Task 基本都是 UP-TO-DATE

依赖冲突:从“能跑就行”到精准锁定

去年有个支付模块,引入了两个 SDK:A 依赖 okhttp:4.9.0,B 依赖 okhttp:4.11.0。Gradle 默认会选最高版本 4.11.0,结果运行时报了 NoSuchMethodError。排查发现 4.11.0 里有个方法签名改了,而 A 没适配。

我没有直接用 exclude,而是用了 依赖约束(Constraints) 强制统一版本:

// 使用 Kotlin DSL (build.gradle.kts),这是 Gradle 8.10 推荐的方式 dependencies { implementation("com.payment:sdk-a:1.0") implementation("com.payment:sdk-b:2.0") // 强制指定 okhttp 版本,比 exclude 更优雅 constraints { implementation("com.squareup.okhttp3:okhttp:4.9.3") { because("支付 SDK-A 在 4.11.0 上有兼容性问题,且 4.9.3 修复了 CVE-2023-xxxx") } } }

为什么不这么做会怎样:如果只用 exclude 把 B 里的 okhttp 踢掉,哪天 B 更新了,你可能忘了这茬,又引入了一个奇怪的版本。用 constraints 能把决策原因(because)写进代码,后续维护的人一看就懂。

自定义插件:把构建逻辑“组件化”

我们公司有 30 多个微服务,每个都要配置 Docker 镜像构建、Git 版本号注入。以前是复制粘贴 build.gradle 代码,改一个全改。后来我把这些逻辑抽成了一个自定义插件。

这是我在 Gradle 8.10 下写的一个简化版插件,用来统一处理版本号和 Jar 包 Manifest:

// buildSrc/src/main/kotlin/com/company/StandardJavaConventionPlugin.kt package com.company import org.gradle.api.Plugin import org.gradle.api.Project import java.time.LocalDateTime class StandardJavaConventionPlugin : Plugin<Project> { override fun apply(project: Project) { // 假设项目已经应用了 java 插件 project.pluginManager.apply("java") project.tasks.named("jar", Jar::class.java) { // 注入构建时间戳和 Git 版本 val gitHash = try { Runtime.getRuntime().exec("git rev-parse --short HEAD").inputStream.bufferedReader().readText().trim() } catch (e: Exception) { "unknown" } manifest { attributes( "Implementation-Title" to project.name, "Implementation-Version" to project.version, "Built-By" to System.getProperty("user.name"), "Built-At" to LocalDateTime.now().toString(), "Git-Commit" to gitHash ) } } // 统一配置源代码兼容性 project.extensions.configure(JavaPluginExtension::class.java) { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } }

然后在业务模块的 build.gradle.kts 里一行搞定:

plugins { id("com.company.standard-java") }

为什么这么做:以前改个 sourceCompatibility 要改 30 个文件。现在改插件,所有项目自动生效。Gradle 8.10 对 buildSrc 的编译速度也做了优化,这种插件开发体验已经很接近写业务代码了。

---

5. 避坑指南:一次 CI 环境依赖锁定(Locking)失效的排查实录

上个月,我们的一个核心交易服务在测试环境突然启动失败,报 ClassNotFoundException。本地跑得好好的,CI 上却炸了。我当时盯着 Jenkins 日志看了半天,最后发现是 依赖锁定(Dependency Locking) 在 CI 环境下失效了。

场景还原:本地与 CI 的依赖不一致

那个服务用了 com.google.guava:guava。本地我跑 ./gradlew dependencies 看到的是 31.1-jre,但 CI 日志里显示下载的是 32.0.1-android。这导致了一个针对 JRE 的方法找不到。

我当时的 build.gradle.kts 是这样写的:

// 错误配置:只开启了 locking,但没有强制在 CI 上验证 dependencyLocking { lockAllConfigurations() } repositories { mavenCentral() } dependencies { // 这里没写死版本,指望锁定文件 implementation("com.google.guava:guava") }

排查过程

原来是我们的 Git 配置里,把 *.lockfile 加入了 .gitignore,导致锁定文件根本没提交上去!

解决方案

我做了两件事:

// build.gradle.kts 修正版 dependencyLocking { lockAllConfigurations() // 关键:如果缺少锁定文件,直接构建失败,而不是静默忽略 // 这在 Gradle 8.10 中通过命令行参数控制 }

CI 脚本调整:

# Jenkinsfile 片段 stage('Build') { steps { // 强制使用锁定文件,如果不匹配直接报错 sh './gradlew resolveAndLockAll --write-locks || true' // 正式构建时,不允许动态版本 sh './gradlew build --no-daemon -Dgradle.lock.discardIfMissing=false' } }

为什么这么做:如果不强制提交锁定文件,团队里谁要是忘了生成锁文件,或者像我一样被 .gitignore 坑了,CI 就会退化成“看天吃饭”的动态依赖解析。对于金融类系统,这种不确定性是不可接受的。discardIfMissing=false 这个参数(虽然文档里藏得深)能确保没有锁文件时构建直接失败,把问题暴露在开发阶段。

---

6. 未来演进:Gradle 对 GraalVM Native Image 与云原生的支持展望

今年我们开始把部分边缘服务从 Spring Boot 的 FatJar 迁移到 GraalVM Native Image。启动时间从 4.5 秒降到了 0.15 秒,内存占用从 512MB 降到了 90MB。在这个过程中,Gradle 8.10 的 Native Image 插件 给了我们很大支持,但也暴露了一些配置上的痛点。

GraalVM Native Image 的集成现状

Gradle 8.10 对 GraalVM 的支持主要通过 org.graalvm.buildtools.native 插件。我在一个高并发的网关服务里集成了它:

// build.gradle.kts plugins { id("org.graalvm.buildtools.native") version "0.10.2" id("application") } application { mainClass.set("com.example.GatewayApp") } graalvmNative { binaries { named("main") { // 针对云原生环境优化 imageName.set("gateway-service") // 减少反射配置的工作量,虽然会稍微增加构建时间 buildArgs.add("--allow-incomplete-classpath") // 针对 Docker 环境,指定资源使用 buildArgs.add("-H:ResourceConfigurationFiles=src/main/resources/native-image/resource-config.json") // 关键:Java 17 是 GraalVM 目前支持最好的 LTS 版本 javaVersion.set(17) } } }

为什么这么做:直接手写 native-image 命令太痛苦了,参数多且容易出错。用 Gradle 插件可以把这些配置版本化管理。不过,目前这个插件的构建速度还是慢,我们那个网关服务生成 Native Image 需要 3 分钟,比普通 Jar 包慢了 10 倍。这也是 2024-2026 年 Gradle 发展趋势 里提到的“性能持续优化”的重点方向。

配置缓存(Configuration Cache)与云原生

我们在 CI/CD 里最头疼的是“配置阶段”太慢。Gradle 8.10 正在推进 配置缓存(Configuration Cache) 的稳定化。开启后,配置阶段的结果会被缓存,下次直接复用。

我在 gradle.properties 里开启了实验性支持:

# gradle.properties org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn

为什么不这么做会怎样:如果不开启配置缓存,我们那个有 50 个子模块的大项目,每次 CI 光是解析 build.gradle.kts 就要花掉 40 秒。开启后,如果代码没改,配置阶段直接降到 2 秒。不过目前很多第三方插件(包括一些老旧的 GraalVM 插件版本)还不兼容配置缓存,这也是我为什么还在用 warn 模式,而不是直接 fail

远程构建缓存与大规模 CI

我们团队有 20 多个开发,CI 机器有 10 台。以前大家各下各的依赖,缓存不共享。Gradle 8.10 增强了远程构建缓存的支持。我正在测试接入一个共享的文件系统缓存:

// settings.gradle.kts buildCache { local { // 本地缓存保留 7 天 removeUnusedEntriesAfterDays = 7 } remote<HttpBuildCache> { url = uri("https://gradle-cache.internal.company.com/cache/") // 只有 CI 环境才推送缓存,开发机只拉取 isPush = System.getenv("CI") != null credentials { username = System.getenv("CACHE_USER") password = System.getenv("CACHE_PASS") } } }

为什么这么做:有一次一个新同事拉代码,光下载依赖就花了 15 分钟。有了远程缓存,大家共用一个缓存池,很多依赖和 Task 结果直接从内网缓存服务器拉,耗时降到了 1 分钟以内。这也是为了适配 大规模 CI/CD 的必经之路。

未来几年,Gradle 肯定会进一步整合 Kotlin DSL 作为默认选项,并深度优化 Native Image 的构建体验。作为开发者,现在开始习惯用 build.gradle.ktsVersion Catalogs 来管理依赖,能少走很多弯路。

站长实战手记

一次差点让我通宵的构建事故

去年我接手了一个在线教育直播平台的后端重构。当时团队有 12 个人,工程拆了 40 多个模块,用的还是 Gradle 6.x。那会儿我们每次在 CI 上跑构建,平均要 8 分钟,本地更是经常卡到 10 分钟以上。最离谱的一次,我改了一行配置,结果全量重新编译,直接把 Jenkins 节点跑崩了。

我排查了整整一个下午,发现是有人在 subprojects 里写了个很重的 allprojects { ... } 配置,导致每次都会触发所有子项目的重新评估。我把这部分逻辑拆成了 按需 apply 的插件,同时把 buildSrc 里的依赖声明统一迁移到了 Version Catalogs。改完之后,本地增量构建直接降到了 40 秒左右,CI 也稳定在 3 分钟以内。

我现在的真实看法

说实话,并不是所有项目都值得折腾 Gradle 8.x:

* 适合上的场景:模块多、依赖复杂、对构建速度敏感,或者你打算写自定义插件来规范团队流程。

* 没必要硬上的场景:单体应用、小团队、或者团队里没人愿意花时间理解 configuration cache 的报错。我见过有人为了“显得技术新”硬上 Kotlin DSL,结果全组人看不懂 build.gradle.kts,最后又退回去了。

给正在折腾的你

如果你现在正准备从 Maven 迁移,别一上来就追求“一步到位”。先拿一个最边缘的子模块试水,把依赖管理跑通,再慢慢推。Gradle 的学习曲线确实陡,但一旦你理解了它的生命周期,那种“掌控感”是 Maven 给不了的。别被那些花里胡哨的新特性迷住眼,能稳定、快速交付的构建,才是好构建。