MongoDB分頁的Java實現和分頁需求的思考


前言

傳統關系數據庫中都提供了基於row number的分頁功能,切換MongoDB后,想要實現分頁,則需要修改一下思路。

傳統分頁思路

假設一頁大小為10條。則

//page 1
1-10

//page 2
11-20

//page 3
21-30
...

//page n
10*(n-1) +1 - 10*n

MongoDB提供了skip()和limit()方法。

skip: 跳過指定數量的數據. 可以用來跳過當前頁之前的數據,即跳過pageSize*(n-1)。
limit: 指定從MongoDB中讀取的記錄條數,可以當做頁面大小pageSize。

所以,分頁可以這樣做:

//Page 1
db.users.find().limit (10)
//Page 2
db.users.find().skip(10).limit(10)
//Page 3
db.users.find().skip(20).limit(10)
........

問題

看起來,分頁已經實現了,但是官方文檔並不推薦,說會掃描全部文檔,然后再返回結果。

The cursor.skip() method requires the server to scan from the beginning of the input results set before beginning to return results. As the offset increases, cursor.skip() will become slower.

所以,需要一種更快的方式。其實和mysql數量大之后不推薦用limit m,n一樣,解決方案是先查出當前頁的第一條,然后順序數pageSize條。MongoDB官方也是這樣推薦的。

正確的分頁辦法

我們假設基於_id的條件進行查詢比較。事實上,這個比較的基准字段可以是任何你想要的有序的字段,比如時間戳。

//Page 1
db.users.find().limit(pageSize);
//Find the id of the last document in this page
last_id = ...
 
//Page 2
users = db.users.find({
  '_id' :{ "$gt" :ObjectId("5b16c194666cd10add402c87")}
}).limit(10)
//Update the last id with the id of the last document in this page
last_id = ...

顯然,第一頁和后面的不同。對於構建分頁API, 我們可以要求用戶必須傳遞pageSize, lastId。

  • pageSize 頁面大小
  • lastId 上一頁的最后一條記錄的id,如果不傳,則將強制為第一頁

降序

_id降序,第一頁是最大的,下一頁的id比上一頁的最后的id還小。

function printStudents(startValue, nPerPage) {
  let endValue = null;
  db.students.find( { _id: { $lt: startValue } } )
             .sort( { _id: -1 } )
             .limit( nPerPage )
             .forEach( student => {
               print( student.name );
               endValue = student._id;
             } );

  return endValue;
}

升序

_id升序, 下一頁的id比上一頁的最后一條記錄id還大。

function printStudents(startValue, nPerPage) {
  let endValue = null;
  db.students.find( { _id: { $gt: startValue } } )
             .sort( { _id: 1 } )
             .limit( nPerPage )
             .forEach( student => {
               print( student.name );
               endValue = student._id;
             } );

  return endValue;
}

一共多少條

還有一共多少條和多少頁的問題。所以,需要先查一共多少條count.

db.users.find().count();

ObjectId的有序性問題

先看ObjectId生成規則:

比如"_id" : ObjectId("5b1886f8965c44c78540a4fc")

取id的前4個字節。由於id是16進制的string,4個字節就是32位,對應id前8個字符。即5b1886f8, 轉換成10進制為1528334072. 加上1970,就是當前時間。

事實上,更簡單的辦法是查看org.mongodb:bson:3.4.3里的ObjectId對象。

public ObjectId(Date date) {
    this(dateToTimestampSeconds(date), MACHINE_IDENTIFIER, PROCESS_IDENTIFIER, NEXT_COUNTER.getAndIncrement(), false);
}

//org.bson.types.ObjectId#dateToTimestampSeconds 
private static int dateToTimestampSeconds(Date time) {
    return (int)(time.getTime() / 1000L);
}

//java.util.Date#getTime
/**
 * Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT
 * represented by this <tt>Date</tt> object.
 *
 * @return  the number of milliseconds since January 1, 1970, 00:00:00 GMT
 *          represented by this date.
 */
public long getTime() {
    return getTimeImpl();
}

