MongoDB分页的范围查询

Posted

技术标签:

【中文标题】MongoDB分页的范围查询【英文标题】:Range query for MongoDB pagination 【发布时间】:2014-01-24 11:56:04 【问题描述】:

我想在 MongoDB 上实现分页。对于我的范围查询,我考虑过使用 ObjectID:

db.tweets.find( _id:  $lt: maxID  ,  limit: 50 )

但是,according to the docs,ObjectID 的结构意味着“ObjectId 值不代表严格的插入顺序”:

ObjectId 值的顺序与生成时间的关系在单秒内并不严格。 如果多个系统,或单个系统上的多个进程或线程生成值,则在一秒钟内; ObjectId 值不代表严格的插入顺序。 客户端之间的时钟偏差也可能导致值的非严格排序,因为客户端驱动程序生成 ObjectId 值,而不是 mongod 进程。

然后我想到了用时间戳查询:

db.tweets.find( created:  $lt: maxDate  ,  limit: 50 )

但是,不能保证日期是唯一的 - 很可能会在同一秒内创建两个文档。这意味着分页时可能会丢失文档。

是否有任何种类的范围查询可以为我提供更高的稳定性?

【问题讨论】:

为什么你认为 ObjectId() 不能给你稳定性? 在我的问题中,根据文档,ObjectID 的结构意味着“ObjectId 值不代表严格的插入顺序”。 【参考方案1】:

尽管您的分页语法错误,但使用 ObjectId() 非常好。你想要:

 db.tweets.find().limit(50).sort("_id":-1);

