构建缓存原理与配置:本地缓存与远程缓存

构建缓存,说白了就是把构建产物存起来,下次直接复用。

我刚开始接触 Gradle 时,总觉得这功能可有可无。直到有一天,团队里一个同事改了行间距,全量构建花了 20 分钟……嗯,从那以后,我逢人就讲缓存一定要配好。

Gradle 缓存到底在做什么?

你写代码,Gradle 执行任务。每个任务都有输入、输出和实现。如果输入没变,任务的结果就可以重用——这就是缓存的核心逻辑。

举个例子:编译 Java 源码时,输入是 .java 文件,输出是 .class 文件。如果源码没变,Gradle 会把上次的 .class 直接拿过来,不再重新编译。

这个过程不是简单的文件复制。Gradle 会计算输入内容的哈希值,作为缓存的 key。只有 key 匹配,才会命中缓存。

关键点:缓存不是帮你存源码,而是存任务输出。输入不变 → 输出不变 → 直接用缓存。

本地缓存:简单但别掉坑

本地缓存默认就开好了。配置在 ~/.gradle/caches 目录下,实际上它里面分了几个子目录:

  • modules-2 — 存放依赖的 jar 包
  • transforms-2 — 存放资源合并、转换后的中间产物
  • build-cache-1 — 存放任务输出缓存

我之前在团队里遇到过一个问题:每次 git 切换分支,本地缓存都会失效一大片。为什么呢?因为分支上 build.gradle 的版本号变了,任务输入自然就变了。

本地缓存的配置其实很简单:

// settings.gradle
buildCache {
    local {
        enabled = true
        directory = new File(rootDir, '.build-cache')
        removeUnusedEntriesAfterDays = 30
    }
}
  • directory — 你可以指定缓存目录,不指定就用默认的。
  • removeUnusedEntriesAfterDays — 我习惯设 30 天。太短了频繁重建,太长了磁盘膨胀。

小技巧:如果你在 CI 上跑构建,可以把本地缓存目录挂在 /tmp 或者专用存储上,避免每次拉取大量缓存文件。

远程缓存:团队协作的加速器

本地缓存只能管一台机器。团队开发时,A 同事编译过的东西,B 同事能不能直接用?远程缓存就是干这个的。

我曾在项目里给团队配过一个 HTTP 远程缓存,效果很明显:原来 CI 构建需要 8 分钟,配置后降到 3 分钟。

远程缓存需要一个后端存储。常见方案:

方案 特点 我踩过的坑
HTTP 缓存服务器(Nginx / Gradle Remote Build Cache 插件) 简单直接,支持文件存储或对象存储 权限控制要自己写,否则谁都能看你的缓存
Redis / 对象存储(S3、MinIO) 高性能,适合大规模团队 配置有点麻烦,我一开始连密钥都配错了
Gradle Enterprise 全集成,带分析面板 付费,但真的好用,团队超过 10 人强烈推荐

配置远程缓存的示例:

// settings.gradle
buildCache {
    remote(HttpBuildCache) {
        url = 'https://cache.mycompany.com/cache/'
        enabled = true
        push = true
        credentials {
            username = 'cache-user'
            password = 'cache-pass'
        }
    }
    local {
        enabled = true
    }
}
  • push = true 表示本地构建完成后,也会把结果上传到远程。否则只拉不推。
  • credentials — 别把密码写在代码里……我建议用环境变量。

警告:远程缓存的 key 是任务输入的哈希。如果你的构建脚本里混入了 buildDir 等变动路径,缓存命中率会极低。我曾经就是因为 buildDir 里放了个时间戳文件,导致天天缓存 miss。

缓存布局与生命周期

Gradle 缓存不是一直不变的。它会根据 LRU(最近最少使用)策略清理旧缓存。

我个人习惯把本地缓存保留 30 天,远程缓存保留更久——因为远程存储一般便宜。但要注意,缓存体积大了以后,网络传输会成为瓶颈。我见过一个项目远程缓存目录达到了 20GB,一次构建光下载缓存就用了 40 秒。

缓存命中率——你最该关心的数字

别光配完就不管了。你得看缓存到底有没有帮上忙。

Gradle 提供了 --build-cache-debug 参数,可以输出每个任务是否命中了缓存:

./gradlew assembleDebug --build-cache-debug

输出会显示类似:

Task :app:compileDebugKotlin FROM-CACHE 1m 2s
Task :app:mergeDebugResources FROM-CACHE 0m 10s
Task :lib:compileDebugJavaWithJavac NOT-CACHED

命中率计算公式很简单:

命中率 = 命中缓存的任务数 / 总任务数 × 100%

我一般要求团队里命中率在 80% 以上才算及格。低于这个数,就要去排查了。

常见原因导致缓存失效

  1. 非确定性任务 — 比如用了 new Date() 或随机数作为输出。
  2. 文件路径硬编码 — 不同机器上路径不同,key 自然不同。
  3. 插件升级 — 任务实现变了,缓存会全部失效。
  4. 环境变量 — 某些任务把环境变量当输入,变量一变就 miss。

我曾经花了一下午排查一个缓存 miss 问题,最后发现是 build.gradle 里有一行 println(new Date()) 作为任务输入……嗯,那之后我学乖了,所有调试打印都关掉。

一张图看懂缓存体系

下面这张图展示了本地缓存和远程缓存在 Gradle 构建中的完整流程:

Gradle 构建缓存体系流程 Gradle 任务执行 本地缓存 远程缓存 ~/.gradle/caches/build-cache-1 LRU 清理(30天默认) HTTP / S3 / Redis / Gradle Enterprise push = true 时上传 缓存命中? 复用上次输出 重新执行任务 命中率是关键指标 低于80%需排查

配置建议汇总

场景 我建议的做法
单人本地开发 默认本地缓存 + removeUnusedEntriesAfterDays = 30
小团队(<10人) 本地缓存 + 远程 HTTP 缓存(简单搭建)
大团队 / CI 环境 本地缓存 + Gradle Enterprise 或 S3 缓存
所有场景 开启 --build-cache-debug 监控命中率

避坑指南:我曾经把 push = true 配置在 CI 上,结果每次本地开发也往远程推缓存。后来发现本地和 CI 的 JDK 版本不一样,导致缓存冲突……建议 CI 和本地使用不同的远程缓存路径,或者分别控制 push 开关。

性能影响:缓存不是免费的

本地缓存的 I/O 开销其实很小,但远程缓存涉及网络传输。如果你的网络带宽不够,一次下载大批量缓存反而比直接编译慢。

我记得有一次客户现场的网络只有 2Mbps,远程缓存下载时间占了构建时间的 60%。后来我们直接关闭了远程缓存,反而更快。

最后的建议:

  • 先确保本地缓存命中率正常,再考虑上远程缓存。
  • 远程缓存一定要设 credentials,别裸奔。
  • 定期清理远程缓存旧数据,否则存储费会超出你的预算。

嗯,关于远程缓存,还有个细节:Gradle 默认不会把缓存推送到远程,需要你显式设 push = true。我习惯在 CI 脚本里动态控制推送开关,避免本地开发时意外污染远程缓存。