ElasticSearch分頁查詢的3個(gè)坑
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競爭對(duì)手一起精進(jìn)!
編輯:業(yè)余草
推薦:https://www.xttblog.com/?p=5351
ES支持的三種分頁查詢方式
From + Size 查詢 Scroll 遍歷查詢 Search After 查詢

「說明:」
官方已經(jīng)不再推薦采用Scroll API進(jìn)行深度分頁。如果遇到超過 10000 的深度分頁,推薦采用search_after + PIT。
官方文檔地址:https://www.elastic.co/guide/en/elasticsearch/reference/7.14/paginate-search-results.html。
分布式系統(tǒng)中的深度分頁問題
「為什么分布式存儲(chǔ)系統(tǒng)中對(duì)深度分頁支持都不怎么友好呢?」
首先我們看一下分布式存儲(chǔ)系統(tǒng)中分頁查詢的過程。
下面是重點(diǎn)。。。
假設(shè)在一個(gè)有 4 個(gè)主分片的索引中搜索,每頁返回10條記錄。
當(dāng)我們請求結(jié)果的第1頁(結(jié)果從 1 到 10 ),每一個(gè)分片產(chǎn)生前 10 的結(jié)果,并且返回給 協(xié)調(diào)節(jié)點(diǎn) ,協(xié)調(diào)節(jié)點(diǎn)對(duì) 40 個(gè)結(jié)果排序得到全部結(jié)果的前 10 個(gè)。
當(dāng)我們請求第 99 頁(結(jié)果從 990 到 1000),需要從每個(gè)分片中獲取滿足查詢條件的前1000個(gè)結(jié)果,返回給協(xié)調(diào)節(jié)點(diǎn), 然后協(xié)調(diào)節(jié)點(diǎn)對(duì)全部 4000 個(gè)結(jié)果排序,獲取前10個(gè)記錄。
當(dāng)請求第10000頁,每頁10條記錄,則需要先從每個(gè)分片中獲取滿足查詢條件的前100010個(gè)結(jié)果,返回給協(xié)調(diào)節(jié)點(diǎn)。然后協(xié)調(diào)節(jié)點(diǎn)需要對(duì)全部(100010 * 分片數(shù)4)的結(jié)果進(jìn)行排序,然后返回前10個(gè)記錄。
可以看到,在分布式系統(tǒng)中,對(duì)結(jié)果排序的成本隨分頁的深度成指數(shù)上升。
這就是 web 搜索引擎對(duì)任何查詢都不要返回超過 10000 個(gè)結(jié)果的原因。

