ElasticSearch 深度分页详解
来源 | OSCHINA 社区
作者 | 京东云开发者-何守优
原文链接:https://my.oschina.net/u/4090830/blog/5593128
1 前言
2 from + size 分页方式
GET /wms_order_sku/_search
{
"query": {
"match_all": {}
},
"from": 10,
"size": 20
}
2.1 Query 阶段
第一步:Client 发送查询请求到 Server 端,Node1 接收到请求然后创建一个大小为 from + size 的优先级队列用来存放结果,此时 Node1 被称为 coordinating node(协调节点);
第二步:Node1 将请求广播到涉及的 shard 上,每个 shard 内部执行搜索请求,然后将执行结果存到自己内部的大小同样为 from+size 的优先级队列里;
第三步:每个 shard 将暂存的自身优先级队列里的结果返给 Node1,Node1 拿到所有 shard 返回的结果后,对结果进行一次合并,产生一个全局的优先级队列,存在 Node1 的优先级队列中。(如上图中,Node1 会拿到 (from + size) * 6 条数据,这些数据只包含 doc 的唯一标识_id 和用于排序的_score,然后 Node1 会对这些数据合并排序,选择前 from + size 条数据存到优先级队列);
2.2 Fetch 阶段
第一步:Node1 根据刚才合并后保存在优先级队列中的 from+size 条数据的 id 集合,发送请求到对应的 shard 上查询 doc 数据详情;
第二步:各 shard 接收到查询请求后,查询到对应的数据详情并返回为 Node1;(Node1 中的优先级队列中保存了 from + size 条数据的_id,但是在 Fetch 阶段并不需要取回所有数据,只需要取回从 from 到 from + size 之间的 size 条数据详情即可,这 size 条数据可能在同一个 shard 也可能在不同的 shard,因此 Node1 使用 multi-get 来提高性能)
第三步:Node1 获取到对应的分页数据后,返回给 Client;
2.3 ES 示例
2.4 实现示例
private SearchHits getSearchHits(BoolQueryBuilder queryParam, int from, int size, String orderField) {
SearchRequestBuilder searchRequestBuilder = this.prepareSearch();
searchRequestBuilder.setQuery(queryParam).setFrom(from).setSize(size).setExplain(false);
if (StringUtils.isNotBlank(orderField)) {
searchRequestBuilder.addSort(orderField, SortOrder.DESC);
}
log.info("getSearchHits searchBuilder:{}", searchRequestBuilder.toString());
SearchResponse searchResponse = searchRequestBuilder.execute().actionGet();
log.info("getSearchHits searchResponse:{}", searchResponse.toString());
return searchResponse.getHits();
}
2.5 小结
3 Scroll 分页方式
3.1 执行过程
3.2 ES 示例
GET /wms_order_sku2021_10/_search?scroll=1m
{
"query": {
"bool": {
"must": [
{
"range": {
"shipmentOrderCreateTime": {
"gte": "2021-10-04 00:00:00",
"lt": "2021-10-15 00:00:00"
}
}
}
]
}
},
"size": 20
}
GET /_search/scroll
{
"scroll":"1m",
"scroll_id" : "DnF1ZXJ5VGhlbkZldGNoIAAAAAJFQdUKFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YxZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAiY--F4WZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJMQKhIFmw2c1hwVFk1UXppbDhZcW1za2ZzdlEAAAACRUHVCxZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAkxAqEcWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAImPvhdFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhBhZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAifjIQgWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAIn4yEHFk4yZjNZVUxsUjM2R2c3UXBVdUdoR3cAAAACJ5db8xZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAifjIQkWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAJFQdUMFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YhZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAieXW_YWcXluTUV6RzhUdHlTQTh5TnFwRm1nUQAAAAInl1v0FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACJ5db9RZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAkVB1Q0WWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhfFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhChZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAkVB1REWWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhgFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACTECoShZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZRAAAAAiY--GEWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUOFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACRUHVEBZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAiY--GQWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUPFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74ZRZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAkxAqEkWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAInl1v3FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACTECoRhZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZR"
}
3.3 实现示例
protected <T> Page<T> searchPageByConditionWithScrollId(BoolQueryBuilder queryParam, Class<T> targetClass, Page<T> page) throws IllegalAccessException, InstantiationException, InvocationTargetException {
SearchResponse scrollResp = null;
String scrollId = ContextParameterHolder.get("scrollId");
if (scrollId != null) {
scrollResp = getTransportClient().prepareSearchScroll(scrollId).setScroll(new TimeValue(60000)).execute()
.actionGet();
} else {
logger.info("基于scroll的分页查询,scrollId为空");
scrollResp = this.prepareSearch()
.setSearchType(SearchType.QUERY_AND_FETCH)
.setScroll(new TimeValue(60000))
.setQuery(queryParam)
.setSize(page.getPageSize()).execute().actionGet();
ContextParameterHolder.set("scrollId", scrollResp.getScrollId());
}
SearchHit[] hits = scrollResp.getHits().getHits();
List<T> list = new ArrayList<T>(hits.length);
for (SearchHit hit : hits) {
T instance = targetClass.newInstance();
this.convertToBean(instance, hit);
list.add(instance);
}
page.setTotalRow((int) scrollResp.getHits().getTotalHits());
page.setResult(list);
return page;
}
3.4 小结
4 Search After 分页方式
4.1 执行过程
后续分页查询以此类推…
4.2 ES 示例
GET /wms_order_sku2021_10/_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"
}
}
]
}
GET /wms_order_sku2021_10/_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]
}
4.3 实现示例
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)) {
log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分页查询,afterId为空");
SearchRequestBuilder searchRequestBuilder = builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH)
.setQuery(queryParam).setSize(pageSize);
scrollResp = searchRequestBuilder.execute()
.actionGet();
log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分页查询,afterId 为空,searchRequestBuilder:{}", searchRequestBuilder);
} else {
log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分页查询,afterId=" + afterId);
Object[] afterIds = JSON.parseObject(afterId, Object[].class);
SearchRequestBuilder searchRequestBuilder = builder.setSearchType(SearchType.DFS_QUERY_THEN_FETCH)
.setQuery(queryParam).searchAfter(afterIds).setSize(pageSize);
log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分页查询,searchRequestBuilder:{}", searchRequestBuilder);
scrollResp = searchRequestBuilder.execute()
.actionGet();
}
SearchHit[] hits = scrollResp.getHits().getHits();
log.info("queryScrollDtoByParamWithSearchAfter基于afterId的分页查询,totalRow={}, size={}, use time:{}", scrollResp.getHits().getTotalHits(), hits.length, System.currentTimeMillis() - now);
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());
}
log.info("es数据转换bean,totalRow={}, size={}, use time:{}", scrollResp.getHits().getTotalHits(), hits.length, System.currentTimeMillis() - now);
return ScrollDto.<T>builder().scrollId(afterId).result(list).totalRow((int) scrollResp.getHits().getTotalHits()).build();
}
4.4 小结
5 总结思考
5.1 ES 三种分页方式对比总结
如果数据量小(from+size 在 10000 条内),或者只关注结果集的 TopN 数据,可以使用 from/size 分页,简单粗暴
数据量大,深度翻页,后台批处理任务(数据迁移)之类的任务,使用 scroll 方式
数据量大,深度翻页,用户实时、高并发查询需求,使用 search after 方式
5.2 个人思考
在一般业务查询页面中,大多情况都是 10-20 条数据为一页,10000 条数据也就是 500-1000 页。正常情况下,对于用户来说,有极少需求翻到比较靠后的页码来查看数据,更多的是通过查询条件框定一部分数据查看其详情。因此在业务需求敲定初期,可以同业务人员商定 1w 条数据的限定,超过 1w 条的情况可以借助导出数据到 Excel 表,在 Excel 表中做具体的操作。
如果给导出中心返回大量数据的场景可以使用 Scroll 或 Search After 分页方式,相比之下最好使用 Search After 方式,既可以保证数据的实时性,也具有很高的搜索性能。
总之,在使用 ES 时一定要避免深度分页问题,要在跳页功能实现和 ES 性能、资源之间做一个取舍。必要时也可以调大 max_result_window 参数,原则上不建议这么做,因为 1w 条以内 ES 基本能保持很不错的性能,超过这个范围深度分页相当耗时、耗资源,因此谨慎选择此方式。
推荐阅读
你好,我是程序猿DD,10年开发老司机、阿里云MVP、腾讯云TVP、出过书创过业、国企4年互联网6年。从普通开发到架构师、再到合伙人。一路过来,给我最深的感受就是一定要不断学习并关注前沿。只要你能坚持下来,多思考、少抱怨、勤动手,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你看好一个事情,一定是坚持了才能看到希望,而不是看到希望才去坚持。相信我,只要坚持下来,你一定比现在更好!如果你还没什么方向,可以先关注我,这里会经常分享一些前沿资讯,帮你积累弯道超车的资本。