为了最大程度地利用任务输出缓存,重要的是正确指定任务的任何必要输入,同时避免不必要的输入。未能指定影响任务输出的输入可能会导致不正确的构建,而不必要地指定不影响任务输出的输入可能会导致缓存未命中。

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

查找任务输出缓存的问题

下面我们描述一个循序渐进的过程,该过程应有助于解决构建中缓存的任何问题。

确保增量构建有效

首先,确保您的构建在没有缓存的情况下可以正常工作。在不启用 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 计算的任务构建缓存键与缓存中任何现有的构建缓存键不同时,就会发生缓存未命中。仅比较构建缓存键本身并不能提供太多信息,因此我们需要查看一些更细粒度的数据才能诊断缓存未命中。可以在关于可缓存任务的部分中找到计算出的构建缓存键的所有输入列表。

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

  • 构建缓存键

  • Task 和 Task 操作实现

    • 类加载器哈希

    • 类名

  • Task 输出属性名称

  • 单个 Task 属性输入哈希

  • 作为 Task 输入属性一部分的文件的哈希

如果您想要有关构建缓存键和单个输入属性哈希的信息,请使用-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™ Comparison 工具快速诊断缓存未命中。

诊断缓存未命中的原因

掌握上一节中的数据后,您应该能够诊断出为什么在构建缓存中找不到特定任务的输出。由于您期望缓存更多任务,因此您应该能够查明哪个构建会生成有问题的工件。

在深入研究如何找出为什么未从缓存中加载某个任务之前,我们应该首先研究哪个任务导致了缓存未命中。如果构建中较早的任务之一未从缓存中加载并且具有不同的输出,则会产生级联效应,从而导致执行依赖的任务。因此,您应该找到第一个执行的可缓存任务,然后从那里继续调查。这可以从 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 方法存储的属性文件包含时间戳,因此每次构建运行时都会导致运行时类路径发生更改。为了解决此问题,请参阅不可重复的任务输出或使用输入规范化

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