在分布式数据集(如elasticsearch)上进行快速深分页查询

  深分页查询是经常困扰程序员的需求。此文给大家带来一种查询算法,可以实现快速任意指定页深分页查询。

  一般分页查询会把分页信息转成跳过(skip)一些数据再获取(take)一些数据的操作,这些skip和take的数据都需要去遍历处理,进行当分页过深时,skip的值会非常大,遍历skip数量的数据需要消耗非常多的系统资源和查询时间。分布式处理是常见的操作加速方式,但在深分页场景下,直接进行深分页查询反而会加倍消耗系统资源和查询时间。以我们常见的分布式数据查询系统elasticsearch为例,下面是段有深分页问题的查询代码示例。

public class ElasticSearchDao {

    // 访问elasticSearch的官方客户端工具类单例。使用时可参考官方文件补齐esSettings, esAddressAry等参数。
    private static final TransportClient transportClient = new PreBuiltTransportClient(esSettings).addTransportAddresses(esAddressAry);

    /**
     * 查询某页数据
     * @param index 要查询的elasticsearch的index名称。
     * @param type 要查询的elasticsearch的type名称。
     * @param columns 要获取的elasticsearch的列名称。
     * @param queryString 查询的elasticsearch的搜索条件。
     * @param sortField 查询数据的排序字段。
     * @param sortType 查询数据的排序类型,asc为升序,否则为降序。
     * @param pageNum 查询数据的页号。不能为空,从1开始。
     * @param pageSize 每页包含多少数据。不能为空,应该大于0。
     * @return 搜索出的分页数据。
     */
    public Page<Dto> searchPage(String index, String type, String queryString, String sortField, String sortType, Integer pageNum, Integer pageSize) {

        // 准备查询参数
        SortOrder sortOrder = "ASC".equalsIgnoreCase(sortType) ? SortOrder.ASC : SortOrder.DESC;

        // 当页数过深时,from值会特别大,造成严重的性能问题。
        int from = (pageNum - 1) * pageSize;
        int size = pageSize;

        // 查询数据
        QueryBuilder searchQuery = buildQuery(queryString);
        SearchRequestBuilder searchQueryBuilder = buildRequest(index, type);
        searchQueryBuilder.setFrom(from).setSize(size);
        searchQueryBuilder.setQuery(searchQuery);
        searchQueryBuilder.addSort(sortField, sortOrder);
        SearchResponse response = searchQueryBuilder.get();

        // 构建返回结果
        Long totalDaoCount = response.getHits().getTotalHits();
        SearchHit[] currentPageData = response.getHits().getHits();
        // Dto类是对外的数据对象,每个实例封装了一条elasticsearch数据。Page类包含当前页Dta的列表,totalDaoCount参数表示符合查询条件的所有数据量。可自行实现它们。
        Page<Dto> result = new Page<Dto>(totalDaoCount, currentPageData, pageNum, pageSize);
        return result;
    }

    // 这里使用queryString查询作为示例,比如查询height字段大于180的数据可以用:height:>180
    // 除了queryString查询外,也可以使用bool查询等其它类型。此深分页算法非常通用,不限制具体的查询类型和条件。
    private QueryBuilder buildQuery(String queryString) {
        int splitIdx = queryString.indexOf(":");
        String field = queryString.substring(0, splitIdx);
        String content = queryString.substring(splitIdx + 1);
        QueryStringQueryBuilder queryBuilder = QueryBuilders.queryStringQuery(content);
        queryBuilder.field(field);
        queryBuilder.fuzziness(Fuzziness.ZERO);
        return queryBuilder;
    }

