3 构建缓存失效排查

排查口诀:先查标志是否开,再看哈希变没变;键值计算逐层对,远程元数据是标尺。

3.1 现象识别

构建缓存失效的典型表现包括:

  • 二次构建时间无改善:完全相同的源码和配置,第二次构建依然全量编译。
  • 远程缓存命中率为 0%:构建扫描(Build Scan)中 cacheable 任务数量正常,但命中率始终为 0。
  • 跨机/CI 环境构建不共享缓存:同一份代码在不同机器上各自全量构建,没有从远程缓存拉取结果。
  • 任务输出不一致:部分任务从缓存恢复,部分任务重新执行,导致构建时间异常波动。

3.2 数据采集

执行以下命令收集缓存失效的第一手数据:

# 开启 --build-cache 并输出 INFO 级别日志
./gradlew clean assemble --build-cache --info 2>&1 | tee build-cache.log

# 生成 Build Scan(用于后续分析缓存键)
./gradlew clean assemble --build-cache --scan

# 查询远程缓存状态(以 Gradle Enterprise 为例)
curl --header "Authorization: Bearer ${ACCESS_}" \
  "https://ge.example.com/api/build-cache/v1/cache/${CACHE_KEY}"

数据采集清单:

数据项来源关键字段
构建日志--info 输出Cache miss / Cache hit / Cache entry not found
Build Scan--scan 报告Task cacheability、Cache key breakdown、Input file fingerprints
远程缓存元数据HTTP 响应头X-Gradle-Cache-Key、Content-Length、ETag
本地缓存目录~/.gradle/caches/build-cache-* 目录、.lock 文件

3.3 原因定位

常见陷阱:即使开了 --build-cache,若 build.gradle 中使用了 outputs.upToDateWhen { false } 或 outputs.cacheIf { false },任务依然不会进入缓存路径。

3.3.1 --build-cache 标志验证

检查构建命令是否真的启用了构建缓存:

# 方法1:查看 Gradle 启动参数
./gradlew --version | grep "Build cache"

# 方法2:从 --info 日志中搜索
grep -i "build cache" build-cache.log
# 预期输出: "Using build cache ..." 或 "Build cache is enabled"

如果日志出现 Build cache is disabled,则检查:

  • 命令行是否省略了 --build-cache。
  • 项目根目录下 gradle.properties 是否设置了 org.gradle.caching=false。
  • 环境变量 GRADLE_OPTS 或 JAVA_TOOL_OPTIONS 是否覆盖了缓存设置。

3.3.2 缓存键(Cache Key)计算差异

每个可缓存任务都有一个唯一的缓存键。当缓存键不匹配时,即使构建输入完全相同,缓存也无法命中。

缓存键组成部分常见差异源排查方法
任务类名插件版本不同导致类路径变化Build Scan → Task details → Class
输入文件的路径与哈希构建根目录不同、符号链接替换对比两台机器的 gradle/user-id.values
任务参数(如 --release)gradle.properties 默认值不一致打印 getOptions().getAll() 到日志
Gradle 版本号跨版本不兼容./gradlew --version
操作系统 / 架构macOS 与 Linux 差异检查 Build Scan → Build environment → OS
排查动作:在 Build Scan 中点击“Cache key breakdown”链接,展开每个任务的缓存键计算树。重点关注 inputFileProperties 和 taskProperties 两个子项的哈希。如果两个成功命中的机器在此树上的任一节点哈希不一致,则缓存键必然不匹配。

3.3.3 输入文件哈希变化追踪

即使代码没有变更,以下操作会导致输入文件哈希变化:

  • CI 中 checkout 时间戳不同:Gradle 在 7.x 版本中默认使用 fileHash,但某些插件仍依赖 fileLastModified。
  • 构建缓存目录被部分清理:其他并发构建进程删除了 .gradle/caches/build-cache-1/ 下的文件。
  • 依赖解析结果变化:即使 build.gradle 不变,远程仓库的 maven-metadata.xml 可能已更新,导致依赖版本静态哈希不同。

使用以下脚本对比两台机器的输入文件指纹:

# 在机器A和机器B分别运行
./gradlew :app:compileJava --build-cache --info 2>&1 | \
  grep -E "(Input file|file hashes|fingerprint)" > fingerprints_A.log

# 对比差异
diff fingerprints_A.log fingerprints_B.log | head -30

3.3.4 远程缓存元数据比对

