必须用带 batchSize 的游标迭代并主动控制超时与断点续传,因 Express 短生命周期与 MongoDB 游标 10 分钟 TTL 冲突,toArray() 和 skip() 易致内存溢出或性能崩溃。

直接结论:别用 toArray(),别用 skip(),必须用带 batchSize 的游标迭代,并在耗时处理中主动控制超时与断点续传。
为什么 Express + Cursor 遍历大数据集容易崩
Express 默认是短生命周期 HTTP 请求模型,而 MongoDB 游标默认 10 分钟无活动就会被服务端自动销毁。如果你在路由 handler 里写 for await (const doc of cursor),中间做一次外部 API 调用或数据库写入,卡住 12 分钟——下一次 getMore 就会报 Cursor not found 错误。这不是代码写错了,是机制冲突。
- Express 路由不等你慢慢遍历完几百万文档,它只管响应超时(通常 30s–2min)
- PyMongo 或 Mongoose 的游标对象本身不感知 Express 的 request 生命周期
- 用
cursor.toArray()会一次性把全部结果加载进 Node.js 进程内存,几十万文档轻松吃光 2GB 内存
用 cursor.batchSize() 控制每次拉取量
默认批次是 101 条或 16MB(先到者),对大数据集来说太小,网络往返频繁;设太大又可能单次拉取阻塞太久。生产建议值:500–2000,视单文档平均大小和下游处理耗时调整。
示例(Mongoose):
const cursor = Model.find({ status: 'pending' })
.cursor()
.batchSize(1000); // ← 关键:显式设置
for await (const doc of cursor) {
await processOneDoc(doc); // 必须是 async 函数
}
-
.cursor()是触发惰性游标的必要调用,没它还是返回 Promise -
batchSize必须在.cursor()之后、迭代开始前设置,中途改无效 - 注意:Mongoose 7+ 中
.cursor()返回的是 Node.js 可迭代对象,不是 PyMongo 那种传统MongoCursor
如何避免游标过期导致中断
核心思路:不依赖单次请求跑完全部数据,而是把遍历拆成“可恢复的批次任务”。关键不是延长游标 TTL,而是绕开它。
- 用
_id范围分片代替游标保持:每次查{ _id: { $gt: lastId } },按升序取limit(1000),记录最后一条的_id作为下次起点 - 把状态存在 Redis 或数据库里,比如
task:process-pending:cursor_state,包含last_id、processed_count、updated_at - 加一层轻量调度:Express 提供一个
POST /api/jobs/start启动任务,再用GET /api/jobs/status查进度,避免长连接阻塞 - 如果非要在单个请求里跑完,必须手动调
cursor.next()并配合setTimeout心跳保活(不推荐,复杂且不可靠)
别踩 sort() + 游标这个坑
很多人想“先排序再分批”,但 cursor.sort({ createdAt: -1 }).batchSize(1000) 在数据量大时会导致全表扫描+内存排序,即使有索引,MongoDB 也要为每个批次重新做排序裁剪,性能断崖式下跌。
- 真正安全的排序分批,只适用于有强单调性字段(如自增
_id、时间戳createdAt)且该字段已建索引 - 若必须按非索引字段排序,先用聚合管道
$sort+$facet拆出 ID 列表,再用 ID 批量反查,比游标更可控 - Mongoose 的
Model.find().sort().cursor()看似合法,实测百万级集合下,第二批次开始延迟飙升,因为游标无法复用排序结果缓存
最常被忽略的一点:游标是否关闭,不取决于你有没有写 cursor.close(),而取决于迭代是否自然结束或异常中断。如果中间 throw 了没 catch,或 Express 请求被客户端取消,游标资源仍挂在 MongoDB 上——得靠服务端 TTL 回收,而不是你的代码。所以,永远优先选基于 _id 的无状态分页,而不是依赖游标生命周期。


















