Back

MongoDB 中的分页模式

MongoDB 中的分页模式

你正在构建一个动态流、产品列表或搜索结果页面。集合已经增长到数百万个文档,突然你的分页查询从毫秒级变成了秒级。罪魁祸首几乎总是相同的:你在大规模数据上使用了 skip 和 limit。

本文涵盖了 MongoDB 的核心分页模式——基于偏移量和基于游标/键集的分页——解释每种模式何时有效,何时无效。没有特定框架的建议,只有随着数据增长仍然有效的模式。

核心要点

  • 使用 skip()limit() 的偏移量分页直观易懂,但随着偏移值增加会线性退化——应将其保留用于浅层分页或小型数据集。
  • 键集(基于游标)分页使用范围查询,无论位置如何都能保持一致的性能,使其成为大型数据集的理想选择。
  • 在键集分页中始终使用唯一的决胜字段(通常是 _id)以防止重复或跳过文档。
  • 根据数据大小、访问模式以及用户是否需要顺序导航或任意页面跳转来选择分页模式。

MongoDB Skip Limit 分页:熟悉的方法

基于偏移量的分页使用 skip()limit() 将结果划分为页面。它很直观,并直接映射到”第1页、第2页、第3页”的 UI 模式。

db.posts.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * pageSize)
  .limit(pageSize)

这种模式适用于浅层分页——前几页结果、数据量有限的管理仪表板,或性能不那么关键的内部工具。

为什么 Skip 在大规模下会退化

MongoDB 索引是 B-tree 结构,而不是数组。虽然数据库可以高效地定位到索引中,但它仍然必须遍历跳过的条目才能到达请求的偏移量。跳过 10 个文档需要遍历 10 个索引条目。跳过 100,000 个则需要遍历 100,000 个条目。

这意味着第 1 页很快,第 100 页较慢,第 10,000 页则明显更慢。CPU 使用率随偏移值线性增加,与页面大小无关。

何时使用偏移量分页:

  • 用户很少导航到前几页之后
  • 总数据集较小(少于 10,000 个文档)
  • 你需要”跳转到第 X 页”功能并接受这种权衡
  • 构建查询时间不那么关键的内部工具

MongoDB 基于游标的分页:一致的性能

MongoDB 键集分页(也称为基于游标的分页)使用范围查询而不是位置偏移。不是说”跳过 1,000 个文档”,而是说”给我这个特定点之后的文档”。

db.posts.find({ createdAt: { $lt: lastSeenDate } })
  .sort({ createdAt: -1 })
  .limit(pageSize)

数据库执行高效的索引定位来找到起始点,然后只读取你需要的文档。第 1 页和第 10,000 页具有相同的性能特征。

决胜字段的要求

单个排序字段在值不唯一时会产生问题。如果多个文档共享相同的 createdAt 时间戳,某些文档可能会在跨页时被跳过或重复。

解决方案是使用唯一决胜字段的复合排序——通常是 _id:

db.posts.find({
  $or: [
    { createdAt: { $lt: lastDate } },
    { createdAt: lastDate, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(pageSize)

这需要一个匹配的复合索引:

db.posts.createIndex({ createdAt: -1, _id: -1 })

索引字段顺序必须与排序顺序匹配才能获得最佳性能。

一致性注意事项

基于游标的分页比偏移量分页更稳定,但它并非神奇地保持一致。如果在请求之间插入、删除文档,或者可变排序字段发生变化,用户仍然可能看到重复或遗漏项目。不同之处在于,键集分页锚定到特定值而不是位置,因此并发写入通常会导致更少的可见异常。

对于不需要完美一致性的动态流和列表,这种权衡通常是可以接受的。

Atlas Search 分页:不同的机制

如果你在使用带有 $search 阶段的 MongoDB Atlas Search,分页的工作方式会有所不同。Atlas Search 使用自己的基于令牌的系统——通过底层的 searchSequenceToken——使用 searchAftersearchBefore 参数,而不是 _id 游标。不要混用这些方法——使用与你的查询类型匹配的分页方法。

选择正确的模式

场景推荐模式
无限滚动动态流键集分页
”加载更多”按钮键集分页
带页码的管理表格偏移量(仅浅层页面)
需要顺序导航的大型数据集键集分页
小型静态数据集两者都可以

决策通常取决于 UI 要求。如果用户需要跳转到任意页面,你只能使用偏移量分页或混合方法。如果顺序导航就足够了——下一页、上一页、加载更多——键集分页的扩展性更好。

结论

使用 skip 和 limit 的偏移量分页简单但随偏移大小线性退化。应将其保留用于浅层分页或小型数据集。

键集分页无论位置如何都能保持一致的性能,但需要使用唯一决胜字段的确定性排序。对于动态流、列表以及用户顺序浏览大型结果集的任何界面,这是更好的选择。

这两种模式都不是普遍错误的。根据你的数据大小、访问模式和 UI 要求来选择。

常见问题

可以,你可以在键集分页中使用多个排序字段。关键要求是你的最后一个字段必须是唯一的,以作为决胜字段。你的复合索引必须与所有排序字段的确切顺序和方向匹配。每增加一个字段,查询逻辑就会变得更复杂,因为你需要嵌套的 OR 条件来处理每个前置字段的相等情况。

对于向后分页,一种常见方法是反转比较运算符和排序方向。如果向前使用 $lt 和降序排序,向后则使用 $gt 和升序排序。获取结果后,在应用程序中反转它们以保持显示顺序。存储每页的第一个和最后一个文档游标以启用双向导航。

如果在用户分页时文档的排序字段发生变化,他们可能会看到该文档两次或完全错过它,这取决于它相对于游标位置是向前还是向后移动。对于频繁更新的字段,考虑使用不可变值(如 _id)作为主要排序键,或者接受这是实时数据的固有限制。

键集分页天然不支持总计数,因为它不跟踪位置。你可以运行单独的计数查询,但这会在大型集合上增加开销。考虑用户是否真的需要精确计数——通常近似计数或简单地指示存在更多结果就能提供足够的上下文,而无需性能成本。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay