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

订单列表第一页很快,第五千页却越来越慢。查询中出现了这样一段代码:
db.orders.find({ status: "paid" })
.sort({ createdAt: -1, _id: -1 })
.skip(99980)
.limit(20)每页 20 条时,第五千页需要跳过前面 99,980 条记录。即使排序使用索引,数据库通常仍要从符合条件的结果中越过前面的记录,才能返回目标页。偏移越大,工作量通常越大。简单增加索引,无法消除深偏移本身的成本。
下一页从哪里继续?
解决思路不是告诉数据库“跳过前面多少条”,而是告诉它“从上一页最后一条记录之后继续”。这就是基于范围查询的游标分页,也常被称为 Keyset Pagination。
先确定稳定的排序规则:
{ createdAt: -1, _id: -1 }createdAt 相同的订单,再按 _id 排序。_id 在集合中唯一,因此可以为每条订单确定一个稳定的位置。本系列使用字符串 ID,它只负责同一时间内的确定排序,不代表创建时间。
第一步:查询第一页
每页展示 20 条,但实际多查询一条,用来判断后面是否还有数据:
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 条只用于判断是否还有下一页,不应该作为下一次查询的边界。
第二步:使用最后一条记录继续查询
假设上一页最后展示的订单是:
const lastTime = ISODate("2026-10-09T02:00:00.000Z");
const lastId = "o_7b1";下一页查询:
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 } 的排序方向继续向后查找。
配套的索引是:
db.orders.createIndex({
status: 1,
createdAt: -1,
_id: -1
});status 是等值筛选字段,放在索引前面;后面的字段与排序顺序一致。这个索引服务于当前这条查询路径,不代表它能自动满足所有订单列表需求。
第三步:生成下一次游标
下一页同样查询 pageSize + 1 条,只展示前 pageSize 条。下一次游标仍然取实际展示的最后一条:
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 对象暴露给前端。服务端可以将必要字段序列化为游标,再编码并签名。
一个游标载荷可以包含:
{
"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 |
|---|---|---|
| A | 10:00:00.000 | c |
| B | 10:00:00.000 | b |
| C | 10:00:00.000 | a |
| D | 09:59:59.000 | d |
| E | 09:59:58.000 | e |
| F | 09:59:57.000 | f |
按 { createdAt: -1, _id: -1 } 排序,第一页显示 A、B,最后一条是 B。下一页需要从 C 开始,也就是查询“时间早于 B,或者时间相同但 ID 小于 B”的记录。
如果只写 createdAt < T,同时间但排在 B 后面的 C 会被跳过;如果写 createdAt <= T,A、B 等同一时间的记录又可能重复出现。加入唯一的第二排序键,才能把一个时间点中的位置精确描述出来。
本系列使用随机字符串 ID,它只负责同一时间内的确定顺序,不代表时间。不能把随机 UUID 当成整体创建时间排序。ObjectId 也不应该被简单解释为严格的全局提交顺序:它只有秒级时间精度,并且由客户端生成,客户端时钟可能不同。
一个实际接口应该返回什么?
接口可以返回当前页数据、是否还有下一页,以及下一次使用的游标:
{
"items": [
{
"id": "o_example",
"createdAt": "2026-10-09T02:00:00.000Z"
}
],
"hasMore": true,
"nextCursor": "服务端编码并验证的继续位置"
}nextCursor 应由服务端生成。解析失败、签名错误、版本不匹配或游标过期时,应返回明确错误,不能静默退回第一页,让用户误以为列表重复是正常行为。
空结果返回 items: []、hasMore: false 和空游标。最后一页即使刚好返回 20 条,也不能仅凭“当前页满了”就断言还有下一页;必须额外查询一条,才能区分“刚好结束”和“仍有更多”。
上一页怎样实现?
连续浏览时,最简单的方案通常是前端保存每一页的请求游标。返回上一页时,重新使用上一页的起始游标请求;也可以直接保留已经加载的页面数据。这样实现相对简单,但重新请求时,动态列表的内容可能已经发生变化。
如果接口需要根据当前页位置反向查询上一页,可以反转比较条件和排序方向。假设当前页第一条订单是 firstOrder:
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 集合与目标集合,确保在固定数据集下没有重复和遗漏。再加入并发写入与状态转换,观察结果是否符合已声明的动态语义。
性能方面,不要只测第一页。分别比较偏移分页和游标分页在不同位置的执行统计:
db.orders.find({ status: "paid" })
.sort({ createdAt: -1, _id: -1 })
.skip(99980)
.limit(20)
.explain("executionStats");再对游标查询执行同样的统计:
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() 必须越过前面的结果。游标分页通过记录上一页最后一条数据的排序键,将下一次查询变成有明确边界的范围查询。
要让它真正可靠,需要同时满足几个条件:排序字段组合能够形成唯一顺序;排序字段尽量不可变;索引与过滤条件、排序方向相匹配;游标经过服务端校验;接口明确普通分页的动态语义与导出任务的一致性要求。
浅页码分页、连续游标分页和后台导出任务解决的是不同问题。先明确用户要完成的业务操作,再选择对应方案,比把所有分页都改成游标更重要。
参考: