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 环境准备
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:构造订单测试数据
目标:生成 10 万订单数据,时间分散在 3 个月内。
use local_life
db.orders_paging.drop()
const now = Date.now()
const threeMonthsAgo = now - 90 * 24 * 3600 * 1000
// 生成订单
const batch = []
for (let i = 0; i < 100000; i++) {
const orderTime = new Date(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: new Date()
})
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 页
const page1 = db.orders_paging.find({})
.sort({ createdAt: -1, _id: -1 })
.skip(0)
.limit(20)
.explain("executionStats")
// 第 5000 页
const page5000 = 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 的游标分页。
// ---- 游标分页函数 ----
function cursorPaginate({ lastCreatedAt, lastId, limit = 20 }) {
const filter = {}
if (lastCreatedAt && lastId) {
// 组合条件:创建时间比上一页最后一条早
// 如果创建时间相同,则 _id 比上一页最后一条小
filter.$or = [
{ createdAt: { $lt: lastCreatedAt } },
{ createdAt: lastCreatedAt, _id: { $lt: lastId } }
]
}
const docs = db.orders_paging.find(filter)
.sort({ createdAt: -1, _id: -1 })
.limit(limit + 1) // 多取一条,判断是否有下一页
.toArray()
const hasNext = docs.length > limit
if (hasNext) docs.pop()
const nextCursor = docs.length > 0
? { lastCreatedAt: docs[docs.length - 1].createdAt, lastId: docs[docs.length - 1]._id }
: null
return { list: docs, nextCursor, hasNext }
}
// ---- 测试游标分页 ----
// 第 1 页
const p1 = cursorPaginate({})
print("=== 游标分页 第1页 ===")
print("文档数:", p1.list.length, "hasNext:", p1.hasNext)
p1.list.slice(0, 3).forEach(d => print(` ${d.orderNo} | ${d.createdAt}`))
// 第 2 页
const p2 = cursorPaginate({ lastCreatedAt: p1.nextCursor.lastCreatedAt,
lastId: p1.nextCursor.lastId })
print("\n=== 游标分页 第2页 ===")
print("文档数:", p2.list.length, "hasNext:", p2.hasNext)
// ---- 游标分页 explain ----
const cursorExplain = 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` 本质上是两个独立查询结果的合并
步骤四:稳定的二级排序
目标:演示不稳定的排序导致的列表翻页重复问题。
// 模拟同时间订单——更新一批订单为同一时间
const sameTime = new Date("2026-01-15T10:00:00Z")
db.orders_paging.updateMany(
{ orderNo: { $in: ["ORD00000100", "ORD00000101", "ORD00000102"] } },
{ $set: { createdAt: sameTime } }
)
// 不稳定排序(只有 createdAt)
const unstable = 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)
const stable = 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 流式读取大量数据,避免内存爆炸。
// 模拟报表导出:导出"已完成"订单
function exportCompletedOrders(batchSize = 500) {
const cursor = db.orders_paging.find({ status: "已完成" })
.sort({ createdAt: -1 })
.batchSize(batchSize) // 每次从服务器取 500 条,而非默认的 101 条
.noCursorTimeout() // 游标不自动超时(长时间导出用)
// 生产环境建议加上 .addCursorFlag('noCursorTimeout', true)
let count = 0
let batch = []
const startTime = 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()
const elapsed = (Date.now() - startTime) / 1000
print(`导出完成: ${count} 条, 耗时: ${elapsed.toFixed(1)}s`)
return count
}
exportCompletedOrders(500)
可能遇到的坑:
batchSize不能太大——每个批次需要落入内存,过大可能导致 mongod 的内存压力和网络传输不稳定。- 长时间导出务必设置
noCursorTimeout,否则游标 10 分钟未消费被服务端自动关闭。 - 流式导出期间如果有写操作修改了数据,游标可能读到同一个文档在游标打开后被移动——这是正常现象,不是 bug。
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch07-create-orders.js |
生成订单测试数据 |
mongodb-lab/scripts/ch07-pagination.js |
offset vs cursor 分页对比 |
mongodb-lab/scripts/ch07-export.js |
流式批处理导出 |
3.4 测试验证
use local_life
// 1. 验证 offset 分页的正确性
const offsetPage = 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. 验证游标分页的无重复性
let seenOrderNos = new Set()
let cursor = {}
for (let page = 0; page < 5; page++) {
const result = cursorPaginate({ lastCreatedAt: cursor.lastCreatedAt,
lastId: cursor.lastId, limit: 20 })
for (const doc of result.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 } }
)
const dup = db.orders_paging.find({ createdAt: sameTime })
.sort({ createdAt: -1, _id: -1 }).toArray()
const orders = 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 分页适用:
- 数据量小的管理后台(如商品类目管理,几百条数据)。
- 用户明确需要跳页且总页数不超过 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 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号