Skip to content

MongoDB 电商实战 04|深分页越来越慢:如何用游标分页解决? ​

MongoDB 电商实战 04|深分页越来越慢:如何用游标分页解决?封面

订单列表第一页很快,第五千页却越来越慢。查询中出现了这样一段代码:

javascript
db.orders.find({ status: "paid" })
  .sort({ createdAt: -1, _id: -1 })
  .skip(99980)
  .limit(20)

每页 20 条时,第五千页需要跳过前面 99,980 条记录。即使排序使用索引,数据库通常仍要从符合条件的结果中越过前面的记录,才能返回目标页。偏移越大,工作量通常越大。简单增加索引,无法消除深偏移本身的成本。

下一页从哪里继续? ​

解决思路不是告诉数据库“跳过前面多少条”,而是告诉它“从上一页最后一条记录之后继续”。这就是基于范围查询的游标分页,也常被称为 Keyset Pagination。

先确定稳定的排序规则:

javascript
{ createdAt: -1, _id: -1 }

createdAt 相同的订单,再按 _id 排序。_id 在集合中唯一,因此可以为每条订单确定一个稳定的位置。本系列使用字符串 ID,它只负责同一时间内的确定排序,不代表创建时间。

第一步:查询第一页 ​

每页展示 20 条,但实际多查询一条,用来判断后面是否还有数据:

javascript
const pageSize = 20;

const firstRows = db.orders.find({ status: "paid" })
  .sort({ createdAt: -1, _id: -1 })
  .limit(pageSize + 1)
  .toArray();

const hasMore = firstRows.length > pageSize;
const items = firstRows.slice(0, pageSize);

// 下一页游标来自最后一条实际展示的记录
const lastOrder = items.length
  ? items[items.length - 1]
  : null;

如果查询到 21 条,就展示前 20 条,并将 hasMore 设为 true。第 21 条只用于判断是否还有下一页,不应该作为下一次查询的边界。

第二步:使用最后一条记录继续查询 ​

假设上一页最后展示的订单是:

javascript
const lastTime = ISODate("2026-10-09T02:00:00.000Z");
const lastId = "o_7b1";

下一页查询:

