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

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

查找任务输出缓存的问题

下面我们描述了一个分步过程,该过程应该有助于解决构建中的缓存问题。

确保增量构建有效

首先,确保您的构建在没有缓存的情况下执行正确的操作。在不启用 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 再次构建:这次所有可缓存的内容都应该从刚刚填充的缓存中加载。

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

完全缓存的任务执行

再次使用以下方法来诊断修复可缓存性问题。

测试缓存可重定位性

一旦所有内容都正确加载,同时在启用本地缓存的情况下构建相同的结账,就可以查看是否存在任何重定位问题。如果在不同位置执行任务时可以重用该任务的输出,则 该任务被认为是可重定位的。 (有关路径敏感性和可重定位性的更多信息。)

应该可重定位但实际上不可重定位的任务通常是任务输入中存在绝对路径的结果。

要发现这些问题,首先在计算机上的两个不同目录中检查项目的相同提交。对于下面的示例,我们假设我们在\~/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

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

Develocity已经为您解决了这个问题;它可以让您使用 Build Scan™ 比较工具快速诊断缓存未命中。

诊断缓存未命中的原因

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

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

第一个非缓存任务

首先,您应该检查任务的实现是否发生变化。这意味着检查任务类本身及其每个操作的类名和类加载器哈希。如果有变化,这意味着构建脚本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)
        }
    }
}

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

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