    // 构建查询请求
    private static SearchRequestBuilder buildRequest(String index, String type) {
        SearchRequestBuilder searchQueryBuilder = transportClient.prepareSearch(index).setTypes(type);
        searchQueryBuilder.setRequestCache(true);
        return searchQueryBuilder;
    }
}

  分布式数据库会设置很多数据节点分担查询负担,但运行上段代码深分页时,数据节点的数量越多,反而越加倍的浪费系统资源。因为当在n个数据节点上skip再take数据时,需要每个数据节点都需要排序遍历skip+take条数据并送往聚合节点,然后聚合节点再对所有的n*(skip+take)条数据进行排序再取出合适的take条数据。更麻烦的是这种资源的消耗集中在聚合节点上,无法通过增加分布式节点扩展查询性能。

  为了解决深分页问题,开发人员会使用卷页功能代替分页,比如elasticsearch的scroll和searchAfter功能,甚至干脆和产品经理沟通,不在产品中设置深分页查询场景。但这些方法并不理想,卷页功能不能任意跳转到指定分页,有时跳转深分页查询是用户核心需求,产品经理无法放弃它。

  深分页查询并不是彻底无解。分布式数据库的一个极大优势是可以进行MapReduce操作,比如按指定条件查询数据数量或最大最小编号,按条件过滤数据等操作,这些都可以在分布式数据集上使用MapReduce模式加速执行。我们可以用这些分布式数据库擅长的操作来分解数据库不擅长的直接深分页操作,最终使分布式数据库可以快速的执行任意深分页查询。

  假设需求是每页数据100条,取第100000页数据,分布式数据库有10个数据节点。直接进行深分页查询的话,聚合节点需要从各个数据节点接收100000000条数据,数据传输和处理成本极高,如果能预先在数据节点过滤掉不需要的数据,就可以极大降低查询的执行成本。比如各个数据节点都先过滤掉前99999000条范围在各自节点的数据,那么各个节点只需要各自再发送给聚合节点1000条数据,然后聚合节点再对这10*1000条数据排序跳过前900条取接下来的100条即可。即:(skip99999900,take100)=(filter99999000,skip900,take100),核心思路是把等号左边的skip大量数据等价转换为等号右边的先filter大量数据再skip小量数据。直接skip大量数据对分布式集群是非常消耗资源的操作,但filter大量数据则是分布式集群容易完成的操作。

  要过滤掉指定数据前的范围数据,数据集需要是良序的,而且需要指定这个排序查询数据。要求数据良序通常不是问题,一般数据都会带标识列或带不重复的时间戳,我们可以直接使用它们排序。如果查询是按某个自定义的条件排序,没有直接的标识列时,我们可以预排序处理数据,预先给数据添自定义的排序号列。确定排序后,当我们指定一个数据编号,分布式数据库可以快速的查询出这个数据编号前有多少条符合条件的数据,如果符合条件的数据太多或太少,我们就向前或向后调整尝试的数据编号,直到找到一条合适的数据,过滤掉这条数据前的范围数据可以使发送给聚合节点的数据降低到可接受范围内。这种尝试可以使用黄金分割法或对半分割法完成,尝试性的分割法会增加查询数据库的次数,但这些尝试性的查询都是使用分布式数据库擅长的MapReduce操作,查询成本极低,而且可以通过扩展数据节点的数量来扩展查询性能。

  下面代码改写了上份代码里的searchPage方法,实现分割尝试法要多写好多代码,不过改写后的逻辑不会再因pageNum的过大而极度消耗系统资源了:

    /**
     * 查询某页数据
     * @param index 要查询的elasticsearch的index名称。
     * @param type 要查询的elasticsearch的type名称。
     * @param columns 要获取的elasticsearch的列名称。
     * @param queryString 查询的elasticsearch的搜索条件。
     * @param sortField 查询数据的排序字段。这需要是个不为空,且几乎没重复值的数值类型的字段的名字。
     * @param sortType 查询数据的排序类型,asc为升级序,否则为降序。
     * @param pageNum 查询数据的页号。不能为空,从1开始。
     * @param pageSize 每页包含多少数据。不能为空,应该大于0。
     * @return 搜索出的分页数据。
     */
    public Page<Dto> searchPage(String index, String type, String queryString, String sortField, String sortType, Integer pageNum, Integer pageSize) {

        // 准备查询参数
        SortOrder sortOrder = "ASC".equalsIgnoreCase(sortType) ? SortOrder.ASC : SortOrder.DESC;
        int from = (pageNum - 1) * pageSize;
        int size = pageSize;

        // 查询数据
        QueryBuilder searchQuery = buildQuery(queryString);

        //获取总数据量,如果没有符合条件的数据直接返回空结果。
        SearchRequestBuilder getTotalCountQuery = buildRequest(index, type);
        getTotalCountQuery.setQuery(searchQuery).setSize(0);
        SearchResponse countResponse = getTotalCountQuery.get();
        Long totalDaoCount = countResponse.getHits().getTotalHits();
        if (totalDaoCount == 0 || from >= totalDaoCount) {
            new Page<Dto>(0, null, pageNum, pageSize);
        }
        SearchHit[] currentPageData = searchRange(index, type, searchQuery, sortField, sortOrder, from, size);

        // 构建返回结果
        // Dto类是对外的数据对象,每个实例封装了一条elasticsearch数据。Page类包含当前页Dta的列表,totalDaoCount参数表示符合查询条件的所有数据量。可自行实现它们。
        Page<Dto> result = new Page<Dto>(totalDaoCount, currentPageData, pageNum, pageSize);
        return result;
    }

    // 允许直接在elasticsearch使用的最大from值
    private static final Integer MAX_QUERY_FROM = 3000;

    // 当from值过大会通过分割法间接进行深分页查询的工具类
    private SearchHit[] searchRange(String index, String type, QueryBuilder searchQuery, String sortField, SortOrder sortOrder, Integer from, Integer size) {
        // 开始查询数据
        SearchHit[] searchHits;
        if (from > MAX_QUERY_FROM) {  //  如果from过大则搜索跳跃点降低from值。
            // 获取符合条件数据的最大排序编号。
            SearchRequestBuilder getMaxFieldValueQuery = buildRequest(index ,type);
            getMaxFieldValueQuery.setQuery(searchQuery).setSize(1).addSort(sortField, SortOrder.DESC);
            Long maxSortFieldValue = Long.valueOf(getMaxFieldValueQuery.get().getHits().getAt(0).getSource().get(sortField).toString());
            // 在起始排序编号和最大排序编号之间查找能过滤掉合适量数据的序号。
            SearchRequestBuilder searchQueryBuilder = buildRequest(index, type);
            searchQueryBuilder.setSize(0).addSort(sortField, sortOrder);
            Long[] skipInfo;
            RangeQueryBuilder rangeQuery;
            if(sortOrder == SortOrder.ASC) {
                skipInfo = findNewSkipInfo(sortField, 0L, maxSortFieldValue, from, searchQueryBuilder, searchQuery);
                rangeQuery = QueryBuilders.rangeQuery(sortField).gt(skipInfo[0]);
            } else {
                skipInfo = findNewSkipInfo(sortField, maxSortFieldValue, 0L, from, searchQueryBuilder, searchQuery);
                rangeQuery = QueryBuilders.rangeQuery(sortField).lt(skipInfo[0]);
            }
            // 只查询找到的序号以后的数据,把前面的数据过滤掉,降低发往聚合节点的数据量。
            searchQuery = QueryBuilders.boolQuery().filter(searchQuery).filter(rangeQuery);
            searchQueryBuilder.setFrom(skipInfo[1].intValue()).setSize(size);
            searchQueryBuilder.setQuery(searchQuery);
            SearchResponse response = searchQueryBuilder.get();
            searchHits = response.getHits().getHits();
        } else {  // 如果from值可以接受直接进行查询
            SearchRequestBuilder searchQueryBuilder = buildRequest(index, type);
            searchQueryBuilder.setFrom(from).setSize(size);
            searchQueryBuilder.setQuery(searchQuery);
            searchQueryBuilder.addSort(sortField, sortOrder);
            SearchResponse response = searchQueryBuilder.get();
            searchHits = response.getHits().getHits();
        }

        return searchHits;
    }

    /**
     * 使用黄金分割算法搜索合适的跳跃值。
     * @param fieldName 查找过滤范围使用的字段
     * @param startFieldValue 跳过数据的起始点
     * @param endFieldValue 跳过数据的结束点
     * @param fromSize 在原数据起始点
     * @param searchRequest 查询elasticsearch的请求工具类visitor实例
     * @param searchQuery 查询elasticsearch的条件
     * @return 长度为2的长整形数组。数据的第1个元素是新的取数据起始点,这个新起点及以前的数据属于被过滤的范围;数据的第2的元素是指从新起点开始还要跳过多少数据。
     */
    private static Long[] findNewSkipInfo(String fieldName, long startFieldValue, long endFieldValue, int fromSize, SearchRequestBuilder searchRequest, QueryBuilder searchQuery) {

        Long[] result;
        long startValue = startFieldValue;
        long endValue = endFieldValue;
        long positionValue;
        positionValue = Double.valueOf(Math.min(startFieldValue, endFieldValue) + Math.abs(startFieldValue - endFieldValue) * (0.618)).longValue();
        RangeQueryBuilder testRange = QueryBuilders.rangeQuery(fieldName);
        if (startValue < endValue) {
            testRange = testRange.gte(startValue).lte(positionValue);
        } else {
            testRange = testRange.lte(startValue).gte(positionValue);
        }
        BoolQueryBuilder testRangeQuery = QueryBuilders.boolQuery().filter(searchQuery).filter(testRange);
        long rangeItemCount = searchRequest.setQuery(testRangeQuery).get().getHits().getTotalHits();
        long skipDistance = fromSize - rangeItemCount;
        if (skipDistance >= 0 && skipDistance <= MAX_QUERY_FROM || startFieldValue == endFieldValue) { // 如果跳跃距离到达可接受范围以内,或者搜索范围已经无法继续缩小,则不再继续寻找。
            result = new Long[] {positionValue, skipDistance};
            return result;
        }
        if (rangeItemCount > fromSize) {
            endFieldValue = positionValue;
        } else {
            startFieldValue = positionValue;
        }
        return findNewSkipInfo(fieldName, startFieldValue, endFieldValue, fromSize, searchRequest, searchQuery);
    }

  除了分页深度过大外,分页大小过大也会消耗很多系统资源。但解决了深分页问题即等于变向了解决大分页问题,因为大分页查询可以转化为深分页查询:(from1000,take9000)=(from1000,take3000 + from4000,take3000 + from7000,take3000)。这样分割加分段查询大批数据的另一个好处是查询操作不会再因受到elasticsearch默认max_result_window不超过10000的限制而失败了。

    /**
     * 当要获取的数据量太大时,分批查询数据。
     */
    private SearchHit[] searchBatch(String index, String type, QueryBuilder searchQuery, String sortField, SortOrder sortOrder, Integer from, Integer size) {
        // 开始查询数据
        SearchHit[] result;
        int maxBatchSize = 3000;
        if(size > maxBatchSize) { // 如果每页数据过多则分批下载
            List<SearchHit> batchList = new ArrayList<>();
            int downSize = size;
            int batchFrom = from;
            SearchHit[] batch;
            while (true) {
                int batchSize = downSize;
                if(batchSize <= 0) {
                    break;
                }
                if (batchSize > maxBatchSize) {
                    batchSize = maxBatchSize;
                }
                batch = searchRange(index, type, searchQuery, sortField, sortOrder, batchFrom, batchSize);
                if(batch.length == 0) {
                    break;
                }
                for (SearchHit searchHit : batch) {
                    batchList.add(searchHit);
                }
                batchFrom += batchSize;
                downSize -= batchSize;
            }
            result = batchList.toArray(new SearchHit[batchList.size()]);
        } else {
            result = searchRange(index, type, searchQuery, sortField, sortOrder, from, size);
        }
        return result;
    }

  还有个可优化之处。当数据集获取范围靠近结尾时,从后向前取数据可以极大的降低分页的深度。例如总数据量为9996条,每页取10条,取第1000页数据,从前往后遍历需要跳过9990条数据,再取6条数据。如果改为从后往前遍历,则直接取开始的6条数据然后反转一下取出数据的顺序就可以了。这个策略可以使最大分页深度降低50%,并且这个优化策略与前面的分割尝试法不冲突,可以集成在一起。

    public Page<Dto> searchPage(String index, String type, String queryString, String sortField, String sortType, Integer pageNum, Integer pageSize) {

        // 准备查询参数
        SortOrder sortOrder = "ASC".equalsIgnoreCase(sortType) ? SortOrder.ASC : SortOrder.DESC;
        int from = (pageNum - 1) * pageSize;
        int size = pageSize;

        // 查询数据
        QueryBuilder searchQuery = buildQuery(queryString);

        //获取总数据量,如果没有符合条件的数据直接返回空结果。
        SearchRequestBuilder getTotalCountQuery = buildRequest(index, type);
        getTotalCountQuery.setQuery(searchQuery).setSize(0);
        SearchResponse countResponse = getTotalCountQuery.get();
        Long totalDaoCount = countResponse.getHits().getTotalHits();
        if (totalDaoCount == 0 || from >= totalDaoCount) {
            new Page<Dto>(0, null, pageNum, pageSize);
        }

        //如果查询内容离尾页较近就反转查询排序,尽量不去深分页。
        boolean reverseOrder = from > (totalDaoCount - from)  ;
        if (reverseOrder) {
            long firstPageSize = totalDaoCount % pageSize;
            long totalPage = totalDaoCount / pageSize + (firstPageSize > 0 ? 1 : 0);
            long reversePage = totalPage - pageNum + 1;
            long reverseSize = reversePage == 1 ? firstPageSize : pageSize;
            long reverseFrom = (reversePage - 1) * pageSize + firstPageSize - pageSize;
            from = Long.valueOf(reverseFrom < 0 ? 0 : reverseFrom).intValue();
            size = Long.valueOf(reverseSize).intValue();

            sortOrder = (sortOrder == SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC);
        }
        SearchHit[] currentPageData = searchBatch(index, type, searchQuery, sortField, sortOrder, from, size);
        //如果反转过查询排序,查询出数据后再反转回正常顺序。
        if (reverseOrder) {
            ArrayUtils.reverse(currentPageData);
        }

        // 构建返回结果
        // Dto类是对外的数据对象,每个实例封装了一条elasticsearch数据。Page类包含当前页Dta的列表,totalDaoCount参数表示符合查询条件的所有数据量。可自行实现它们。
        Page<Dto> result = new Page<Dto>(totalDaoCount, currentPageData, pageNum, pageSize);
        return result;
    }

  最后一个小的优化。上面代码有个findNewSkipInfo方法使用了自动递归,虽然分割法非常高效,在这儿使用自动递归应该不会遇到堆栈溢出问题,但随手把自动递归改写成手动递归算是个好习惯,而且findNewSkipInfo使用的尾递归非常容易优化,可以顺手改了。下面附上最后的整体代码,优化完分页问题的代码和最前面的一版已经非常不一样了,但public方法的参数和返回结果没有变化,保持了接口稳定。由于是在查询算法逻辑层面分解了深分页性能问题,这段代码可以极速完成极深的分页查询,且不会引发聚合节点崩溃等故障。

