ElasticSearch 的 from+size、scroll、scroll-scan、sliced scroll-sacn、search after

參考文章:

使用scroll實現Elasticsearch數據遍歷和深度分頁

Elasticsearch 5.x 源碼分析(3)from size, scroll 和 search after

ElasticSearch官方文檔

Elasticsearch搜索類型講解

理解“query then fetch”和“dfs query then fetch”


目錄

1、ElasticSearch 搜索內部執行原理

(1)Query階段

(2)Fetch 階段

(3)ES搜索類型

2、from+size

3、scroll

(1)初始化

(2)遍歷

4、Scroll-Scan

5、Sliced scroll-scan

6、search after


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 沒有第一次全量的排序,而且不會存在快照過期導致的數據實時性問題。

 

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