在本文中,我们将深入探讨 Quickwit 的架构及其关键组件。本文与 Quickwit 的基准测试 配合阅读效果更佳,该测试基于一个 23TB 的数据集。
- https://quickwit.io/blog/benchmarking-quickwit-engine-on-an-adversarial-dataset
Quickwit 标志的灵感来源于 Paul 的想法,即软件和动态艺术具有一种有趣的共性。如果你观看 Theo Jansen 的作品,你会看到迷人的风力驱动雕塑在海滩上行走,其复杂程度让人难以理解其工作原理。这种神秘感也类似于使用软件的感受。当我们查看大型代码库时,这些复杂的构造能够正常运行似乎有些神奇……然而,大多数时候它们确实可以正常工作。
- https://github.com/fulmicoton
- https://www.youtube.com/watch?v=LewVEF2B_pM&ab_channel=theojansen
但我们是工程师!我们不满足于神奇的现象,我们想要了解事物的工作原理,并且不会仅仅因为软件的美观而做出决定……(是吗?)。确实,如果我们不想让引擎在关键时刻出问题并且能够安心睡觉的话(我也犯过很多这样的错误……),理解引擎内部工作原理是非常重要的。没有必要涵盖所有细节,建立一个系统的良好心理模型就足够了,这样可以理解它的局限性和如何高效地使用它。
这就是本文的目的:深入探讨 Quickwit 的架构及其关键组件,以便你可以建立一个简洁准确的系统心理模型。现在让我们开始吧!
计算与存储解耦
Quickwit 架构的核心原则是计算与存储的分离。我们的方法与 Datadog 通过 Husky 所做的非常相似(但早于他们),目标也是相同的:成本效益和可扩展性,同时避免集群管理的噩梦。
- https://www.datadoghq.com/blog/engineering/introducing-husky/
这种方法使我们将索引(写路径)和搜索(读路径)完全分开。索引器和搜索器通过元存储共享相同的世界视图。索引器向存储写入数据并更新元存储,而搜索器从存储和元存储读取数据。
在当前的 Quickwit 中,元存储通常由 PostgreSQL 数据库支持。但是 Quickwit 还有一个更简单的实现,其中元存储由存储在对象存储上的简单 JSON 文件支持;对于简单的情况,无需依赖外部数据库。这是本文选择的配置。
图片
索引
为了向 Quickwit 提供数据,你通常会将 JSON 文档发送到索引器的摄入 API。索引器然后对这些文档进行“索引”:它将文档流分割成短批次,并为每个批次创建一个索引片段(具体来说是一个文件)。我们可以配置生成这些片段的时间间隔(参见 commit_timeout_secs)。这些片段随后上传到 S3。
- https://quickwit.io/docs/configuration/index-config#indexing-settings
在上传开始时,索引器将元存储中的片段元数据标记为 Staged。一旦上传完成,状态变为 Published,表示片段可用于搜索。从文档摄入到搜索准备好的时间间隔称为“搜索时间”,大致等于 commit_timeout_secs + upload_time。
这些步骤由一个处理管道实现,详细信息见此 相关文章。
- https://quickwit.io/blog/quickwit-actor-framework
图片
解析 ‘Split’
为了提供搜索查询的上下文,理解 “split” 包含的内容至关重要,因为这是索引的数据单位。它是一个独立的索引,具有自己的模式,并且包含了针对快速有效搜索和分析操作优化的数据结构:
- 倒排索引:用于全文搜索。对于那些想深入了解的人,我强烈推荐阅读 fulmicoton 的博客文章 1 和 2。
https://twitter.com/fulmicoton
https://fulmicoton.com/posts/behold-tantivy/
https://fulmicoton.com/posts/behold-tantivy-part2/
- 列式存储:用于排序和分析目的。
- 行式存储:用于根据文档 ID 检索文档。
- 热缓存:可以将其视为索引的蓝图。包含前面数据结构的元数据,确保搜索器仅检索必要的数据。热缓存是搜索器检索的第一部分数据;通常保留在内存中。
为了说明这一点,我们来看一个实际例子:一个包含 1000 万条 GitHub 归档事件的 split,其中所有文档字段都启用了所有数据结构。
图片
split 的大小约为 15GB。与未压缩的文档大小相比,其压缩比接近 3。需要注意的是,可以通过仅在相关字段上启用特定存储来优化这一比率。
最后,热缓存具有一个很好的特性:它占 split 大小的比例不到 0.1%,大约 10MB,这意味着它可以轻松地放入 RAM 中。
关于合并的一点说明
你可能会疑惑,Quickwit 如何能够在索引器每 10 秒提交一次的情况下生成包含 1000 万份文档的 split。答案在于 Quickwit 的合并管道,它负责将一组 split 合并,直到达到一定数量的文档。这主要有两个原因:
- 性能提升:你不希望在每次搜索请求时打开许多小的 split。这也大大减少了元存储需要处理的数据量。通过合并,我们最终可以在 PostgreSQL 中为每 1000 万份文档保留一行记录。
- 成本效益:你希望限制每次搜索请求中的 GET 请求次数。
搜索
在读取侧,当搜索器接收到搜索请求时,它会经历以下步骤:
- 元存储检索:搜索器获取 metastore.json 文件。
- split 列表:列出与搜索请求相关的 split。这一阶段特别利用了搜索请求的时间范围来排除不符合时间范围的 split。
- 叶搜索执行:对于每个 split,执行并发的「叶搜索」。它包括:
(IO) 获取 split 的热缓存。
(IO) 预热阶段:对于每个词条,它获取发布列表的字节范围,然后获取发布列表本身。如果有必要,它还会获取搜索所需的定位信息和列。为了便于阅读,预热阶段在下面的图表中表示为单一的获取,但实际上可能是多次获取。
(CPU) 搜索阶段:在 split 上运行查询。
- 结果聚合:聚合叶搜索的结果。
- 文档获取:搜索器从相关 split 中获取文档。
- 结果返回:最终将匹配的文档和/或聚合返回给用户。
这导致了以下读取路径:
图片
元存储
让我们回到索引器和搜索器共享的视图:元存储。它存储了我们在前几节部分看到的关于索引的关键信息:
- 索引配置:文档映射、时间戳字段、合并策略等。
- split 元数据:如果你的数据集有一个时间字段,Quickwit 会特别存储每个 split 的最小和最大时间戳值,从而在搜索时启用基于时间的过滤。
- 检查点:对于每个数据源,一个“源检查点”记录了已处理的文档的截止点。如果你使用 Kafka 作为数据源,检查点包含每个分区的起始和结束偏移量,这些偏移量被索引到 Quickwit 中。这解锁了恰好一次语义。
元存储可以由 PostgreSQL 或存储在对象存储上的单个 JSON 文件支持。后者用于本文,因为它是最简单的配置。以下是元存储内容的一个快照:
metastore.json
{
"index_uri": "s3://indexes/{my-index}",
"index_config": {...},
"splits": [
{
"split_id": "...",
"num_docs": 10060714,
"split_state": "Published",
"time_range": {
"start": 1691719200,
"end": 1691936694
},
"split_footer": {
"start": 1612940400,
"end": 1616529599
}
...
}
],
"checkpoints": {
"a": "00000000000000000128",
"b": "00000000000000060187",
...
}
}
你注意到那个令人好奇的 split_footer 字段了吗?那是……热缓存的字节范围!
分布式索引和搜索
分布式索引和搜索引入了一些挑战:
- 集群形成:Quickwit 使用了一个名为 Chitchat 的开源实现来形成集群,这个实现基于 Scuttlebutt。
https://quickwit.io/docs/overview/architecture#cluster-formation
https://www.cs.cornell.edu/home/rvr/papers/flowgossip.pdf
- 元存储写入:在写入路径上,只有一个进程应该处理对元存储文件的写入。为此,一个单独的 metastore 角色实例读取/写入 JSON 文件。其他集群成员向此实例发送读取/写入 gRPC 请求。
- 索引任务分配:一个控制平面角色负责将索引任务分配给各个索引器。
- 搜索工作负载分配:这需要一个 Map-Reduce 机制。接收搜索请求的搜索器承担“根”角色,将叶子请求委托给“叶子节点”,然后聚合并返回结果。
以下是写入路径的可视化表示:
图片
类似地,对于读取路径:
图片
要进行更深入的了解,请参阅我们的 文档。
- https://quickwit.io/docs/overview/architecture