Quickwit 101 - 基于對象存儲的分布式搜索引擎架構
在本文中,我們將深入探討 Quickwit 的架構及其關鍵組件。本文與 Quickwit 的基準測試 配合閱讀效果更佳,該測試基于一個 23TB 的數(shù)據(jù)集。
- https://quickwit.io/blog/benchmarking-quickwit-engine-on-an-adversarial-dataset
Quickwit 標志的靈感來源于 Paul 的想法,即軟件和動態(tài)藝術具有一種有趣的共性。如果你觀看 Theo Jansen 的作品,你會看到迷人的風力驅動雕塑在海灘上行走,其復雜程度讓人難以理解其工作原理。這種神秘感也類似于使用軟件的感受。當我們查看大型代碼庫時,這些復雜的構造能夠正常運行似乎有些神奇……然而,大多數(shù)時候它們確實可以正常工作。
- https://github.com/fulmicoton
- https://www.youtube.com/watch?v=LewVEF2B_pM&ab_channel=theojansen
但我們是工程師!我們不滿足于神奇的現(xiàn)象,我們想要了解事物的工作原理,并且不會僅僅因為軟件的美觀而做出決定……(是嗎?)。確實,如果我們不想讓引擎在關鍵時刻出問題并且能夠安心睡覺的話(我也犯過很多這樣的錯誤……),理解引擎內部工作原理是非常重要的。沒有必要涵蓋所有細節(jié),建立一個系統(tǒng)的良好心理模型就足夠了,這樣可以理解它的局限性和如何高效地使用它。
這就是本文的目的:深入探討 Quickwit 的架構及其關鍵組件,以便你可以建立一個簡潔準確的系統(tǒng)心理模型?,F(xiàn)在讓我們開始吧!
計算與存儲解耦
Quickwit 架構的核心原則是計算與存儲的分離。我們的方法與 Datadog 通過 Husky 所做的非常相似(但早于他們),目標也是相同的:成本效益和可擴展性,同時避免集群管理的噩夢。
- https://www.datadoghq.com/blog/engineering/introducing-husky/
這種方法使我們將索引(寫路徑)和搜索(讀路徑)完全分開。索引器和搜索器通過元存儲共享相同的世界視圖。索引器向存儲寫入數(shù)據(jù)并更新元存儲,而搜索器從存儲和元存儲讀取數(shù)據(jù)。
在當前的 Quickwit 中,元存儲通常由 PostgreSQL 數(shù)據(jù)庫支持。但是 Quickwit 還有一個更簡單的實現(xiàn),其中元存儲由存儲在對象存儲上的簡單 JSON 文件支持;對于簡單的情況,無需依賴外部數(shù)據(jù)庫。這是本文選擇的配置。
圖片
索引
為了向 Quickwit 提供數(shù)據(jù),你通常會將 JSON 文檔發(fā)送到索引器的攝入 API。索引器然后對這些文檔進行“索引”:它將文檔流分割成短批次,并為每個批次創(chuàng)建一個索引片段(具體來說是一個文件)。我們可以配置生成這些片段的時間間隔(參見 commit_timeout_secs)。這些片段隨后上傳到 S3。
- https://quickwit.io/docs/configuration/index-config#indexing-settings
在上傳開始時,索引器將元存儲中的片段元數(shù)據(jù)標記為 Staged。一旦上傳完成,狀態(tài)變?yōu)?nbsp;Published,表示片段可用于搜索。從文檔攝入到搜索準備好的時間間隔稱為“搜索時間”,大致等于 commit_timeout_secs + upload_time。
這些步驟由一個處理管道實現(xiàn),詳細信息見此 相關文章。
- https://quickwit.io/blog/quickwit-actor-framework
圖片
解析 ‘Split’
為了提供搜索查詢的上下文,理解 “split” 包含的內容至關重要,因為這是索引的數(shù)據(jù)單位。它是一個獨立的索引,具有自己的模式,并且包含了針對快速有效搜索和分析操作優(yōu)化的數(shù)據(jù)結構:
- 倒排索引:用于全文搜索。對于那些想深入了解的人,我強烈推薦閱讀 fulmicoton 的博客文章 1 和 2。
https://twitter.com/fulmicoton
https://fulmicoton.com/posts/behold-tantivy/
https://fulmicoton.com/posts/behold-tantivy-part2/
- 列式存儲:用于排序和分析目的。
- 行式存儲:用于根據(jù)文檔 ID 檢索文檔。
- 熱緩存:可以將其視為索引的藍圖。包含前面數(shù)據(jù)結構的元數(shù)據(jù),確保搜索器僅檢索必要的數(shù)據(jù)。熱緩存是搜索器檢索的第一部分數(shù)據(jù);通常保留在內存中。
為了說明這一點,我們來看一個實際例子:一個包含 1000 萬條 GitHub 歸檔事件的 split,其中所有文檔字段都啟用了所有數(shù)據(jù)結構。
圖片
split 的大小約為 15GB。與未壓縮的文檔大小相比,其壓縮比接近 3。需要注意的是,可以通過僅在相關字段上啟用特定存儲來優(yōu)化這一比率。
最后,熱緩存具有一個很好的特性:它占 split 大小的比例不到 0.1%,大約 10MB,這意味著它可以輕松地放入 RAM 中。
關于合并的一點說明
你可能會疑惑,Quickwit 如何能夠在索引器每 10 秒提交一次的情況下生成包含 1000 萬份文檔的 split。答案在于 Quickwit 的合并管道,它負責將一組 split 合并,直到達到一定數(shù)量的文檔。這主要有兩個原因:
- 性能提升:你不希望在每次搜索請求時打開許多小的 split。這也大大減少了元存儲需要處理的數(shù)據(jù)量。通過合并,我們最終可以在 PostgreSQL 中為每 1000 萬份文檔保留一行記錄。
- 成本效益:你希望限制每次搜索請求中的 GET 請求次數(shù)。
搜索
在讀取側,當搜索器接收到搜索請求時,它會經(jīng)歷以下步驟:
- 元存儲檢索:搜索器獲取 metastore.json 文件。
- split 列表:列出與搜索請求相關的 split。這一階段特別利用了搜索請求的時間范圍來排除不符合時間范圍的 split。
- 葉搜索執(zhí)行:對于每個 split,執(zhí)行并發(fā)的「葉搜索」。它包括:
(IO) 獲取 split 的熱緩存。
(IO) 預熱階段:對于每個詞條,它獲取發(fā)布列表的字節(jié)范圍,然后獲取發(fā)布列表本身。如果有必要,它還會獲取搜索所需的定位信息和列。為了便于閱讀,預熱階段在下面的圖表中表示為單一的獲取,但實際上可能是多次獲取。
(CPU) 搜索階段:在 split 上運行查詢。
- 結果聚合:聚合葉搜索的結果。
- 文檔獲?。核阉髌鲝南嚓P split 中獲取文檔。
- 結果返回:最終將匹配的文檔和/或聚合返回給用戶。
這導致了以下讀取路徑:
圖片
元存儲
讓我們回到索引器和搜索器共享的視圖:元存儲。它存儲了我們在前幾節(jié)部分看到的關于索引的關鍵信息:
- 索引配置:文檔映射、時間戳字段、合并策略等。
- split 元數(shù)據(jù):如果你的數(shù)據(jù)集有一個時間字段,Quickwit 會特別存儲每個 split 的最小和最大時間戳值,從而在搜索時啟用基于時間的過濾。
- 檢查點:對于每個數(shù)據(jù)源,一個“源檢查點”記錄了已處理的文檔的截止點。如果你使用 Kafka 作為數(shù)據(jù)源,檢查點包含每個分區(qū)的起始和結束偏移量,這些偏移量被索引到 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 字段了嗎?那是……熱緩存的字節(jié)范圍!
分布式索引和搜索
分布式索引和搜索引入了一些挑戰(zhàn):
- 集群形成:Quickwit 使用了一個名為 Chitchat 的開源實現(xiàn)來形成集群,這個實現(xiàn)基于 Scuttlebutt。
https://quickwit.io/docs/overview/architecture#cluster-formation
https://www.cs.cornell.edu/home/rvr/papers/flowgossip.pdf
- 元存儲寫入:在寫入路徑上,只有一個進程應該處理對元存儲文件的寫入。為此,一個單獨的 metastore 角色實例讀取/寫入 JSON 文件。其他集群成員向此實例發(fā)送讀取/寫入 gRPC 請求。
- 索引任務分配:一個控制平面角色負責將索引任務分配給各個索引器。
- 搜索工作負載分配:這需要一個 Map-Reduce 機制。接收搜索請求的搜索器承擔“根”角色,將葉子請求委托給“葉子節(jié)點”,然后聚合并返回結果。
以下是寫入路徑的可視化表示:
圖片
類似地,對于讀取路徑:
圖片
要進行更深入的了解,請參閱我們的 文檔。
- https://quickwit.io/docs/overview/architecture