关于Elasticsearch深度分页的一点思考

关于Elasticsearch深度分页的一点思考首先,这篇文章不会给你一个深度分页开箱即用的方案,因为它存在缺陷以及有一定的限制性。

首先,这篇文章不会给你一个深度分页开箱即用的方案,因为它存在缺陷以及有一定的限制性。主要还是最近开发过程中遇到类似的需求,所以正好整理整个过程中的一点思考,毕竟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

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注