From + Size 查詢
準(zhǔn)備數(shù)據(jù)
PUT user_index
{
"mappings": {
"properties": {
"id": {"type": "integer"},
"name": {"type": "keyword"}
}
}
}
POST user_index/_bulk
{ "create": { "_id": "1" }}
{ "id":1,"name":"老萬"}
{ "create": { "_id": "2" }}
{ "id":2,"name":"老王"}
{ "create": { "_id": "3" }}
{ "id":3,"name":"老劉"}
{ "create": { "_id": "4" }}
{ "id":4,"name":"小明"}
{ "create": { "_id": "5" }}
{ "id":5,"name":"小紅"}
查詢演示
「無條件查詢」
POST user_index/_search
默認(rèn)返回前10個(gè)匹配的匹配項(xiàng)。其中:
from:未指定,默認(rèn)值是 0,注意不是1,代表當(dāng)前頁返回?cái)?shù)據(jù)的起始值。 size:未指定,默認(rèn)值是 10,代表當(dāng)前頁返回?cái)?shù)據(jù)的條數(shù)。
「指定from+size查詢」
POST user_index/_search
{
"from": 0,
"size": 10,
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"}
]
}
max_result_window
es 默認(rèn)采用的分頁方式是 from+ size 的形式,在深度分頁的情況下,這種使用方式效率是非常低的。
比如 from = 5000,size=10, es 需要在各個(gè)分片上匹配排序并得到5000*10條有效數(shù)據(jù),然后在結(jié)果集中取最后 10條數(shù)據(jù)返回,這種方式類似于 mongo 的 skip + size。
除了效率上的問題,還有一個(gè)無法解決的問題是,es 目前支持最大的 skip 值是 「max_result_window ,默認(rèn)為 10000」。也就是當(dāng) from + size > max_result_window 時(shí),es 將返回錯(cuò)誤。
POST user_index/_search
{
"from": 10000,
"size": 10,
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"}
]
}
這是ElasticSearch最簡單的分頁查詢,但以上命令是會(huì)報(bào)錯(cuò)的。
報(bào)錯(cuò)信息,指window默認(rèn)是10000。
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10001]. 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",
怎么解決這個(gè)問題,首先能想到的就是調(diào)大這個(gè) window。
PUT user_index/_settings
{
"index" : {
"max_result_window" : 20000
}
}
然后這種方式只能暫時(shí)解決問題,當(dāng)es 的使用越來越多,數(shù)據(jù)量越來越大,深度分頁的場景越來越復(fù)雜時(shí),如何解決這種問題呢?
「官方建議:」
避免過度使用 from 和 size 來分頁或一次請求太多結(jié)果。
不推薦使用 from + size 做深度分頁查詢的核心原因:
搜索請求通??缭蕉鄠€(gè)分片,每個(gè)分片必須將其請求的命中內(nèi)容以及任何先前頁面的命中內(nèi)容加載到內(nèi)存中。 對(duì)于翻頁較深的頁面或大量結(jié)果,這些操作會(huì)顯著增加內(nèi)存和 CPU 使用率,從而導(dǎo)致性能下降或節(jié)點(diǎn)故障。
Search After 查詢
search_after 參數(shù)使用上一頁中的一組排序值來檢索下一頁的數(shù)據(jù)。
使用 search_after 需要具有相同查詢和排序值的多個(gè)搜索請求。如果在這些請求之間發(fā)生刷新,結(jié)果的順序可能會(huì)發(fā)生變化,從而導(dǎo)致跨頁面的結(jié)果不一致。為防止出現(xiàn)這種情況,您可以創(chuàng)建一個(gè)時(shí)間點(diǎn) (PIT) 以保留搜索中的當(dāng)前索引狀態(tài)。
時(shí)間點(diǎn)Point In Time(PIT)保障搜索過程中保留特定事件點(diǎn)的索引狀態(tài)。
「注意??:」
es 給出了 search_after 的方式,這是在 >= 5.0 版本才提供的功能。
Point In Time(PIT)是 Elasticsearch 7.10 版本之后才有的新特性。
「PIT的本質(zhì):存儲(chǔ)索引數(shù)據(jù)狀態(tài)的輕量級(jí)視圖?!?/strong>
如下示例能很好的解讀 PIT 視圖的內(nèi)涵。
#1、給索引user_index創(chuàng)建pit
POST /user_index/_pit?keep_alive=5m
#2、統(tǒng)計(jì)當(dāng)前記錄數(shù) 5
POST /user_index/_count
#3、根據(jù)pit統(tǒng)計(jì)當(dāng)前記錄數(shù) 5
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
]
}
#4、插入一條數(shù)據(jù)
POST user_index/_bulk
{ "create": { "_id": "6" }}
{ "id":6,"name":"老李"}
#5、數(shù)據(jù)總量 6
POST /user_index/_count
#6、根據(jù)pit統(tǒng)計(jì)數(shù)據(jù)總量還是 5 ,說明是根據(jù)時(shí)間點(diǎn)的視圖進(jìn)行統(tǒng)計(jì)。
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
]
}
有了 PIT,search_after 的后續(xù)查詢都是基于 PIT 視圖進(jìn)行,能有效保障數(shù)據(jù)的一致性。
search_after 分頁查詢可以簡單概括為如下幾個(gè)步驟。
獲取索引的pit
POST /user_index/_pit?keep_alive=5m
根據(jù)pit首次查詢
說明:根據(jù)pit查詢的時(shí)候,不用指定索引名稱。
GET /_search
{
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIODBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "1m"
},
"sort": [
{"id": "asc"}
]
}
查詢結(jié)果:返回的sort值為2.
hits" : [
{
"_index" : "user_index",
"_type" : "_doc",
"_id" : "2",
"_score" : null,
"_source" : {
"id" : 2,
"name" : "老王"
},
"sort" : [
2
]
}
]
根據(jù)search_after和pit進(jìn)行翻頁查詢
說明:
search_after指定為上一次查詢返回的sort值。
要獲得下一頁結(jié)果,請使用最后一次命中的排序值(包括 tiebreaker)作為 search_after 參數(shù)重新運(yùn)行先前的搜索。如果使用 PIT,請?jiān)?pit.id 參數(shù)中使用最新的 PIT ID。搜索的查詢和排序參數(shù)必須保持不變。如果提供,則 from 參數(shù)必須為 0(默認(rèn)值)或 -1。
GET /_search
{
"size": 1,
"query": {
"match_all": {}
},
"pit": {
"id": "i6-xAwEKdXNlcl9pbmRleBZYTXdtSFRHeVJrZVhCby1OTjlHMS1nABZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3AAAAAAAAIOJ7FmdBWEd2UmFVVGllZldNdnhPZDJmX0EBFlhNd21IVEd5UmtlWEJvLU5OOUcxLWcAAA==",
"keep_alive": "5m"
},
"sort": [
{"id": "asc"}
],
"search_after": [
2
]
}
search_after 優(yōu)缺點(diǎn)分析
search_after 查詢僅支持向后翻頁。
不嚴(yán)格受制于max_result_window,可以無限制往后翻頁。
單次請求值不能超過 max_result_window;但總翻頁結(jié)果集可以超過。
面試題思考
為什么采用 search_after查詢能解決深度分頁的問題?search_after + pit 分頁查詢過程中,PIT 視圖過期怎么辦? search_after查詢,如果需要回到前幾頁怎么辦?
Scroll 遍歷查詢
ES 官方不再推薦使用Scroll API進(jìn)行深度分頁。如果您需要在分頁超過 10000 個(gè)點(diǎn)擊時(shí)保留索引狀態(tài),請使用帶有時(shí)間點(diǎn) (PIT) 的 search_after 參數(shù)。
相比于 From + size 和 search_after 返回一頁數(shù)據(jù),Scroll API 可用于從單個(gè)搜索請求中檢索大量結(jié)果(甚至所有結(jié)果),其方式與傳統(tǒng)數(shù)據(jù)庫中游標(biāo)(cursor)類似。
Scroll API 原理上是對(duì)某次查詢生成一個(gè)游標(biāo) scroll_id, 后續(xù)的查詢只需要根據(jù)這個(gè)游標(biāo)去取數(shù)據(jù),直到結(jié)果集中返回的 hits 字段為空,就表示遍歷結(jié)束。scroll_id 的生成可以理解為建立了一個(gè)臨時(shí)的歷史快照,在此之后的增刪改查等操作不會(huì)影響到這個(gè)快照的結(jié)果。
所有文檔獲取完畢之后,需要手動(dòng)清理掉 scroll_id。雖然es 會(huì)有自動(dòng)清理機(jī)制,但是 srcoll_id 的存在會(huì)耗費(fèi)大量的資源來保存一份當(dāng)前查詢結(jié)果集映像,并且會(huì)占用文件描述符。所以用完之后要及時(shí)清理。使用 es 提供的 CLEAR_API 來刪除指定的 scroll_id
首次查詢,并獲取_scroll_id
POST /user_index/_search?scroll=1m
{
"size": 1,
"query": {
"match_all": {}
}
}
返回結(jié)果:
{
"_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlQBZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3",
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 6,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "user_index",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"id" : 1,
"name" : "老萬"
}
}
]
}
}
根據(jù)scroll_id遍歷數(shù)據(jù)
POST /_search/scroll
{
"scroll" : "1m",
"scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3"
}
刪除游標(biāo)scroll
DELETE /_search/scroll
{
"scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFmdBWEd2UmFVVGllZldNdnhPZDJmX0EAAAAAACDlKxZ0TEpMcVRuNFRxaWI4cXFTVERhOHR3"
}
優(yōu)缺點(diǎn)
scroll查詢的相應(yīng)數(shù)據(jù)是非實(shí)時(shí)的,這點(diǎn)和 PIT 視圖比較類似,如果遍歷過程中插入新的數(shù)據(jù),是查詢不到的。
并且保留上下文需要足夠的堆內(nèi)存空間。
適用場景
全量或數(shù)據(jù)量很大時(shí)遍歷結(jié)果數(shù)據(jù),而非分頁查詢。
「官方文檔強(qiáng)調(diào):」
不再建議使用scroll API進(jìn)行深度分頁。如果要分頁檢索超過 Top 10000+ 結(jié)果時(shí),推薦使用:PIT + search_after。
業(yè)務(wù)層面優(yōu)化
很多時(shí)候,技術(shù)解決不了的問題,可以通過業(yè)務(wù)層面變通下來解決!
比如,針對(duì)分頁場景,我們可以采用如下優(yōu)化方案。
增加默認(rèn)的篩選條件
通過盡可能的增加默認(rèn)的篩選條件,如:時(shí)間周期和最低評(píng)分,減少滿足條件的數(shù)據(jù)量,避免出現(xiàn)深度分頁的情況。
采用滾動(dòng)增量顯示
典型場景比如手機(jī)上面瀏覽微博,可以一直往下滾動(dòng)加載。
示例:
如下列表展示中,取消了分頁按鈕,通過滾動(dòng)條增量加載數(shù)據(jù)。

