MongoDB count准确性问题的背景
提及MongoDB中count的准确性,很多开发者首先会想到secondary延迟可能导致读不到最新数据。然而,问题远比这复杂。在MongoDB 4.0官方文档中,明确写道:
On a sharded cluster, db.collection.count() without a query predicate can result in an inaccurate count if orphaned documents exist or if a chunk migration is in progress.
To a void these situations, on a sharded cluster, use the db.collection.aggregate() method
而回顾MongoDB 3.6时代,文档的表述则是:
On a sharded cluster, db.collection.count() can result in an inaccurate count if orphaned documents exist or if a chunk migration is in progress.
To a void these situations, on a sharded cluster, use the db.collection.aggregate() method
通过对比可以发现,4.0版本中,只有不带谓词条件的全表count才可能不准确,且仅出现在两种场景下——存在孤立文档,或者后台正在进行move chunk操作。而在3.6及更早版本中,即使带上了过滤条件,同样可能出现问题。
本文就围绕这两个场景,详细剖析不准确的原因,以及如何规避。
孤立文档(orphaned documents)导致count不准确
孤立文档的定义与产生原因
孤立文档,简单来说,就是在move chunk过程中进程异常崩溃,导致迁移失败或清理源端旧数据失败,结果同一份记录在源端和目标端各保留了一份。在MongoDB分片集群的设计中,每个文档只能属于一个chunk、一个shard。多出来的那些就是“孤儿”。
可以想象,多了这些孤儿,count出的数字自然虚高。而且如果孤儿数量过大,还会白白占用磁盘空间。
move chunk的流程大致如下:
- 负载均衡器向源端分片发送moveChunk命令
- 源端开始内部迁移数据块,迁移期间所有读写请求仍由源端处理
- 目标端分片建立对应索引
- 目标端开始接收从源端复制过来的chunk数据
- 复制完成后,目标端开启同步进程,接收迁移期间产生的增量数据
- 增量同步完成后,源端连接config数据库修改元数据(chunk归属信息)
- 元数据修改完成后,源端分片开始删除之前迁移的chunk数据

这里有一个关键设计:从源端到目标端复制数据是串行的,一次只迁移一个chunk。但最后一步——清理源端旧数据——却是异步的。也就是说,元数据修改完成后,系统立刻就能开始下一个chunk的迁移,无需等待清理完成。清理任务被放入队列慢慢处理。如果队列堆积,恰在此时primary节点崩溃,那么源端那些还没来得及删除的数据就变成了孤立文档。
现象模拟与描述
从流程来看,如果迁移过程中途失败,目标端也可能产生孤立文档,但由于串行处理,最多只有一个chunk出问题。但如果是最后一步清理失败,源端可能累积大量chunk的孤儿。
模拟方法很简单:在大量move chunk期间强制杀掉主节点的mongod进程。例如,集群里本来只有一个shard,再添加一个新shard,必然会触发大量chunk迁移。以下是真实测试结果:
// 添加sharding前,确认sh.isBalancerRunning()为false
mongos> db.user.count({_id:{$gte:0}})
43937296
mongos> db.user.count()
43937296
// 添加分片过程中kill -9 mongod进程,重新拉起各个分片
mongos> db.user.count({_id:{$gte:0}})
43937296
mongos> db.user.count()
51028273
mongos> db.user.aggregate([{ $count:"myCount"}])
{ "myCount" : 43937296 }
看到差别了吗?只有不带谓词条件的全表count出了问题,返回5102万,而带条件的count和aggregate结果都是正确的4393万。原因很简单:不带谓词的count直接从元数据累加各个分片chunk的统计值,孤立文档也被算进去了。这个案例中凭空多出了709万个孤立文档。
规避与消除孤立文档的方法
从源头减少孤立文档的产生,可以修改配置让清理操作变成同步的。这样即使primary挂了,最多只有一个chunk可能产生孤立文档。但说实话,这个方法意义不大,不推荐。设置方式如下:
use config
db.settings.update(
{ "_id" : "balancer" },
{ $set : { "_waitForDelete" : true } },
{ upsert : true }
)
如果已经产生孤立文档怎么办?MongoDB提供了清理命令,需要在每个shard节点上依次执行:
var nextKey = { };
var result;
while ( nextKey != null ) {
result = db.adminCommand( { cleanupOrphaned: "test.user", startingFromKey: nextKey } );
if (result.ok != 1) print("Unable to complete at this time: failure or timeout.")
printjson(result);
nextKey = result.stoppedAtKey;
}
move chunk期间count不准确
现象描述
通过mongod日志或执行sh.isBalancerRunning()可以确认当前是否在move chunk。为了观察更清楚,我们把_waitForDelete设为1(即迁移完立刻删除源端数据),然后执行无谓词count,会发现一个规律:count值先快速上涨,然后缓慢下降,每个chunk迁移都重复这个过程,直到负载均衡器停下来,count才稳定到准确值。
原因分析
- move chunk过程中,数据在源端和目标端同时存在(尚未完成迁移),此时执行无谓词count,两端未迁移完的chunk数据都被统计进来,所以数值上升。
- 等到数据复制结束、元数据修改完成,源端开始清理数据,count值逐渐回落。注意,清理过程比复制要慢得多,所以下降会比上涨更平缓。
有人可能会问:普通query在move chunk期间为什么不会出错?因为普通query会参考config server的元数据,只查询那些确实属于当前shard的chunk。而count(尤其是无谓词版本)直接拿元数据里的统计值累加,没有做“唯一归属”校验。在4.0之前,即使带了谓词条件的count也不做这个校验,所以也会不准确;4.0之后带谓词的count底层逻辑改成了和普通query一样,才杜绝了问题。
从设计哲学来看,4.0没有修正无谓词count的准确性问题,其实是一种性能与准确性的权衡。很多业务场景并不需要精确计数,反而更看重“fast count”——直接从元数据返回结果,省去遍历数据的开销。如果需要精确值,官方也提供了替代方案——aggregate。与其说这是bug,不如说是两条命令的语义没有统一,容易让人踩坑。
改进措施与规避方法
- 设置负载均衡窗口:限制move chunk只在低峰期进行,兼顾效率与准确性。
- 重要场景换成aggregate:需要精确count的地方,用
db.collection.aggregate([{ $count: "count" }])代替。 - 升级版本:如果业务依赖带谓词的count,建议将MongoDB升级到4.0以上。
- 清理孤立文档:一旦发现大量孤立文档,用前面提到的cleanupOrphaned命令及时清理。
