ES分頁看這篇就夠了

引言

我們使用mysql的時候經常遇到分頁查詢的場景,在mysql中使用limit關鍵字來實現分頁。比如下面的示例。

select * from orders_history where type=8 limit 100,100;
select * from orders_history where type=8 limit 1000,100;
select * from orders_history where type=8 limit 10000,100;

ElasticsSearch(以下簡稱ES)同樣也有很多分頁查詢的場景,比如在數據量比較大的情況下,並且查詢條件比較複雜,在mysql中無法命中索引,我們往往會選擇使用ES的分頁查詢。

ES實現分頁查詢有幾種不同的方案,每種方案都有自己的優缺點,下面就帶你來分別看下。

示例中使用的數據我儘量使用kibana自帶的,這樣方便有些讀者實際測試文中的示例。

分頁的幾種方案

from/size方案

這是ES分頁最常用的一種方案,跟mysql類似,from指定查詢的起始位置,size表示從起始位置開始的文檔數量。看個例子。

GET /kibana_sample_data_ecommerce/_search
{
  "from": 0, 
  "size" : 10,
  "query": {
    "bool": {
      "must": [
        {"match": {
          "customer_first_name": "Diane"
        }}
      ],
      "filter": {
        "range": {
          "order_date": {
            "gte": "2020-01-03"
          }
        }
      }
    }
  }, 
  "sort": [
    {
      "order_date": {
        "order": "asc"
      }
    }
  ]
}

這個例子是查詢客戶名字帶有diane,並且訂單時間大於2020-01-03的訂單信息,並且查詢的結果按照時間升序。

使用起來很簡單,不過ES默認的分頁深度是10000,也就是說from+size超過10000就會報錯,我們可以試下,會報下面的錯誤:

