第2章 Gradle 缓存机制深度剖析:任务输出缓存、构建缓存、本地缓存与远程缓存的差异、缓存键计算规则、快照和输入文件依赖

本章从缓存体系出发,逐层拆解 Gradle 四种缓存类型的定位、存储方式与生命周期;深入缓存键的 SHA-256 计算过程、输入快照的生成规则,以及如何通过输入文件依赖控制缓存命中。内容覆盖原理、配置、调试与常见失误,是后续章节优化缓存命中率的理论基础。

核心认知: Gradle 缓存不是简单的“文件复制”,而是一个基于内容寻址的、可分布式的键值存储系统。只有理解键的组成,才能防掉缓存“明明没改却缓存不命中”的坑。

2.1 缓存体系概览

Gradle 4.3 之后引入的构建缓存(Build Cache)由任务输出缓存(Task Output Cache)上层封装而成,本地实现与远程实现共享同一套键规则。下表对比四类缓存的核心差异:

缓存类型存储范围存储介质生命周期键计算基准典型场景
任务输出缓存单个任务内存 + 本地文件构建进程内任务输入 + 快照增量编译
构建缓存整个构建压缩 tar 包跨构建(可持久化)同上,包装为 tar本地多次构建复用
本地缓存单机~/.gradle/caches/build-cache-1/按 LRU 策略淘汰任务输出缓存键开发者日常构建
远程缓存团队 / CIHTTP 服务(S3/Artifactory)服务端策略同上 + 远程 URL 哈希CI/CD 共享加速
Gradle 缓存体系层级与键计算流程 输入文件 / 参数 输入文件快照 SHA-256 缓存键 (任务类 + 输入 + 快照) 任务输出缓存 (内存级) 本地构建缓存 (.gradle/caches) 远程构建缓存 (HTTP/S3) 键计算依赖 • 任务类全限定名 • 所有 @Input 参数值 • 输入文件内容哈希 快照规则 • 基于内容(非时间戳) • 相对路径归一化

2.2 任务输出缓存

任务输出缓存是最底层、粒度最小的缓存单元。当 Gradle 确定一个任务的输入(Inputs)与前一次执行完全一致时,直接复用上一次任务产生的输出文件(Outputs),不再执行该任务。

启用方式

在自定义任务上添加 @CacheableTask 注解,并确保 @Input、@InputFiles、@OutputDirectory 等注解正确标记。

@CacheableTask
abstract class MyTransformTask extends DefaultTask {

    @InputFile
    abstract RegularFileProperty getInputFile()

    @OutputDirectory
    abstract DirectoryProperty getOutputDir()

    @TaskAction
    void transform() {
        // 实际转换逻辑
    }
}

缓存键计算规则

缓存键(Cache Key)由以下元素拼接后经 SHA-256 哈希得到:

  • 任务类型的完全限定类名(含类加载器)。
  • 所有 @Input / @InputFiles 注解声明的参数值。
  • 输入文件的 内容哈希(非修改时间),按文件在输入集合中的相对路径顺序排列。
  • 任务 action 自身无法直接注入,但通过类名和 Gradle 版本间接隐含。
陷阱: 如果 @InputFiles 使用了 getAbsolutePath() 或依赖了环境变量,不同机器上同一份代码会产生不同的缓存键,导致 100% 未命中。

2.3 构建缓存(本地与远程)

构建缓存将任务输出缓存打包为 .tar 压缩包,并存入本地目录或远程存储。当 Gradle 需要某个任务的输出时,先用键查找本地缓存;不存在则请求远程缓存;远程缓存也未命中时才会重新执行任务。

本地缓存位置

~/.gradle/caches/build-cache-1/

远程缓存配置

在 settings.gradle.kts 或 settings.gradle 中声明:

// settings.gradle.kts
buildCache {
    local {
        isEnabled = true
        removeUnusedEntriesAfterDays = 30
    }
    remote {
        url = uri("https://cache.mycompany.com/cache/")
        isPush = true
        isEnabled = true
        credentials {
            username = "gradle"
            password = System.getenv("CACHE_PASS")
        }
    }
}

调试命令

gradle build --build-cache --info  # 查看缓存命中/未命中详情
gradle build --cache-miss-tracking  # 生成未命中分析报告

2.4 快照(Snapshot)和输入文件依赖