public class ElasticSearchDao {

    // 访问elasticSearch的官方客户端工具类单例。使用时可参考官方文件补齐esSettings, esAddressAry等参数。
    private static final TransportClient transportClient = new PreBuiltTransportClient(esSettings).addTransportAddresses(esAddressAry);

    /**
     * 查询某页数据
     * @param index 要查询的elasticsearch的index名称。
     * @param type 要查询的elasticsearch的type名称。
     * @param columns 要获取的elasticsearch的列名称。
     * @param queryString 查询的elasticsearch的搜索条件。
     * @param sortField 查询数据的排序字段。这需要是个不为空,且几乎没重复值的数值类型的字段的名字。
     * @param sortType 查询数据的排序类型,asc为升序,否则为降序。
     * @param pageNum 查询数据的页号。不能为空,从1开始。
     * @param pageSize 每页包含多少数据。不能为空,应该大于0。
     * @return 搜索出的分页数据。
     */
    public Page<Dto> searchPage(String index, String type, String queryString, String sortField, String sortType, Integer pageNum, Integer pageSize) {

        // 准备查询参数
        SortOrder sortOrder = "ASC".equalsIgnoreCase(sortType) ? SortOrder.ASC : SortOrder.DESC;
        int from = (pageNum - 1) * pageSize;
        int size = pageSize;

        // 查询数据
        QueryBuilder searchQuery = buildQuery(queryString);

        //获取总数据量,如果没有符合条件的数据直接返回空结果。
        SearchRequestBuilder getTotalCountQuery = buildRequest(index, type);
        getTotalCountQuery.setQuery(searchQuery).setSize(0);
        SearchResponse countResponse = getTotalCountQuery.get();
        Long totalDaoCount = countResponse.getHits().getTotalHits();
        if (totalDaoCount == 0 || from >= totalDaoCount) {
            new Page<Dto>(0, null, pageNum, pageSize);
        }

        //如果查询内容离尾页较近就反转查询排序,尽量不去深分页。
        boolean reverseOrder = from > (totalDaoCount - from)  ;
        if (reverseOrder) {
            long firstPageSize = totalDaoCount % pageSize;
            long totalPage = totalDaoCount / pageSize + (firstPageSize > 0 ? 1 : 0);
            long reversePage = totalPage - pageNum + 1;
            long reverseSize = reversePage == 1 ? firstPageSize : pageSize;
            long reverseFrom = (reversePage - 1) * pageSize + firstPageSize - pageSize;
            from = Long.valueOf(reverseFrom < 0 ? 0 : reverseFrom).intValue();
            size = Long.valueOf(reverseSize).intValue();

            sortOrder = (sortOrder == SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC);
        }
        SearchHit[] currentPageData = searchBatch(index, type, searchQuery, sortField, sortOrder, from, size);
        //如果反转过查询排序,查询出数据后再反转回正常状态。
        if (reverseOrder) {
            ArrayUtils.reverse(currentPageData);
        }

        // 构建返回结果
        // Dto类是对外的数据对象,每个实例封装了一条elasticsearch数据。Page类包含当前页Dta的列表,totalDaoCount参数表示符合查询条件的所有数据量。可自行实现它们。
        Page<Dto> result = new Page<Dto>(totalDaoCount, currentPageData, pageNum, pageSize);
        return result;
    }