小范圍跳頁
通過對(duì)分頁組件的設(shè)計(jì),禁止用戶直接跳轉(zhuǎn)到非常大的頁碼中。比如直接跳轉(zhuǎn)到最后一頁這種操作。
示例:google搜索的小范圍跳頁。

總結(jié)
分布式存儲(chǔ)引擎的深度分頁目前沒有完美的解決方案。
比如針對(duì)百度、google這種全文檢索的查詢,通過From+ size返回Top 10000 條數(shù)據(jù)完全能滿足使用需求,末尾查詢評(píng)分非常低的結(jié)果一般參考意義都不大。
From+ size:需要隨機(jī)跳轉(zhuǎn)不同分頁(類似主流搜索引擎)、Top 10000 條數(shù)據(jù)之內(nèi)分頁顯示場景。 search_after:僅需要向后翻頁的場景及超過Top 10000 數(shù)據(jù)需要分頁場景。 Scroll:需要遍歷全量數(shù)據(jù)場景 。 max_result_window:調(diào)大治標(biāo)不治本,不建議調(diào)過大。 PIT:本質(zhì)是視圖。


百度搜索的分頁最多只能到 76 頁,不管你搜索的結(jié)果匹配了多少內(nèi)容,只能翻到第 76 頁,而且也只能小范圍跳頁。



分布式存儲(chǔ)引擎的搜索,有天然的缺陷存在,沒有完美的方案。當(dāng)存在技術(shù)解決不了的問題,那就從產(chǎn)品層面解決它。
