1 前语
ElasticSearch 是一个实时的分布式查找与剖析引擎,常用于很多非结构化数据的存储和快速检索场景,具有很强的扩展性。纵使其有许多长处,在查找领域远超联系型数据库,但依然存在与联系型数据库相同的深度分页问题,本文就此问题做一个实践性剖析探讨2 from + size 分页方法
from + size 分页方法是 ES 最根本的分页方法,相似于联系型数据库中的 limit 方法。from 参数表明:分页开端方位;size 参数表明:每页获取数据条数。例如:GET/wms_order_sku/_search
{
"query":{
"match_all":{}
},
"from":10,
"size":20
}
该条 DSL 句子表明从查找成果中第 10 条数据方位开端,取之后的 20 条数据作为成果回来。这种分页方法在 ES 集群内部是怎么履行的呢?在 ES 中,查找一般包括 2 个阶段,Query 阶段和 Fetch 阶段,Query 阶段首要确定要获取哪些 doc,也便是回来所要获取 doc 的 id 调集,Fetch 阶段首要通过 id 获取具体的 doc。2.1 Query 阶段
如上图所示,Query 阶段大致分为 3 步:第一步: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 阶段
如上图所示,当 Query 阶段结束后立马进入 Fetch 阶段,Fetch 阶段也分为 3 步:第一步: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 示例
依据上述我们对 from + size 分页方法两阶段的剖析会发现,假如开端方位 from 或者页条数 size 特别大时,关于数据查询和 coordinating node 成果兼并都是巨大的功用损耗。例如:索引 wms_order_sku 有 1 亿数据,分 10 个 shard 存储,当一个恳求的 from = 1000000, size = 10。在 Query 阶段,每个 shard 就需求回来 1000010 条数据的_id 和_score 信息,而 coordinating node 就需求接纳 10 * 1000010 条数据,拿到这些数据后需求进行大局排序取到前 1000010 条数据的_id 调集保存到 coordinating node 的优先级行列中,后续在 Fetch 阶段再去获取那 10 条数据的概况回来给客户端。剖析:这个例子的履行进程中,在 Query 阶段会在每个 shard 上均有巨大的查询量,回来给 coordinating node 时需求履行很多数据的排序操作,并且保存到优先级行列的数据量也很大,占用很多节点机器内存资源。2.4 完成示例
privateSearchHitsgetSearchHits(BoolQueryBuilderqueryParam,intfrom,intsize,StringorderField){
SearchRequestBuildersearchRequestBuilder=this.prepareSearch();
searchRequestBuilder.setQuery(queryParam).setFrom(from).setSize(size).setExplain(false);
if(StringUtils.isNotBlank(orderField)){
searchRequestBuilder.addSort(orderField,SortOrder.DESC);
}
log.info("getSearchHitssearchBuilder:{}",searchRequestBuilder.toString());
SearchResponsesearchResponse=searchRequestBuilder.execute().actionGet();
log.info("getSearchHitssearchResponse:{}",searchResponse.toString());
returnsearchResponse.getHits();
}
2.5 小结
其实 ES 对成果窗口的回来数据有默认 10000 条的限制(参数:index.max_result_window = 10000),当 from + size 的条数大于 10000 条时 ES 提示能够通过 scroll 方法进行分页,十分不主张调大成果窗口参数值。3 Scroll 分页方法
scroll 分页方法相似联系型数据库中的 cursor(游标),首次查询时会生成并缓存快照,回来给客户端快照读取的方位参数(scroll_id),后续每次恳求都会通过 scroll_id 拜访快照完成快速查询需求的数据,有效下降查询和存储的功用损耗。3.1 履行进程
scroll 分页方法在 Query 阶段相同也是 coordinating node 播送查询恳求,获取、兼并、排序其他 shard 回来的数据_id 调集,不同的是 scroll 分页方法会将回来数据_id 的调集生成快照保存到 coordinating node 上。Fetch 阶段以游标的方法从生成的快照中获取 size 条数据的_id,并去其他 shard 获取数据概况回来给客户端,一起将下一次游标开端的方位标识_scroll_id 也回来。这样下次客户端发送获取下一页恳求时带上 scroll_id 标识,coordinating node 会从 scroll_id 符号的方位获取接下来 size 条数据,一起再次回来新的游标方位标识 scroll_id,这样依次类推直到取完一切数据。3.2 ES 示例
第一次查询时不需求传入_scroll_id,只需带上 scroll 的过期时刻参数(scroll=1m)、每页巨细(size)以及需求查询数据的自定义条件即可,查询后不仅会回来成果数据,还会回来_scroll_id。privateSearchHitsgetSearchHits(BoolQueryBuilderqueryParam,intfrom,intsize,StringorderField){
SearchRequestBuildersearchRequestBuilder=this.prepareSearch();
searchRequestBuilder.setQuery(queryParam).setFrom(from).setSize(size).setExplain(false);
if(StringUtils.isNotBlank(orderField)){
searchRequestBuilder.addSort(orderField,SortOrder.DESC);
}
log.info("getSearchHitssearchBuilder:{}",searchRequestBuilder.toString());
SearchResponsesearchResponse=searchRequestBuilder.execute().actionGet();
log.info("getSearchHitssearchResponse:{}",searchResponse.toString());
returnsearchResponse.getHits();
}
第2次查询时不需求指定索引,在 JSON 恳求体中带上前一个查询回来的 scroll_id,一起传入 scroll 参数,指定改写查找成果的缓存时刻(上一次查询缓存 1 分钟,本次查询会再次重置缓存时刻为 1 分钟)GET/_search/scroll
{
"scroll":"1m",
"scroll_id":"DnF1ZXJ5VGhlbkZldGNoIAAAAAJFQdUKFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YxZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAiY--F4WZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJMQKhIFmw2c1hwVFk1UXppbDhZcW1za2ZzdlEAAAACRUHVCxZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAkxAqEcWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAImPvhdFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhBhZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAifjIQgWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAIn4yEHFk4yZjNZVUxsUjM2R2c3UXBVdUdoR3cAAAACJ5db8xZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAifjIQkWTjJmM1lVTGxSMzZHZzdRcFV1R2hHdwAAAAJFQdUMFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74YhZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAieXW_YWcXluTUV6RzhUdHlTQTh5TnFwRm1nUQAAAAInl1v0FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACJ5db9RZxeW5NRXpHOFR0eVNBOHlOcXBGbWdRAAAAAkVB1Q0WWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhfFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACJ-MhChZOMmYzWVVMbFIzNkdnN1FwVXVHaEd3AAAAAkVB1REWWUZzYThja0RSNHlWSkpuQ210MUw0UQAAAAImPvhgFmZJaE0za1VsVGJpT1VxWkNRakpIaWcAAAACTECoShZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZRAAAAAiY--GEWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUOFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACRUHVEBZZRnNhOGNrRFI0eVZKSm5DbXQxTDRRAAAAAiY--GQWZkloTTNrVWxUYmlPVXFaQ1FqSkhpZwAAAAJFQdUPFllGc2E4Y2tEUjR5VkpKbkNtdDFMNFEAAAACJj74ZRZmSWhNM2tVbFRiaU9VcVpDUWpKSGlnAAAAAkxAqEkWbDZzWHBUWTVRemlsOFlxbXNrZnN2UQAAAAInl1v3FnF5bk1Fekc4VHR5U0E4eU5xcEZtZ1EAAAACTECoRhZsNnNYcFRZNVF6aWw4WXFtc2tmc3ZR"
}
3.3 完成示例
protected<T>Page<T>searchPageByConditionWithScrollId(BoolQueryBuilderqueryParam,Class<T>targetClass,Page<T>page)throwsIllegalAccessException,InstantiationException,InvocationTargetException{
SearchResponsescrollResp=null;
StringscrollId=ContextParameterHolder.get("scrollId");
if(scrollId!=null){
scrollResp=getTransportClient().prepareSearchScroll(scrollId).setScroll(newTimeValue(60000)).execute()
.actionGet();
}else{
logger.info("根据scroll的分页查询,scrollId为空");
scrollResp=this.prepareSearch()
.setSearchType(SearchType.QUERY_AND_FETCH)
.setScroll(newTimeValue(60000))
.setQuery(queryParam)
.setSize(page.getPageSize()).execute().actionGet();
ContextParameterHolder.set("scrollId",scrollResp.getScrollId());
}
SearchHit[]hits=scrollResp.getHits().getHits();
List<T>list=newArrayList<T>(hits.length);
for(SearchHithit:hits){
Tinstance=targetClass.newInstance();
this.convertToBean(instance,hit);
list.add(instance);
}
page.setTotalRow((int)scrollResp.getHits().getTotalHits());
page.setResult(list);
returnpage;
}
3.4 小结
scroll 分页方法的长处便是减少了查询和排序的次数,防止功用损耗。缺陷便是只能完成上一页、下一页的翻页功用,不兼容通过页码查询数据的跳页,一起因为其在查找初始化阶段会生成快照,后续数据的改变无法及时体现在查询成果,因而更加合适一次性批量查询或非实时数据的分页查询。启用游标查询时,需求留意设定期望的过期时刻(scroll = 1m),以下降维持游标查询窗口所需消耗的资源。留意这个过期时刻每次查询都会重置改写为 1 分钟,表明游标的搁置失效时刻(第2次以后的查询必须带 scroll = 1m 参数才干完成)4 Search After 分页方法
Search After 分页方法是 ES 5 新增的一种分页查询方法,其完成的思路同 Scroll 分页方法根本一致,通过记录上一次分页的方位标识,来进行下一次分页数据的查询。比较于 Scroll 分页方法,它的长处是能够实时体现数据的改变,解决了查询快照导致的查询成果推迟问题。4.1 履行进程
Search After 方法也不支持跳页功用,每次查询一页数据。第一次每个 shard 回来一页数据(size 条),coordinating node 一共获取到 shard 数 * size 条数据 , 接下来 coordinating node 在内存中进行排序,取出前 size 条数据作为第一页查找成果回来。当拉取第二页时,不同于 Scroll 分页方法,Search After 方法会找到第一页数据被拉取的最大值,作为第二页数据拉取的查询条件。这样每个 shard 还是回来一页数据(size 条),coordinating node 获取到 shard 数 * size 条数据进行内存排序,获得前 size 条数据作为大局的第二页查找成果。后续分页查询以此类推…
4.2 ES 示例
第一次查询只传入排序字段和每页巨细 sizeGET/wms_order_sku2021_10/_search
{
"query":{
"bool":{
"must":[
{
"range":{
"shipmentOrderCreateTime":{
"gte":"2021-10-1200:00:00",
"lt":"2021-10-1500:00:00"
}
}
}
]
}
},
"size":20,
"sort":[
{
"_id":{
"order":"desc"
}
},{
"shipmentOrderCreateTime":{
"order":"desc"
}
}
]
}
接下来每次查询时都带上本次查询的最终一条数据的 _id 和 shipmentOrderCreateTime 字段,循环往复就能够完成不断下一页的功用GET/wms_order_sku2021_10/_search
{
"query":{
"bool":{
"must":[
{
"range":{
"shipmentOrderCreateTime":{
"gte":"2021-10-1200:00:00",
"lt":"2021-10-1500: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(
BoolQueryBuilderqueryParam,Class<T>targetClass,intpageSize,StringafterId,
List<FieldSortBuilder>fieldSortBuilders){
SearchResponsescrollResp;
longnow=System.currentTimeMillis();
SearchRequestBuilderbuilder=this.prepareSearch();
if(CollectionUtils.isNotEmpty(fieldSortBuilders)){
fieldSortBuilders.forEach(builder::addSort);
}
builder.addSort("_id",SortOrder.DESC);
if(StringUtils.isBlank(afterId)){
log.info("queryScrollDtoByParamWithSearchAfter根据afterId的分页查询,afterId为空");
SearchRequestBuildersearchRequestBuilder=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);
SearchRequestBuildersearchRequestBuilder=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={},usetime:{}",scrollResp.getHits().getTotalHits(),hits.length,System.currentTimeMillis()-now);
now=System.currentTimeMillis();
List<T>list=newArrayList<>();
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={},usetime:{}",scrollResp.getHits().getTotalHits(),hits.length,System.currentTimeMillis()-now);
returnScrollDto.<T>builder().scrollId(afterId).result(list).totalRow((int)scrollResp.getHits().getTotalHits()).build();
}
4.4 小结
Search After 分页方法选用记录作为游标,因而 Search After 要求 doc 中至少有一条大局唯一变量(示例中运用_id 和时刻戳,实际上_id 已经是大局唯一)。Search After 方法是无状态的分页查询,因而数据的变更能够及时的反映在查询成果中,防止了 Scroll 分页方法无法获取最新数据变更的缺陷。一起 Search After 不用保护 scroll_id 和快照,因而也节约很多资源。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 根本能保持很不错的功用,超越这个范围深度分页适当耗时、耗资源,因而谨慎挑选此方法。
– EOF –
链接:https://my.oschina.net/u/4090830/blog/5593128
(版权归原一切,侵删)