    /**
     * 当要获取的数据量太大时,分批查询数据。
     */
    private SearchHit[] searchBatch(String index, String type, QueryBuilder searchQuery, String sortField, SortOrder sortOrder, Integer from, Integer size) {
        // 开始查询数据
        SearchHit[] result;
        int maxBatchSize = 3000;
        if(size > maxBatchSize) { // 如果每页数据过多则分批下载
            List<SearchHit> batchList = new ArrayList<>();
            int downSize = size;
            int batchFrom = from;
            SearchHit[] batch;
            while (true) {  // 循环下载每批数据,直到结束或下载不到数据为止
                int batchSize = downSize;
                if(batchSize <= 0) {
                    break;
                }
                if (batchSize > maxBatchSize) {
                    batchSize = maxBatchSize;
                }
                batch = searchRange(index, type, searchQuery, sortField, sortOrder, batchFrom, batchSize);
                if(batch.length == 0) {
                    break;
                }
                for (SearchHit searchHit : batch) {
                    batchList.add(searchHit);
                }
                batchFrom += batchSize;
                downSize -= batchSize;
            }
            result = batchList.toArray(new SearchHit[batchList.size()]);
        } else {
            result = searchRange(index, type, searchQuery, sortField, sortOrder, from, size);
        }
        return result;
    }

