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

当你在搜索引擎中处理数百万份文档时,每一毫秒都至关重要。这就是为什么我们在 v1.12 中使用全新的索引器,彻底重新构想了 Meilisearch 处理数据的方式。我们从头重写了索引器——它是处理文档和构建搜索结构的核心组件。结果如何?一个更快、更高效、更具可扩展性的系统。
这次彻底的重新设计从根本上改变了我们的搜索引擎处理数据的方式。在本文中,我们将向您展示我们如何实现了最高 4 倍的文档更新速度提升和 30% 的数据库大小减小,以及这对您的应用程序意味着什么。
面向未来的基础
我们将此版本命名为 索引器 2024 版
,这反映了我们构建一个能够为 Meilisearch 未来多年性能提供动力的基础的愿景。受 Rust 的版本模型启发,这不仅仅是一个更新——它更是 Meilisearch 演进的新篇章,为未来搜索技术的创新奠定了基础。
(与 Rust 版本一样,我们选择的是开发年份而非发布年份😁)
为什么要编写一个新的索引器?
“医生”不会重写索引器
你需要一个**非常**充分的理由来重写一个索引器。而我们有。
正如我们的 CTO Kero 在他的博客文章中直言不讳地指出:Meilisearch 太慢了。新的索引器正是我们应对这一挑战的答案——从头重写以大幅提升速度。
新索引器的主要目标是提高数千万文档规模的大型索引的索引速度,减轻我们基础设施的一些压力,更好地利用机器资源,并以结构化的方式解决一些长期存在的痛点。
问题的核心:性能提升
有时候你真的需要更快
在各种场景下,新索引器比已优化的 v1.11 索引器更快,有时甚至快数倍。
在多核、良好 I/O 和大内存的机器上,性能大幅提升。插入新文档的速度快了两倍,而在大型数据库中增量更新文档的速度快了四倍!
虽然目标是提高大型机器的性能,但我们也成功地在核心数和内存较低的普通机器上保持了相似或更好的性能(尽管良好的 I/O 对数据库来说仍然至关重要)。
此外,对于云客户,我们正在探索通过使用原生 CPU 指令 和 链接时优化 (LTO) 来优化我们的构建,从而实现高达 12% 的性能提升。我们还在考虑像 配置文件引导优化 (PGO) 这样的高级技术,到目前为止,它在原生指令和 LTO 的基础上又带来了额外的 12% 改进。
实现方式:更多并行、更少 I/O 操作、管道式写入
Meilisearch 过去会将文档负载拆分为多个块(大致每个索引线程一个),然后每个块会经历整个提取过程。一旦一个块处理完成,它就会被持久化到数据库中。
这种方法在性能方面存在一些缺点:
-
由于大量临时数据同时被提取,它必须先写入磁盘,然后再读回并持久化到数据库中,这导致了大量的 I/O 操作。
-
由于各个块是独立构建的,可能导致我们为每个块向数据库写入相同的键,再次造成不必要的写入。
-
由于块通常会同时完成计算,索引过程在计算块时会密集使用所有 CPU,但在将所有内容写入数据库时,却会完全变为单线程。
旧索引过程的简化表示
在新索引器中,Meilisearch 并行迭代文档,一次执行一个提取操作。提取的结果随后并行合并,以计算要发送到数据库的键和值列表。这些键和值通过一个充当管道的通道发送到写入器线程,从而减少了合并器线程等待写入器线程的时间。
通过管道化,合并线程等待时间减少
我们成功地实现了合并步骤的并行化,这得益于一个关键的见解:虽然提取步骤在多个线程之间分割文档,但合并步骤必须在多个线程之间分割数据库键。例如,为了构建将单词与包含该单词的文档列表匹配的反向索引,每个线程读取部分文档以提取其包含的单词,然后将所有包含某个单词的文档合并成一个列表。
为了实现这一点,所有线程在提取步骤中对其遇到的每个词进行确定性哈希。然后在合并步骤中,根据词的哈希值在线程之间分配要处理的词。
索引过程 2024 版的简化表示
这种新方法的缺点是,一些 CPU 密集型操作,例如文档分词,必须在所有步骤中重复。尽管如此,并行合并能够更好地利用机器的多个核心,从而减少 Meilisearch 单线程运行的时间。
此外,通过不重复键写入,数据库大小减少了 30% 以上。
更好的内存控制和使用
Meilisearch 使用 bumpalo 来改善内存控制。Bumpalo 是一种竞技场分配器,它允许批量释放内存并查询在竞技场中分配了多少内存。
竞技场分配器常用于视频游戏中,处理每个游戏帧都需要重新创建的对象,因为在竞技场中分配非常廉价(只需指针移动),而且只要没有析构函数需要运行,就可以批量释放(再次只需指针移动)。
在某些方面,用 Meilisearch 索引文档也有“帧”。在新的索引方法中,我们甚至可以检测到两种这样的帧:一种是非常短命的帧,对应于每个文档上完成的工作。另一种是寿命较长的帧,它从每个提取步骤开始,到合并器完成向写入器发送所有数据后结束。“文档”竞技场包含与读取和分词该文档相关的所有分配,而“提取”竞技场包含与将要合并并写入数据库的提取数据相关的所有分配。
在此过程中,Meilisearch 查询“提取”竞技场的大小,以检测其内存使用是否超过阈值。当发生这种情况时,Meilisearch 会将多余的数据溢出到磁盘,从而使内存使用保持在控制之下。
如果提取的数据适合内存,它将永远不会写入磁盘,从而减少 I/O 操作。
更快、更可靠的任务取消
以前,在旧版索引器中,文档块非常小,因此只允许在块处理完成后进行取消是有意义的。不幸的是,在写入临时数据到磁盘时,小块效率低下,因此我们在几个版本前决定切换到“每核心一个块”的模型。这给取消带来了负面影响,因为取消几乎要等到所有线程处理完它们的块之后,也就是在索引过程的末尾才能被处理。
随着新的 Meilisearch 迭代处理文档,并进一步按每个提取步骤拆分进程,任务取消比以往任何时候都快,通常在 1 秒内完成处理!
献给所有后悔一次性发送所有这些文档的你
更好的错误信息
新架构将文档 ID 暴露给所有提取器。这意味着错误消息可以像 此 PR 中所做的那样,通过这些信息得到丰富:了解哪个文档导致了错误,有助于更有效地进行故障排除。
通过进度实现更好的可观察性
最后,新的索引器带来了更好的可观察性:因为我们既知道当前所处的步骤,也知道此步骤已处理的文档数量,所以我们可以在新增的 batches
路由 中以 progress
对象的形式暴露这些信息。
{
"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 的新用户,也可以在我们的云端免费试用。