MongoDB的ObjectId應該是隨着時間而增加的,即后插入的id會比之前的大。但考量id的生成規則,最小時間排序區分是秒,同一秒內的排序無法保證。當然,如果是同一台機器的同一個進程生成的對象,是有序的。

如果是分布式機器,不同機器時鍾同步和偏移的問題。所以,如果你有個字段可以保證是有序的,那么用這個字段來排序是最好的。_id則是最后的備選方案。

如果我一定要跳頁

上面的分頁看起來看理想,雖然確實是,但有個剛需不曾指明---我怎么跳頁。

我們的分頁數據要和排序鍵關聯,所以必須有一個排序基准來截斷記錄。而跳頁,我只知道第幾頁,條件不足,無法分頁了。

現實業務需求確實提出了跳頁的需求,雖然幾乎不會有人用,人們更關心的是開頭和結尾,而結尾可以通過逆排序的方案轉成開頭。所以,真正分頁的需求應當是不存在的。如果你是為了查找某個記錄,那么查詢條件搜索是最快的方案。如果你不知道查詢條件,通過肉眼去一一查看,那么下一頁足矣。

說了這么多,就是想扭轉傳統分頁的概念,在互聯網發展的今天,大部分數據的體量都是龐大的,跳頁的需求將消耗更多的內存和cpu,對應的就是查詢慢。

當然,如果數量不大,如果不介意慢一點,那么skip也不是啥問題,關鍵要看業務場景。

我今天接到的需求就是要跳頁,而且數量很小,那么skip吧,不費事,還快。

來看看大廠們怎么做的

Google最常用了,看起來是有跳頁選擇的啊。再仔細看,只有10頁,多的就必須下一頁,並沒有提供一共多少頁,跳到任意頁的選擇。這不就是我們的find-condition-then-limit方案嗎,只是他的一頁數量比較多,前端或者后端把這一頁給切成了10份。

同樣,看Facebook,雖然提供了總count,但也只能下一頁。

其他場景,比如Twitter,微博,朋友圈等,根本沒有跳頁的概念的。

排序和性能

前面關注於分頁的實現原理,但忽略了排序。既然分頁,肯定是按照某個順序進行分頁的,所以必須要有排序的。

MongoDB的sort和find組合

db.bios.find().sort( { name: 1 } ).limit( 5 )
db.bios.find().limit( 5 ).sort( { name: 1 } )

這兩個都是等價的,順序不影響執行順序。即,都是先find查詢符合條件的結果,然后在結果集中排序。

我們條件查詢有時候也會按照某字段排序的,比如按照時間排序。查詢一組時間序列的數據,我們想要按照時間先后順序來顯示內容,則必須先按照時間字段排序,然后再按照id升序。

db.users.find({name: "Ryan"}).sort( { birth: 1, _id: 1 } ).limit( 5 )

我們先按照birth升序,然后birth相同的record再按照_id升序,如此可以實現我們的分頁功能了。

多字段排序

db.records.sort({ a:1, b:-1})

表示先按照a升序,再按照b降序。即,按照字段a升序,對於a相同的記錄,再用b降序,而不是按a排完之后再全部按b排。

示例:

db.user.find();

結果:

{ 
    "_id" : ObjectId("5b1886ac965c44c78540a4fb"), 
    "name" : "a", 
    "age" : 1.0, 
    "id" : "1"
}
{ 
    "_id" : ObjectId("5b1886f8965c44c78540a4fc"), 
    "name" : "a", 
    "age" : 2.0, 
    "id" : "2"
}
{ 
    "_id" : ObjectId("5b1886fa965c44c78540a4fd"), 
    "name" : "b", 
    "age" : 1.0, 
    "id" : "3"
}
{ 
    "_id" : ObjectId("5b1886fd965c44c78540a4fe"), 
    "name" : "b", 
    "age" : 2.0, 
    "id" : "4"
}
{ 
    "_id" : ObjectId("5b1886ff965c44c78540a4ff"), 
    "name" : "c", 
    "age" : 10.0, 
    "id" : "5"
}

按照名稱升序,然后按照age降序

db.user.find({}).sort({name: 1, age: -1})

結果:  
{ 
    "_id" : ObjectId("5b1886f8965c44c78540a4fc"), 
    "name" : "a", 
    "age" : 2.0, 
    "id" : "2"
}
{ 
    "_id" : ObjectId("5b1886ac965c44c78540a4fb"), 
    "name" : "a", 
    "age" : 1.0, 
    "id" : "1"
}
{ 
    "_id" : ObjectId("5b1886fd965c44c78540a4fe"), 
    "name" : "b", 
    "age" : 2.0, 
    "id" : "4"
}
{ 
    "_id" : ObjectId("5b1886fa965c44c78540a4fd"), 
    "name" : "b", 
    "age" : 1.0, 
    "id" : "3"
}
{ 
    "_id" : ObjectId("5b1886ff965c44c78540a4ff"), 
    "name" : "c", 
    "age" : 10.0, 
    "id" : "5"
}

用索引優化排序

到這里必須考慮下性能。

$sort and Memory Restrictions

The $sort stage has a limit of 100 megabytes of RAM. By default, if the stage exceeds this limit, $sort will produce an error. To allow for the handling of large datasets, set the allowDiskUse option to true to enable $sort operations to write to temporary files. See the allowDiskUse option in db.collection.aggregate() method and the aggregate command for details.

Changed in version 2.6: The memory limit for $sort changed from 10 percent of RAM to 100 megabytes of RAM.

從2.6開始,sort只排序100M以內的數據,超過將會報錯。可以通過設置allowDiskUse來允許排序大容量數據。

有索引的排序會比沒有索引的排序快,所以官方推薦為需要排序的key建立索引。

索引

對於單key排序,建立單獨索引

db.records.createIndex( { a: 1 } )

索引可以支持同排序和逆序的sort

索引又分升序(1)和降序(-1),索引定義的排序方向以及逆轉方向可以支持sort。對於上述單key索引a,可以支持sort({a:1})升序和sort({a:-1})降序。

對於多字段排序

如果想要使用索引。則可以建立復合(compound index)索引為

db.records.createIndex( { a: 1, b:-1 } )

復合索引的字段順序必須和sort一致

復合多字段索引的順序要和sort的字段一致才可以走索引。比如索引{a:1, b:1}, 可以支持sort({a:1, b:1})和逆序sort({a:-1, b:-1}), 但是,不支持a,b顛倒。即,不支持sort({b:1, a:1}).

復合索引支持sort同排序和逆序

索引{a:1, b:-1} 可以支持sort({a:1, b:-1}), 也可以支持sort({a:-1, b:1})

復合索引可以前綴子集支持sort

對於多字段復合索引,可以拆分成多個前綴子集。比如{a:1, b:1, c:1}相當於

{ a: 1 }
{ a: 1, b: 1 }
{ a: 1, b: 1, c: 1 }

示例:

Example Index Prefix
db.data.find().sort( { a: 1 } ) { a: 1 }
db.data.find().sort( { a: -1 } ) { a: 1 }
db.data.find().sort( { a: 1, b: 1 } ) { a: 1, b: 1 }
db.data.find().sort( { a: -1, b: -1 } ) { a: 1, b: 1 }
db.data.find().sort( { a: 1, b: 1, c: 1 } ) { a: 1, b: 1, c: 1 }
db.data.find( { a: { $gt: 4 } } ).sort( { a: 1, b: 1 } ) { a: 1, b: 1 }

復合索引的非前綴子集可以支持sort,前提是前綴子集的元素要在find的查詢條件里是equals

這個條件比較繞口,復合索引的非前綴子集,只要find和sort的字段要組成索引前綴,並且find里的條件必須是相等。

示例

Example Index Prefix
db.data.find( { a: 5 } ).sort( { b: 1, c: 1 } ) { a: 1 , b: 1, c: 1 }
db.data.find( { b: 3, a: 4 } ).sort( { c: 1 } ) { a: 1, b: 1, c: 1 }
db.data.find( { a: 5, b: { $lt: 3} } ).sort( { b: 1 } ) { a: 1, b: 1 }

find和sort的字段加起來滿足前綴子集,find條件中可以使用其他字段進行非equals比較。

對於既不是前綴子集,也不是find相等條件的。索引無效。比如,對於索引{a:1, b:1, c:1}。以下兩種方式不走索引。

db.data.find( { a: { $gt: 2 } } ).sort( { c: 1 } )
db.data.find( { c: 5 } ).sort( { c: 1 } )

Java代碼分頁

由於確實有跳頁的需求,目前還沒有發現性能問題,仍舊采用skip做分頁,當然也兼容條件分頁

public PageResult<StatByClientRs> findByDurationPage(FindByDurationPageRq rq) {
    final Criteria criteriaDefinition = Criteria.where("duration").is(rq.getDuration());

    final Query query = new Query(criteriaDefinition).with(new Sort(Lists.newArrayList(new Order(Direction.ASC, "_id"))));

    //分頁邏輯
    long total = mongoTemplate.count(query, StatByClient.class);
    Integer pageSize = rq.getPageSize();
    Integer pageNum = rq.getPageNum();
    String lastId = rq.getLastId();
    final Integer pages = (int) Math.ceil(total / (double) pageSize);
    if (pageNum<=0 || pageNum> pages) {
        pageNum = 1;
    }
   
    if (StringUtils.isNotBlank(lastId)) {
        if (pageNum != 1) {
            criteriaDefinition.and("_id").gt(new ObjectId(lastId));
        }
        query.limit(pageSize);
    } else {
        int skip = pageSize * (pageNum - 1);
        query.skip(skip).limit(pageSize);
    }
    List<StatByClient> statByClientList = mongoTemplate.find(query, StatByClient.class);

    PageResult<StatByClientRs> pageResult = new PageResult<>();
    pageResult.setTotal(total);
    pageResult.setPages(pages);
    pageResult.setPageSize(pageSize);
    pageResult.setPageNum(pageNum);
    pageResult.setList(mapper.mapToListRs(statByClientList));
    return pageResult;
}

這個示例中,目標是根據duration查詢list,結果集進行分頁。當請求體中包含lastId,那就走下一頁方案。如果想要跳頁,就不傳lastId,隨便你跳吧。

抽取分頁代碼為公共工具類

考慮分頁需求的旺盛,每個集合都這樣寫感覺比較麻煩,而且容易出錯。我們來把這個封裝成單獨一個PageHelper


import com.google.common.collect.Lists;
import com.shuwei.d2.message.PageResult;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Component;

/**
 * MongoDB分頁查詢工具類.
 *
 * @author Ryan Miao at 2018-06-07 14:46
 **/
@Component
public class MongoPageHelper {

    public static final int FIRST_PAGE_NUM = 1;
    public static final String ID = "_id";
    private final MongoTemplate mongoTemplate;

    @Autowired
    public MongoPageHelper(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }


    /**
     * 分頁查詢,直接返回集合類型的結果.
     *
     * @see MongoPageHelper#pageQuery(org.springframework.data.mongodb.core.query.Query,
     * java.lang.Class, java.util.function.Function, java.lang.Integer, java.lang.Integer,
     * java.lang.String)
     */
    public <T> PageResult<T> pageQuery(Query query, Class<T> entityClass, Integer pageSize,
        Integer pageNum) {
        return pageQuery(query, entityClass, Function.identity(), pageSize, pageNum, null);
    }

    /**
     * 分頁查詢,不考慮條件分頁,直接使用skip-limit來分頁.
     *
     * @see MongoPageHelper#pageQuery(org.springframework.data.mongodb.core.query.Query,
     * java.lang.Class, java.util.function.Function, java.lang.Integer, java.lang.Integer,
     * java.lang.String)
     */
    public <T, R> PageResult<R> pageQuery(Query query, Class<T> entityClass, Function<T, R> mapper,
        Integer pageSize, Integer pageNum) {
        return pageQuery(query, entityClass, mapper, pageSize, pageNum, null);
    }

