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

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

查找任务输出缓存问题

下面我们将介绍一个分步过程,它应该有助于找出构建中缓存的任何问题。

确保增量构建工作正常

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

$ ./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)时所有内容都能正确加载,就该看看是否存在*重定位问题*了。如果一个任务在不同位置执行时其输出可以被重用,则该任务被认为是*可重定位的*。(关于此内容的更多信息,请参见路径敏感性和可重定位性。)

应该可重定位但不可重定位的任务通常是因为任务输入中存在绝对路径所致。

为了发现这些问题,首先在您机器上的两个不同目录中检出项目的同一个提交。在下面的示例中,假设我们在 \~/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™ 比较工具快速诊断缓存未命中。

诊断缓存未命中的原因

有了上一节的数据,您应该能够诊断出为什么构建缓存中找不到特定任务的输出。既然您期望更多的任务被缓存,您应该能够找到产生相关 artifact 的构建。

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

first non cached task

首先,您应该检查任务的实现是否发生变化。这意味着要检查任务类本身及其每个动作的类名和类加载器哈希。如果发生了变化,则表明构建脚本、buildSrc 或 Gradle 版本发生了变化。

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

如果实现相同,则需要开始比较两个构建之间的输入。应该至少有一个输入哈希不同。如果它是简单的值属性,则表示任务的配置发生了变化。例如,这可能由以下原因引起

  • 更改构建脚本,

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

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

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

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

示例

我们将引导您完成诊断缓存未命中的过程。假设我们有两个构建 AB,我们期望子项目 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 方法存储的属性文件包含时间戳,这将导致每次构建运行时运行时类路径发生变化。为了解决这个问题,请参见不可重复的任务输出或使用输入规范化

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