ElasticSearch 是一个实时的分布式搜索与分析引擎,常用于大量非结构化数据的存储和快速检索场景,具有很强的扩展性。纵使其有诸多优点,在搜索领域远超关系型数据库,但依然存在与关系型数据库同样的深度分页问题,本文将介绍ES的多种分页方式以及深分页的处理。
From + Size 分页方式
from + size 分页方式是 ES 最基本的分页方式,类似于关系型数据库中的 limit 方式。from 参数表示:分页起始位置;size 参数表示:每页获取数据条数。例如:
GET /index/_search
{
"from": 10,
"size": 20
}
代码示例
private SearchHits getSearchHits(BoolQueryBuilder builder, int from, int size) {
SearchRequestBuilder searchRequestBuilder = this.prepareSearch();
searchRequestBuilder.setQuery(builder).setFrom(from).setSize(size).setExplain(false);
SearchResponse searchResponse = searchRequestBuilder.execute().actionGet();
return searchResponse.getHits();
}
该条 DSL 语句表示从搜索结果中第 10 条数据位置开始,取之后的 20 条数据作为结果返回。
注: ES 对结果窗口的返回数据有默认 10000 条的限制(参数:index.max_result_window = 10000),当> from + size 的条数大于 10000 条时 ES 提示可以通过 scroll 方式进行分页,非常不建议调大结果窗口参数值。
Scroll 滚动分页
scroll 分页方式类似关系型数据库中的 cursor,首次查询时会生成并缓存快照,返回给客户端快照读取的位置参数(scroll_id),后续每次请求都会通过 scroll_id 访问快照实现快速查询需要的数据,有效降低查询和存储的性能损耗。
SearchRequest searchRequest = new SearchRequest();
searchRequest.indices(index);
searchRequest.types(EsDicConstant.FULL_TEXT);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.fetchSource(new String[], null);
sourceBuilder.size(10);
searchRequest.scroll(TimeValue.timeValueSeconds(60));
searchRequest.source(sourceBuilder);
SearchResponse search = restClient.search(searchRequest, RequestOptions.DEFAULT);
while (search.getHits().getHits().length > 0) //滚动查询
{
SearchHits hits = search.getHits();
BulkRequest bulkRequest = new BulkRequest();
bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE);
for (SearchHit searchHit : hits)
{
String id = searchHit.getId();
JSONObject jsonObject = JSONObject.parseObject(searchHit.getSourceAsString());
}
if (search.getHits().getHits().length < 10)
{
break;
}
String scrollId = search.getScrollId();
if (StringUtils.isEmpty(scrollId))
{
break;
}
SearchScrollRequest searchScrollRequest = new SearchScrollRequest(scrollId);
searchScrollRequest.scroll(TimeValue.timeValueSeconds(60));
search = restClient.scroll(searchScrollRequest, RequestOptions.DEFAULT);
}
第一次查询时不需要传入_scroll_id,只要带上 scroll 的过期时间参数(scroll=1m)、每页大小(size)以及需要查询数据的自定义条件即可,查询后不仅会返回结果数据,还会返回_scroll_id。
GET /_search/scroll
{ "scroll":"1m",
"scroll_id" : "DnF1ZXJ5VGhlbkZldGNoIAAA"
}
第二次查询时不需要指定索引,在 JSON 请求体中带上前一个查询返回的 scroll_id,同时传入 scroll 参数,指定刷新搜索结果的缓存时间(上一次查询缓存 1 分钟,本次查询会再次重置缓存时间为 1 分钟)
总结
scroll分页方式的优点就是减少了查询和排序的次数,避免性能损耗。缺点就是只能实现上一页、下一页的翻页功能,不兼容通过页码查询数据的跳页,同时由于其在搜索初始化阶段会生成快照,后续数据的变化无法及时体现在查询结果,因此更加适合一次性批量查询或非实时数据的分页查询。启用游标查询时,需要注意设定期望的过期时间(scroll= 1m),以降低维持游标查询窗口所需消耗的资源。注意这个过期时间每次查询都会重置刷新为 1 分钟,表示游标的闲置失效时间(第二次以后的查询必须带 scroll = 1m 参数才能实现)
Search After 分页
Search After 分页方式是 ES 5 新增的一种分页查询方式,其实现的思路同 Scroll 分页方式基本一致,通过记录上一次分页的位置标识,来进行下一次分页数据的查询。相比于 Scroll 分页方式,它的优点是可以实时体现数据的变化,解决了查询快照导致的查询结果延迟问题。
GET /index/_search
{
"query": {
"bool": {
"must": [
{
"range": {
"shipmentOrderCreateTime": {
"gte": "2021-10-12 00:00:00",
"lt": "2021-10-15 00:00:00"
}
}
}
]
}
},
"size": 20,
"sort": [
{
"_id": {
"order": "desc"
}
},{
"shipmentOrderCreateTime":{
"order": "desc"
}
}
]
}
接下来每次查询时都带上本次查询的最后一条数据的 _id 和 shipmentOrderCreateTime 字段,循环往复就能够实现不断下一页的功能
实现示例
GET /INDEX/_search { "query": {
"bool": {
"must": [
{
"range": {
"shipmentOrderCreateTime": {
"gte": "2021-10-12 00:00:00",
"lt": "2021-10-15 00:00:00"
}
}
}
]
} }, "size": 20, "sort": [
{
"_id": {
"order": "desc"
}
},{
"shipmentOrderCreateTime":{
"order": "desc"
}
} ], "search_after": ["SO-460_152-1447931043809128448-100017918838",1634077436000] }
public <T> ScrollDto<T> queryScrollDtoByParamWithSearchAfter(
BoolQueryBuilder queryParam, Class<T> targetClass, int pageSize, String afterId,
List<FieldSortBuilder> fieldSortBuilders) {
SearchResponse scrollResp;
long now = System.currentTimeMillis();
SearchRequestBuilder builder = this.prepareSearch();
if (CollectionUtils.isNotEmpty(fieldSortBuilders)) {
fieldSortBuilders.forEach(builder::addSort);
}
builder.addSort("_id", SortOrder.DESC);
if (StringUtils.isBlank(afterId)) {
SearchRequestBuilder searchRequestBuilder = builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH)
.setQuery(queryParam).setSize(pageSize);
scrollResp = searchRequestBuilder.execute()
.actionGet();
} else {
Object[] afterIds = JSON.parseObject(afterId, Object[].class);
SearchRequestBuilder searchRequestBuilder = builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH)
.setQuery(queryParam).searchAfter(afterIds).setSize(pageSize);
scrollResp = searchRequestBuilder.execute()
.actionGet();
}
SearchHit[] hits = scrollResp.getHits().getHits();
now = System.currentTimeMillis();
List<T> list = new ArrayList<>();
if (ArrayUtils.getLength(hits) > 0) {
list = Arrays.stream(hits)
.filter(Objects::nonNull)
.map(SearchHit::getSourceAsMap)
.filter(Objects::nonNull)
.map(JSON::toJSONString)
.map(e -> JSON.parseObject(e, targetClass))
.collect(Collectors.toList());
afterId = JSON.toJSONString(hits[hits.length - 1].getSortValues());
}
scrollResp.getHits().getTotalHits(), hits.length, System.currentTimeMillis() - now);
return ScrollDto.<T>builder().scrollId(afterId).result(list).totalRow((int) scrollResp.getHits().getTotalHits()).build();
}
总结
Search After 分页方式采用记录作为游标,因此 Search After 要求 doc 中至少有一条全局唯一变量(示例中使用_id和时间戳,实际上_id 已经是全局唯一)。Search After 方式是无状态的分页查询,因此数据的变更能够及时的反映在查询结果中,避免了Scroll 分页方式无法获取最新数据变更的缺点。同时 Search After 不用维护 scroll_id 和快照,因此也节约大量资源。
三种对比
- 如果数据量小(from+size 在 10000 条内),或者只关注结果集的 TopN 数据,可以使用 from/size 分页,简单粗暴
- 数据量大,深度翻页,后台批处理任务(数据迁移)之类的任务,使用 scroll 方式
- 数据量大,深度翻页,用户实时、高并发查询需求,使用 search after 方式