这表示您希望推文按_id 值按降序排序,并且您想要最近的 50 条。您的问题是,当当前结果集发生变化时分页很棘手 - 所以不要使用 skip 进行下一个页面,您要记下结果集中最小的 _id(第 50 个最近的 _id 值,然后获取下一页:

 db.tweets.find( _id :  "$lt" : <50th _id>   ).limit(50).sort("_id":-1);

这将为您提供下一条“最新”推文,而不会有新的推文扰乱您的分页。

完全没有必要担心_id 的值是否严格对应于插入顺序 - 它将足够接近 99.999%,并且实际上没有人关心哪条推文首先出现的亚秒级 - 你甚至可能请注意 Twitter 经常显示乱序的推文,这并不重要。

如果它很关键,那么您将不得不使用相同的技术,但使用“tweet date”,其中该日期必须是时间戳,而不仅仅是日期。

【讨论】:

我对使用 ObjectID 进行范围查询的主要担心是它们不会代表严格的顺序。如果两个文档是使用不同的 MongoDB 进程创建的(我不知道什么时候会发生这种情况;可能在分片数据库上),这将意味着 ObjectID 的中间两个片段(“一个 3 字节的机器标识符,一个 2 字节的进程 id") 会有所不同。我担心这种事情会影响排序(以及范围查询)。 是的,它们确实代表了严格的秩序。 ObjectId() 将以相同的方式进行确定性和一致的排序。这样想,如果您对“1,a,1”和“1,b,1”进行排序,那么两者中的 1 相同并不重要,因为 a>b 所以这两个将总是 相对于彼此的顺序相同。 这就是重点。下一页将在第一个查询执行后读取,因此如果您非常关心在页面中保持正确的稳定顺序,您将错过推文。此外,如果您移至上一页,您可能会看到插入在已看到的推文之间的推文,这可能是可接受的或不可接受的(尽管这通常是一个非常糟糕的用户体验)。 快速问题&lt;50th _id&gt; 您是否将其用作第 50 个 _id 的某种占位符?或者它是一个特殊的查询参数?因为我在谷歌上找不到任何其他类似查询的示例。它甚至不在用于范围查询的 mongo 文档中 这是您放置从上一批中获得的最后一个 _id 的值的位置,因此是占位符,而不是特殊语法。一般表示“此处填写适当的值”【参考方案2】:

推文“实际”时间戳(即发布推文的时间和您希望对其排序的标准)与推文“插入”时间戳(即添加到本地集合的时间)不会不同。当然,这取决于您的应用程序,但很可能会出现推文插入可能被批处理或以其他方式最终以“错误”顺序插入的情况。因此,除非您在 Twitter 工作(并且可以访问以正确顺序插入的集合),否则您将无法依赖 $naturalObjectID 进行排序逻辑。

Mongo 文档建议 skip and limit for paging:

db.tweets.find(created: $lt: maxID).
          sort(created: -1, username: 1).
          skip(50).limit(50); //second page

但是,使用 skip 时存在性能问题:

cursor.skip() 方法通常代价高昂,因为它需要服务器从集合或索引的开头开始遍历以获取偏移或跳过位置,然后再开始返回结果。随着偏移量的增加,cursor.skip() 将变得更慢并且更占用 CPU。

发生这种情况是因为skip 不适合 MapReduce 模型并且不是一个可以很好扩展的操作,您必须等待一个排序集合变得可用才能“切片”。现在limit(n) 听起来像一个同样糟糕的方法,因为它“从另一端”应用了类似的约束;但是,在应用排序后,引擎能够通过在遍历集合时仅在内存中保留每个分片的 n 个元素来优化流程。

另一种方法是使用基于范围的分页。检索第一页推文后,您知道最后一条推文的 created 值是什么,因此您只需用这个新值替换原来的 maxID

db.tweets.find(created: $lt: lastTweetOnCurrentPageCreated).
          sort(created: -1, username: 1).
          limit(50); //next page

像这样执行find 条件可以很容易地并行化。但是如何处理下一个以外的页面呢?您不知道第 5、10、20 页甚至上一页页的开始日期! @SergioTulentsev 建议 creative chaining of methods 但我主张在单独的 pages 集合中预先计算聚合字段的首尾范围;这些可以在更新时重新计算。此外,如果您对DateTime 不满意(注意性能备注)或担心重复值,您应该考虑compound indexes 时间戳+帐户绑定(因为用户不能同时发两次推文) ,甚至是两者的人工聚合:

db.pages.
find(pagenum: 3)
> pagenum:3; begin:"01-01-2014@BillGates"; end:"03-01-2014@big_ben_clock"

db.tweets.
find(_sortdate: $lt: "03-01-2014@big_ben_clock", $gt: "01-01-2014@BillGates").
sort(_sortdate: -1).
limit(50) //third page

使用聚合字段进行排序“在折叠中”工作(尽管可能有更多的犹太洁食方法来处理这种情况)。这可以设置为a unique index,并在插入时纠正值,单个推文文档看起来像


  _id: ...,
  created: ...,    //to be used in markup
  user: ...,    //also to be used in markup
  _sortdate: "01-01-2014@BillGates" //sorting only, use date AND time

【讨论】:

我认为使用skip 是不好的做法? docs.mongodb.org/manual/reference/method/cursor.skip @OliverJosephAsh:它很昂贵,但似乎是实现分页的唯一方法(示例甚至在文档中)。我想不出在您的示例中使用基于范围的分页的方法(即查询“下一个 10”结果是微不足道的,但是您将如何计算跳转到页面的正确范围,例如 55?)。我很快就会扩展我的答案。 跳转到一个页面并不是我需要的。我只需要能够说“给我接下来的 50 个文件”。对不起,如果我把它称为分页混淆了。 使用 skip 绝对没问题(只是不能跳过 70% 的集合或类似的东西。@ov 我不知道你在其中引入 MapReduce 是什么意思 - 它有 nothing与查询有关。唯一索引也完全不适合这个。 skip 是一个糟糕的分页选择,除了一些特定的条件。它本质上就像在 mysql 中使用 LIMIT X,Y 一样,您可以找到很多关于为什么它是一个糟糕选择的文章。【参考方案3】:

即使有多个文档在同一毫秒内插入/更新,即使来自多个客户端(生成 ObjectId),以下方法也可以工作。为简单起见,在以下查询中,我将投影 _id、lastModifiedDate。

    第一页,取第一页的modifiedTime(降序),ObjectId(升序)排序的结果。

    db.product.find(,"_id":1,"lastModifiedDate":1).sort("lastModifiedDate":-1, "_id":1).limit(2)

记下在此页面中获取的最后一条记录的 ObjectId 和 lastModifiedDate。 (样体,lmd)

    对于 sencod 页面,包括查询条件以搜索 if (lastModifiedDate = lmd AND oid > loid) OR (lastModifiedDate

db.productfind($or:["lastModifiedDate":$lt:lmd,"_id":1,"lastModifiedDate":1,$and:["lastModifiedDate":lmd,"_id":$gt:loid]],"_id":1,"lastModifiedDate":1).sort("lastModifiedDate":-1, "_id":1).limit(2)

对后续页面重复相同的操作。

【讨论】:

【参考方案4】:

如果您将查询限制在前一秒(或者不关心亚秒以下的怪异可能性),ObjectIds 应该足以用于分页。如果这还不足以满足您的需求,那么您将需要实现一个像自动增量一样工作的 ID 生成系统。

更新:

要查询前一秒的 ObjectId,您需要手动构造一个 ObjectID。

参见ObjectId http://docs.mongodb.org/manual/reference/object-id/的规范

尝试在 mongos 中使用此表达式。

 _id : 
  
      $lt : ObjectId(Math.floor((new Date).getTime()/1000 - 1).toString(16)+"ffffffffffffffff")
  


末尾的“f”是为了最大化可能与时间戳无关的随机位,因为您正在执行小于查询。

我建议在您的应用程序服务器上而不是在 mongos 上实际创建 ObjectId,因为如果您有很多用户,这种类型的计算会减慢您的速度。

【讨论】:

有趣,谢谢。减去一秒,您将如何使用 ID 进行查询? 使用日期可能更容易,因为我已经存储了它?如果我确实按照您上面的演示进行操作,我想我需要将输入日期替换为我希望它小于的 ObjectID 的时间戳? 是的,您需要将日期替换为 ObjectId 的时间戳。如果亚秒级推文很少,则使用存储的日期就足够了,否则 ObjectId 是您的最佳选择。 我看不出使用存储日期或使用 ObjectID 中的时间戳之间有什么区别。他们肯定都会引入“亚秒级怪异”吗? 如果您还没有按 ObjectId 对结果进行排序,那么仅使用日期约束应该会有亚秒级的怪异。如果您在一秒钟内创建了足够多的推文以超过每页的结果,那么使用日期也不会让您分页。这是一个边缘案例。【参考方案5】:

我已经以这种方式使用 mongodb _id 构建了一个分页。

// import ObjectId from mongodb
let sortOrder = -1;
let query = []
if (prev) 
    sortOrder = 1
    query.push(title: 'findTitle', _id:$gt: ObjectId('_idValue'))


if (next) 
    sortOrder = -1
    query.push(title: 'findTitle', _id:$lt: ObjectId('_idValue'))


db.collection.find(query).limit(10).sort(_id: sortOrder)

【讨论】:

以上是关于MongoDB分页的范围查询的主要内容,如果未能解决你的问题,请参考以下文章

mongodb 多表分页怎么查询

MongoDB分页的Java实现和分页需求的思考

如何使用MongoDB聚合进行分页?

带有可分页的 Spring 自定义查询

MongoDB分页获取数据排序阶段缓存溢出问题

MongoDB 范围分页