8、构建缓存:本地缓存与远程缓存、缓存键的生成、缓存命中与失效、配置构建缓存

构建缓存这东西,说白了就是「别重复干活」。

你想想看,每次构建都重新编译、重新测试,多浪费啊。尤其是大项目,一次构建十几分钟甚至半小时,谁受得了?我个人习惯是把构建缓存当作性能优化的第一道防线——它见效快,配置简单,而且几乎零风险。

8.1 本地缓存 vs 远程缓存

Gradle 的缓存分两种:本地缓存和远程缓存。它们解决的问题不一样。

本地缓存,就是把你本机上一次构建的结果存下来。下次构建时,如果输入没变,直接复用。这个默认就是开启的,你几乎不用操心。

远程缓存,则是把构建结果上传到一台共享服务器(比如用 HTTP 或 S3 协议)。团队里其他人构建时,如果命中相同的缓存键,直接下载结果,不用重新编译。

我在项目中遇到过这样的情况:一个微服务项目,十几个模块,每次 CI 构建都要跑 20 分钟。后来配了远程缓存,构建时间直接降到 5 分钟。为什么?因为大部分模块的输入根本没变,直接从缓存拉结果就行。

核心区别:本地缓存只对你自己有用,远程缓存能让整个团队受益。

8.2 缓存键是怎么生成的?

缓存键,就是 Gradle 用来判断「这次构建和上次构建是不是一回事」的依据。它由一组输入属性决定。

举个例子,一个 Java 编译任务的缓存键通常包括:

  • 源代码文件的路径和内容哈希
  • 编译选项(比如 JDK 版本、编码方式)
  • 依赖的 classpath 文件哈希
  • 注解处理器配置

Gradle 会把这些输入属性拼成一个哈希值,作为缓存键。如果两次构建的哈希值一样,就认为输出可以复用。

嗯,这里要注意:不是所有输入都会被纳入缓存键。比如构建脚本本身的变化,默认不会触发缓存失效。我曾经踩过这个坑——改了 build.gradle 里的一个插件版本,但缓存没失效,结果跑出来的产物还是旧的。排查了半天才发现是缓存没清。

避坑指南:如果你改了构建脚本或插件配置,记得手动清理缓存。命令是 gradle cleanBuildCache

8.3 缓存命中与失效

缓存命中,就是 Gradle 发现缓存键匹配,直接复用结果。缓存失效,就是输入变了,需要重新执行任务。

但有些情况会让缓存「意外失效」:

  • 时间戳变化:有些工具会在文件里嵌入时间戳,导致每次构建的输入都不一样。我建议用 gradle.properties 里的 org.gradle.caching=true 来强制开启缓存,同时避免使用 @Sensitive 注解标记时间戳。
  • 绝对路径:如果任务输入里包含了绝对路径(比如 /home/user/project/src),换一台机器构建就会失效。解决办法是用相对路径。
  • 随机数或 UUID:有些构建脚本里会生成随机数作为输出文件名,这会让缓存永远无法命中。我个人习惯用固定的命名规则,或者把随机数排除在缓存键之外。

为什么会这样?说白了,Gradle 的缓存机制非常严格——它只认输入,不认输出。只要输入有一丁点不同,它就认为需要重新执行。

小技巧:可以用 --build-cache 参数临时开启缓存调试模式,看看哪些任务命中了缓存,哪些没命中。输出里会显示 FROM-CACHEREBUILD 字样。

8.4 配置构建缓存

配置构建缓存,其实就两步:开启缓存,指定远程缓存地址。

先看本地缓存的配置。在 gradle.properties 里加上:

org.gradle.caching=true

这就够了。本地缓存默认存在 ~/.gradle/caches/build-cache-1 目录下。

再看远程缓存。你需要一个支持 HTTP 协议的缓存服务器,比如用 gradle-enterprise-cache 或者自己搭一个。然后在 build.gradle 里配置:

buildCache {
    remote(HttpBuildCache) {
        url = 'https://cache.example.com/cache/'
        push = true
    }
}

push = true 表示允许把本地的构建结果上传到远程缓存。如果你只是拉取,不贡献,可以设成 false

我在项目中遇到过一个问题:远程缓存服务器挂了,构建直接失败。后来我加了个 allowUntrustedServer = true 和超时配置,才解决了。嗯,这里要提醒你:远程缓存不是必须的,如果团队不大,本地缓存就够用了。

最佳实践:CI 环境里一定要开启远程缓存,而且 push 只让 CI 机器做,开发机只拉取不上传。这样可以避免开发机上传了不完整的缓存。

8.5 缓存键的自定义与调试

有时候,默认的缓存键不够用。比如你有一个自定义任务,它的输入是某个外部 API 的响应。这时候你需要手动指定缓存键。

Gradle 提供了 @Input@InputFile@InputDirectory 等注解来标记输入属性。举个例子:

abstract class MyTask extends DefaultTask {
    @Input
    abstract Property<String> getApiVersion()

    @InputFile
    abstract RegularFileProperty getConfigFile()

    @TaskAction
    void doWork() {
        // 任务逻辑
    }
}

这样,apiVersionconfigFile 的变化就会影响缓存键。

调试缓存键也很简单。用 --info 日志级别运行构建,会看到类似这样的输出:

Task ':compileJava' has not been cached because its inputs have changed

我个人习惯在开发阶段用 --no-build-cache 关闭缓存,避免缓存干扰调试。等代码稳定了再开启。

避坑指南:自定义任务里,千万别忘了给输出目录加 @OutputDirectory 注解。否则 Gradle 不知道输出在哪,缓存了也没用。

8.6 构建缓存的知识体系

下面这张图,帮你理清构建缓存的核心逻辑:

构建缓存核心逻辑 任务输入 源代码 / 配置 / 依赖 缓存键生成 输入属性哈希 缓存命中 复用输出 缓存未命中 重新执行 任务输出 class / jar / 报告 缓存存储 本地 / 远程 缓存键由输入属性哈希决定,命中则复用输出,未命中则重新执行 输出结果会存入缓存,供后续构建使用 本地缓存:本机复用 远程缓存:团队共享

构建缓存的核心逻辑,说白了就是「输入决定输出」。输入没变,输出直接复用;输入变了,重新执行。本地缓存和远程缓存只是存储位置不同,原理是一样的。

最后,我想说一句:构建缓存不是银弹。它解决的是「重复构建」的问题,但解决不了「构建本身太慢」的问题。如果你的任务本身就很慢(比如跑一个大型测试套件),缓存只能帮你省掉重复执行的时间,第一次构建该慢还是慢。这时候,你需要结合增量构建、任务并行化等手段来优化。

嗯,关于构建缓存,就聊这么多。记住一句话:能缓存就缓存,不能缓存就优化任务本身


公众号:蓝海资料掘金营,微信deep3321