    /**
     * 分頁查詢.
     *
     * @param query Mongo Query對象,構造你自己的查詢條件.
     * @param entityClass Mongo collection定義的entity class,用來確定查詢哪個集合.
     * @param mapper 映射器,你從db查出來的list的元素類型是entityClass, 如果你想要轉換成另一個對象,比如去掉敏感字段等,可以使用mapper來決定如何轉換.
     * @param pageSize 分頁的大小.
     * @param pageNum 當前頁.
     * @param lastId 條件分頁參數, 區別於skip-limit,采用find(_id>lastId).limit分頁.
     * 如果不跳頁,像朋友圈,微博這樣下拉刷新的分頁需求,需要傳遞上一頁的最后一條記錄的ObjectId。 如果是null,則返回pageNum那一頁.
     * @param <T> collection定義的class類型.
     * @param <R> 最終返回時,展現給頁面時的一條記錄的類型。
     * @return PageResult,一個封裝page信息的對象.
     */
    public <T, R> PageResult<R> pageQuery(Query query, Class<T> entityClass, Function<T, R> mapper,
        Integer pageSize, Integer pageNum, String lastId) {
        //分頁邏輯
        long total = mongoTemplate.count(query, entityClass);
        final Integer pages = (int) Math.ceil(total / (double) pageSize);
        if (pageNum <= 0 || pageNum > pages) {
            pageNum = FIRST_PAGE_NUM;
        }
        final Criteria criteria = new Criteria();
        if (StringUtils.isNotBlank(lastId)) {
            if (pageNum != FIRST_PAGE_NUM) {
                criteria.and(ID).gt(new ObjectId(lastId));
            }
            query.limit(pageSize);
        } else {
            int skip = pageSize * (pageNum - 1);
            query.skip(skip).limit(pageSize);
        }

        final List<T> entityList = mongoTemplate
            .find(query.addCriteria(criteria)
                    .with(new Sort(Lists.newArrayList(new Order(Direction.ASC, ID)))),
                entityClass);

        final PageResult<R> pageResult = new PageResult<>();
        pageResult.setTotal(total);
        pageResult.setPages(pages);
        pageResult.setPageSize(pageSize);
        pageResult.setPageNum(pageNum);
        pageResult.setList(entityList.stream().map(mapper).collect(Collectors.toList()));
        return pageResult;
    }

}

對了,還有PageResult對象


import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import io.swagger.annotations.ApiModelProperty;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 分頁結果.
 * @author Ryan
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(Include.NON_NULL)
public class PageResult<T> {

    @ApiModelProperty("頁碼,從1開始")
    private Integer pageNum;

    @ApiModelProperty("頁面大小")
    private Integer pageSize;

    @ApiModelProperty("總數")
    private Long total;

    @ApiModelProperty("總頁數")
    private Integer pages;

    @ApiModelProperty("數據")
    private List<T> list;

}

使用工具類

最初的查詢語句,業務邏輯和分頁邏輯分開。

public PageResult<StatByClientRs> findByDurationPage(FindByDurationPageRq rq) {
    final Query query = new Query(Criteria.where("duration").is(rq.getDuration()));

    return mongoPageHelper.pageQuery(query, StatByClient.class, mapper::mapToRs, rq.getPageSize(),
        rq.getPageNum(), rq.getLastId());
}

把工具類共享到maven倉庫

新建一個maven項目,https://github.com/Ryan-Miao/mongo-page-helper

修改並提取剛才的工具類。

如何使用

必須結合spring-boot-starter-data-mongodb來使用.

在pom里添加repository

<repositories>
    <repository>
        <id>jitpack.io</id>
        <url>https://jitpack.io</url>
    </repository>
</repositories>

再引入依賴

<dependency>
    <groupId>com.github.Ryan-Miao</groupId>
    <artifactId>mongo-page-helper</artifactId>
    <version>1.0</version>
</dependency>

配置Configuration

@Configuration
public class MongoConfiguration{

    @Autowired
    private MongoTemplate mongoTemplate;
    @Bean
    public MongoPageHelper mongoPageHelper() {
        return new MongoPageHelper(mongoTemplate);
    }

}

然后就可以使用MongoPageHelper來注入了。

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM