參考文章:
使用scroll實現Elasticsearch數據遍歷和深度分頁
Elasticsearch 5.x 源碼分析(3)from size, scroll 和 search after
理解“query then fetch”和“dfs query then fetch”
目錄
1、ElasticSearch 搜索內部執行原理
ElasticSearch 中,搜索一般包括兩個階段,query 和 fetch 階段,可以簡單的理解,query 階段確定要取哪些 doc,fetch 階段取出具體的 doc。
(1)Query階段
如上圖所示,描述了一次搜索請求的 query 階段。
-
Client 發送一次搜索請求,node3 接收到請求,然後,node3 創建一個大小爲 from + size 的優先級隊列用來存結果,我們管 node3 叫 coordinating node。
-
coordinating node 將請求廣播到涉及到的 shards,每個 shard 在內部執行搜索請求,然後,將結果存到內部的大小同樣爲 from + size 的優先級隊列裏,可以把優先級隊列理解爲一個包含 topN 結果的列表。
-
每個 shard 把暫存在自身優先級隊列裏的數據返回給 coordinating node,coordinating node 拿到各個 shards 返回的結果後對結果進行一次合併,產生一個全局的優先級隊列,存到自身的優先級隊列裏。
在上面的例子中,coordinating node 拿到 ( from + size ) * 3 條數據,然後合併排序後選擇前面的 from + size 條數據存到優先級隊列,以便 fetch 階段使用。另外,各個分片返回給 coordinating node 的數據用於選出前 from + size 條數據,所以只需要返回唯一標記 doc 的 _id 以及用於排序的 _score 即可,這樣也可以保證返回的數據量足夠小。
coordinating node 計算好自己的優先級隊列後,query 階段結束,進入 fetch 階段。
(2)Fetch 階段
query 階段只是知道了要取哪些數據,但是並沒有取具體的數據,這就是 fetch 階段要做的。
上圖展示了 fetch 過程:
-
coordinating node 發送 GET 請求到相關 shards。
-
shard 根據 doc 的 _id 取到數據詳情,然後返回給 coordinating node。
-
coordinating node 返回數據給 Client。
coordinating node 的優先級隊列裏有 from + size 個 _doc _id,但是在 fetch 階段,並不需要取回所有數據,只需要取優先級隊列裏的第 from 到 from + size 條數據即可。
需要取的數據可能在不同分片,也可能在同一分片,coordinating node 使用 multi-get 來避免多次去同一分片取數據,從而提高性能。
(3)ES搜索類型
-
query and fetch:向索引的所有 shard 都發出查詢請求,各 shard 返回的時候把元素文檔( document )和計算後的排名信息一起返回。這種搜索方式是最快的。因爲相比下面的幾種搜索方式,這種查詢方法只需要去 shard 查詢一次。但是各個 shard 返回的結果的數量之和可能是用戶要求的 size 的 n 倍( n 爲 shard 數)。目前es5.3版本以後已經捨棄這個類型了。
-
query then fetch(默認的搜索方式):如果你搜索時,沒有指定搜索方式,就是使用的這種搜索方式。這種搜索方式,大概分兩個步驟,第一步,先向所有的 shard 發出請求,各 shard 只返回排序和排名相關的信息(注意,不包括文檔 document ),然後按照各 shard 返回的分數進行重新排序和排名,取前 size 個文檔。然後進行第二步,去相關的 shard 取 document。這種方式返回的 document 與用戶要求的 size 是相等的。
-
DFS query and fetch:這種方式比第一種方式多了一個初始化散發( initial scatter )步驟(預查詢每個 shard,詢問 Term 和Document frequency,並根據 Term 和 Document frequency 進行打分),有這一步,據說可以更精確控制搜索打分和排名。目前es5.3版本以後已經捨棄這個類型了。
-
DFS query then fetch:比第2種方式多了一個初始化散發( initial scatter )步驟。
2、from+size
這種方式需要查詢 from + size 的條數時,coordinate node 會向該索引的其餘的 shards 發送同樣的請求,等所有 shards 返回一共( from + size ) * shards 條數據後,在 coordinate node 做一次排序,最終抽取出真正的第 from 到 from + size 條結果。
舉個例子,一個索引,有10億數據,分10個 shards,然後,一個搜索請求,from=1,000,000,size=100,這時候,會帶來嚴重的性能問題:CPU、內存、IO、網絡帶寬等等。CPU、內存和IO消耗容易理解,網絡帶寬問題稍難理解一點。在 query 階段,每個 shards 需要返回 1,000,100 條數據給 coordinating node,而 coordinating node 需要接收 10 * 1,000,100 條數據,即使每條數據只有 _doc _id 和 _score,這數據量也很大了,而且,這才一個查詢請求,那如果再乘以100呢?
所以在深度分頁遍歷時,from + size這種方式並不合適,但是可以採用 Elasticsearch 提供的 scroll 方式來實現深度分頁的遍歷。
String ip = "ip";
int port = 9200;
String index_name = "index";
String user_name = "user";
String passwd = "passwd";
RestClientBuilder builder = RestClient.builder(new HttpHost(ip, port, "http"));
// 鑑權
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(user_name, passwd));
builder.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
RestHighLevelClient client = new RestHighLevelClient(builder);
SearchRequest searchRequest = new SearchRequest().indices(index_name);
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.rangeQuery("gid").from(0).to(1000))
.from(0)
.size(200);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
3、scroll
scroll 可以理解爲關係型數據庫裏的 cursor,因此,scroll 並不適合用來做實時搜索,而更適用於後臺批處理任務,比如羣發。
scroll 可以分爲初始化和遍歷兩步,初始化時將所有符合搜索條件的搜索結果緩存起來,可以想象成快照,在遍歷時,從這個快照裏取數據,也就是說,在初始化後對索引插入、刪除、更新數據都不會影響遍歷結果。
from + size 和 scroll 都需要執行多次 fetch 階段,但是相比於 from + size 方式,scroll 只做一次 query 階段,一次將所有滿足條件的結果都取出來,緩存到內存中,然後每次請求都是先在內存的快照中找到需要的 docid,然後去 shards 中獲取數據。也就是說,from + size 會 query 多次,分頁越深 query 的條數越多;而 scroll 一次會將全量數據取出,分頁的深度對其沒有影響。
scroll 使用方式如下,
(1)初始化
POST ip:port/index/type/_search?scroll=1m
{
"query": { "match_all": {}}
}
初始化時需要像普通 search 一樣,指明 index 和 type (當然,search 是可以不指明 index 和 type 的),然後,加上參數 scroll,表示暫存搜索結果的時間,其它就像一個普通的 search 請求一樣。初始化返回一個 _scroll_id,_scroll_id 用來下次取數據用。
(2)遍歷
POST /_search?scroll=1m
{
"scroll_id":"XXXXXXXXXXXXXXXXXXXXXXX I am scroll id XXXXXXXXXXXXXXX"
}
這裏的 scroll_id 即 上一次遍歷取回的 _scroll_id 或者是初始化返回的 _scroll_id,同樣的,需要帶 scroll 參數。 重複這一步驟,直到返回的數據爲空,即遍歷完成。注意,每次都要傳參數 scroll,刷新搜索結果的緩存時間。另外,不需要指定 index 和 type。設置 scroll 的時候,需要使搜索結果緩存到下一次遍歷完成,同時,也不能太長,畢竟內存空間有限。
String ip = "ip";
int port = 9200;
String index_name = "index";
String user_name = "user";
String passwd = "passwd";
RestClientBuilder builder = RestClient.builder(new HttpHost(ip, port, "http"));
// 鑑權
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(user_name, passwd));
builder.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
RestHighLevelClient client = new RestHighLevelClient(builder);
SearchRequest searchRequest = new SearchRequest()
.indices(index_name)
.scroll("2m");
// 初始化
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.rangeQuery("gid").from(0).to(1000))
.size(200);
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
// 遍歷
String scrollId = searchResponse.getScrollId();
while (true) {
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId).scroll("2m");
searchResponse = client.scroll(scrollRequest, RequestOptions.DEFAULT);
SearchHits hits2 = searchResponse.getHits();
if (hits2.getHits().length == 0)
break;
scrollId = searchResponse.getScrollId();
}
4、Scroll-Scan
Elasticsearch 提供了 Scroll-Scan 方式進一步提高遍歷性能。還是上面的例子,微信大V要給粉絲羣發這種後臺任務,是不需要關注順序的,只要能遍歷所有數據即可,這時候,就可以用Scroll-Scan。
Scroll-Scan 的遍歷與普通 Scroll 一樣,初始化存在一點差別。
POST ip:port/index/type/_search?search_type=scan&scroll=1m&size=50
{
"query": { "match_all": {}}
}
需要指明參數:
-
search_type。賦值爲 scan,表示採用 Scroll-Scan 的方式遍歷,同時告訴 Elasticsearch 搜索結果不需要排序。
-
scroll。同上,傳時間。
-
size。與普通的 size 不同,這個 size 表示的是每個 shard 返回的 size 數,最終結果最大爲 number_of_shards * size。
Scroll-Scan 方式與普通 Scroll 有幾點不同:
-
Scroll-Scan 結果沒有排序,按 index 順序返回,沒有排序,可以提高取數據性能。
-
初始化時只返回 _scroll_id,沒有具體的 hits 結果。
-
size 控制的是每個分片的返回的數據量而不是整個請求返回的數據量。
Scroll-Scan在ES 2.1版本中已經啓用,但是仍可以使用 _doc 來實現,在請求時加上下述參數即可。
"sort":["_doc"]
// 初始化階段加上sort by _doc即可
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.rangeQuery("gid").from(0).to(1000))
.size(200)
.sort("_doc");
5、Sliced scroll-scan
這種方式實際上就是並行的scroll-scan,通過傳遞slice參數,達到分塊獨立獲取數據的目的。
POST ip:port/index/type/_search?scroll=1m
{
"query": { "match_all": {}},
"slice": {
"id": 0,
"max": 5
}
}
POST ip:port/index/type/_search?scroll=1m
{
"query": { "match_all": {}},
"slice": {
"id": 1,
"max": 5
}
}
上邊的示例可以單獨請求兩塊數據,最終五塊數據合併的結果與直接scroll-scan相同。其中max是分塊數,id是第幾塊。官方文檔中建議max的值不要超過shard的數量,否則可能會導致內存爆炸。
final int max = 5;
final CountDownLatch countDownLatch = new CountDownLatch(max);
for (int j = 0; j < max; j++) {
final int i = j;
Thread sliceThread = new Thread(new Runnable() {
@Override
public void run() {
try {
// scroll-scan 代碼,唯一區別就是添加slice參數
/*SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.rangeQuery("gid").from(0).to(100))
.slice(new SliceBuilder("gid", i, max))
.size(100)
.sort("_doc");*/
countDownLatch.countDown();
} catch (IOException e) {
e.printStackTrace();
}
}
});
sliceThread.start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
6、search after
這是 Elasticsearch 5 新引入的一種分頁查詢機制,其實原理幾乎就是和 scroll 一樣,簡單三句話介紹 search after 怎麼用就是:
-
它必須先要指定排序(因爲一定要按排序記住座標)
-
必須從第一頁開始搜起(你可以隨便指定一個座標讓它返回結果,只是你不知道會在全量結果的何處)
-
從第一頁開始以後每次都帶上 search_after=lastEmittedDocFieldValue 從而爲無狀態實現一個狀態,說白了就是把每次固定的from + size 偏移變成一個確定值 lastEmittedDocFieldValue,而查詢則從這個偏移量開始獲取 size 個 doc(每個 shard 獲取size 個,coordinate node 最後彙總 shards * size 個。
最後一點非常重要,也就是說,無論去到多少頁,coordinate node 向其它 node 發送的請求始終就是請求 size 個 docs,是個常量,而不再是 from + size那樣,越往後,你要請求的 docs 就越多,而要丟棄的垃圾結果也就越多。也就是,如果我要做非常多頁的查詢時,最起碼 search after 是一個常量查詢延遲和開銷,並無什麼副作用。search after的原理就是這篇文章(業界難題-“跨庫分頁”的四種方案)裏的業務折中法-禁止跳頁查詢。
search after 不用取全量數據快照,每次記錄上一次的遊標,然後再執行一次 from + size 的 query 階段,但與 from + szie 不同的是,search after 會加上一個限制條件 filter_value>遊標指向的value(即上文請求中的 lastEmittedDocFieldValue)。這樣每次就只會請求 size * shards 條數據,保證其實一個常量查詢延遲和開銷。相比於 scroll 沒有第一次全量的排序,而且不會存在快照過期導致的數據實時性問題。