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

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

查找任务输出缓存问题

下面我们将描述一个逐步过程,该过程应该有助于解决构建中与缓存相关的任何问题。

确保增量构建有效

首先,确保您的构建在没有缓存的情况下能够正常运行。在启用 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 或不同版本的 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 方法存储的属性文件包含时间戳,因此每次构建运行时都会导致运行时类路径发生更改。为了解决此问题,请参阅 不可重复的任务输出 或使用 输入规范化

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