ES 分頁方案

來份鍋包肉發表於2021-04-08

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 的查詢過程為:

  1. 每個 shard 將所在資料載入到記憶體並排序,然後取前 1005 個,返回給 coordinator.
  2. 每個 shard 都執行上面的操作。
  3. 最後 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 的請求方式分為兩步:

  1. 第一次請求,ES 會返回生成生成的 scroll_id
  2. 之後的請求,不斷使用 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 是比較好的一種方式。

參考

深度分頁

scroll

阿里-分頁方式

相關文章