    // 允许直接在elasticsearch使用的最大from值
    private static final Integer MAX_QUERY_FROM = 3000;

    // 当from值过大会通过分割法间接进行深分页查询的工具类
    private SearchHit[] searchRange(String index, String type, QueryBuilder searchQuery, String sortField, SortOrder sortOrder, Integer from, Integer size) {
        // 开始查询数据
        SearchHit[] searchHits;
        if (from > MAX_QUERY_FROM) {  //  如果from过大则搜索跳跃点降低from值。
            SearchRequestBuilder getMaxFieldValueQuery = buildRequest(index ,type);
            getMaxFieldValueQuery.setQuery(searchQuery).setSize(1).addSort(sortField, SortOrder.DESC);
            Long maxSortFieldValue = Long.valueOf(getMaxFieldValueQuery.get().getHits().getAt(0).getSource().get(sortField).toString());

            SearchRequestBuilder searchQueryBuilder = buildRequest(index, type);
            searchQueryBuilder.setSize(0).addSort(sortField, sortOrder);
            Long[] skipInfo;
            RangeQueryBuilder rangeQuery;

            // 使用分割算法查找新的数据起始点和skip值,新数据起始点及以前的数据会被数据节点直接过滤掉,不再送往聚合节点。
            if(sortOrder == SortOrder.ASC) {
                skipInfo = findNewSkipInfo(sortField, 0L, maxSortFieldValue, from, searchQueryBuilder, searchQuery);
                rangeQuery = QueryBuilders.rangeQuery(sortField).gt(skipInfo[0]);
            } else {
                skipInfo = findNewSkipInfo(sortField, maxSortFieldValue, 0L, from, searchQueryBuilder, searchQuery);
                rangeQuery = QueryBuilders.rangeQuery(sortField).lt(skipInfo[0]);
            }
            searchQuery = QueryBuilders.boolQuery().filter(searchQuery).filter(rangeQuery);
            searchQueryBuilder.setFrom(skipInfo[1].intValue()).setSize(size);
            searchQueryBuilder.setQuery(searchQuery);
            SearchResponse response = searchQueryBuilder.get();
            searchHits = response.getHits().getHits();
        } else {  // 如果from值可以接受直接进行查询
            SearchRequestBuilder searchQueryBuilder = buildRequest(index, type);
            searchQueryBuilder.setFrom(from).setSize(size);
            searchQueryBuilder.setQuery(searchQuery);
            searchQueryBuilder.addSort(sortField, sortOrder);
            SearchResponse response = searchQueryBuilder.get();
            searchHits = response.getHits().getHits();
        }

        return searchHits;
    }

