不能用 skip() 做断点续传,因其在大数据量下性能极差,需全量扫描索引;应使用基于单调可排序字段(如 created_at 与 _id 组合)的游标查询,配合 $gt 和 $or 实现稳定分页。

为什么不能用 skip() 做断点续传
因为 skip() 在大数据量下性能极差:它会强制扫描并跳过前 N 条文档,即使你只取 1 条,MongoDB 仍要遍历全部索引项。导出千万级集合时,越往后越慢,甚至卡死。游标方式本质是“记住上一次停在哪”,靠的是稳定、单调、可排序的字段(如 _id 或时间戳字段),而不是跳过多少条。
必须确保排序字段有唯一且可比较的值
最常用的是 _id(ObjectId 默认按插入时间递增,且全局唯一),但要注意:_id 并不严格保序(跨 mongos 或写入延迟可能导致微小乱序);更稳妥的是显式使用带索引的 created_at 字段,并配合 ObjectId 消除时间重复问题。导出前务必确认该字段已建索引,否则查询会全表扫描。
- 推荐排序组合:
{ created_at: 1, _id: 1 }(升序),导出时用$gt查下一批 - 避免用
updated_at—— 多次更新会导致重复或遗漏 - 如果用
created_at,必须确保应用层写入时不会出现未来时间或严重时钟漂移
游标导出的核心逻辑:每次查 $gt 上次最后一条
关键不是“从哪开始”,而是“比上一条更大”。假设上次导出的最后一条是 { created_at: ISODate("2024-05-01T10:00:00Z"), _id: ObjectId("6632a1b2c3d4e5f678901234") },下一次查询应为:
db.orders.find({
created_at: { $gt: ISODate("2024-05-01T10:00:00Z") }
}).sort({ created_at: 1, _id: 1 }).limit(1000)
但如果存在多条同时间戳的文档,仅靠 created_at 无法保证顺序一致。此时必须补上 _id 条件:
db.orders.find({
$or: [
{ created_at: { $gt: ISODate("2024-05-01T10:00:00Z") } },
{
created_at: ISODate("2024-05-05T10:00:00Z"),
_id: { $gt: ObjectId("6632a1b2c3d4e5f678901234") }
}
]
}).sort({ created_at: 1, _id: 1 }).limit(1000)
- 实际代码中建议封装成函数,把“最后一条”对象转为安全的
$or查询条件 - 不要依赖
find().skip().limit()的结果来取“最后一条”,而应显式.sort().limit(1).toArray()拿到末尾文档 - 导出脚本必须持久化记录游标位置(例如写入本地 JSON 文件或独立 collection),断点恢复时读取它
导出过程中如何应对数据变更
断点续传本身不解决并发写入问题。如果导出期间有新文档插入、旧文档修改 created_at,可能造成遗漏或重复。务实做法是:
- 导出前尽量协调业务方暂停写入,或选择低峰期执行
- 若必须在线导出,接受“最终一致性”——即导出结果反映的是某个近似快照,而非绝对精确时刻
- 避免在导出逻辑中做
$set或更新操作;所有写入(如记录游标位置)必须用原子操作(findOneAndUpdate)防止竞态 - 如果业务允许,可在导出起始时间加一个缓冲窗口(如提前 5 分钟开始),再用时间范围 + 游标双重校验
真正难的不是写出游标查询,而是让游标值在各种边界场景(空集合、单文档、时间重复、索引缺失)下依然能稳定推进——这需要每轮都校验返回结果长度和游标值有效性,而不是盲目信任上一轮的“最后一条”。











