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

在搜索引擎中处理数百万文档时,每一毫秒都至关重要。这就是为什么我们通过 v1.12 中全新的索引器,彻底重新构思了 Meilisearch 处理数据的方式。我们从头重写了索引器——这个核心组件负责处理您的文档并构建搜索结构。结果呢?一个更快、更高效、更具可扩展性的系统。
这次彻底的重新设计从根本上改变了我们的搜索引擎处理数据的方式。在本文中,我们将向您展示我们如何实现了文档更新速度提升 4 倍,数据库大小减小 30%,以及这对您的应用程序意味着什么。
未来基石
我们将此版本命名为索引器 2024 版
,这反映了我们构建一个能够在未来几年为 Meilisearch 性能提供动力的基础的愿景。受Rust 的版本模型启发,这不仅仅是一次更新,更是 Meilisearch 发展历程中的一个新篇章,为未来搜索技术的创新奠定了基础。
(也像 Rust 版本一样,我们采用了它开发年份而不是发布年份😁)
为何要重写索引器?
医生不会重写索引器
你需要一个*非常*充分的理由来重写一个索引器。而我们有。
正如我们的 CTO Kero 在他的博客文章中直言不讳地指出:Meilisearch 太慢了。新的索引器就是我们对这个挑战的回答——从零开始重写以大幅提高速度。
新索引器的主要目标是提高拥有数千万文档的大型索引的索引速度,以减轻我们基础设施的一些压力,更好地利用机器资源,并以结构化的方式解决一些长期存在的问题。
核心问题:性能提升
有时你真的需要更快
在各种场景下,新索引器的速度比优化过的 v1.11 索引器更快,有时甚至快了好几倍。
在多核、良好 I/O 和大量内存的机器上,性能大幅提升。插入新文档的速度是原来的两倍,而在大型数据库中增量更新文档的速度快了 4 倍!
虽然目标是提升大型机器上的性能,但我们成功地在核数较少、内存较低的普通机器上保持了相似或更佳的性能(尽管良好的 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 的新用户,也可以在我们的云端免费试用。