    /**
     * 使用黄金分割算法搜索合适的跳跃值。
     * @param fieldName 查找过滤范围使用的字段,这需要是个数据类型字段的名字。
     * @param startFieldValue 跳过数据的起始点
     * @param endFieldValue 跳过数据的结束点
     * @param fromSize 在原数据起始点
     * @param searchRequest 查询elasticsearch的请求工具类visitor实例
     * @param searchQuery 查询elasticsearch的条件
     * @return 长度为2的长整形数组。数据的第1个元素是新的取数据起始点,这个新起点及以前的数据属于被过滤的范围;数据的第2的元素是指从新起点开始还要跳过多少数据。
     */
    private static Long[] findNewSkipInfo(String fieldName, long startFieldValue, long endFieldValue, int fromSize, SearchRequestBuilder searchRequest, QueryBuilder searchQuery) {

        Long[] result;
        long startValue = startFieldValue;
        long endValue = endFieldValue;
        long positionValue;
        int findCount = 0;
        while (true) {
            positionValue = Double.valueOf(Math.min(startFieldValue, endFieldValue) + Math.abs(startFieldValue - endFieldValue) * (1 - 0.618)).longValue();
            RangeQueryBuilder testRange = QueryBuilders.rangeQuery(fieldName);
            if (startValue < endValue) {
                testRange = testRange.gte(startValue).lte(positionValue);
            } else {
                testRange = testRange.lte(startValue).gte(positionValue);
            }
            BoolQueryBuilder testRangeQuery = QueryBuilders.boolQuery().filter(searchQuery).filter(testRange);
            long rangeItemCount = searchRequest.setQuery(testRangeQuery).get().getHits().getTotalHits();
            long skipDistance = fromSize - rangeItemCount;
            if (skipDistance >= 0 && skipDistance <= MAX_QUERY_FROM || startFieldValue == endFieldValue) { // 如果跳跃距离到达可接受范围以内,或者搜索范围已经无法继续缩小,则不再继续寻找。
                result = new Long[] {positionValue, skipDistance};
                break;
            }
            // 正常情况下,即使数据条数达到UnsignedLong的最大值,黄金分割法使用76次也可以把搜索范围降到MAX_FROM_QUERY大小: (18446744073709552046 * 0.618 ^ 76) < 3000
            // 如果第76次搜索还找不到合适的跳跃值,可能是选择的索引数据有问题,比如索引列含有大量的重复值,导致搜索范围无法缩小。此时直接报错退出,不再死循环尝试。
            if(findCount++ > 75) {
                throw new RuntimeException("搜索跳跃点错误,无法在预想次数内找到合适跳跃点。");
            }
            if (rangeItemCount > fromSize) {
                endFieldValue = positionValue;
            } else {
                startFieldValue = positionValue;
            }
        }
        return result;
    }