输入文件的快照并非使用文件最后修改时间(mtime),而是通过 内容寻址 方式计算每个文件的哈希值(默认为 SHA-256)。Gradle 在解析输入时会执行以下步骤:

  1. 遍历输入文件集合(@InputFiles、@Classpath 等)。
  2. 对每个文件按相对路径(相对于输入根目录)计算内容哈希。
  3. 将所有文件哈希与路径列表序列化为规范化的快照字符串。
  4. 将该快照字符串作为缓存键的一部分输入 SHA-256 计算。

输入文件依赖:如果任务 A 的 @OutputDirectory 被任务 B 的 @InputFiles 引用,那么任务 B 的缓存键会包含任务 A 的输出快照。这种依赖关系使缓存失效自动向下游传播。

关键: 快照基于内容而非时间戳意味着以下修改不会破坏缓存:
  • 文件时间戳改变但内容不变。
  • 文件系统重命名或移动(路径会变,注意路径归一化)。
  • 在构建机器和目标机器之间拷贝文件。

2.5 缓存键计算深度解析

Gradle 内部使用 org.gradle.caching.internal.origin.OriginMetadata 记录构建源信息。键计算的完整公式可视为:

CacheKey = SHA-256(
    taskClass.fqn + ":" +
    gradleVersion + ":" +
    serialize(inputs) +  // 所有 @Input 注解属性值
    serialize(inputFilesSnapshots)  // 内容哈希列表
)

常见导致键不同的原因:

影响因素示例解决方案
路径@InputFile 使用 getAbsolutePath()使用 @Classpath 或相对路径
环境变量依赖 System.getenv("HOME")通过 @Internal 或 Provider 包装
时间戳信息在任务 action 中使用 System.currentTimeMillis()注解为 @Internal
类路径版本使用未 pinned 的 plugin 版本固定版本或使用 Gradle 版本目录
文件编码/换行Linux 与 Windows 换行差异在 Git 中配置 .gitattributes 统一换行

2.6 调试与诊断

当缓存命中率异常时,可通过以下手段进行诊断:

  1. 构建扫描(Build Scan):--scan 参数会在 gradle.com 生成一份包含缓存命中/未命中详情、任务输入变化归因的可视化报告。
  2. --cache-miss-tracking:Gradle 7.5+ 支持,在项目根目录生成 build/cache-miss-tracker 报告,列出每个未命中任务的具体差异。
  3. 打印缓存键:通过 gradle --build-cache --info --cache-miss-tracking 并查看日志中的 Cache key for task ':xxx' is XXXXX。
# 以纯文本形式查看任务输入快照
gradle help --task :compileJava --info

2.7 常见错误与解决方案

典型反面案例: 团队将 Git 的 fileMode 包含在 hash 中,导致不同操作系统下同一个文件的快照不同,远程缓存几乎从未命中。解决方法:在 build.gradle.kts 中配置输入归一化。
// 归一化配置示例
normalization {
    runtimeClasspath {
        ignoreFileName = true
        ignoreClasspathEntryMetaInf = false
    }
    properties("**/version.properties") {
        // 忽略特定属性文件中的 build 时间戳
        ignoreProperty("buildTime")
    }
}

其他常见错误:

  • 远程缓存超时:在大文件场景下,HTTP 连接超时可设置 .cleanupInterval 和 connectionTimeout。
  • 缓存空间撑爆:本地缓存默认无上限,建议设置 removeUnusedEntriesAfterDays = 30。
  • 权限拒绝:远程缓存服务需要认证,务必通过 CI 环境变量注入,避免硬编码。

2.8 性能与稳定性优化

  1. 减少键的噪声:将非关键输入(如临时文件、日志级别)标识为 @Internal,避免不必要的变化。
  2. 使用 @SkipWhenEmpty:当输入文件集合为空时跳过任务并直接复用上次输出。
  3. 调整远程缓存并发:在 gradle.properties 中设置 org.gradle.caching.http.maxConnections=8 提高 I/O 并发。
  4. 结合 Git 哈希:在 CI 上,使用 Git commit hash 作为构建的前置标识,配合 --build-cache 在相同代码变更时最大化复用。
  5. 定期清理本地缓存:通过 gradle cleanBuildCache 或脚本定时删除过期条目。