当前位置: 首页 > news >正文

第7章:分页、排序与游标——订单列表怎么越翻越慢

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 条读到你要跳过的位置,然后把前面的结果全部丢弃,从第 skip+1 条开始返回。就像你要看一本书的第 1000 页,你必须先把前 999 页翻过去——即使你一眼都没看,手也得动 999 次。

技术映射skip(N)的底层实现是扫描前 N 条并丢弃,时间复杂度 O(N)。当 N 为 10 万时,无论是否有索引,都需要在索引树中定位并跳过 10 万个条目。

小白(若有所思):那怎么解决?如果只是做个之前/之后的翻页,不需要跳到任意页?

大师:这就是游标分页(Cursor Pagination)登场的时候了。游标分页的原理很简单——记录上一页最后一条文档的排序字段值,下一页从这个值之后开始查。

小胖:说人话!我还是没听懂。

大师:传统 offset 分页:跳过前 100 条,取 20 条。游标分页:上次最后一个订单是 ID=1000,这次从_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.ymlps

3.2 分步实现

步骤一:构造订单测试数据

目标:生成 10 万订单数据,时间分散在 3 个月内。

use local_life db.orders_paging.drop()constnow=Date.now()constthreeMonthsAgo=now-90*24*3600*1000// 生成订单constbatch=[]for(leti=0;i<100000;i++){constorderTime=newDate(threeMonthsAgo+Math.random()*(now-threeMonthsAgo))batch.push({orderNo:"ORD"+String(i).padStart(8,'0'),userId:"USER_"+Math.floor(Math.random()*5000),totalAmount:NumberDecimal((Math.random()*2000+10).toFixed(2)),status:["待支付","已支付","已完成","已取消"][Math.floor(Math.random()*4)],itemCount:Math.floor(Math.random()*10)+1,createdAt:orderTime,updatedAt:newDate()})if(batch.length===1000){db.orders_paging.insertMany(batch,{ordered:false})batch.length=0}}if(batch.length>0)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 页constpage1=db.orders_paging.find({}).sort({createdAt:-1,_id:-1}).skip(0).limit(20).explain("executionStats")// 第 5000 页constpage5000=db.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,limit=20}){constfilter={}if(lastCreatedAt&&lastId){// 组合条件:创建时间比上一页最后一条早// 如果创建时间相同,则 _id 比上一页最后一条小filter.$or=[{createdAt:{$lt:lastCreatedAt}},{createdAt:lastCreatedAt,_id:{$lt:lastId}}]}constdocs=db.orders_paging.find(filter).sort({createdAt:-1,_id:-1}).limit(limit+1)// 多取一条,判断是否有下一页.toArray()consthasNext=docs.length>limitif(hasNext)docs.pop()constnextCursor=docs.length>0?{lastCreatedAt:docs[docs.length-1].createdAt,lastId:docs[docs.length-1]._id}:nullreturn{list:docs,nextCursor,hasNext}}// ---- 测试游标分页 ----// 第 1 页constp1=cursorPaginate({})print("=== 游标分页 第1页 ===")print("文档数:",p1.list.length,"hasNext:",p1.hasNext)p1.list.slice(0,3).forEach(d=>print(`${d.orderNo}|${d.createdAt}`))// 第 2 页constp2=cursorPaginate({lastCreatedAt:p1.nextCursor.lastCreatedAt,lastId:p1.nextCursor.lastId})print("\n=== 游标分页 第2页 ===")print("文档数:",p2.list.length,"hasNext:",p2.hasNext)// ---- 游标分页 explain ----constcursorExplain=db.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` 本质上是两个独立查询结果的合并

步骤四:稳定的二级排序

目标:演示不稳定的排序导致的列表翻页重复问题。

// 模拟同时间订单——更新一批订单为同一时间constsameTime=newDate("2026-01-15T10:00:00Z")db.orders_paging.updateMany({orderNo:{$in:["ORD00000100","ORD00000101","ORD00000102"]}},{$set:{createdAt:sameTime}})// 不稳定排序(只有 createdAt)constunstable=db.orders_paging.find({}).sort({createdAt:-1})// 注意:没有 _id 兜底.limit(3).toArray()print("=== 不稳定排序(只有 createdAt) ===")unstable.forEach(d=>print(`${d.orderNo}|${d.createdAt}`))print("没有 _id 兜底,相同 createdAt 的文档顺序是不确定的")// 稳定排序(createdAt + _id)conststable=db.orders_paging.find({}).sort({createdAt:-1,_id:-1}).limit(3).toArray()print("\n=== 稳定排序(createdAt + _id) ===")stable.forEach(d=>print(`${d.orderNo}|${d.createdAt}|${d._id}`))// _id 是唯一的,保证了相同 createdAt 下的确定性顺序

步骤五:流式批量导出

目标:用 MongoDB cursor 流式读取大量数据,避免内存爆炸。