javascript
db.orders.find({
  status: "paid",
  $or: [
    { createdAt: { $lt: lastTime } },
    { createdAt: lastTime, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(21)

查询条件有两部分:

  • createdAt 小于上一页最后一条订单的时间;
  • 或者 createdAt 相同,但 _id 小于上一页最后一条订单的 ID。

这相当于沿着 { createdAt: -1, _id: -1 } 的排序方向继续向后查找。

配套的索引是:

javascript
db.orders.createIndex({
  status: 1,
  createdAt: -1,
  _id: -1
});

status 是等值筛选字段,放在索引前面;后面的字段与排序顺序一致。这个索引服务于当前这条查询路径,不代表它能自动满足所有订单列表需求。

第三步:生成下一次游标 ​

下一页同样查询 pageSize + 1 条,只展示前 pageSize 条。下一次游标仍然取实际展示的最后一条:

javascript
const rows = db.orders.find({
  status: "paid",
  $or: [
    { createdAt: { $lt: lastTime } },
    { createdAt: lastTime, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(pageSize + 1)
.toArray();

const hasMore = rows.length > pageSize;
const items = rows.slice(0, pageSize);
const lastOrder = items.length
  ? items[items.length - 1]
  : null;

// hasMore 为 true 时,再由服务端根据 lastOrder 生成 nextCursor

这里的核心规则只有一条:游标必须取最后一条实际展示的记录,而不是额外查询出来的那一条。

createdAt 应保存为 BSON Date,并在订单创建后保持不可变;_id 应保持唯一且类型一致。否则,即使查询条件写对了,也可能因为排序字段变化或类型不一致而出现重复、遗漏。

游标也是接口合同 ​

数据库查询使用的是时间和 ID,接口通常不会直接把 BSON 对象暴露给前端。服务端可以将必要字段序列化为游标,再编码并签名。

一个游标载荷可以包含:

json
{
  "v": 1,
  "sort": "createdAt_desc_id_desc",
  "createdAt": "2026-10-09T02:00:00.000Z",
  "id": "o_7b1",
  "filterHash": "服务端计算的筛选条件指纹",
  "exp": 1791504000
}

这里的 JSON 只是结构示意,不应直接信任前端提交的字段。服务端解析时至少要检查:

  • 游标格式、版本、字段类型和长度;
  • 签名是否有效,是否已经过期;
  • 排序定义和当前筛选条件是否一致;
  • 当前用户是否仍有权限访问这些订单。

Base64URL 只是编码,不是加密,也不能防止篡改。若需要防篡改,可以使用经过设计的签名格式,例如 HMAC。签名不能替代权限控制:每次请求仍要根据当前登录身份重新生成允许访问的数据范围。

游标不要放用户地址、手机号等敏感信息。createdAt 在接口层可以使用 UTC 的 ISO 8601 字符串,服务端解码后必须转换回 BSON Date 再参与查询。游标还可以包含过期时间,避免旧游标长期有效。

改变状态筛选、时间范围或排序方式后,应从第一页重新开始,或者拒绝使用旧游标。否则,上一种查询条件生成的位置可能不再适用于新查询。pageSize 可以允许在合理范围内调整,但服务端必须限制最大值,不能因为客户端传入很大的数字而读取大量数据。

这不自动提供快照 ​

游标分页解决的是“从哪里继续读取”,并不自动保证多次请求读取的是同一个数据快照。

例如,客服正在翻看已支付订单,期间一张待支付订单变成了已支付。后续请求仍然使用 status: "paid",这条订单是否出现在后续页面,取决于它的排序位置和状态变化时机。普通的多次查询不是同一时点的数据集合。

对于实时客服列表,通常可以接受这种动态语义,并在页面顶部提示有新订单或列表内容已更新。对于财务审核、结算和正式导出,则需要固定业务口径,不能把普通游标分页当成快照。

MongoDB 5.0 起,部分读取操作可以在多文档事务之外使用 readConcern: "snapshot",例如 find 和 aggregate。配合 atClusterTime,可以让多次读取指定同一个时间点的数据。但这不是无限期快照:读取必须处于 minSnapshotHistoryWindowInSeconds 保留的历史窗口内,超出窗口可能失败。若要采用这种方式,服务端需要取得并保存第一次读取所使用的时间戳,并让后续读取复用;具体调用方式应按 MongoDB 版本和所用驱动验证。

因此,短时间、边界明确的读取任务可以评估 Snapshot Read;长时间运行的导出,更适合生成独立结果快照或使用不可变的业务事实。不要为了浏览器翻页而长期保持一个数据库事务。

客服真正需要的是“找到订单”,还是“跳到某一页”? ​

当客服要求查看第几千页时,先问清楚操作目的。很多时候,他想找某一天的订单、某个用户的记录,或者一段时间内未发货的订单。页码只是旧系统留下的入口,并不代表业务真的需要随机访问第几千个结果区间。

如果补充时间筛选、订单号精确查询与用户入口,就能把大量深分页需求转成更小范围的访问。这里的价值不是让客服适应数据库限制,而是帮助他用更直接的方式完成工作。产品设计与数据库性能经常在这一层相遇。

用六条数据看清边界条件 ​

假设时间倒序列表里有六张订单,其中三张创建时间相同:

订单createdAt_id
A10:00:00.000c
B10:00:00.000b
C10:00:00.000a
D09:59:59.000d
E09:59:58.000e
F09:59:57.000f

按 { createdAt: -1, _id: -1 } 排序,第一页显示 A、B,最后一条是 B。下一页需要从 C 开始,也就是查询“时间早于 B,或者时间相同但 ID 小于 B”的记录。

如果只写 createdAt < T,同时间但排在 B 后面的 C 会被跳过;如果写 createdAt <= T,A、B 等同一时间的记录又可能重复出现。加入唯一的第二排序键,才能把一个时间点中的位置精确描述出来。

本系列使用随机字符串 ID,它只负责同一时间内的确定顺序,不代表时间。不能把随机 UUID 当成整体创建时间排序。ObjectId 也不应该被简单解释为严格的全局提交顺序:它只有秒级时间精度,并且由客户端生成,客户端时钟可能不同。

一个实际接口应该返回什么? ​

接口可以返回当前页数据、是否还有下一页,以及下一次使用的游标:

json
{
  "items": [
    {
      "id": "o_example",
      "createdAt": "2026-10-09T02:00:00.000Z"
    }
  ],
  "hasMore": true,
  "nextCursor": "服务端编码并验证的继续位置"
}

nextCursor 应由服务端生成。解析失败、签名错误、版本不匹配或游标过期时,应返回明确错误,不能静默退回第一页,让用户误以为列表重复是正常行为。

空结果返回 items: []、hasMore: false 和空游标。最后一页即使刚好返回 20 条,也不能仅凭“当前页满了”就断言还有下一页;必须额外查询一条,才能区分“刚好结束”和“仍有更多”。

上一页怎样实现? ​

连续浏览时,最简单的方案通常是前端保存每一页的请求游标。返回上一页时,重新使用上一页的起始游标请求;也可以直接保留已经加载的页面数据。这样实现相对简单,但重新请求时,动态列表的内容可能已经发生变化。

如果接口需要根据当前页位置反向查询上一页,可以反转比较条件和排序方向。假设当前页第一条订单是 firstOrder:

javascript
const firstTime = firstOrder.createdAt;
const firstId = firstOrder._id;

const rows = db.orders.find({
  status: "paid",
  $or: [
    { createdAt: { $gt: firstTime } },
    { createdAt: firstTime, _id: { $gt: firstId } }
  ]
})
.sort({ createdAt: 1, _id: 1 })
.limit(pageSize + 1)
.toArray();

const hasMoreBeforePreviousPage = rows.length > pageSize;
const previousItems = rows.slice(0, pageSize).reverse();

正常列表按时间和 ID 倒序显示;查询上一页时,使用 $gt,并将两个排序字段都反转为升序。查询结果取最靠近当前页边界的 pageSize 条,再反转回正常展示顺序。多查的一条用于判断所返回的上一页之前是否还有数据。

无论使用游标栈还是反向查询,都要向产品说明:这是动态列表中的前后位置,不是永远不变的页码。如果用户希望返回时看到完全相同的内容,可以由前端保留已加载结果;这是一种界面缓存策略,仍不能把它说成数据库快照。

游标分页能否直接跳到第 1000 页? ​

通常不能在没有中间位置的情况下免费做到。游标保存的是某个排序位置,想直接跳到第 1000 页,需要先知道那个位置在哪里。

可以通过预先维护边界、固定结果集或特定业务序号支持跳转,但这些方案会增加存储、更新和失效管理责任。多数后台更适合浅页码加筛选,或连续游标加搜索。

如果产品必须随机跳页,就要明确它的成本与适用范围。限制最大偏移、缩小时间范围、缓存总数,都是可能的方案。不要让接口仍然接收任意页码,内部却悄悄只支持前几十页,这会造成难以发现的业务遗漏。

总页数与总条数从哪里来? ​

countDocuments() 本身也有工作量,复杂筛选下的精确总数不一定便宜。每次翻页都重新精确计数,可能抵消列表查询的优化。产品应该判断是否真的需要实时精确总数,还是显示“已加载多少条”和“还有更多”就够用。

后台确实需要总数时,可以单独计算并说明更新时间,或在明确范围内执行计数。不要把估算总量与某个复杂筛选后的精确匹配数量混用。列表和总数不是同一时点读取时,也应允许它们出现合理的瞬时差异。

用户翻页时有新订单,会重复吗? ​

如果新订单的 createdAt 晚于当前游标位置,它通常位于当前列表已经浏览过的位置之前,后续页不会重新回到顶部把它插入已浏览页面。这比偏移分页在插入记录后发生位置移动更容易预测。

但如果系统允许补录订单、回填较早的 createdAt,新记录也可能落入后续查询范围。更常见的变化是订单状态进入或退出筛选范围:订单可能在翻页过程中加入结果集,也可能从结果集中消失。

因此,创建时间最好由服务端生成并保持不可变;支付时间则在首次支付时确定。对于实时客服列表,可以允许顶部提示“有新订单”;对于审核、结算或导出任务,应选择固定业务范围或建立结果快照。不要长期保持一个数据库事务,跨用户几分钟的交互维持快照,这会引入资源与时间限制问题。

导出与页面浏览应该拆开 ​

用户要导出一个月订单时,不应让浏览器反复请求普通列表,再拼出文件。更合适的方式是由后台创建导出任务,记录权限范围、筛选条件、截止条件和统计口径,再按稳定游标分批读取并生成文件。任务可以记录检查点,失败后从检查点恢复;导出结果还应设置过期时间和访问权限。

固定上界可以排除后来创建时间晚于上界的订单,但不能自动冻结已有订单的状态和其他字段。要求强一致报表时,还需要不可变交易事实、版本化数据或专门快照机制。对于 MongoDB 5.0 及以上版本,可以评估在快照历史保留窗口内使用 readConcern: "snapshot" 和 atClusterTime;任务可能运行很久时,则不应假设历史快照一定一直可用。

把“设置截止时间”写成“得到完全一致快照”,是分页与导出教程里容易出现的隐蔽错误。

怎样验证分页方案? ​

先用固定数据验证完整遍历,至少包含:

  • 相同毫秒时间、不同 _id 的多条订单;
  • 第一页不足页大小、最后一页刚好满页;
  • 空集合和没有下一页的情况;
  • 非法、过期、被篡改的游标;
  • 改变筛选条件、排序版本和用户权限。

比较完整遍历得到的 ID 集合与目标集合,确保在固定数据集下没有重复和遗漏。再加入并发写入与状态转换,观察结果是否符合已声明的动态语义。

性能方面,不要只测第一页。分别比较偏移分页和游标分页在不同位置的执行统计:

javascript
db.orders.find({ status: "paid" })
  .sort({ createdAt: -1, _id: -1 })
  .skip(99980)
  .limit(20)
  .explain("executionStats");

再对游标查询执行同样的统计:

javascript
db.orders.find({
  status: "paid",
  $or: [
    { createdAt: { $lt: lastTime } },
    { createdAt: lastTime, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(21)
.explain("executionStats");

重点观察:

指标观察目的
nReturned实际返回了多少条记录
totalKeysExamined检查了多少索引键
totalDocsExamined检查了多少文档
winningPlan实际采用了什么执行计划

不要要求游标查询一定只检查 21 个索引键或 21 个文档。扫描量还会受到筛选条件选择性、数据分布和执行计划影响。真正要验证的是:在代表性数据下,游标查询不再随着“已经翻过多少页”而持续付出跳过前面所有结果的成本。

测试还应记录接口总延迟、并发情况和索引大小。分页设计的目标,是让成本和行为都可预测,而不是把一个 .skip() 简单替换成 $lt 就结束。

翻页协议中的几个小细节,会决定是否真的好用 ​

第一,第一页没有游标时,使用相同的筛选与排序规则;后续请求携带游标,服务端仍要重新确认权限。

第二,字符串比较受排序规则(collation)影响。若查询涉及字符串排序,查询使用的排序规则必须与索引相匹配。游标中的 ID 也必须按照数据库实际 BSON 类型解析,不能把 ObjectId 当成普通字符串直接参与比较。本系列使用字符串 ID,是为了让示例中的类型保持一致。

第三,BSON Date 内部存储的是 UTC 时间对应的毫秒数,并不保存原始时区信息。接口序列化时间时,应保留毫秒精度并使用明确的时区表示,例如 UTC 格式的 ISO 8601 字符串。服务端解码游标后,再将它转换为 BSON Date 参与比较。

第四,订单被删除时,游标保存的是值边界,不要求边界记录仍然存在。但如果排序字段发生变化,记录就可能跨过边界。因此,用于分页的字段应尽量稳定;游标记录找不到不一定是错误,字段类型或排序版本不匹配则应该拒绝。

给产品的验收演示,比只给性能图更重要 ​

演示第一页、下一页、上一页、新订单插入、订单状态改变以及修改筛选后重置。让客服确认“当前列表会变化”符合工作方式,再决定是否保留已加载页面。对审核任务则演示固定结果集或任务式导出,确认审核对象不会被动态筛选悄悄改变。

同时说明页码展示方式。可以显示“第几批”或已浏览记录数,但不要暗示可以直接跳到任何页。将行为提前讲清楚,会减少用户把正常更新当成数据库漏数据的情况。

技术验收与产品验收应该结合:扫描量随继续位置保持合理,用户也能顺利找到目标订单。一个性能很好却无法表达状态变化的列表,依然需要改进;而一个界面熟悉却允许无限深偏移的接口,也需要明确成本与限制。

本篇小结 ​

深分页的核心问题,不是页码本身,而是 skip() 必须越过前面的结果。游标分页通过记录上一页最后一条数据的排序键,将下一次查询变成有明确边界的范围查询。

要让它真正可靠,需要同时满足几个条件:排序字段组合能够形成唯一顺序;排序字段尽量不可变;索引与过滤条件、排序方向相匹配;游标经过服务端校验;接口明确普通分页的动态语义与导出任务的一致性要求。

浅页码分页、连续游标分页和后台导出任务解决的是不同问题。先明确用户要完成的业务操作,再选择对应方案,比把所有分页都改成游标更重要。


参考:

上次更新于: