使用真实 Elasticsearch 进行更快的集成测试

作者:来自 Elastic Piotr Przybyl

了解如何使用各种数据初始化和性能改进技术加快 Elasticsearch 的自动化集成测试速度。

在本系列的第 1 部分中,我们探讨了如何编写集成测试,让我们能够在真实的 Elasticsearch 环境中测试软件,并非难事。本文将演示各种数据初始化和性能改进的技术。

不同的目的,不同的特点

一旦测试基础设施设置完毕,并且项目已经使用集成测试框架进行至少一个测试(例如我们在演示项目中使用 Testcontainers),添加更多测试就变得很容易,因为它不需要模拟。例如,如果你需要验证 1776 年获取的书籍数量是否正确,你只需添加一个测试,如下所示:

复制代码
@Test
void shouldFetchTheNumberOfBooksPublishedInGivenYear() {
    var systemUnderTest = new BookSearcher(client);
    int books = systemUnderTest.numberOfBooksPublishedInYear(1776);
    Assertions.assertEquals(2, books, "there were 2 books published in 1776 in the dataset");
}

只要用于初始化 Elasticsearch 的数据集已包含相关数据,这就足够了。创建此类测试的成本很低,维护它们几乎毫不费力(因为它主要涉及更新 Docker 镜像版本)。

没有软件是独立存在的

如今,我们编写的每一个软件都与其他系统相连。虽然使用模拟的测试非常适合验证我们正在构建的系统的行为,但集成测试让我们确信整个解决方案能够按预期运行并将继续如此。这可能会让我们忍不住添加越来越多的集成测试。

集成测试有其成本

然而,集成测试并非免费。由于其性质 ------ 超越了仅在内存中的设置 ------ 它们往往更慢,从而浪费我们的执行时间。

平衡收益(集成测试带来的信心)与成本(测试执行时间和计费,通常直接转化为云供应商的发票)至关重要。我们可以让它们运行得更快,而不是因为测试速度慢而限制测试数量。这样,我们可以在添加更多测试的同时保持相同的执行时间。本文的其余部分将重点介绍如何实现这一点。

让我们重新回顾一下我们迄今为止使用的示例,因为它非常慢并且需要优化。对于本次和后续实验,我假设 Elasticsearch Docker 映像已被提取,因此不会影响时间。另外,请注意,这不是一个合适的基准,而是一个一般准则。

利用 Elasticsearch 的测试也可以从性能提升中受益

Elasticsearch 经常被选为搜索解决方案是因为其高性能表现。开发人员通常会非常谨慎地优化生产代码,尤其是在关键路径上。然而,测试通常被视为次要,导致测试运行缓慢,以至于很少有人愿意运行测试。但情况并不一定要如此。通过一些简单的技术调整和方法上的改变,集成测试可以运行得更快。

让我们从当前的集成测试套件开始。该测试套件按预期运行,但仅运行三个测试时,通过执行 time ./mvnw test '-Dtest=*IntTest*' 需要耗时五分半钟 ------ 每个测试大约 90 秒。请注意,你的结果可能会因硬件、网络速度等因素而有所不同。

如果可以,请批量处理

在集成测试套件中,许多性能问题源于数据初始化效率低下。虽然某些流程在生产流程中可能是自然的或可接受的(例如,数据在用户输入时到达),但这些流程可能不是测试的最佳选择,因为我们需要快速批量导入数据。

我们示例中的数据集约为 50 MiB,包含近 81,000 条有效记录。如果我们单独处理和索引每条记录,我们最终会发出 81,000 个请求,只是为了为每个测试准备数据。

而不是像在主分支中那样使用简单的循环逐个索引文档:

复制代码
boolean hasNext = false;
do {
    try {
        Book book = it.nextValue();
        client.index(i -> i.index("books").document(book));
        hasNext = it.hasNextValue();
    } catch (JsonParseException | InvalidFormatException e) {
        // ignore malformed data
    }
} while (hasNext);

我们应该使用批处理方法,例如使用 BulkIngester。这允许并发索引请求,每个请求发送多个文档,从而大大减少请求数量:

复制代码
try (BulkIngester<?> ingester = BulkIngester.of(bi -> bi
    .client(client)
    .maxConcurrentRequests(20)
    .maxOperations(5000))) {

    boolean hasNext = true;
    while (hasNext) {
        try {
            Book book = it.nextValue();
            ingester.add(BulkOperation.of(b -> b
                .index(i -> i
                    .index("books")
                    .document(book))));
            hasNext = it.hasNextValue();
        } catch (JsonParseException | InvalidFormatException e) {
            // ignore malformed data
        }
    }
}