    // 构建类似elasticsearch的queryString查询,比如查询height字段大于180的数据可以用:height:>180
    // 构建queryString不是深分页查询的演示重点,所以没有实现完整功能的queryString查询。
    private QueryBuilder buildQuery(String queryString) {
        int splitIdx = queryString.indexOf(":");
        String field = queryString.substring(0, splitIdx);
        String content = queryString.substring(splitIdx + 1);
        QueryStringQueryBuilder queryBuilder = QueryBuilders.queryStringQuery(content);
        queryBuilder.field(field);
        queryBuilder.fuzziness(Fuzziness.ZERO);
        return queryBuilder;
    }

    // 构建查询请求
    private static SearchRequestBuilder buildRequest(String index, String type) {
        SearchRequestBuilder searchQueryBuilder = transportClient.prepareSearch(index).setTypes(type);
        searchQueryBuilder.setRequestCache(true);
        return searchQueryBuilder;
    }
}

  在5台8核16G内存全固态硬盘服务器做数据结点的elasticsearch集群上测试,准备1000万条数据,每页10条,共分100万页。为了尽量模拟真实环境,在另一个网段的业务服务器上执行查询算法。查询第1页,从请求发出到数据查询处理并传输完毕,测得平均耗时是10毫秒。测试直接跳转到最后一页,由于反转排序逻辑起了作用,平均查询耗时25毫秒。理论上这个算法查询数据集中间的数据会最慢,在不对集群进行预热的情况下,从第1页直接跳转到第50万页,查询耗时400毫秒左右,在第50万页附近多次查询后,由于elasticsearch的缓存起了作用,跳转页的平均查询耗时会降到200毫秒以下。

  到此全文结束,祝大家圣诞快乐。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值