{
  "error": {
    "root_cause": [
      {
        "type": "illegal_argument_exception",
        "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10009]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
      }
    ],
    "type": "search_phase_execution_exception",
    "reason": "all shards failed",
    "phase": "query",
    "grouped": true,

其實很多時候,業務場景很少遇到這種深度分頁的情況,一般通過頁面查詢,不會有人會翻到這麼深的頁數。

不過,如果我們的業務場景確實需要超過10000條記錄的分頁,有辦法解決嗎?當然有。ES內部是通過index.max_result_window這個參數控制分頁深度的,我們可以針對特定的索引來修改這個值。

curl -XPUT IP:PORT/index_name/_settings -d '{ "index.max_result_window" :"100000"}'

這裏是把深度分頁的限制改成了10萬。

事實上,ES之所以有這個限制,是因爲在分佈式環境下深度分頁的查詢效率會非常低。比如我們現在查詢第from=990,size=10這樣的條件,這個在業務層就是查詢第990頁,每頁展示10條數據。

但是在ES處理的時候,會分別從每個分片上拿到1000條數據,然後在coordinating的節點上根據查詢條件聚合出1000條記錄,最後返回其中的10條。所以分頁越深,ES處理的開銷就大,佔用內存就越大。

在這裏插入圖片描述

search after方案

有時候我們會遇到一些業務場景,需要進行很深度的分頁,但是可以不指定頁數翻頁,只要可以實時請求下一頁就行。比如一些實時滾動的場景。

ES爲這種場景提供了一種解決方案:search after。

search after利用實時有遊標來幫我們解決實時滾動的問題,簡單來說前一次查詢的結果會返回一個唯一的字符串,下次查詢帶上這個字符串,進行下一頁的查詢。看個例子:

GET /kibana_sample_data_ecommerce/_search
{
  "size" : 2,
  "query": {
    "bool": {
      "must": [
        {"match": {
          "customer_first_name": "Diane"
        }}
      ],
      "filter": {
        "range": {
          "order_date": {
            "gte": "2020-01-03"
          }
        }
      }
    }
  }, 
  "sort": [
    {
      "order_date": "desc",
      "_id": "asc"
      
    }
  ]
}

首先查詢第一頁數據,我這裏指定取回2條,條件跟上一節一樣。唯一的區別在於sort部分我多加了id,這個是爲了在order_date字段一樣的情況下告訴ES一個可選的排序方案。因爲search after的遊標是基於排序產生的。

注意看查詢結果的最後,有個類似下面這樣的東東:

"sort" : [
          1580597280000,
          "RZz1f28BdseAsPClqbyw"
        ]

下一頁的查詢中,我們帶上這個玩意,如下:

GET /kibana_sample_data_ecommerce/_search
{
  "size" : 2,
  "query": {
    "bool": {
      "must": [
        {"match": {
          "customer_first_name": "Diane"
        }}
      ],
      "filter": {
        "range": {
          "order_date": {
            "gte": "2020-01-03"
          }
        }
      }
    }
  }, 
  "search_after": 
      [
          1580597280000,
          "RZz1f28BdseAsPClqbyw"
        ],
  "sort": [
    {
      "order_date": "desc",
      "_id": "asc"
      
    }
  ]
}

就這樣一直操作就可以實現不斷的查看下一頁了。

其實仔細想想這個操作原理並不複雜,以前筆者在mysql的場景下也用過類似的方案。我們來看看上一節討論的那個問題,比如通過一直下一頁,翻到了990頁,當繼續下頁時,因爲有了排序的唯一標識,ES只需從每個分片上拿到滿足條件的10條文檔,然後基於這30條文檔最終聚合成10條結果返回即可。

在這裏插入圖片描述
很顯然,開銷小很多。

scroll api方案

還有一種查詢場景,我們需要一次性或者每次查詢大量的文檔,但是對實時性要求並不高。ES針對這種場景提供了scroll api的方案。這個方案犧牲了實時性,但是查詢效率確實非常高。
先來看個示例:

POST /kibana_sample_data_ecommerce/_search?scroll=1m
{
    "size": 10,
    "query": {
        "match_all" : {
        }
    }
}

首先我們第一次查詢,會生成一個當前查詢條件結果的快照,後面的每次滾屏(或者叫翻頁)都是基於這個快照的結果,也就是即使有新的數據進來也不會別查詢到。

上面這個查詢結果會返回一個scroll_id,拷貝過來,組成下一條查詢語句,

POST /_search/scroll
{
    "scroll" : "1m",
  "scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAA5AWNGpKbFNMZnVSc3loXzQwb0tJZHBtZw=="
}

以此類推,後面每次滾屏都把前一個的scroll_id複製過來。注意到,後續請求時沒有了index信息,size信息等,這些都在初始請求中,只需要使用scroll_id和scroll兩個參數即可。

很多人對scroll這個參數容易混淆,誤認爲是查詢的限制時間。這個理解是錯誤的。這個時間其實指的是es把本次快照的結果緩存起來的有效時間。

scroll 參數相當於告訴了 ES我們的search context要保持多久,後面每個 scroll 請求都會設置一個新的過期時間,以確保我們可以一直進行下一頁操作。

我們繼續討論一個問題,scroll這種方式爲什麼會比較高效?

ES的檢索分爲查詢(query)和獲取(fetch)兩個階段,query階段比較高效,只是查詢滿足條件的文檔id彙總起來。fetch階段則基於每個分片的結果在coordinating節點上進行全局排序,然後最終計算出結果。

scroll查詢的時候,在query階段把符合條件的文檔id保存在前面提到的search context裏。 後面每次scroll分批取回只是根據scroll_id定位到遊標的位置,然後抓取size大小的結果集即可。

這裏只簡單說下。後面計劃以源碼的方式詳細分析原理。

總結

  • from/size方案的優點是簡單,缺點是在深度分頁的場景下系統開銷比較大,佔用較多內存。

  • search after基於ES內部排序好的遊標,可以實時高效的進行分頁查詢,但是它只能做下一頁這樣的查詢場景,不能隨機的指定頁數查詢。

  • scroll方案也很高效,但是它基於快照,不能用在實時性高的業務場景,建議用在類似報表導出,或者ES內部的reindex等場景。

參考

  • https://www.elastic.co/guide/en/elasticsearch/reference/7.1/search-request-from-size.html

  • https://www.elastic.co/guide/en/elasticsearch/reference/7.1/search-request-scroll.html

  • https://www.elastic.co/guide/cn/elasticsearch/guide/cn/_query_phase.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章