介绍 Meilisearch 的下一代索引器:更新速度提升 4 倍,存储空间减少 30%
2024 版索引器通过并行处理、优化的 RAM 使用和增强的可观察性,彻底改变了搜索性能。了解我们最新版本的新功能。

当您在搜索引擎中处理数百万个文档时,每一毫秒都很重要。这就是为什么我们使用 v1.12 中的新索引器完全重新构想 Meilisearch 处理数据的方式。我们从头开始重写了索引器——处理文档并构建搜索结构的核心组件。结果呢?一个更快、更高效、更可扩展的系统。
这种彻底的重新设计从根本上改变了我们的搜索引擎处理数据的方式。在本文中,我们将向您展示我们如何实现高达 4 倍的文档更新速度提升和 30% 的数据库大小减小,以及这对您的应用程序意味着什么。
未来的基础
我们将此版本命名为 2024 版索引器
,反映了我们构建一个能够为 Meilisearch 未来多年性能提供动力的基础的愿景。受到 Rust 版本模型 的启发,这不仅仅是一个更新——这是 Meilisearch 发展的新篇章,为未来搜索技术的创新奠定了基础。
(也像 Rust 版本一样,我们采用了开发年份而不是发布年份 😁)
为什么要编写新的索引器?
博士不会重写索引器
您需要一个非常充分的理由来重写索引器。而我们有一个。
正如我们的 CTO Kero 在他的博客文章中直言不讳地指出:Meilisearch 太慢了。新的索引器是我们对这一挑战的回应——从头开始重写以显著提高速度。
新索引器的主要目标是提高包含数千万个文档的较大索引的索引速度,以减轻我们基础设施的一些压力,更好地利用机器资源,并以结构化的方式解决一些长期存在的痛点。
问题的关键:性能改进
有时你真的需要更快
在各种情况下,新的索引器都比已经优化的 v1.11 索引器更快,有时甚至快好几倍。
在具有多核、良好 IO 和大量 RAM 的机器上,性能大大提高。插入新文档的速度快两倍,而在大型数据库中增量更新文档的速度快 4 倍!
虽然目标是提高大型机器的性能,但我们成功地为核心较少和 RAM 较小的普通机器保持了相似或更好的性能(尽管良好的 IO 对数据库仍然至关重要)。
此外,对于云客户,我们正在探索通过使用原生 CPU 指令和 链接时优化 (LTO) 来优化我们的构建,从而产生高达 12% 的性能提升。我们还在考虑诸如 配置文件引导优化 (PGO) 等高级技术,到目前为止,与原生指令和 LTO 相比,这已产生了额外的 12% 的性能提升。
实现方式:更多并行性、更少的 I/O 操作、流水线写入
Meilisearch 过去常常将文档负载拆分为多个块(每个索引线程大约一个),然后在整个提取过程中处理每个块。一旦一个块完成,它就会被持久化到数据库中。
从性能方面来看,这种方法有一些缺点
-
由于同时提取了大量临时数据,因此必须将其写入磁盘,然后再读回以持久化到数据库中,从而导致大量的 I/O 操作。
-
由于块是彼此独立构建的,因此可能会导致我们为每个块将相同的键写入数据库,再次导致不必要的写入。
-
由于块平均会在同一时间完成计算,因此索引过程会在计算块时密集使用所有 CPU,但在将所有内容写入数据库时,则完全是单线程的。
以前索引过程的简化表示
在新的索引器中,Meilisearch 并行迭代文档,一次执行一个提取操作。然后并行合并提取的结果,以计算要发送到数据库的键和值列表。这些键和值通过充当管道的通道发送到写入线程,从而减少合并线程等待写入线程的时间。
由于流水线,合并线程等待时间更少
由于一个关键的见解,我们成功地使合并步骤并行化:虽然提取步骤在多个线程之间拆分文档,但合并步骤必须在多个线程之间拆分数据库键。例如,要构建将单词与包含该单词的文档列表匹配的反向索引,每个线程读取一些文档以提取其中包含的单词,然后将包含单词的所有文档合并到一个列表中。
为了实现这一点,提取步骤中的所有线程都会确定性地哈希处理遇到的每个单词。然后在合并步骤中,要处理的单词根据其哈希值在线程之间进行分区。
2024 版索引过程的简化表示
这种新方法的缺点是一些 CPU 密集型操作(例如文档分词)必须在所有步骤中重复执行。尽管如此,并行合并可以更多地利用机器的多个核心,从而减少 Meilisearch 花费在单线程上的时间。
此外,通过不重复键写入,数据库大小已减少了 30% 以上。
更好的 RAM 控制和使用
Meilisearch 使用 bumpalo 来改进 RAM 控制。Bumpalo 是一种 arena 分配器,允许批量释放内存并查询 arena 中分配了多少内存。
Arena 分配器通常用于视频游戏中,用于必须在每个游戏帧上重新创建的对象,因为在 arena 中分配非常便宜(只需指针碰撞),并且只要没有析构函数需要运行,就可以批量释放(又一次指针碰撞)。
在某些方面,使用 Meilisearch 索引文档也具有“帧”。在新的索引方法中,我们甚至可以检测到两种这样的帧:一个非常短暂的帧,对应于在每个文档上完成的工作。以及一个持续时间更长的帧,它在每个提取步骤的开始时开始,并在合并器完成向写入器发送所有内容后立即结束。“文档”arena 包含与读取和标记化该文档相关的所有分配,而“提取”arena 包含与将被合并并写入数据库的已提取数据相关的所有分配。
在此过程中,Meilisearch 查询“提取”arena 的大小,以检测其 RAM 使用率何时超过阈值。当发生这种情况时,Meilisearch 会将多余的数据溢出到磁盘,以便 RAM 使用率保持在受控范围内。
如果提取的数据适合 RAM,则永远不会写入磁盘,从而减少 I/O 操作。
更快更可靠的任务取消
从历史上看,在以前的索引器中,文档块通常非常小,因此仅在块处理完成后才允许取消是有意义的。不幸的是,当将临时数据写入磁盘时,小块效率低下,因此我们决定在几个版本前切换到“每个核心一个块”模型。这对取消产生了不利影响,因为它几乎会在索引过程结束时在所有线程完成处理其块后才被处理。
随着新的 Meilisearch 迭代文档,并通过每个提取步骤进一步拆分流程,任务取消比以往任何时候都更快,通常在 1 秒内处理完毕!
对于所有后悔一次发送所有这些文档的您
更好的错误消息
新的架构向所有提取器公开文档 ID。这意味着可以像在 此 PR 中所做的那样,使用此信息来丰富错误消息:知道哪个文档导致了错误,可以更有效地进行故障排除。
通过进度实现更好的可观察性
最后,新的索引器带来了更好的可观察性:因为我们知道我们处于哪个步骤以及此步骤处理了多少文档,所以我们可以将此信息作为进度对象在新添加的 batches
路由中公开。
{
"uid": 160,
"progress": {
"steps": [
{
"currentStep": "processing tasks",
"finished": 0,
"total": 2
},
{
"currentStep": "indexing",
"finished": 2,
"total": 3
},
{
"currentStep": "extracting words",
"finished": 3,
"total": 13
},
{
"currentStep": "document",
"finished": 12300,
"total": 19546
}
],
"percentage": 37.986263
},
"details": {
"receivedDocuments": 19547,
"indexedDocuments": null
},
"stats": {
"totalNbTasks": 1,
"status": {
"processing": 1
},
"types": {
"documentAdditionOrUpdate": 1
},
"indexUids": {
"mieli": 1
}
},
"duration": null,
"startedAt": "2024-12-12T09:44:34.124726733Z",
"finishedAt": null
}
在上面,我们知道我们已完成从 19546 个文档中提取 12300 个文档的单词。总共有 13 个索引步骤,我们已经完成了其中的 3 个,全局完成度为 37.986263%。
请注意,progress
对象仅应用于显示目的,我们不保证步骤名称或编号在不同版本之间保持不变。
添加 progress
对象对引擎的可观察性是一个福音,这已经为我们带来了实际的胜利。我们仅通过重复调用 batches
路由并查看哪个步骤经常出现,就确定了客户工作负载中的瓶颈。
最好的还在后头!
新的索引器在可维护性方面达到了新的高度,其结构将“业务”提取器与其支持功能(取消、进度、读取文档)解耦,使我们能够快速迭代功能,例如用于微调索引行为的新设置和 AI 稳定化。未来,我们计划在设置任务中添加进度,并使用每个步骤的计时来丰富已完成的批次。欢迎在 产品讨论 中分享您希望在 Meilisearch 中看到的内容。如果您是 Meilisearch 的新手,您也可以在我们的 Cloud 上免费试用。