当本地缓存缺失,且远程缓存无法命中时,需要比对远程缓存元数据:

# 获取本地预期的缓存键(来自 Build Scan)
EXPECTED_KEY="1a2b3c4d..."

# 查询远程缓存状态
curl -I "https://ge.example.com/api/build-cache/v1/cache/${EXPECTED_KEY}"

# 如果返回 404,则远程无此键
# 如果返回 200,检查 Content-Length 是否为空
# 如果返回 304(未修改),说明缓存键已存在但本地缓存过期
元数据判断规则:
- 本地无缓存条目 + 远程 404 = 缓存从未被写入。
- 本地无缓存条目 + 远程 200 + Content-Length = 缓存键有效,可从远程恢复。
- 本地有缓存条目 + 远程 200 = 元数据一致,检查本地缓存是否损坏(尝试 --no-build-cache 清空本地缓存后重试)。
构建缓存失效排查流程图 排查链路:现象 → 数据采集 → 缓存键计算差异 → 根因定位 → 修复 现象识别 数据采集 缓存键匹配? 输入文件哈希变化 远程元数据比对 根因定位与修复 否 否

3.4 修复动作

根据定位的根因,选择对应的修复方案:

根因修复动作验证方法
--build-cache 未生效在 gradle.properties 添加 org.gradle.caching=true,或修改 CI 脚本确保每次传递 --build-cache重建后日志输出 "Build cache is enabled"
缓存键计算差异锁定 Gradle 版本、插件版本、JDK 版本;规范构建根目录路径Build Scan 中两台机器的缓存键 breakdown 完全一致
输入文件哈希变化移除 outputs.upToDateWhen 条件;设置 fileHashes 为稳定实现;清理 CI 工作区的路径差异对比两次构建的 inputFileProperties 哈希保持一致
远程缓存元数据不一致检查远程缓存配额和清理策略;确保所有构建节点使用相同的远程缓存 URL 和认证令牌手动查询远程缓存键返回 200 且 Content-Length 非空
极端情况:如果修复后依然失效,可以尝试将 org.gradle.caching.debug=true 写入 gradle.properties,然后在日志中搜索 Cache key for task 和 inputs.files 的完整指纹树。逐行对比两个构建的指纹树,定位到最小不一致节点。

3.5 复测

执行以下标准复测流程:

  1. 清理本地缓存:rm -rf ~/.gradle/caches/build-cache-*
  2. 构建一次:./gradlew clean assemble --build-cache(预期全量构建,填充本地缓存)
  3. 立即重建:./gradlew assemble --build-cache --info > rebuild.log
  4. 验证命中率:从 rebuild.log 统计 Cache hit 行数,预期所有可缓存任务均命中
  5. 远程验证:在另一台机器或在相同机器上使用 GRADLE_USER_HOME 指向空目录,重复步骤2-3,确保远程缓存命中

复测结果判断:

  • 成功:第二次构建时间缩短 80% 以上,任务日志显示 FROM-CACHE。
  • 部分成功:部分任务命中、部分 Miss → 定位未命中的任务,重复 3.3 节的排查流程。
  • 完全失败:回到 3.1 节重新识别现象。

3.6 预防

预防清单:
  • 版本锁定:将 Gradle 版本、插件版本、JDK 版本写入 gradle-wrapper.properties 和 CI 配置。
  • 规范化构建环境:使用 Docker 容器或统一构建镜像,确保 user.dir、os.name 等系统属性一致。
  • 输入文件稳定性:避免在构建过程中修改 src 目录下的文件;生成文件的输出目录使用 build/,不要与源文件混淆。
  • 远程缓存监控:在 CI 中定期运行缓存命中率检查脚本,当命中率连续下降超过 10% 时触发告警。
  • 定期审计:每两周执行一次全量构建并生成 Build Scan,对比上一周期的缓存键分布。

对于持续集成的场景,建议在 buildSrc 或 settings.gradle.kts 中预制缓存键差异检测逻辑:

// settings.gradle.kts 片段:缓存键版本标记
buildCache {
    local {
        isEnabled = true
        removeUnusedEntriesAfterDays = 7
    }
    remote {
        url = uri("https://ge.example.com/cache/")
        isPush = System.getenv("CI") == "true"
        // 缓存键版本前缀,确保跨构建不意外匹配
        credentials {
            username = "build-cache"
            password = System.getenv("GRADLE_CACHE_PASSWORD") ?: ""
        }
    }
}