// 模拟报表导出:导出"已完成"订单functionexportCompletedOrders(batchSize=500){constcursor=db.orders_paging.find({status:"已完成"}).sort({createdAt:-1}).batchSize(batchSize)// 每次从服务器取 500 条,而非默认的 101 条.noCursorTimeout()// 游标不自动超时(长时间导出用)// 生产环境建议加上 .addCursorFlag('noCursorTimeout', true)letcount=0letbatch=[]conststartTime=Date.now()while(cursor.hasNext()){batch.push(cursor.next())if(batch.length>=batchSize){count+=batch.length// 模拟写入文件或对象存储// fs.writeFileSync(`export_batch_${count}.json`, JSON.stringify(batch))batch=[]if(count%10000===0){print(`已导出${count}条...`)}}}// 最后一组if(batch.length>0){count+=batch.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 分页的正确性constoffsetPage=db.orders_paging.find({}).sort({createdAt:-1,_id:-1}).skip(0).limit(20).toArray()print("offset 第1页:",offsetPage.length,"条 PASS"+(offsetPage.length===20?"":" FAIL"))// 2. 验证游标分页的无重复性letseenOrderNos=newSet()letcursor={}for(letpage=0;page<5;page++){constresult=cursorPaginate({lastCreatedAt:cursor.lastCreatedAt,lastId:cursor.lastId,limit:20})for(constdocofresult.list){if(seenOrderNos.has(doc.orderNo)){print("重复订单:",doc.orderNo," FAIL")}seenOrderNos.add(doc.orderNo)}cursor=result.nextCursor||{}}print("游标分页 5 页无重复:",seenOrderNos.size===100?"PASS":"FAIL")// 3. 验证稳定排序db.orders_paging.updateMany({orderNo:"ORD00000100"},{$set:{createdAt:sameTime}})constdup=db.orders_paging.find({createdAt:sameTime}).sort({createdAt:-1,_id:-1}).toArray()constorders=dup.map(d=>d.orderNo)print("同时间排序:",orders)// 期望每次运行顺序一致// 4. 清理// db.orders_paging.drop()

4. 项目总结

4.1 offset 分页 vs 游标分页 vs 全量导出

维度offset 分页游标分页(Cursor)流式导出
实现复杂度低(skip+limit)中(需游标编解码)中(游标流式读取)
第 1 页性能优秀优秀N/A
第 100 页性能差(O(N))优秀(O(1)定位)N/A
支持跳页支持不支持N/A
实时性低(准实时)
数据一致性每页独立快照每页独立快照导出过程中数据可能变
适宜数据量< 5000 条结果集不限不限

4.2 适用场景

offset 分页适用

  1. 数据量小的管理后台(如商品类目管理,几百条数据)。
  2. 用户明确需要跳页且总页数不超过 50 页。
  3. 全量数据集较小且不需要高频访问。

游标分页适用

  1. App/Web 的无限滚动列表(朋友圈、订单列表)。
  2. 搜索结果——用户不会翻到 50 页以后。
  3. 实时 Feed 流——新数据持续产生,offset 会导致重复问题。

不适用场景

  1. 游标分页不能用于需要"总页数"展示的场景——因为你永远不知道"最后一页"是什么。
  2. 数据量大且需要任意跳页的报表,建议用预计算或 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 思考题

  1. 如果订单表按userId做了分片,游标分页中的_id还能作为稳定排序的兜底吗?为什么?
  2. 游标分页如何支持"向前翻页"(上一页)?设计一个支持双向翻页的游标分页方案。

(答案将在第 8 章末尾揭晓)


上一章思考题答案

  1. 复合索引{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}索引。

  2. _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 实战修炼与源码剖析

http://www.jsqmd.com/news/1208709/

相关文章:

  • HarmonyOS掌上记账APP开发实践第29篇:条件渲染与 ForEach — 动态 UI 的构建策略
  • (12/99)汽车电子--体系
  • 突破平台界限:用Xenia Canary在PC上重温Xbox 360游戏的技术秘籍
  • 浪琴官方售后服务中心电话和完整维修地址实地考察报告多信源验证(2026年7月更新) - 浪琴服务中心
  • 2026超限运输检测仪品牌排行榜,广州聚杰稳居行业前列 - 品牌速递
  • 【Cursor国际化实战指南】:20年资深工程师亲授多语言支持从0到1落地的7大避坑法则
  • 2026不停车称重系统厂家推荐,广州聚杰是头部品牌 - 品牌速递
  • Ubuntu手动编译Linux内核全流程指南
  • Flash Attention V3:大模型训练加速的核心技术解析
  • 大型语言模型(LLM)幻觉问题综述:归因理论与缓解技术进展(2023–2025)
  • SpringBoot应用构建XSS与SQL注入双重防御体系实战
  • 如何快速多开暗黑破坏神2重制版:D2RML完整指南
  • 上海钻石回收门店怎么选?性价比深度对比 - 讯息早知道
  • Cursor AB测试代码工程化实践(从本地调试到生产灰度的7步标准化流程)
  • 华为/阳光/锦浪逆变器接入:云端API对接 vs SunSpec边缘解析(附协议配置)
  • HarmonyOS7 富文本混排页实战:RichText 与 Span 不只是会用,还要用得顺手
  • Win10官方ISO下载指南与安全安装全攻略
  • Autorize插件安装教程(Burp Suite)
  • 2026年包装海绵产业格局与鹤山市溢盛包装制品有限公司供应能力解析 - 甄选服务推荐
  • 玻璃基板在CPO封装中的技术优势与应用前景解析
  • 2026年7月最新南通卡地亚官方售后客户服务热线与维修网点地址汇总 - 卡地亚服务中心
  • 2026非现场执法品牌排名一览,广州聚杰树立行业标杆 - 品牌速递
  • 15.ai停运后,基于Coqui TTS搭建本地语音合成替代方案
  • 【关注可白嫖源码】--课程设计+毕业设计+springboot校园流浪动物健康档案系统[编号:project17069](案例分析)
  • 2026 高信效度十六型人格测试 TOP12 渠道榜单,适配国人本土化修订题库 - 时讯资讯
  • TVA:具身智能技术生态的强力引擎(20)
  • C 语言工业级通用组件手写 05:定时器最小堆
  • 昇腾950超节点真机亮相2026世界人工智能大会
  • CatSeedLogin终极实战:5步构建专业级Minecraft服务器安全防护体系
  • 第三:Pytest常用运行方式