MongoDB的skip()操作需扫描并丢弃前N条文档,导致CPU和内存随skip值线性增长;当skip达数万时,查询延迟陡增、并发易超时,本质是游标机制限制而非驱动问题。

为什么 find().skip().limit() 在分页量大时会变慢?
Motor 是 Tornado 的异步 MongoDB 驱动,但 skip().limit() 并非真正“无阻塞”——它会让 MongoDB 扫描并跳过前 N 条文档,当 skip 值达到几万甚至几十万时,查询延迟陡增,CPU 和内存压力明显上升。这不是 Motor 或 Tornado 的问题,而是 MongoDB 本身的游标机制限制。
真实场景中,用户翻到第 500 页(每页 20 条 → skip=9980)时响应可能超 1s,且并发一高就容易触发 Tornado 的 AsyncHTTPClient 超时或连接池耗尽。
- 避免用
skip()实现深度分页,尤其当总文档数 > 10 万 - 用“基于游标”的分页(cursor-based pagination),依赖排序字段(如
_id或时间戳)做边界过滤 - 确保排序字段有索引,否则
sort()本身就会全表扫描
如何用 find() + sort() + limit() 实现游标分页?
核心思路:不记“第几页”,而记“上一页最后一条的排序值”。比如按 created_at 降序,请求下一页时,把上一页最后一条的 created_at 当作查询起点:
# 上一页最后一条是 { created_at: ISODate("2024-05-20T10:30:00Z"), _id: ObjectId("...") }
# 下一页查询:
db.collection.find({
"created_at": { "$lt": ISODate("2024-05-20T10:30:00Z") }
}).sort({ "created_at": -1 }).limit(20)
注意:必须用严格比较($lt / $gt),不能用 $lte,否则可能重复或漏掉同时间戳的文档。
- 如果排序字段可能重复(如
created_at),建议复合排序:sort({"created_at": -1, "_id": -1}),用_id做唯一兜底 - 首次请求不带游标条件,但需明确指定
sort和limit - 返回结果时,把最后一条的排序键(如
doc["created_at"]和doc["_id"])拼成 base64 字符串作为next_cursor传给前端
Motor 中如何正确 await 分页查询并处理空结果?
Motor 的 find() 返回的是 AsyncIOMotorCursor,必须用 to_list() 或遍历才能获取结果。直接 await collection.find(...) 不会报错,但返回的是游标对象,不是数据。
常见错误写法:docs = await collection.find(...).sort(...).limit(20) —— 这里 docs 是游标,不是列表,后续 len(docs) 会报错。
- 正确做法:用
await cursor.to_list(length=20),显式限制最大长度,防止 OOM - 如果结果为空(
len(docs) == 0),说明已到末页,应停止返回next_cursor - 不要用
count_documents()获取总数——它会阻塞、不可扩展;游标分页本就不需要总数 - 示例关键片段:
cursor = collection.find({"status": "active"})
cursor.sort([("created_at", -1), ("_id", -1)])
cursor.limit(20)
if last_cursor:
dt, oid = decode_cursor(last_cursor) # 自定义解码
cursor = cursor.filter({"$or": [
{"created_at": {"$lt": dt}},
{"created_at": dt, "_id": {"$lt": oid}}
]})
docs = await cursor.to_list(length=20)
哪些字段适合做游标分页的排序依据?
_id(ObjectId)天然有序、全局唯一、有默认索引,是最稳妥的选择。但业务上常需按时间、状态、评分等字段排序,这时要注意:
- 时间字段(如
created_at)必须是写入时精确到毫秒的datetime,避免用date或字符串格式 - 避免用低基数字段(如
status只有 "draft"/"published" 两种值)做主排序,会导致大量文档落在同一“游标点”,分页不均匀 - 如果必须按用户自定义字段(如
score)排序,且该字段更新频繁,要考虑索引维护成本和查询一致性(MongoDB 读取可能短暂滞后) - 上线前务必在目标集合上执行
explain("executionStats")验证查询是否命中索引,重点关注nReturned和totalDocsExamined是否接近
游标分页真正的难点不在代码实现,而在设计阶段就确认排序字段的稳定性、唯一性与索引覆盖——一旦上线,改排序逻辑往往意味着全量重建索引或迁移数据。