这一简单的改变将整体测试时间缩短至 3 分 40 秒左右,即每次测试大约 73 秒。虽然这是一个不错的改进,但我们可以进一步改进。

保持本地化

我们在上一步中通过限制网络往返缩短了测试时长。在不改变测试本身的情况下,我们是否可以消除更多的网络调用?

让我们回顾一下当前的情况:

  • 在每次测试之前,我们都会反复从远程位置获取测试数据。
  • 在获取数据时,我们会将其批量发送到 Elasticsearch 容器。

我们可以通过将数据尽可能靠近 Elasticsearch 容器来提高性能。还有什么比容器本身更近呢?

将数据批量导入 Elasticsearch 的一种方法是 _bulk REST API,我们可以使用 curl 调用它。此方法允许我们发送以换行符分隔的 JSON 格式编写的大型有效负载(例如,来自文件)。格式如下所示:

复制代码
action_and_meta_data\n
optional_source\n
action_and_meta_data\n
optional_source\n
....
action_and_meta_data\n
optional_source\n

确保最后一行为空。

在我们的例子中,文件可能如下所示:

复制代码
{"index":{"_index":"books"}}
{"title":"...","description":"...","year":...,"publisher":"...","ratings":...}
{"index":{"_index":"books"}}
{"title":"Whispers of the Wicked Saints","description":"Julia ...","author":"Veronica Haddon","year":2005,"publisher":"iUniverse","ratings":3.72}

理想情况下,我们可以将这些测试数据存储在一个文件中,并将其包含在存储库中,例如 src/test/resources/。如果这不可行,我们可以使用简单的脚本或程序从原始数据生成文件。例如,请查看演示存储库中的 CSV2JSONConverter.java

一旦我们在本地有了这样的文件(这样我们就消除了获取数据的网络调用),我们就可以解决另一点,即:将文件从运行测试的机器复制到运行 Elasticsearch 的容器中。这很容易,我们可以在定义容器时使用单个方法调用 withCopyToContainer 来做到这一点。所以更改后它看起来像这样:

复制代码
@Container
ElasticsearchContainer elasticsearch =
    new ElasticsearchContainer(ELASTICSEARCH_IMAGE)
        .withCopyToContainer(MountableFile.forHostPath("src/test/resources/books.ndjson"), "/tmp/books.ndjson");

最后一步是从容器内部发出请求,将数据发送到 Elasticsearch。我们可以通过在容器内运行 curl 来使用 curl 和 _bulk 端点执行此操作。虽然这可以在 CLI 中使用 docker exec 完成,但在我们的 @BeforeEach 中,它变成了 elasticsearch.execInContainer,如下所示:

复制代码
ExecResult result = elasticsearch.execInContainer(
    "curl", "https://localhost:9200/_bulk?refresh=true", "-u", "elastic:changeme",
    "--cacert", "/usr/share/elasticsearch/config/certs/http_ca.crt",
    "-X", "POST",
    "-H", "Content-Type: application/x-ndjson",
    "--data-binary", "@/tmp/books.ndjson"
);
assert result.getExitCode() == 0;

从顶部开始,我们以这种方式向 _bulk 端点发出 POST 请求(并等待刷新完成),使用默认密码以用户 elastic 进行身份验证,接受自动生成的自签名证书(这意味着我们不必禁用 SSL/TLL),有效负载是 /tmp/books.ndjson 文件的内容,该文件在启动时复制到容器中。这样,我们减少了频繁网络调用的需要。假设 books.ndjson 文件已经存在于运行测试的机器上,则总持续时间减少到 58 秒。

少(通常)即是多

在上一步中,我们减少了测试中与网络相关的延迟。现在,让我们解决 CPU 使用率问题。

依赖 @Testcontainers 和 @Container 注释并没有错。但关键是要了解它们的工作原理:当你使用 @Container 注释实例字段时,Testcontainers 将为每个测试启动一个新容器。由于容器启动不是免费的(它需要时间和资源),所以我们要为每个测试支付这笔费用。

在某些情况下(例如,测试系统启动行为时),为每个测试启动一个新容器是必要的,但在我们的例子中不是。我们不必为每个测试启动一个新容器,而是为所有测试保留相同的容器和 Elasticsearch 实例,只要我们在每次测试之前正确重置容器的状态即可。

首先,将容器设为静态字段。接下来,在创建 books 索引(通过定义映射)并用文档填充它之前,如果现有索引来自之前的测试,请删除它。

因此,setupDataInContainer() 应该以类似以下内容开头:

