首先,这篇文章不会给你一个深度分页开箱即用的方案,因为它存在缺陷以及有一定的限制性。主要还是最近开发过程中遇到类似的需求,所以正好整理整个过程中的一点思考,毕竟Elasticsearch中的深度分页,更抽象的说分布式分页(distributed pagination)本来就不是一个容易的事情。
1. 深度分页的产品逻辑
本质上讲,深度分页是一个搜索的伪需求,搜索的本质是为了让用户能更快的找到结果,如果需要翻个几千页才能命中,那用户早就关网页了。像Google和Baidu搜索时也会对于这方面进行处理,笔者曾经看过一个关于Elasticsearch深度分页的帖子,其中就提到了Google对于超过1000页的搜索会进行限制。
2. 深度分页的程序逻辑
2.1 为什么Elasticsearch要限制深度分页
这里的深度分页实际上包含了一个分布式的概念,因为单机一般是没有这个问题。具体到Elasticsearch中,深度分页的核心问题在于: 内存和网络的压力。
以5个分片(shard)为例:
from=0, size=10, 需要从每个节点拉取10条记录,然后拉取到Coordinating节点, 进行内存排序最后输出10条。上述的拉取和内存排序分别对于了网络传输和内存。
from=10000, size=10, 这个时候每个节点需要拉取10010条记录,同样进行上面的过程。
随着from的增大,拉取的文档变多,对于网络和内存的压力都会不断增长,最终就是传输失败或者内存爆掉。因此Elasticsearch提供了index.max_result_window 参数,当from + size > index.max_result_window时,请求会被拒绝被抛出如下错误:
"Result window is too large, from + size must be less than or equal to: [10000]
but was [10001]. See the scroll api for a more efficient way to request large
data sets. This limit can be set by changing the [index.max_result_window] index
level setting."
按照官方提示,我们要么采取Scroll API,要么进一步增大max_result_window, 不过这两种方式都有各自的问题。前者只适用于非实时分页的场景,后者无异于饮鸩止渴,因为不可能无限制增大。
2.2 实时分页的解决思路
首先假定一个场景,就是用户的数据查询,某个项目用户有如下信息(注解是spring-data-elasticsearch提供的)。
@Getter
@Setter
@Document(indexName = "user", type="_doc", createIndex = false)
public class UserDocument extends ESDocument {
@Id
private String id;
// name表示在es中的字段名
@Field(type = FieldType.Keyword, name = "project_id")
private String projectId;
@Field(type = FieldType.Keyword, name = "uid")
private Integer uid;
@Field(type = FieldType.Text, name = "name")
private String name;
@Field(type = FieldType.Date, name = "last_login_time")
private Long lastLoginTime;
}
上面提到的Scroll API并不适用于实时分页,因为需要一页一页的滚到目标页,具体的实现可参照官方Java High Level Rest Client Scroll API。而且这种方式有个严重的缺陷就是不能重试,因为持续滚动返回的scrollId一样,如果因为网络或其它原因重试就会直接略过当前页面导致数据丢失。社区已经有人提出改进方案,不过目前并没有合并。
针对实时分页,目前的解决思路就是:
普通的from+size组合search_after,同时要注意排序条件以及排序值的输出
2.2.1 当from + size <= max_result_window时,使用普通分页查询。
由于search_after机制需要根据上一页查询的最后结果去获取next page数据,所以需要通过排序字段精确的标识下一页的数据,如果选定的排序字段有重复的,那么就有可能造成重复或者丢失。这个就类似于多字段排序,如果第一个字段相同,则按照第二个字段进一步比较。所以我们需要指定一个tie_breaker_id,翻译过来就是当出现等同值的时候如何更好的区分,一般建议将_id赋值某个字段,比如说es_id,来作为该属性。
所以无论用户有没有显示的传递排序条件,我们需要在程序中去添加这个es_id, 比如说按照最后登录时间排序的话,真正发送的请求就应该是拼接es_id的。
POST /user/_search
{
"size": 10,
"from": 0,
"sort": [
{
"last_login_time": "DESC"
},
{
"es_id": "DESC"
}
]
}
2.2.2 当from + size > max_result_window时, 使用search_after查询
如果指定排序条件的话,在返回的响应中,每个命中文档中会返回当次sort属性对应的值,在Java中对应类型是Object[]和排序指定的字段顺序一一对应。
每次渲染时,取出当前查询返回的最后一个文档,拿出sort values,设置到searchAfter属性中。下次调用时,修改页码即可进行下一次查询。但是search_after有个限制,就是不能随意翻页,即from始终为0,只能指定size大小,简单理解就是自排序值之后的多少个文档,相当于只能向前翻页。这个其实也很好理解,如果随意翻页不回到普通分页的问题了嘛。
所以这个方案最好是从产品逻辑上进行相应调整:
- 超过指定页码之后只显示下一页按钮避免跳页,类似于滚动加载下一页。当页面页码超过1000页之后(后面会具体解释),只显示下一页按钮并且不再展示具体的页码,只展示当前页码。
- 对于url上面的参数进行处理(编码或者加密),让用户无法猜出查询参数即不能直接输入页码
2.3 代码实现核心解析
关于es-deep-pagination的完整实现,可通过底部了解更多查看我的github项目spring-boot-learning中的相关内容 https://github.com/jacoffee/spring-boot-learning。
以下主要演示如何触发上述两种调用,按照上面UserDocument的格式插入6条数据,并且显示设定maxResultWindow=3,即from + size > 3时触发search_after查询。
2.3.1 程序分页和Elasticsearch分页转换
按照默index.max_result_window=10000, 一页为10条的话,最大的from就是9990(9990 + 10 <= 10000)。
但程序中传递的页码最大却是1000,下面通过代码简单解释下:
Spring-data-elasticsearch对于分页的转换,Elasticsearch restful查询中的from就是下面的startRecord。
// org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate
public class ElasticsearchRestTemplate extends AbstractElasticsearchTemplate {
private SearchRequest prepareSearch(
Query query, Optional<QueryBuilder> builder,
@Nullable Class<?> clazz
) {
if (query.getPageable().isPaged()) {
startRecord =
query.getPageable().getPageNumber() *
query.getPageable().getPageSize();
sourceBuilder.size(query.getPageable().getPageSize());
}
sourceBuilder.from(startRecord);
}
}
项目中对于Page的抽象
@Getter
@Setter
public class Page {
// 起始页,默认为1
private Integer pageNum = 1;
// 每页数据大小,默认为10
private Integer pageSize = 10;
// 当次查询总行数
private Long totalRows;
// 当次查询总页数
private Long totalPage;
// 排序
public List<Order> orders;
// search after值
private Object[] searchAfter = new Object[0];
}
注意上面提到的9990实际就是代码中的startRecord,所以相当于query.getPageable().getPageNumber() * query.getPageable().getPageSize() <= 9990。不过我们一般的分页是从1开始,而Elasticsearch的from是从0开始的。
因此也就是(pageNum – 1) * 10 <= 9990,也就是1000。
2.3.2 普通查询
POST localhost:8088/v1/user/list
{
"projectId": "jacoffee",
"page": {
"pageNum": 1,
"pageSize": 3,
"orders": [
{
"name": "last_login_time",
"direction": "DESC"
}
]
}
}
响应结果,注意每次响应中的searchAfter值和最后一个文档的sort值一样
{
"code": 0,
"data": {
"page": {
"pageNum": 1,
"pageSize": 3,
"totalRows": 6,
"totalPage": 2,
"searchAfter": [
1594046183187,
"jacoffee_3"
]
},
"data": [
{
"sort": [
1594046183787,
"jacoffee_2"
],
"id": "jacoffee_2",
"uid": 2,
"name": "allen2"
},
{
"sort": [
1594046183687,
"jacoffee_1"
],
"id": "jacoffee_1",
"uid": 1,
"name": "allen1"
},
{
"sort": [
1594046183187,
"jacoffee_3"
],
"id": "jacoffee_3",
"uid": 3,
"name": "allen3"
}
]
}
}
2.3.3 触发search_after
沿用上一次请求中的searchAfter参数并且增加页码,searchAfter笔者进行了相应的封装,底层肯定还是官方的search_after api。
{
"projectId": "jacoffee",
"page": {
"pageNum": 2,
"pageSize": 3,
"orders": [
{
"name": "last_login_time",
"direction": "DESC"
}
],
"searchAfter": [
1594046183187,
"jacoffee_3"
]
}
}
响应结果如下,从uid可以看出和正常分页效果一样。
{
"code": 0,
"data": {
"page": {
"pageNum": 2,
"pageSize": 3,
"totalRows": 6,
"totalPage": 3,
"searchAfter": [
1594046181187,
"jacoffee_5"
]
},
"data": [
{
"sort": [
1594046182187,
"jacoffee_4"
],
"uid": 4,
"name": "allen4"
},
{
"sort": [
1594046181287,
"jacoffee_6"
],
"uid": 6,
"name": "allen6"
},
{
"sort": [
1594046181187,
"jacoffee_5"
],
"uid": 5,
"name": "allen5"
}
]
}
}
3. 总结
关于Elasticsearch深度分页最好是从产品上规避。实在不行可以稍稍降低用户体验,超过分页限制之后只能点击下一页而不能跳跃式选择页码。最后如果有关于深度分页更好的实现也希望分享交流。
今天的文章关于Elasticsearch深度分页的一点思考分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/7839.html