MongoDB深分页性能优化:告别skip,拥抱游标分页与索引设计

发布时间:2026/10/13 22:40:11
MongoDB深分页性能优化:告别skip,拥抱游标分页与索引设计 有一类性能问题我几乎每隔几个月就要跟人解释一遍MongoDB 分页到底怎么搞才能既支持大数据量又不被skip()拖垮。早些年我们一个后台管理系统的列表页数据量到了一千多万条之后翻到第 500 页的接口直接花了 20 多秒DBA 一看慢日志全是被这条分页查询打出来的。后来我把方案从skip() limit()改成基于排序键的游标分页同样的数据量翻到后面的页面稳定在 100 毫秒以内。这篇文章就把当时踩过的坑、对比过的方案和最终落地的替换思路完整写出来希望能帮遇到同样问题的朋友少走弯路。1. 为什么skip()在浅分页时看不出问题一到深分页就崩1.1 一条skip()查询的真实执行路径很多人以为find().skip(10000).limit(20)只是“跳过前 1 万条再取 20 条”性能应该不错。但 MongoDB 本身没有“直接从第 10001 条开始取”的能力它的执行逻辑是先把满足条件的文档找出来按排序条件排好序然后从头开始一条一条数数到第 10001 条才开始返回。这个过程中的关键代价是“找出来”和“排好序”。如果查询条件没有命中索引MongoDB 就必须对集合做全表扫描如果排序字段没有索引那它在返回结果之前还得把符合条件的文档全部加载进内存做排序。数据量小的时候这些操作都在毫秒级完成但当集合有几百万甚至上千万条记录时skip()的偏移量越大白白丢弃的文档就越多。用一个生活化的例子来理解你有一本 1000 页的电话簿想找第 500 页的人名正常人会直接翻到中间但skip()的做法是从第 1 页开始往下翻每翻一页都确认一次“这是不是第 500 页”翻错了一页还要重新来耗时自然随页码增长而线性增加。1.2 从explain()结果里看真相不要猜直接让 MongoDB 把执行计划告诉我们。在 mongo shell 里跑一下db.orders.find({ status: shipped }) .sort({ createdAt: -1 }) .skip(100000) .limit(20) .explain(executionStats);重点关注这几项stage如果出现COLLSCAN说明查询条件没有走索引你最该优化的不是分页而是过滤条件。如果排序字段没有索引还可能出现SORTstage。totalDocsExamined这个数字最能说明问题。skip(100000).limit(20)可能让 MongoDB 扫描并丢弃 10 万条文档然后才返回 20 条。docsReturned最后返回的文档数。executionTimeMillis整体耗时。在我当年那个慢查询上totalDocsExamined和totalKeysExamined都是七位数而docsReturned只有 20。也就是说数据库为了让你翻到第 5000 页把前面几十万条文档反复扫了一遍又一遍。这种浪费是架构层面的指望调一个参数就能解决是不可能的。1.3 大数据量下的真实软件测试偏移量越大耗时越离谱我专门造了一批测试数据来对比集合里放了 1000 万条订单记录过滤条件是status shipped排序字段是createdAt。翻页位置和耗时的大致关系如下翻页位置skip 偏移量排序内存总耗时约第 1 页0小20ms第 100 页2000中等120ms第 1000 页20000较大900ms第 5000 页100000很大6.8s第 10000 页200000极大15s这里还没算上并发。如果系统同时有几十个用户在不同位置翻页每次翻页都要去重新扫描那个偏移量之前的所有数据数据库 I/O 会被瞬间打满。所以结论很直接skip()适合“数据量少 翻页浅”的场景不适合“数据量大 深分页”的场景。如果你的系统已经出现了翻页越来越慢或者是导出任务在取第 10 万条数据时超时那就该换方案了。2. 替代方案的核心思路把“翻页”改成“定位”2.1 先想清楚你要哪种分页模型在做优化之前必须分清业务需要的是“页数分页”还是“游标分页”。页数分页用户能看到“第 1 页、第 2 页……第 100 页”的页码可以随意跳转。这类需求常见于后台管理系统。它的特点是必须知道总页数所以一定要先count总数。游标分页用户只能“上一页 / 下一页”地顺序翻不能直接跳到任意页面。这种模式常见于信息流、消息记录、日志列表。它的特点是无需计算总页数天然适合用排序键做范围查询。skip()能同时满足两种模式但它的大问题就出在“翻页越深越贵”。如果你做的是信息流这类顺序浏览场景完全没必要用页数分页来折磨数据库。改成游标分页后每次查询都是从上一页的最后一条记录继续往后找查询代价跟翻多少页没关系只跟“找 20 条新数据”有关这才是真正的线性收益。2.2 计算总页数也有讲究别把countDocuments()用错只要是页数分页就必须知道总文档数。MongoDB 里建议用countDocuments()来精确计算它底层是聚合框架能正确处理过滤条件。一个容易踩的坑是collection.estimatedDocumentCount()很快但它不基于过滤条件只是从集合的元信息里获取一个估算值。如果业务上可以接受“页数显示个大概”用它可以省不少事但如果有严格的过滤条件必须用const total await db.collection(orders) .countDocuments({ status: shipped });这里第二个坑是countDocuments的过滤条件要和查询列表的过滤条件保持一致。不然总数是 100 万列表只查得到 10 万条翻到 11 页直接空白用户会以为系统坏了。在数据量特别大的情况下如果countDocuments本身要跑好几秒我会建议业务方接受估算总数或者干脆把“总页数”这个万恶之源从 UI 上拿掉只保留“上一页 / 下一页 / 回到首页”。为用户体验牺牲一点精确度换来的是数据库的长期稳定。2.3 keyset 分页Seek 分页的模型谈到替代skip()最经典的就是 keyset 分页也叫 Seek 分页。它的核心思想不是“跳过前面 N 条”而是“记住最后一条记录的位置直接从那个位置后面继续找”。举个例子按createdAt倒序排列的订单列表第一页查到了最后一条记录的createdAt 某个时间戳 a第二页的查询条件就变成db.orders.find({ status: shipped, createdAt: { $lt: a } }) .sort({ createdAt: -1 }) .limit(20);这样 MongoDB 只需要通过索引找到比a更早的 20 条记录完全不需要跳过任何数据。“跳过”这个动作彻底消失了。keyset 分页有两个硬性前提排序字段必须有索引。排序字段的值最好是唯一的或者至少是“近似唯一”否则可能出现同一页里重复数据。最常见的做法是用_id或createdAt如果你发现排序字段存在大量重复比如很多人排序用status但 status 只有 2 个值那就必须引入第二个排序字段来做二次排序比如{ status: 1, createdAt: -1 }甚至可以再加_id保证排序结果完全稳定。2.4 一个可直接落地的 keyset 模板下面是一个基于 Node.js MongoDB 驱动的参考实现重点看逻辑不用在意具体库的语法差异。// 第一页没有 lastId直接从最开始查 const pageSize 20; const filter { status: shipped }; const cursor db.collection(orders).find(filter) .sort({ _id: -1 }) .limit(pageSize); const list await cursor.toArray(); const lastId list.length ? list[list.length - 1]._id : null; // 第二页、第三页带上 lastId const filterWithSeek { status: shipped, _id: { $lt: lastId } }; const nextPage await db.collection(orders) .find(filterWithSeek) .sort({ _id: -1 }) .limit(pageSize) .toArray();如果排序字段不是_id而是业务时间字段就得把它们合成一个条件// 排序字段{ createdAt: -1, _id: -1 } // 假设上一页最后一条createdAt lastCreatedAt, _id lastId const filter { status: shipped, $or: [ { createdAt: { $lt: lastCreatedAt } }, { createdAt: lastCreatedAt, _id: { $lt: lastId } } ] };这段代码的精髓在于先按createdAt找更早的数据如果createdAt相等再用_id下的主键次序把同一条时间线上的数据排清楚。这样分页是严格稳定的无论怎么刷新页面都不会串页。3. 过滤、排序、分页一起出现时索引该怎么做3.1 从“过滤字段”到“排序字段”的索引顺序很多人建索引时只知道要给排序字段建索引但没想过过滤字段和排序字段合在一起时的顺序。MongoDB 索引设计有一条核心经验先放等值过滤字段再放排序字段最后放分页键。比如查询条件是status shipped排序字段是createdAt: -1索引就建db.orders.createIndex({ status: 1, createdAt: -1 });这样 MongoDB 可以先用status定位到“已发货”的所有记录再在这些记录上以createdAt倒序索引顺序扫描不用额外排序更不用skip。如果业务上还需要用_id来做分页稳定性索引可以扩展成db.orders.createIndex({ status: 1, createdAt: -1, _id: -1 });这个索引的好处是整个查询、排序、分页的数据都能从索引里拿到数据位置不需要回表读取每一篇文档来比较。我们程序的耗时从秒级降到几十毫秒主要就是靠这个索引。3.2 多字段排序时keyset 分页怎么写条件当一个列表需要按多个字段排序时例如sort({ price: 1, createdAt: -1 })keyset 分页就需要记录一组排序键而不能只记一个_id。假设第一页最后一条文档是{ price: 100, createdAt: 2021-01-01, _id: 123 }第二页的查询条件为db.products.find({ $or: [ { price: { $gt: 100 } }, { price: 100, createdAt: { $lt: 2021-01-01 } }, { price: 100, createdAt: 2021-01-01, _id: { $gt: 123 } } ] }) .sort({ price: 1, createdAt: -1, _id: 1 }) .limit(20);排序方向不同比较运算符也不同。这里是price升序所以用$gtcreatedAt降序所以用$lt到最后用_id兜底来保证排重。每次翻页时把上一页的这三个字段序列化到页参数里下一轮查询直接用。这段逻辑看起来复杂但它换来的是“翻到第几百页都不卡”。实际项目中我还遇到过更复杂的情况用户可以在线切换排序字段比如先按时间排、再按价格排。这种动态排序对 keyset 完全无能为力因为索引是固定的不可能实时为每一种排序建索引。所以我会在架构上做限制允许乱跳页的列表最多只支持两三种排序方式每种排序对应一个固定索引前端切换排序时明确提示用户“翻页到当前第 2 层继续翻页将重新计数”。3.3 当业务上“必须跳页”时怎么补救如果后台系统就是需要一个“跳到第 N 页”的入口那 keyset 分页没法直接满足。这时候有两个补救方向限制最大可跳转页数。在产品侧规定只允许查看前 100 页之后的列表只能通过“按时间范围筛选”来缩小查询范围。这种做法很多后台系统都在用运营想看旧数据时可以通过自定义起始时间来定位。利用“区间快照”做页数映射。定期把当期数据的分页快照存下来比如把_id范围映射到页号。翻页时先查快照再按_id做 keyset 查询。这个方案实现成本高但能兼顾跳页和性能。另外如果业务只是要“导出全量数据”那根本不需要分页 API直接用游标迭代整个集合就好了const cursor db.collection(orders).find({ status: shipped }).sort({ _id: 1 }); cursor.forEach(doc { /* 写入导出文件 */ });用游标一条一条处理好过在内存里拼一个巨大的数组这点很重要。4. 数据量到了“连 keyset 都不太对劲”的极端场景4.1 先评估你的“大数据量”到底有多大很多文章说“几十万条数据就不要用 skip”其实有点绝对了。如果集合里就几万条记录翻到第 20 页也不会有明显问题如果集合有 5000 万条即便用 keyset 也要注意每次范围查询的过滤字段能不能高效命中索引。我会把分页优化的优先级画成一条渐进的路线数据量 1 万skip()完全可用别浪费时间。1 万 到 100 万优先用游标分页 复合索引性能提升明显。100 万 到 1000 万考虑按时间分区、按业务维度拆分集合再分页。上亿级此时分页已经不是主要问题。全局的数据检索靠搜索引擎少折腾 MongoDB 的深分页。4.2 按时间分桶把“百万页”变成“多个小列表”如果数据是持续增长的日志、订单流水它天然有时间属性。一个很有效的方法是“分桶”按天 / 按月建集合比如orders_2024_01、orders_2024_02。分页时先定位到所在的桶再在桶内做 keyset 分页。跨桶查询时按时间先后合并各桶结果。这个方案让单个集合始终不会太大。哪怕整体数据量是 1 亿拆成 24 个桶后每个桶不到 500 万条配合索引分页速度和刚上线时没什么区别。缺点是跨桶分页的“跳页”变得更难了。但应对大数据量的业务本来就要接受顺序浏览这是某种意义上的取舍。4.3 不要为了“加快分页”而放弃聚合框架MongoDB 聚合框架的$skip和$limit走的是同一套底层逻辑聚合里的$skip照样会带来深分页性能问题。很多人以为改成聚合就“优化”了结果只是把慢查询从 find 换成了 db.collection.aggregate耗时不变。聚合框架真正用来做大数据量分析的场景是先$match把范围缩小再$group做汇总最后$sort取一个较小区间的 Top N。如果你是在聚合里对整个集合$sort $skip那就和find().sort().skip()一样低效必须用前面的索引和 keyset 思路重构。4.4 作为最后手段的“前 N 页缓存”如果产品无法接受任何限制并且业务方就是要在百万级数据上无限跳页那唯一现实的做法是把热度高的前 N 页比如前 5000 页的结果缓存起来。当用户访问的页码对应缓存命中时直接返回超出缓存范围时强制走有序查询并提示“该页数据量大加载可能需要几秒”。这个方案不解决根本问题但它能贴合真实用户行为。因为绝大多数用户只会翻前几十页没有人真的会翻到第 100 万页。5. 实践中的避坑清单这些都是真实环境里踩过的坑5.1 排序字段有大量重复值时第一页和第二页会重复数据有一次我们用sort({ status: 1 })做 keyset 分页因为status只有“待处理”“已完成”“已取消”三类导致大量文档拥有相同的status值。上一页取到的status作为下一页$gt条件结果是第二页直接跳过了上一页同值的剩下数据第三页又开始重复展示。解决办法就是引入“次级排序键”。凡是非唯一排序字段一律在后面追加_id让排序值变成唯一组合。这也是为什么几乎每张表的 keyset 分页都建议用{ 业务字段, _id }索引。5.2_id不是自增数字别拿它当页码很多从关系型数据库转过来的开发者习惯用主键 ID 做范围分页然后默认_id是连续的 1、2、3。但 MongoDB 默认_id是 ObjectId它大致按时间递增却并不保证连续而且如果你手动指定过_id可能完全跳跃。用_id做分页键没问题但不要拿它去算“第几页”只能作为“上一页位置”的游标。另外ObjectId 的前 4 个字节本身就是时间戳按_id倒序来分页天然等价于“按插入时间倒序”。如果你恰好要在“刚插入的数据排前面”的场景直接用_id就够了不用额外维护createdAt。5.3 删除数据后页数总数和列表不一致这个问题不只在 MongoDB 里出现所有数据库都有用户在翻页的过程中后台可能有数据被删除导致跳页时出现“页数对不上”、“某一页只有 5 条”。对策是列表页只显示“上一页 / 下一页”不显示总页数必须显示页数时以进入列表那一刻的快照总数为准。拿到用户请求的页码后重新按最新条件 count 一遍如果小于目标页码就回退到最后一页。5.4 游标分页的“游标”不要暴露原始 ID如果你把上一页最后一条的_id直接放进 URL 传给前端用户可以很容易绕过你设计的过滤条件比如传一个不属于他权限范围的 ID。更稳妥的做法是给游标做一个编码例如把{ lastId, createdAt, token }序列化后加签名服务端每次解析后校验签名同时再校验该记录是否仍满足业务过滤条件。这种细节在大数据量场景下被忽视等出了问题往往就是安全漏洞。5.5 别忘记“单个文档巨大”这个隐形坑分页优化的重点虽然常被放在skip()和索引上但列表接口还有一种常见慢来源返回了太多大字段。一条订单记录带一个大附件备注字段200 字节可能变成 2MB。翻页时虽然扫描的文档数少了但网络传输照样卡。在处理大数据量分页时建议尽量做字段投影db.orders.find(filter, { title: 1, createdAt: 1, _id: 1 }) .sort({ createdAt: -1, _id: -1 }) .limit(20);如果必须展示大字段也要在点赞、备注这种“详情”接口再单独查询。列表页只负责轻量展示。6. 选型决策清单下次做分页前先对着表看一眼我把上面的思路整理成一张决策参考表直接对着业务场景选方案就行。业务场景推荐方案核心动作数据量几万条后台管理skip()够用不折腾后台管理数据量大但允许看前 N 页限制页码 keyset限制跳转其余用游标信息流、消息记录顺序浏览keyset 分页记住上一页最后一条排序键数据量千万级时间属性强按时间分桶 keyset拆集合桶内分页需要自由跳页数据量极大前 N 页缓存 索引兜底缓存热点接受部分慢查询导出全部数据游标遍历别用分页接口真实项目中我很少看到“一种方案走天下”的分页系统。合理的做法是根据不同的接口和数据特征选择不同策略热门接口热烈用 keyset冷门后台用有限skip()导出用游标。这样既保证用户体验又不会为了极端场景付出过高维护成本。最后再分享一个实战小技巧不管用哪种方案在索引建好、代码上线前一定先用explain(executionStats)实测一下totalDocsExamined和totalKeysExamined。这两个值越接近返回条数说明你的查询越是“指哪打哪”。如果totalDocsExamined比docsReturned高出三个数量级哪怕接口响应时间暂时能接受迟早都会在数据量翻倍后变成灾难。把分页优化的功夫花在降低扫描量上比单纯调超时时间可靠得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询