复制代码
ExecResult result = elasticsearch.execInContainer(
   "curl", "https://localhost:9200/books", "-u", "elastic:changeme",
   "--cacert", "/usr/share/elasticsearch/config/certs/http_ca.crt",
   "-X", "DELETE"
);
// we don't check the result, because the index might not have existed

// now we create the index and give it a precise mapping, just like for production
result = elasticsearch.execInContainer(
   "curl", "https://localhost:9200/books", "-u", "elastic:changeme",
   "--cacert", "/usr/share/elasticsearch/config/certs/http_ca.crt",
   "-X", "PUT",
   "-H", "Content-Type: application/json",
   "-d", """
       {
         "mappings": {
           "properties": {
             "title": { "type": "text" },
             "description": { "type": "text" },
             "author": { "type": "text" },
             "year": { "type": "short" },
             "publisher": { "type": "text" },
             "ratings": { "type": "half_float" }
           }
         }
       }
       """
);
assert result.getExitCode() == 0;

如你所见,我们可以使用 curl 在容器内执行几乎任何命令。这种方法有两个显著的​​优势:

  • 速度:如果有效负载(如 books.ndjson)已经在容器内,我们就不需要重复复制相同的数据,从而大大缩短了执行时间。
  • 语言独立性:由于 curl 命令与测试的编程语言无关,因此它们更容易理解和维护,即使对于那些可能更熟悉其他技术堆栈的人来说也是如此。

虽然使用原始 curl 调用对于生产代码来说并不理想,但它是测试设置的有效解决方案。尤其是与单个容器启动结合使用时,这种方法将我的测试执行时间缩短到大约 30 秒。

还值得注意的是,在演示项目(分支 data-init)中,目前只有三个集成测试,大约一半的总持续时间花在容器启动上。初始预热后,每个测试大约需要 3 秒钟。因此,再添加三个测试不会使总时间增加一倍至 30 秒,而只会增加大约 9-10 秒。可以在 IDE 中观察到测试执行时间(包括数据初始化):

总结

在这篇文章中,我展示了使用 Elasticsearch 进行集成测试的几项改进:

  • 集成测试可以在不改变测试本身的情况下运行得更快 ------ 只需重新考虑数据初始化和容器生命周期管理。
  • Elasticsearch 应该只启动一次,而不是每次测试都启动一次。
  • 当数据尽可能接近 Elasticsearch 并高效传输时,数据初始化效率最高。
  • 虽然减少测试数据集大小是一种明显的优化(这里没有介绍),但有时并不切实际。因此,我们专注于展示技术方法。

总体而言,我们显著缩短了测试套件的持续时间 ------ 从 5.5 分钟缩短到 30 秒左右 ------ 降低了成本并加快了反馈循环。

在下一篇文章中,我们将探索更先进的技术,以进一步减少 Elasticsearch 集成测试的执行时间。

如果你的案例使用了上述技术之一,或者你在我们的讨论论坛社区 Slack 频道上有任何疑问,请告诉我们。

准备好自己尝试一下了吗?开始免费试用

想要获得 Elastic 认证吗?了解下一期 Elasticsearch 工程师培训何时举行!

原文:Faster integration tests with real Elasticsearch - Search Labs

相关推荐
Elastic 中国社区官方博客14 小时前
Elasticsearch 中使用 NVIDIA cuVS 实现最高快 12 倍的向量索引速度:GPU 加速第 2 章
大数据·人工智能·elasticsearch·搜索引擎·ai·全文检索·数据库架构
越来越无动于衷14 小时前
HTTP 文件服务器 Windows 开机自启动全维度总结
服务器·windows·http
0***863314 小时前
SQL Server2019安装步骤+使用+解决部分报错+卸载(超详细 附下载链接)
javascript·数据库·ui
wstcl14 小时前
通过EF Core将Sql server数据表移植到MySql
数据库·mysql·sql server·efcore
jqpwxt14 小时前
启点智慧景区多商户分账系统,多业态景区收银管理系统
大数据·旅游
聪聪那年2214 小时前
Oracle 11g windows 10安装与卸载
数据库·oracle
jkyy201414 小时前
线上线下融合、跨场景协同—社区健康医疗小屋的智能升级
大数据·人工智能·物联网·健康医疗
前端之虎陈随易14 小时前
MoonBit内置数据结构详解
数据结构·数据库·redis
杭州泽沃电子科技有限公司15 小时前
煤化工精炼与加工环节的监测:智能平台如何保障最终产品价值与环保合规?
运维·科技
小二·15 小时前
Spring框架入门:TX 声明式事务详解
java·数据库·spring