$sample 默认很慢是因为采用随机跳过+顺序扫描策略,易触发大量磁盘i/o;5.0+版本可利用_id索引优化,但仅在无写入压力、_id分布均匀时有效,且不能与无索引$match共用。

为什么 $sample 默认很慢
$sample 在 MongoDB 中默认不走索引,而是采用“随机跳过 + 顺序扫描”策略:先估算集合总文档数,再随机选取偏移量,然后从头遍历直到命中。当集合超过百万级文档、且没有内存缓存时,这个过程极易触发大量磁盘 I/O,db.currentOp() 中常看到 stage: "COLLSCAN" 和高 executionTimeMillisEstimate。尤其在 WiredTiger 引擎下,若 %dirty 持续 >10%,$sample 延迟会进一步放大。
用 { _id: 1 } 索引加速 $sample 的前提与限制
WiredTiger 引擎中,_id 索引是唯一强制存在的有序索引,其值(ObjectId)本身包含时间戳和递增序列。只要采样不依赖业务语义随机性,就可以利用它绕过全表扫描:
- 必须使用
$sample配合$sort+$limit替代写法:db.collection.aggregate([{$sort: {_id: 1}}, {$sample: {size: 100}}])—— 注意:这不是标准用法,实际无效;真正有效的是{$sample: {size: 100}}自身在 5.0+ 版本中已支持利用_id索引的优化路径,但仅当集合无写入压力、_id分布均匀时才稳定生效 - 不能与
$match同时使用(除非该$match字段本身有高效索引),否则优化自动失效,回落为 COLLSCAN - 采样结果不是严格均匀分布:因 ObjectId 时间成分导致新文档集中于索引尾部,高频写入场景下易偏向“最新数据”
更可控的替代方案:基于时间字段 + 范围采样
对时序类集合(如物联网设备数据、日志),用带索引的时间字段比 _id 更可靠:
- 确保
timestamp字段有升序索引:db.device_data.createIndex({timestamp: 1}) - 先查出时间范围:
db.device_data.aggregate([{$group: {_id: null, min: {$min: "$timestamp"}, max: {$max: "$timestamp"}}}]) - 在应用层生成 N 个均匀分布的时间点,再用
$in或多次$gte/$lt查询逼近采样效果,例如:db.device_data.find({timestamp: {$gte: ISODate("2026-05-10T00:00:00Z"), $lt: ISODate("2026-05-10T00:00:01Z")}}).limit(1) - 该方式可精准控制分布,且全部走索引,但需额外一次聚合查询获取时间边界
真正高效的随机采样:加一个随机数字段并建索引
这是生产环境最推荐的做法,适用于需要真正随机、且能接受写入开销的场景:
- 在插入/更新时,为每条文档生成一个
rand字段(如Math.random()或UUID().toString().substring(0,8)),并确保该字段值在 [0,1) 区间均匀分布 - 为该字段建立索引:
db.collection.createIndex({rand: 1}) - 采样时直接用:
db.collection.aggregate([{$sample: {size: 1000}}])—— 此时 MongoDB 会自动识别rand索引并执行索引跳跃式采样,实测 TB 级集合单次采样稳定在 200ms 内 - 注意:该字段需随写入持续更新分布均匀性;若批量导入后不再写入,建议后续用
db.collection.updateMany({}, [{$set: {rand: {$rand: {}}]})(MongoDB 4.4+ 支持)重置
真正难的不是加字段,而是让这个 rand 值在长期写入后仍保持统计意义上的均匀——一旦倾斜,索引采样就会退化。定期用 mongotop 观察该索引的 readLock 热度,比盯着 $sample 延迟更有价值。











