为了充分利用任务输出缓存,正确指定任务所需的所有输入,同时避免不必要的输入非常重要。未能指定影响任务输出的输入可能导致构建不正确,而无谓地指定不影响任务输出的输入则可能导致缓存未命中。

本章旨在找出缓存未命中的原因。如果您遇到了意外的缓存命中,我们建议将您期望触发缓存未命中的任何更改声明为任务的输入。

发现任务输出缓存问题

下面我们描述了一个分步过程,它应该有助于消除您构建中缓存的任何问题。

确保增量构建正常工作

首先,确保您的构建在没有缓存的情况下也能正常工作。在不启用 Gradle 构建缓存的情况下运行两次构建。预期结果是所有产生文件输出的可操作任务都应该是最新的。您应该在命令行上看到类似以下内容:

$ ./gradlew clean --quiet (1)
$ ./gradlew assemble (2)

BUILD SUCCESSFUL
4 actionable tasks: 4 executed

$ ./gradlew assemble (3)

BUILD SUCCESSFUL
4 actionable tasks: 4 up-to-date
1 确保我们通过先运行 clean 来开始,没有任何遗留结果。
2 在这些示例中,我们假设您的构建是通过运行 assemble 任务来表示的,但您可以替换为您构建中任何有意义的任务。
3 不运行 clean 再次运行构建。
没有输出或没有输入的任务将始终执行,但这不应该是一个问题。

使用如下所述的方法来诊断修复那些应该是最新的但却不是的任务。如果您发现一个任务已过期,但没有可缓存的任务依赖于其结果,那么您无需对此做任何处理。目标是为可缓存任务实现稳定的任务输入

使用本地缓存进行就地缓存

当您对最新性能满意时,您可以重复上述实验,但这次使用干净的构建,并打开构建缓存。使用干净构建并打开构建缓存的目标是从缓存中检索所有可缓存任务。

运行此测试时,请确保您未配置任何 remote 缓存,并且已启用将数据存储到 local 缓存中。这些是默认设置。

这在命令行上看起来会是这样

$ rm -rf ~/.gradle/caches/build-cache-1 (1)
$ ./gradlew clean --quiet (2)
$ ./gradlew assemble --build-cache (3)

BUILD SUCCESSFUL
4 actionable tasks: 4 executed

$ ./gradlew clean --quiet (4)
$ ./gradlew assemble --build-cache (5)

BUILD SUCCESSFUL
4 actionable tasks: 1 executed, 3 from cache
1 我们希望从一个空的本地缓存开始。
2 清理项目以删除之前构建中任何不需要的残留物。
3 构建一次以填充缓存。
4 再次清理项目。
5 再次构建:这次所有可缓存的内容都应该从刚刚填充的缓存中加载。

您应该看到所有可缓存任务都从缓存中加载,而不可缓存任务应该被执行。

fully cached task execution

同样,使用以下方法来诊断修复可缓存性问题。

测试缓存可重定位性

一旦一切在启用本地缓存的情况下构建同一检出时都能正常加载,就该看看是否存在任何重定位问题了。如果一个任务的输出在不同位置执行时可以重复使用,则该任务被认为是可重定位的。(更多信息请参阅路径敏感性和可重定位性。)

那些应该可重定位但实际不可重定位的任务,通常是因为任务输入中存在绝对路径而导致的。

要发现这些问题,首先在您机器的两个不同目录中检出您项目的相同提交。以下示例中,假设我们在 ~/checkout-1~/checkout-2 中都有一个检出。

与之前的测试一样,您不应配置任何 remote 缓存,并且应启用存储到 local 缓存中。
$ rm -rf ~/.gradle/caches/build-cache-1 (1)
$ cd ~/checkout-1 (2)
$ ./gradlew clean --quiet (3)
$ ./gradlew assemble --build-cache (4)

BUILD SUCCESSFUL
4 actionable tasks: 4 executed

$ cd ~/checkout-2 (5)
$ ./gradlew clean --quiet (6)
$ ./gradlew clean assemble --build-cache (7)

BUILD SUCCESSFUL
4 actionable tasks: 1 executed, 3 from cache
1 首先删除本地缓存中的所有条目。
2 进入第一个检出目录。
3 清理项目以删除之前构建中任何不需要的残留物。
4 运行构建以填充缓存。
5 转到另一个检出目录。
6 再次清理项目。
7 再次运行构建。

您应该会看到与之前就地缓存测试步骤完全相同的结果。

跨平台测试

