
1. 项目背景业务场景本地生活电商用户量突破 50 万日订单量超过 10 万。用户打开我的订单页面前 3 页秒开没问题但从第 50 页开始页面转圈能转 10 秒——用户情绪从耐心等待变成这 App 是不是崩了然后直接卸载。运营后台更夸张——导出全部已完成的订单做个月度报表点了导出按钮后去开了个会回来页面还在 loading。更微妙的是同一个订单在第一页和第 100 页搜出来的顺序还不一样——因为排序条件不够稳定相同时间的订单在不同页面之间跳来跳去用户体验极差。痛点分页问题看似简单但坑极多。用skip limit翻到第 1 万页时MongoDB 需要定位并跳过前面 199,980 条数据即使索引命中也需要遍历这么多条文档多用户并发深翻页时大量 skip 操作耗尽内存和 IOPS排序字段不够稳定——同一秒创建的订单两次查询顺序可能不同导致列表中出现重复或缺失运营报表导出用应用层循环查完全部数据装进内存500 万条订单直接 OOM。2. 项目设计小胖把笔记本屏幕转过来大师你看我的订单列表翻到第 100 页就要 8 秒开发说是因为 MongoDB 不行要考虑换回 MySQL。到底怎么回事大师别急着换数据库。这是经典的深分页问题不是 MongoDB 的锅MySQL 用OFFSET翻到第 100 页一样要 8 秒。罪魁祸首是skip limit的底层机制。小胖skip 不就是跳过去吗难道它还要把跳过的数据看一遍再扔大师说对了。MongoDB 在查询时索引扫描会逐条读取并计数从第 1 条读到你要跳过的位置然后把前面的结果全部丢弃从第 skip1 条开始返回。就像你要看一本书的第 1000 页你必须先把前 999 页翻过去——即使你一眼都没看手也得动 999 次。技术映射skip(N)的底层实现是扫描前 N 条并丢弃时间复杂度 O(N)。当 N 为 10 万时无论是否有索引都需要在索引树中定位并跳过 10 万个条目。小白若有所思那怎么解决如果只是做个之前/之后的翻页不需要跳到任意页大师这就是游标分页Cursor Pagination登场的时候了。游标分页的原理很简单——记录上一页最后一条文档的排序字段值下一页从这个值之后开始查。小胖说人话我还是没听懂。大师传统 offset 分页跳过前 100 条取 20 条。游标分页上次最后一个订单是 ID1000这次从_id 1000开始取 20 条。区别在哪儿offset 需要数到第 100 条cursor 直接从 ID 1000 后面开始——不需要数数一步到位。小胖但这要求_id是按顺序排列的如果我用 ObjectId同一种内生成的 ID 顺序不是严格的吧大师你很敏锐。ObjectId 的确不保证严格的跨机器顺序。所以业界通常的做法是基于业务时间字段做游标比如createdAt。但createdAt也存在问题——同一毫秒内创建的几条订单顺序仍然不确定。小白那怎么保证稳定排序呢大师增二级排序字段通常用_id.sort({createdAt:-1,_id:-1})这样即使createdAt相同_id也能兜底保证排序稳定。游标值cursor则由这两个字段组合构成。技术映射稳定的排序必须确保排序键的组合是唯一的。单时间字段不够必须追加一个唯一字段如_id保证顺序无歧义。小胖那前端翻页的时候怎么传这个游标总不能每次都把整个对象传给后端吧大师通常做法是后端对 cursor 值做 Base64 编码前端拿到一个不透明的字符串eyJjcmVhdGVkQXQiOi...翻页时原样传回后端解码后构造{ $lt: [lastCreatedAt, lastId] }条件即可。小白但如果用户需要直接跳转到第 50 页呢游标分页做不到这个——它只能上一页/下一页。大师对这是游标分页的根本局限。在用户侧我们给出的方案是混合策略99% 的用户只看前 5 页用游标分页体验最好运营后台偶尔需要跳页前 100 页用 offset 索引勉强可接受超过 100 页的直接告诉他们请用筛选条件缩小范围——没有正常用户会翻 100 页商品。技术映射分页策略的选择取决于用户行为。观察用户行为数据——90% 的用户只看第一页翻到第 3 页的人不到 5%为这 5% 的人优化第 50 页性能性价比很低。小胖那报表导出呢运营要一次性导出全部订单做 Excel总不能让他们一页一页翻吧大师报表导出和在线分页是两个完全不同的场景。在线分页讲究即时、少量、高频报表导出讲究全量、允许慢、一次性。导出的正确做法是使用游标cursor流式读取批量写入临时文件或对象存储不把全量数据塞进内存。允许耗时数分钟甚至小时级别只要不把服务器搞挂。大师总结记住三点——高频小页面用游标分页偶尔跳页用 offset 索引全量导出用流式批处理。三者各司其职不要用同一种方式解决所有问题。3. 项目实战3.1 环境准备dockercompose-fmongodb-lab/docker-compose.ymlps3.2 分步实现步骤一构造订单测试数据目标生成 10 万订单数据时间分散在 3 个月内。use local_life db.orders_paging.drop()constnowDate.now()constthreeMonthsAgonow-90*24*3600*1000// 生成订单constbatch[]for(leti0;i100000;i){constorderTimenewDate(threeMonthsAgoMath.random()*(now-threeMonthsAgo))batch.push({orderNo:ORDString(i).padStart(8,0),userId:USER_Math.floor(Math.random()*5000),totalAmount:NumberDecimal((Math.random()*200010).toFixed(2)),status:[待支付,已支付,已完成,已取消][Math.floor(Math.random()*4)],itemCount:Math.floor(Math.random()*10)1,createdAt:orderTime,updatedAt:newDate()})if(batch.length1000){db.orders_paging.insertMany(batch,{ordered:false})batch.length0}}if(batch.length0)db.orders_paging.insertMany(batch,{ordered:false})print(插入完成, 共:,db.orders_paging.countDocuments(),条)// 创建索引db.orders_paging.createIndex({createdAt:-1,_id:-1},{name:idx_createdAt_id})db.orders_paging.createIndex({userId:1,createdAt:-1},{name:idx_userId_createdAt})步骤二offset 分页的深分页问题演示目标对比第 1 页和第 5000 页的耗时差异。// 第 1 页constpage1db.orders_paging.find({}).sort({createdAt:-1,_id:-1}).skip(0).limit(20).explain(executionStats)// 第 5000 页constpage5000db.orders_paging.find({}).sort({createdAt:-1,_id:-1}).skip(100000)// 跳过 10 万条前端第 5001 页每页 20 条.limit(20).explain(executionStats)print( Offset 分页性能对比 )print(第 1 页:)print( 扫描索引键:,page1.executionStats.totalKeysExamined)print( 扫描文档数:,page1.executionStats.totalDocsExamined)print( 耗时(ms):,page1.executionStats.executionTimeMillis)print(第 5000 页 (skip 100000):)print( 扫描索引键:,page5000.executionStats.totalKeysExamined)print( 扫描文档数:,page5000.executionStats.totalDocsExamined)print( 耗时(ms):,page5000.executionStats.executionTimeMillis)// 核心发现skip 越大totalKeysExamined 越大需要遍历所有跳过的键步骤三游标分页实战目标实现基于createdAt_id的游标分页。// ---- 游标分页函数 ----functioncursorPaginate({lastCreatedAt,lastId,limit20}){constfilter{}if(lastCreatedAtlastId){// 组合条件创建时间比上一页最后一条早// 如果创建时间相同则 _id 比上一页最后一条小filter.$or[{createdAt:{$lt:lastCreatedAt}},{createdAt:lastCreatedAt,_id:{$lt:lastId}}]}constdocsdb.orders_paging.find(filter).sort({createdAt:-1,_id:-1}).limit(limit1)// 多取一条判断是否有下一页.toArray()consthasNextdocs.lengthlimitif(hasNext)docs.pop()constnextCursordocs.length0?{lastCreatedAt:docs[docs.length-1].createdAt,lastId:docs[docs.length-1]._id}:nullreturn{list:docs,nextCursor,hasNext}}// ---- 测试游标分页 ----// 第 1 页constp1cursorPaginate({})print( 游标分页 第1页 )print(文档数:,p1.list.length,hasNext:,p1.hasNext)p1.list.slice(0,3).forEach(dprint(${d.orderNo}|${d.createdAt}))// 第 2 页constp2cursorPaginate({lastCreatedAt:p1.nextCursor.lastCreatedAt,lastId:p1.nextCursor.lastId})print(\n 游标分页 第2页 )print(文档数:,p2.list.length,hasNext:,p2.hasNext)// ---- 游标分页 explain ----constcursorExplaindb.orders_paging.find({$or:[{createdAt:{$lt:p1.nextCursor.lastCreatedAt}},{createdAt:p1.nextCursor.lastCreatedAt,_id:{$lt:p1.nextCursor.lastId}}]}).sort({createdAt:-1,_id:-1}).limit(20).explain(executionStats)print(\n 游标分页第2页 Explain )print( 扫描索引键:,cursorExplain.executionStats.totalKeysExamined)print( 扫描文档数:,cursorExplain.executionStats.totalDocsExamined)print( 耗时(ms):,cursorExplain.executionStats.executionTimeMillis)// 对比 offset 第 5000 页的耗时——差距巨大// 注意$or 的 explain 显示 totalKeysExamined 可能比 offset 大// 因为 $or 本质上是两个独立查询结果的合并步骤四稳定的二级排序目标演示不稳定的排序导致的列表翻页重复问题。// 模拟同时间订单——更新一批订单为同一时间constsameTimenewDate(2026-01-15T10:00:00Z)db.orders_paging.updateMany({orderNo:{$in:[ORD00000100,ORD00000101,ORD00000102]}},{$set:{createdAt:sameTime}})// 不稳定排序只有 createdAtconstunstabledb.orders_paging.find({}).sort({createdAt:-1})// 注意没有 _id 兜底.limit(3).toArray()print( 不稳定排序只有 createdAt )unstable.forEach(dprint(${d.orderNo}|${d.createdAt}))print(没有 _id 兜底相同 createdAt 的文档顺序是不确定的)// 稳定排序createdAt _idconststabledb.orders_paging.find({}).sort({createdAt:-1,_id:-1}).limit(3).toArray()print(\n 稳定排序createdAt _id )stable.forEach(dprint(${d.orderNo}|${d.createdAt}|${d._id}))// _id 是唯一的保证了相同 createdAt 下的确定性顺序步骤五流式批量导出目标用 MongoDB cursor 流式读取大量数据避免内存爆炸。// 模拟报表导出导出已完成订单functionexportCompletedOrders(batchSize500){constcursordb.orders_paging.find({status:已完成}).sort({createdAt:-1}).batchSize(batchSize)// 每次从服务器取 500 条而非默认的 101 条.noCursorTimeout()// 游标不自动超时长时间导出用// 生产环境建议加上 .addCursorFlag(noCursorTimeout, true)letcount0letbatch[]conststartTimeDate.now()while(cursor.hasNext()){batch.push(cursor.next())if(batch.lengthbatchSize){countbatch.length// 模拟写入文件或对象存储// fs.writeFileSync(export_batch_${count}.json, JSON.stringify(batch))batch[]if(count%100000){print(已导出${count}条...)}}}// 最后一组if(batch.length0){countbatch.length}cursor.close()constelapsed(Date.now()-startTime)/1000print(导出完成:${count}条, 耗时:${elapsed.toFixed(1)}s)returncount}exportCompletedOrders(500)可能遇到的坑batchSize不能太大——每个批次需要落入内存过大可能导致 mongod 的内存压力和网络传输不稳定。长时间导出务必设置noCursorTimeout否则游标 10 分钟未消费被服务端自动关闭。流式导出期间如果有写操作修改了数据游标可能读到同一个文档在游标打开后被移动——这是正常现象不是 bug。3.3 完整代码清单文件用途mongodb-lab/scripts/ch07-create-orders.js生成订单测试数据mongodb-lab/scripts/ch07-pagination.jsoffset vs cursor 分页对比mongodb-lab/scripts/ch07-export.js流式批处理导出3.4 测试验证use local_life// 1. 验证 offset 分页的正确性constoffsetPagedb.orders_paging.find({}).sort({createdAt:-1,_id:-1}).skip(0).limit(20).toArray()print(offset 第1页:,offsetPage.length,条 PASS(offsetPage.length20?: FAIL))// 2. 验证游标分页的无重复性letseenOrderNosnewSet()letcursor{}for(letpage0;page5;page){constresultcursorPaginate({lastCreatedAt:cursor.lastCreatedAt,lastId:cursor.lastId,limit:20})for(constdocofresult.list){if(seenOrderNos.has(doc.orderNo)){print(重复订单:,doc.orderNo, FAIL)}seenOrderNos.add(doc.orderNo)}cursorresult.nextCursor||{}}print(游标分页 5 页无重复:,seenOrderNos.size100?PASS:FAIL)// 3. 验证稳定排序db.orders_paging.updateMany({orderNo:ORD00000100},{$set:{createdAt:sameTime}})constdupdb.orders_paging.find({createdAt:sameTime}).sort({createdAt:-1,_id:-1}).toArray()constordersdup.map(dd.orderNo)print(同时间排序:,orders)// 期望每次运行顺序一致// 4. 清理// db.orders_paging.drop()4. 项目总结4.1 offset 分页 vs 游标分页 vs 全量导出维度offset 分页游标分页Cursor流式导出实现复杂度低skiplimit中需游标编解码中游标流式读取第 1 页性能优秀优秀N/A第 100 页性能差O(N)优秀O(1)定位N/A支持跳页支持不支持N/A实时性高高低准实时数据一致性每页独立快照每页独立快照导出过程中数据可能变适宜数据量 5000 条结果集不限不限4.2 适用场景offset 分页适用数据量小的管理后台如商品类目管理几百条数据。用户明确需要跳页且总页数不超过 50 页。全量数据集较小且不需要高频访问。游标分页适用App/Web 的无限滚动列表朋友圈、订单列表。搜索结果——用户不会翻到 50 页以后。实时 Feed 流——新数据持续产生offset 会导致重复问题。不适用场景游标分页不能用于需要总页数展示的场景——因为你永远不知道最后一页是什么。数据量大且需要任意跳页的报表建议用预计算或 Elasticsearch。4.3 注意事项注意事项说明排序必须稳定单字段排序同值数据会导致翻页数据重复或遗漏务必用_id兜底游标编码推荐 Base64 编码游标值避免前端暴露内部 ID 结构skip的 CPU 开销大量 skip 会占满单个 CPU 核心阻塞同机器其他请求游标超时默认 10 分钟空闲超时长时间遍历要调用noCursorTimeout()batchSize设置MongoDB 默认首批返回 101 条文档后续批次最大 16MB。大数据量遍历设置batchSize(1000)减少网络往返4.4 常见踩坑经验故障案例一offset 分页 无 sort 导致数据重复某后台管理页面没写 sort直接用skip limit做分页。用户翻页时发现第 5 页和第 6 页有相同的几条订单。根因MongoDB 不保证未排序结果的顺序——自然顺序是数据在磁盘上存储的物理顺序而 update 后文档可能在磁盘上移动位置。解决强制所有分页查询加 sort至少sort({ _id: 1 })。故障案例二$or游标分页的性能误解有团队看到游标分页的 explain 中totalKeysExamined比 offset 还大于是放弃游标分页。根因$or会被优化器拆成两个独立索引扫描每个子句各自扫描索引所以totalKeysExamined约为 offset 的两倍。但是$or的两个子句从索引中找到精确位置后直接定位跳过数百万条索引条目时不需要逐个遍历——totalKeysExamined不代表遍历了多少个而是检查了多少个。实际上游标分页比 offset skip 100000 快得多。故障案例三导出时游标超时运营部门有一个 500 万条数据的月度导出任务跑了 30 分钟后突然报CursorNotFound。根因导出时用了find().toArray()一次性加载到内存中途数据量太大导致处理变慢游标空闲超过 10 分钟被自动关闭。解决改用逐条消费 noCursorTimeout() 每批次记录进度实现断点续跑。4.5 思考题如果订单表按userId做了分片游标分页中的_id还能作为稳定排序的兜底吗为什么游标分页如何支持向前翻页上一页设计一个支持双向翻页的游标分页方案。答案将在第 8 章末尾揭晓上一章思考题答案复合索引{a:1, b:1, c:1}支持的排序{a:1}、{a:1, b:1}、{a:1, b:1, c:1}以及它们的全降序版本{a:-1, b:-1, c:-1}。会导致内存排序的场景{b:1}(跳过 a)、{a:1, c:1}(跳过 b)、{a:1, b:-1}(方向不一致除非索引是全降序)。MongoDB 可以反向遍历索引所以{a:-1, b:-1}也能命中{a:1, b:1}索引。_id索引是 MongoDB 的内置默认索引有独特属性无法删除dropIndex(_id_)会报错、所有文档必有_id字段相当于全覆盖、自动创建且名为_id_。再建一个{_id: 1}索引是多余且浪费的——_id_已经是唯一索引重复建只会增加写入开销而无任何查询收益。延伸阅读与资源python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析