MongoDB分页的Java实现和分页需求的思考
<h2 id="前言">前言</h2><p>传统关系数据库中都提供了基于row number的分页功能,切换MongoDB后,想要实现分页,则需要修改一下思路。</p>
<h2 id="传统分页思路">传统分页思路</h2>
<p>假设一页大小为10条。则</p>
<pre><code>//page 1
1-10
//page 2
11-20
//page 3
21-30
...
//page n
10*(n-1) +1 - 10*n
</code></pre>
<p>MongoDB提供了skip()和limit()方法。</p>
<p><strong>skip</strong>: 跳过指定数量的数据. 可以用来跳过当前页之前的数据,即跳过pageSize*(n-1)。<br>
<strong>limit</strong>: 指定从MongoDB中读取的记录条数,可以当做页面大小pageSize。</p>
<p>所以,分页可以这样做:</p>
<pre><code>//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)
........
</code></pre>
<p><strong>问题</strong></p>
<p>看起来,分页已经实现了,但是官方文档并不推荐,说会扫描全部文档,然后再返回结果。</p>
<blockquote>
<p>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.</p>
</blockquote>
<p>所以,需要一种更快的方式。其实和mysql数量大之后不推荐用limit m,n一样,解决方案是先查出当前页的第一条,然后顺序数pageSize条。MongoDB官方也是这样推荐的。</p>
<h2 id="正确的分页办法">正确的分页办法</h2>
<p>我们假设基于_id的条件进行查询比较。事实上,这个比较的基准字段可以是任何你想要的有序的字段,比如时间戳。</p>
<pre><code>//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 = ...
</code></pre>
<p>显然,第一页和后面的不同。对于构建分页API, 我们可以要求用户必须传递pageSize, lastId。</p>
<ul>
<li>pageSize 页面大小</li>
<li>lastId 上一页的最后一条记录的id,如果不传,则将强制为第一页</li>
</ul>
<h3 id="降序">降序</h3>
<p><code>_id</code>降序,第一页是最大的,下一页的id比上一页的最后的id还小。</p>
<pre><code class="language-js">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;
}
</code></pre>
<h3 id="升序">升序</h3>
<p><code>_id</code>升序, 下一页的id比上一页的最后一条记录id还大。</p>
<pre><code class="language-js">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;
}
</code></pre>
<h3 id="一共多少条">一共多少条</h3>
<p>还有一共多少条和多少页的问题。所以,需要先查一共多少条count.</p>
<pre><code>db.users.find().count();
</code></pre>
<h3 id="objectid的有序性问题">ObjectId的有序性问题</h3>
<p>先看ObjectId生成规则:</p>
<p><img src="http://oe20lp6p0.bkt.clouddn.com/2018/page/mongo-id.png" alt="" loading="lazy"></p>
<p>比如<code>"_id" : ObjectId("5b1886f8965c44c78540a4fc")</code></p>
<p>取id的前4个字节。由于id是16进制的string,4个字节就是32位,对应id前8个字符。即<code>5b1886f8</code>, 转换成10进制为<code>1528334072</code>. 加上1970,就是当前时间。</p>
<p>事实上,更简单的办法是查看org.mongodb:bson:3.4.3里的ObjectId对象。</p>
<pre><code class="language-java">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.
*
* @returnthe number of milliseconds since January 1, 1970, 00:00:00 GMT
* represented by this date.
*/
public long getTime() {
return getTimeImpl();
}
</code></pre>
<p>MongoDB的ObjectId应该是随着时间而增加的,即后插入的id会比之前的大。但考量id的生成规则,最小时间排序区分是秒,同一秒内的排序无法保证。当然,如果是同一台机器的同一个进程生成的对象,是有序的。</p>
<p>如果是分布式机器,不同机器时钟同步和偏移的问题。所以,如果你有个字段可以保证是有序的,那么用这个字段来排序是最好的。<code>_id</code>则是最后的备选方案。</p>
<h3 id="如果我一定要跳页">如果我一定要跳页</h3>
<p>上面的分页看起来看理想,虽然确实是,但有个刚需不曾指明---我怎么跳页。</p>
<p>我们的分页数据要和排序键关联,所以必须有一个排序基准来截断记录。而跳页,我只知道第几页,条件不足,无法分页了。</p>
<p>现实业务需求确实提出了跳页的需求,虽然几乎不会有人用,人们更关心的是开头和结尾,而结尾可以通过逆排序的方案转成开头。所以,真正分页的需求应当是不存在的。如果你是为了查找某个记录,那么查询条件搜索是最快的方案。如果你不知道查询条件,通过肉眼去一一查看,那么下一页足矣。</p>
<p>说了这么多,就是想扭转传统分页的概念,在互联网发展的今天,大部分数据的体量都是庞大的,跳页的需求将消耗更多的内存和cpu,对应的就是查询慢。</p>
<p>当然,如果数量不大,如果不介意慢一点,那么skip也不是啥问题,关键要看业务场景。</p>
<p>我今天接到的需求就是要跳页,而且数量很小,那么skip吧,不费事,还快。</p>
<p><strong>来看看大厂们怎么做的</strong></p>
<p>Google最常用了,看起来是有跳页选择的啊。再仔细看,只有10页,多的就必须下一页,并没有提供一共多少页,跳到任意页的选择。这不就是我们的find-condition-then-limit方案吗,只是他的一页数量比较多,前端或者后端把这一页给切成了10份。<br>
<img src="http://oe20lp6p0.bkt.clouddn.com/2018/page/google-search-results-600x105.jpg" alt="" loading="lazy"></p>
<p>同样,看Facebook,虽然提供了总count,但也只能下一页。<br>
<img src="http://oe20lp6p0.bkt.clouddn.com/2018/page/fb-search-600x121.jpg" alt="" loading="lazy"></p>
<p>其他场景,比如Twitter,微博,朋友圈等,根本没有跳页的概念的。</p>
<h2 id="排序和性能">排序和性能</h2>
<p>前面关注于分页的实现原理,但忽略了排序。既然分页,肯定是按照某个顺序进行分页的,所以必须要有排序的。</p>
<p>MongoDB的sort和find组合</p>
<pre><code>db.bios.find().sort( { name: 1 } ).limit( 5 )
db.bios.find().limit( 5 ).sort( { name: 1 } )
</code></pre>
<p>这两个都是等价的,顺序不影响执行顺序。即,都是先find查询符合条件的结果,然后在结果集中排序。</p>
<p>我们条件查询有时候也会按照某字段排序的,比如按照时间排序。查询一组时间序列的数据,我们想要按照时间先后顺序来显示内容,则必须先按照时间字段排序,然后再按照id升序。</p>
<pre><code>db.users.find({name: "Ryan"}).sort( { birth: 1, _id: 1 } ).limit( 5 )
</code></pre>
<p>我们先按照birth升序,然后birth相同的record再按照_id升序,如此可以实现我们的分页功能了。</p>
<h3 id="多字段排序">多字段排序</h3>
<pre><code>db.records.sort({ a:1, b:-1})
</code></pre>
<p>表示先按照a升序,再按照b降序。即,按照字段a升序,对于a相同的记录,再用b降序,而不是按a排完之后再全部按b排。</p>
<p>示例:</p>
<pre><code>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"
}
</code></pre>
<p>按照名称升序,然后按照age降序</p>
<pre><code>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"
}
</code></pre>
<h3 id="用索引优化排序">用索引优化排序</h3>
<p>到这里必须考虑下性能。</p>
<blockquote>
<p><strong>$sort and Memory Restrictions</strong></p>
<p>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 <code>allowDiskUse</code> 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.</p>
<p>Changed in version 2.6: The memory limit for $sort changed from 10 percent of RAM to 100 megabytes of RAM.</p>
</blockquote>
<p>从2.6开始,sort只排序100M以内的数据,超过将会报错。可以通过设置<code>allowDiskUse</code>来允许排序大容量数据。</p>
<p>有索引的排序会比没有索引的排序快,所以官方推荐为需要排序的key建立索引。</p>
<h3 id="索引">索引</h3>
<p>对于单key排序,建立单独索引</p>
<pre><code>db.records.createIndex( { a: 1 } )
</code></pre>
<p><strong>索引可以支持同排序和逆序的sort</strong></p>
<p>索引又分升序(1)和降序(-1),索引定义的排序方向以及逆转方向可以支持sort。对于上述单key索引a,可以支持<code>sort({a:1})</code>升序和<code>sort({a:-1})</code>降序。</p>
<p>对于多字段排序</p>
<p>如果想要使用索引。则可以建立复合(compound index)索引为</p>
<pre><code>db.records.createIndex( { a: 1, b:-1 } )
</code></pre>
<p><strong>复合索引的字段顺序必须和sort一致</strong></p>
<p>复合多字段索引的顺序要和sort的字段一致才可以走索引。比如索引<code>{a:1, b:1}</code>, 可以支持<code>sort({a:1, b:1})</code>和逆序<code>sort({a:-1, b:-1})</code>, 但是,不支持a,b颠倒。即,不支持<code>sort({b:1, a:1})</code>.</p>
<p><strong>复合索引支持sort同排序和逆序</strong></p>
<p>索引<code>{a:1, b:-1}</code> 可以支持<code>sort({a:1, b:-1})</code>, 也可以支持<code>sort({a:-1, b:1})</code></p>
<p><strong>复合索引可以前缀子集支持sort</strong></p>
<p>对于多字段复合索引,可以拆分成多个前缀子集。比如<code>{a:1, b:1, c:1}</code>相当于</p>
<pre><code>{ a: 1 }
{ a: 1, b: 1 }
{ a: 1, b: 1, c: 1 }
</code></pre>
<p>示例:</p>
<table 1,="" 1="">
<thead>
<tr>
<th>Example</th>
<th>Index Prefix</th>
</tr>
</thead>
<tbody>
<tr 1="">
<td>db.data.find().sort( { a: 1 } )</td>
<td></td>
</tr>
<tr 1="">
<td>db.data.find().sort( { a: -1 } )</td>
<td></td>
</tr>
<tr 1,="" 1="">
<td>db.data.find().sort( { a: 1, b: 1 } )</td>
<td></td>
</tr>
<tr 1,="" 1="">
<td>db.data.find().sort( { a: -1, b: -1 } )</td>
<td></td>
</tr>
<tr 1,="" 1="">
<td>db.data.find().sort( { a: 1, b: 1, c: 1 } )</td>
<td></td>
</tr>
<tr>
<td>db.data.find( { a: { $gt: 4 } } ).sort( { a: 1, b: 1 } )</td>
<td></td>
</tr>
</tbody>
</table>
<p><strong>复合索引的非前缀子集可以支持sort,前提是前缀子集的元素要在find的查询条件里是equals</strong></p>
<p>这个条件比较绕口,复合索引的非前缀子集,只要find和sort的字段要组成索引前缀,并且find里的条件必须是相等。</p>
<p>示例</p>
<table 1,="" 1="">
<thead>
<tr>
<th>Example</th>
<th>Index Prefix</th>
</tr>
</thead>
<tbody>
<tr 1="" ,="" 1,="">
<td>db.data.find( { a: 5 } ).sort( { b: 1, c: 1 } )</td>
<td></td>
</tr>
<tr 1,="" 1="">
<td>db.data.find( { b: 3, a: 4 } ).sort( { c: 1 } )</td>
<td></td>
</tr>
<tr>
<td>db.data.find( { a: 5, b: { $lt: 3} } ).sort( { b: 1 } )</td>
<td></td>
</tr>
</tbody>
</table>
<p>find和sort的字段加起来满足前缀子集,find条件中可以使用其他字段进行非equals比较。</p>
<p>对于既不是前缀子集,也不是find相等条件的。索引无效。比如,对于索引<code>{a:1, b:1, c:1}</code>。以下两种方式不走索引。</p>
<pre><code>db.data.find( { a: { $gt: 2 } } ).sort( { c: 1 } )
db.data.find( { c: 5 } ).sort( { c: 1 } )
</code></pre>
<h2 id="java代码分页">Java代码分页</h2>
<p>由于确实有跳页的需求,目前还没有发现性能问题,仍旧采用skip做分页,当然也兼容条件分页</p>
<pre><code class="language-java">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;
}
</code></pre>
<p>这个示例中,目标是根据duration查询list,结果集进行分页。当请求体中包含<code>lastId</code>,那就走下一页方案。如果想要跳页,就不传<code>lastId</code>,随便你跳吧。</p>
<h3 id="抽取分页代码为公共工具类">抽取分页代码为公共工具类</h3>
<p>考虑分页需求的旺盛,每个集合都这样写感觉比较麻烦,而且容易出错。我们来把这个封装成单独一个PageHelper</p>
<pre><code class="language-java">
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;
}
}
</code></pre>
<p>对了,还有PageResult对象</p>
<pre><code class="language-java">
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;
}
</code></pre>
<h3 id="使用工具类">使用工具类</h3>
<p>最初的查询语句,业务逻辑和分页逻辑分开。</p>
<pre><code class="language-java">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());
}
</code></pre>
<h3 id="把工具类共享到maven仓库">把工具类共享到maven仓库</h3>
<p>新建一个maven项目,https://github.com/Ryan-Miao/mongo-page-helper</p>
<p>修改并提取刚才的工具类。</p>
<h4 id="如何使用">如何使用</h4>
<p>必须结合spring-boot-starter-data-mongodb来使用.</p>
<p>在pom里添加repository</p>
<pre><code class="language-xml"><repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
</code></pre>
<p>再引入依赖</p>
<pre><code class="language-xml"><dependency>
<groupId>com.github.Ryan-Miao</groupId>
<artifactId>mongo-page-helper</artifactId>
<version>1.0</version>
</dependency>
</code></pre>
<p>配置Configuration</p>
<pre><code class="language-java">@Configuration
public class MongoConfiguration{
@Autowired
private MongoTemplate mongoTemplate;
@Bean
public MongoPageHelper mongoPageHelper() {
return new MongoPageHelper(mongoTemplate);
}
}
</code></pre>
<p>然后就可以使用MongoPageHelper来注入了。</p>
<h2 id="参考">参考</h2>
<ul>
<li>官方分页推荐</li>
<li>官方sort文档</li>
<li>官方使用索引优化sort文档</li>
<li>官方复合索引</li>
<li>如何正确看待分页的需求</li>
<li>http://ian.wang/35.htm</li>
<li>https://cnodejs.org/topic/559a0bf493cb46f578f0a601</li>
</ul>
</div>
<div id="MySignature" role="contentinfo">
<div>
<p> 关注我的公众号</p>
<img src="https://images2018.cnblogs.com/blog/686418/201808/686418-20180822091328437-1109977663.jpg">
</div>
唯有不断学习方能改变!
-- <b>Ryan Miao</b><br><br>
来源:https://www.cnblogs.com/woshimrf/p/mongodb-pagenation-performance.html
頁:
[1]