如果您的构建通过了重定位测试,那么它的状态已经很好了。如果您的构建需要支持多个平台,最好查看所需任务是否也能在不同平台之间重用。跨平台构建的一个典型例子是,CI 运行在 Linux VM 上,而开发人员使用 macOS 或 Windows,或者不同种类或版本的 Linux。

要测试跨平台缓存重用,请设置一个 remote 缓存(参见在 CI 构建之间共享结果),并从一个平台填充它,然后从另一个平台使用它。

增量缓存使用

完成这些完全缓存构建的实验后,您可以继续尝试对项目进行典型更改,并查看是否有足够的任务仍被缓存。如果结果不令人满意,您可以考虑重构项目以减少不同任务之间的依赖关系。

评估缓存性能随时间变化

考虑记录构建的执行时间,生成图表,并分析结果。留意某些模式,例如即使您期望编译被缓存,构建仍然重新编译所有内容。

您还可以手动或自动更改代码库,并检查预期的任务集是否已缓存。

如果您的任务重新执行而不是从缓存中加载其输出,那么这可能表明您的构建存在问题。下一节将解释调试缓存未命中的技术。

诊断缓存未命中的有用数据

当 Gradle 计算出与缓存中任何现有构建缓存键都不同的任务构建缓存键时,就会发生缓存未命中。仅凭构建缓存键本身无法提供太多信息,因此我们需要查看更细粒度的数据才能诊断缓存未命中。计算构建缓存键的所有输入列表可以在可缓存任务部分中找到。

从最粗粒度到最细粒度,我们将用于比较两个任务的项是

  • 构建缓存键

  • 任务和任务操作实现

    • 类加载器哈希

    • 类名

  • 任务输出属性名称

  • 单独的任务属性输入哈希

  • 作为任务输入属性一部分的文件的哈希值

如果您需要构建缓存键和单个输入属性哈希的信息,请使用 -Dorg.gradle.caching.debug=true

$ ./gradlew :compileJava --build-cache -Dorg.gradle.caching.debug=true

.
.
.
Appending implementation to build cache key: org.gradle.api.tasks.compile.JavaCompile_Decorated@470c67ec713775576db4e818e7a4c75d
Appending additional implementation to build cache key: org.gradle.api.tasks.compile.JavaCompile_Decorated@470c67ec713775576db4e818e7a4c75d
Appending input value fingerprint for 'options' to build cache key: e4eaee32137a6a587e57eea660d7f85d
Appending input value fingerprint for 'options.compilerArgs' to build cache key: 8222d82255460164427051d7537fa305
Appending input value fingerprint for 'options.debug' to build cache key: f6d7ed39fe24031e22d54f3fe65b901c
Appending input value fingerprint for 'options.debugOptions' to build cache key: a91a8430ae47b11a17f6318b53f5ce9c
Appending input value fingerprint for 'options.debugOptions.debugLevel' to build cache key: f6bd6b3389b872033d462029172c8612
Appending input value fingerprint for 'options.encoding' to build cache key: f6bd6b3389b872033d462029172c8612
.
.
.
Appending input file fingerprints for 'options.sourcepath' to build cache key: 5fd1e7396e8de4cb5c23dc6aadd7787a - RELATIVE_PATH{EMPTY}
Appending input file fingerprints for 'stableSources' to build cache key: f305ada95aeae858c233f46fc1ec4d01 - RELATIVE_PATH{.../src/main/java=IGNORED / DIR, .../src/main/java/Hello.java='Hello.java' / 9c306ba203d618dfbe1be83354ec211d}
Appending output property name to build cache key: destinationDir
Appending output property name to build cache key: options.annotationProcessorGeneratedSourcesDirectory
Build cache key for task ':compileJava' is 8ebf682168823f662b9be34d27afdf77

日志显示例如哪些源文件构成了 compileJava 任务的 stableSources。要查找两次构建之间的实际差异,您需要自己匹配并比较这些哈希值。

Develocity 已经为您处理了这个问题;它允许您使用 Build Scan™ 比较工具快速诊断缓存未命中。

诊断缓存未命中的原因

有了上一节的数据,您应该能够诊断为什么构建缓存中找不到特定任务的输出。由于您期望更多的任务被缓存,您应该能够精确指出会产生相关工件的构建。

在深入探讨如何找出为什么某个任务没有从缓存中加载之前,我们应该首先了解是哪个任务导致了缓存未命中。如果构建中较早的任务未能从缓存中加载且输出不同,就会产生级联效应,导致依赖任务被执行。因此,您应该找到第一个被执行的可缓存任务,并从那里继续调查。这可以通过 Build Scan™ 的时间轴视图来完成。

first non cached task

首先,您应该检查任务的实现是否已更改。这意味着要检查任务类本身及其每个操作的类名和类加载器哈希值。如果存在更改,则表示构建脚本、buildSrc 或 Gradle 版本已更改。

buildSrc 的输出变化也意味着您的构建添加的所有逻辑都被标记为已更改。特别是,添加到可缓存任务的自定义操作将被标记为已更改。这可能是有问题的,请参阅关于 doFirstdoLast 的部分

如果实现相同,那么您需要开始比较两次构建之间的输入。应该至少有一个不同的输入哈希值。如果它是一个简单的值属性,那么任务的配置发生了变化。这可能发生,例如通过

  • 更改构建脚本,

  • 有条件地为 CI 或开发人员构建配置不同的任务,

  • 依赖于任务配置的系统属性或环境变量,

  • 或者输入中包含绝对路径。

如果更改的属性是文件属性,那么原因可能与值属性的更改相同。最有可能的是文件系统上的文件发生了 Gradle 检测到此输入差异的方式。最常见的情况是源代码因签入而更改。也可能是任务生成的文件发生了更改,例如因为它包含时间戳。如Java 版本跟踪中所述,Java 版本也会影响 Java 编译器的输出。如果您不期望该文件是任务的输入,则可能应该修改任务的配置以不包含它。例如,您的集成测试配置包含所有单元测试类作为依赖项,其效果是当单元测试更改时,所有集成测试都会重新执行。另一种可能是任务跟踪的是绝对路径而不是相对路径,并且项目目录的位置在磁盘上发生了变化。

示例

我们将引导您完成诊断缓存未命中的过程。假设我们有构建 A 和构建 B,并且我们期望子项目 sub1 的所有测试任务在构建 B 中都被缓存,因为只有另一个子项目 sub2 的一个单元测试发生了变化。然而,该子项目的所有测试都被执行了。由于缓存未命中时会产生级联效应,我们需要找到导致缓存链失败的任务。这可以通过过滤所有已执行的可缓存任务,然后选择第一个任务来轻松完成。在我们的案例中,事实证明子项目 internal-testing 的测试被执行了,即使该项目没有代码更改。这意味着 classpath 属性发生了变化,并且运行时类路径上的某些文件确实发生了变化。深入研究后,我们实际上发现该项目中任务 processResources 的输入也发生了变化。最后,我们在构建文件中发现了这个

build.gradle.kts
val currentVersionInfo = tasks.register<CurrentVersionInfo>("currentVersionInfo") {
    version = project.version as String
    versionInfoFile = layout.buildDirectory.file("generated-resources/currentVersion.properties")
}

sourceSets.main.get().output.dir(currentVersionInfo.map { it.versionInfoFile.get().asFile.parentFile })

abstract class CurrentVersionInfo : DefaultTask() {
    @get:Input
    abstract val version: Property<String>

    @get:OutputFile
    abstract val versionInfoFile: RegularFileProperty

    @TaskAction
    fun writeVersionInfo() {
        val properties = Properties()
        properties.setProperty("latestMilestone", version.get())
        versionInfoFile.get().asFile.outputStream().use { out ->
            properties.store(out, null)
        }
    }
}
build.gradle
def currentVersionInfo = tasks.register('currentVersionInfo', CurrentVersionInfo) {
    version = project.version
    versionInfoFile = layout.buildDirectory.file('generated-resources/currentVersion.properties')
}

sourceSets.main.output.dir(currentVersionInfo.map { it.versionInfoFile.get().asFile.parentFile })

abstract class CurrentVersionInfo extends DefaultTask {
    @Input
    abstract Property<String> getVersion()

    @OutputFile
    abstract RegularFileProperty getVersionInfoFile()

    @TaskAction
    void writeVersionInfo() {
        def properties = new Properties()
        properties.setProperty('latestMilestone', version.get())
        versionInfoFile.get().asFile.withOutputStream { out ->
            properties.store(out, null)
        }
    }
}

由于 Java 的 Properties.store 方法存储的属性文件包含时间戳,这将导致每次构建运行时运行时类路径发生更改。为了解决这个问题,请参阅不可重复的任务输出或使用输入规范化

编译类路径不受影响,因为编译避免会忽略类路径上非类文件。