第2章 构建缓存的内部机制
构建缓存的内部机制决定了缓存能否被命中以及缓存结果是否安全可用。理解缓存键的生成逻辑、输入/输出快照的采集方式、任务输出缓存的存储结构以及命中判定规则,是正确使用和调试 Gradle 构建缓存的必备基础。本章从这四个核心环节入手,逐步拆解缓存系统的运行原理。
2.1 缓存键生成逻辑
每个可缓存的任务在第一次执行后,Gradle 会根据任务的输入声明生成一个全局唯一的 缓存键(Cache Key)。缓存键是一个 SHA-256 字符串,后续构建中若任务输入完全相同,则直接通过该键命中缓存,跳过任务执行。
2.1.1 缓存键的组成
缓存键由以下六个维度拼接后哈希得到:
| 组成维度 | 来源注解 | 示例值 |
|---|---|---|
| 任务类全名 | 任务实现类 | com.example.MyTransformTask |
| 任务路径 | 项目 + 任务名 | :app:transformData |
| 输入文件快照 | @InputFile / @InputDirectory | sha256:e3b0c442... |
| 输入属性值 | @Input | encoding=UTF-8 |
| 嵌套输入快照 | @Nested | 嵌套对象的所有输入哈希 |
| 构建环境属性 | @Option / 全局配置 | gradleVersion=8.5 |
@Input* 系列注解标记的属性才会参与缓存键生成。@OutputFile、@OutputDirectory 等输出声明不会影响缓存键,它们只决定缓存的内容和恢复路径。
2.1.2 生成流程
- Gradle 收集任务上所有
@Input*注解属性的规范表示(文件内容哈希、字符串值、枚举名称等)。 - 按属性名字典序排序,消除执行顺序敏感度。
- 将排序后的键值对序列化为规范字节流。
- 对字节流进行 SHA-256 哈希,生成 64 位十六进制缓存键。
// 示例:自定义任务的缓存键生成输入
abstract class DataTransformTask extends DefaultTask {
@InputFile
abstract RegularFileProperty getInputFile()
@Input
abstract Property<String> getEncoding()
@OutputDirectory
abstract DirectoryProperty getOutputDir()
@TaskAction
void transform() {
// 业务逻辑
}
}
--build-cache-debug 参数可以看到每个可缓存任务的缓存键及输入快照明细。
2.2 输入/输出快照
快照(Snapshot)是对任务输入或输出在某一时刻的状态进行哈希记录。Gradle 使用快照来比较任务的前后状态变化,从而判定缓存是否可用。
2.2.1 输入快照
输入快照在任务执行前采集,用于生成缓存键。对于文件/目录类型输入,Gradle 会递归计算所有文件的 内容哈希,而非仅依赖最后修改时间戳。这意味着即使文件时间戳变化但内容未变,缓存键仍保持不变,从而避免不必要的缓存失效。
| 输入类型 | 快照策略 | 性能影响 |
|---|---|---|
| @InputFile | 哈希单个文件内容 | 低(仅一个文件) |
| @InputDirectory | 递归哈希目录下所有文件 | 中~高(取决于文件数量) |
| @Classpath | 按 classpath 顺序哈希 jar/class 文件 | 中 |
| @Input | 直接序列化属性值 | 极低 |
| @Nested | 递归采集嵌套对象的输入属性 | 取决于嵌套深度 |
2.2.2 输出快照
输出快照在任务执行后采集,用于验证任务实际产生了哪些输出文件及其内容。输出快照不参与缓存键生成,但会作为缓存内容完整性的校验依据。当从缓存恢复输出时,Gradle 会将恢复后的文件与输出快照对比,确保缓存未被篡改或损坏。
2.3 任务输出缓存
任务输出缓存是缓存系统中真正存储构建产物的部分。每次任务执行完成后,Gradle 将所有输出文件/目录打包压缩,并以缓存键为文件名存入缓存仓库。
2.3.1 缓存内容范围
- 被缓存的内容:所有
@OutputFile、@OutputDirectory、@Output注解的文件/目录产物。 - 不被缓存的内容:任务运行时产生的临时文件、日志流、控制台输出、
@Internal注解的临时属性。
2.3.2 本地缓存存储结构
本地缓存位于 ~/.gradle/caches/build-cache-1/ 目录下,采用两级目录散列方式组织:
~/.gradle/caches/build-cache-1/
├── a3/
│ ├── a3f1c2e4b5d6...
│ └── a3f2b4c6d8e0...
├── b7/
│ ├── b7c8d9e0f1a2...
│ └── b7d8e9f0a1b2...
└── ...
每个缓存条目包含两个文件:
| 文件 | 后缀 | 说明 |
|---|---|---|
| 缓存数据 | .cache | 打包压缩后的任务输出产物 |
| 元数据 | .properties | 记录缓存键、创建时间、Gradle 版本、输入快照摘要等信息 |
2.3.3 远程缓存
远程缓存(通过 HTTP 或 S3 等协议共享)同样以缓存键为存储标识,但 Gradle 默认会对缓存数据进行GZip 压缩,并在传输时校验 Content-MD5 头部,确保网络传输不损坏数据。
2.4 缓存命中判定规则
当任务被调度执行时,Gradle 会按照以下规则判定是否可以使用缓存结果:
- 任务是否可缓存:检查任务是否带有
@CacheableTask注解,且没有@DisableCachingByDefault限制。 - 输入快照是否已存在:对当前任务的输入属性采集快照,生成候选缓存键。
- 缓存键是否存在于仓库:依次查询本地缓存 → 远程缓存(若配置),查找匹配的缓存条目。
- 输出快照校验:若找到缓存条目,将条目中记录的输出快照与期望的输出路径状态进行比对,确认缓存内容完整且未被污染。
- 构建环境兼容性:检查缓存条目元数据中的 Gradle 版本、JDK 版本等环境属性是否与当前构建兼容(默认不开启,可通过
buildCache.requireCleanEnvironment开启)。
只有上述五条全部通过,Gradle 才判定为缓存命中(Cache Hit),并直接从缓存中恢复输出产物;任何一条不满足则判定为缓存未命中(Cache Miss),任务正常执行。
- 任务缺少
@CacheableTask注解 - 输入文件内容发生变化(哪怕只有一个字节)
- 任务路径变更(如重构后移动了任务定义位置)
- Gradle 版本升级导致缓存键算法改变
- 远程缓存不可达或权限不足
2.5 整体机制流程图
下图将缓存键生成、快照采集、仓库查询和命中判定整合为一个完整流程,帮助建立系统性的认知:
2.6 调试与验证
在开发或排查缓存问题时,以下调试手段能帮助你快速定位缓存未命中的原因:
| 调试方式 | 命令 / API | 输出信息 |
|---|---|---|
| 查看缓存键 | --build-cache-debug | 每个任务的输入快照哈希、缓存键值 |
| 查看缓存命中/未命中 | --info | 日志中标记 TaskCacheHit 或 TaskCacheMiss |
| 查看缓存存储位置 | --dry-run --info | 缓存仓库路径、条目大小 |
| 强制清理本地缓存 | cleanBuildCache 任务 | 清空 build-cache-1 目录 |
| 远程缓存连通性测试 | gradle build --build-cache --scan | 远程缓存上传/下载耗时及状态码 |
--build-cache-debug 并将日志输出为构建产物,当缓存命中率异常下降时可快速回溯分析。同时定期使用 cleanBuildCache 任务清理过期本地缓存,避免磁盘空间膨胀。
2.7 常见错误与应对
- 缓存键频繁变化:检查任务是否使用了
@Input注解了路径或时间戳属性。改用相对路径或去除不稳定输入。 - 本地缓存占用过大:设置
buildCache.local.removeUnusedEntriesAfterDays = 7自动清理超过 7 天未命中的条目。 - 远程缓存上传失败:确认远程缓存服务端支持
PUT方法,并检查网络代理设置。可在buildCache.remote.push = false临时关闭上传。 - 输出恢复后任务仍然失败:输出快照校验不通过,通常是因为缓存条目被损坏。删除对应缓存条目后重新构建即可。
2.8 性能与稳定性优化
- 减少快照计算范围:对于大型目录输入,使用
@InputFiles+@PathSensitive(PathSensitivity.NAME_ONLY)仅跟踪文件名变化,降低哈希计算开销。 - 合理设置缓存条目存活时间:本地缓存默认 30 天无访问即清理,远程缓存可通过服务端配置 TTL。根据项目迭代节奏调整,避免频繁重建缓存。
- 使用缓存分区:在多模块项目中,为不同模块配置独立的缓存仓库前缀,避免模块间缓存键冲突导致的反复失效。
- 启用异步缓存写入:设置
org.gradle.caching.local.storeAsynchronously=true让缓存写入不阻塞任务执行线程,提升构建吞吐量。