ES 分頁方案
ES 中,存在三種常見的分頁方案:
- FROM, SIZE
- Search-After
- Scroll
下面將依次比較三種方案之間的 trede-off,並給出相應建議的應用場景。
常見分頁,FROM, SIZE
ES 提供了常見的分頁功能,通過在 search API 中,指定 from 和 size 來實現分頁的效果:
{
"from": 10,
"size": 20,
"sort": [{"timestamp": "desc"}],
"query": {
"match_all": {} # 返回所有 doc
}
}
from: 表示起點位置,預設是 0.
size:表示返回的數量,預設是 10.
這種分頁方式到沒什麼好說的,但需要注意的是由於 ES 為了支援海量資料的查詢,本身採用了分散式的架構。
而對於分散式架構來說,存在一個典型的深度分頁的問題。
ES 在儲存資料時,會將其分配到不同的 shard 中。在查詢時,如果 from 值過大,就會導致分頁起點太深。
每個 shard 查詢時,都會將 from 之前位置的所有資料和請求 size 的總數返回給 coordinator. 簡單來說,就是想取第 n 頁的內容,但是卻返回了前 n 的內容。
而對於 coordinator 來說,會顯著導致記憶體和CPU使用率升高,特別是在高併發的場景下,導致效能下降或者節點故障。
舉例來說,當前 ES 共有 4 個 shard,並且每個 shard 沒有副本。假如分頁的大小為 10. 然後想取第101 頁前 5 條內容。對應的 from = 1000,size = 5.
ES 的查詢過程為:
- 每個 shard 將所在資料載入到記憶體並排序,然後取前 1005 個,返回給 coordinator.
- 每個 shard 都執行上面的操作。
- 最後 coordinator 將 1005 * 4 = 4020 條資料排序,然後取 5 條資料返回。
可以發現,from 的位置太深,造成了如下的問題:
- 返回給 coordinator 數值太大,明明就需要 5 條資料,但卻給 coordinator 1005 條資料
- coordinator 需要處理每個 shard 返回前 101 頁的結果。但需要的僅是第 101 頁的內容,卻對前 101 頁的內容進行了排序,浪費了記憶體和 cpu 的資源。
ES 為了規避這個問題,通過設定 max_result_window 來限制 from 和 size 的大小,預設大小僅支援 10000 條。當超過 10000 的大小,則會報出異常。
在頁數不深或者考慮記憶體,低併發等情況,可以通過臨時調整 max_result_window 來解決該問題,但如果頁數太深則建議使用的 Search-After 的方式。
SearchAfter 分頁
為了應對深度分頁的情況,ES 推薦使用 SearchAfter 的方式,來實現資料的深度翻頁檢索。
在具體實現上,通過動態指標的技術。在第一次使用 search api 查詢時,附帶一個 sort 引數,其中 sort 的值必須唯一,可以用 _id
作為排序引數。
{
"from": 0,
"size": 1,
"sort": [
{"timestamp": "desc"},
{"_id": "asc"}
],
"query": {
"match_all": {}
}
}
每個 shard 在排序後會記錄當前查詢的最後位置,然後將其返回。
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 10,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "cmi_alarm_info",
"_type": "_doc",
"_id": "1,3,d_to_s_JitterAvg",
"_score": null,
"_source": {
"src_device_id": 1,
"dst_device_id": 3,
"type": "d_to_s_JitterAvg",
"status": "normal",
"create_time": 1617085800
},
"sort": [
1617085800,
"1,3,d_to_s_JitterAvg"
]
}
]
}
}
下次查詢時,在 search_after
攜帶 Response 中返回的 sort 引數,實現分頁的查詢。
{
"from": 0,
"size": 1,
"sort": [
{"timestamp": "desc"},
{"_id": "asc"}
],
"query": {
"match_all": {}
},
"search_after": [
1617084900,
"13,3,d_to_s_JitterAvg"
]
}
和 from size 的查詢方式相比, 每個 shard 每次返回給 coordinator 的結果僅為 size 數量,將空間複雜度由 O(n) 降為 O(1).
但 Search-after 也有一些問題:
首先就是不支援跳頁的情況。
如果需求上一定需要跳頁時,只能通過 from 或者 size 的方式。同時為了避免深度分頁的問題,一般可以採用限制頁面數量的方式。在確定 size 後,設定一個最大的分頁值。在查詢時,分頁數不允許超過該值。
其次,隨著翻頁深度的增加,查詢的效率也會有所降低,但不會導致 OOM,算是可以完成深度查詢的任務。原因在於,雖然說通過排序欄位,可以很好的定位出下一次翻頁的開始位置。但在每次請求時,從頭掃描該欄位,找到該欄位的位置。頁數越深,找到該位置的時間也就越長。
Scroll 分頁
雖然說 search-after 可以在一定程度上避免深度分頁的問題,但在處理大資料量,效率並不高。在一些對實時性要求不高的場景,如利用 Spark 進行大規模計算時。就可以利用 scroll 分頁的方式,檢索所有資料。
scroll 的請求方式分為兩步:
- 第一次請求,ES 會返回生成生成的 scroll_id
- 之後的請求,不斷使用 scroll_id 進行查詢,直到所有資料被檢索完成。
第一次請求,新增 scroll 標識,並拿到 scroll_id 作為下次請求的引數:
POST /my-index-000001/_search?scroll=1m
{
"size": 100,
"query": {
"match": {
"message": "foo"
}
}
}
Response:
{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAADlx8Wb0VzanNRSENRbUtBQVEzbHltcF9WQQ==",
"took": 0,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
}
}
第二次請求,使用 scroll_id 直到遍歷完所有資料:
POST /_search/scroll
{
"scroll" : "1m",
"scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
對於 Scroll 來說,會返回第一次請求時刻的所有文件,之後文件的改變並不會被查詢到,保留的時間通過 scroll 引數指定。在查詢效能上,時間和空間複雜度都為 O(1),能以恆定的速度查詢完所有資料。
在原理上,相當於第一次查詢階段, 保留所有的 doc id 資訊。在隨後的查詢中,根據的需要的 doc id,在不同的 shard 中拉取不同的文件。和 search-after 相比,省去了每次都要全域性排序的過程。
總結
from, size 適用於常見的查詢,例如需要支援跳頁並實時查詢的場景。但查詢深度過深時,會有深度分頁的問題,造成 OOM.
如果在業務上,可以不選擇跳頁的方式,可以使用的 search-after 的方式避免深度分頁的問題。但如果一定要跳頁的話,只能採用限制最大分頁數的方式。
但對於超大資料量,以及需要高併發獲取等離線場景,scroll 是比較好的一種方式。