直接count()在高频场景下变慢,因无索引或复杂查询会触发全表扫描,即使有索引也可能因ttl/稀疏索引或$or/$regex退化为逐文档检查,且从节点延迟导致结果滞后。

为什么直接 count() 在高频场景下会越来越慢
MongoDB 的 count() 操作在无索引或查询条件复杂时,实际会触发 collection scan,尤其是当集合变大、并发统计请求增多,CPU 和 I/O 压力会明显上升。更关键的是:即使加了索引,count({status: "active"}) 这类带筛选的统计,在 WiredTiger 引擎下仍可能绕过索引计数优化(比如存在 TTL 索引、稀疏索引或查询含 $or/$regex),最终退化为逐文档检查。
- 真实场景中,用户中心页每秒要查「当前在线设备数」「今日新增订单数」,靠实时
count()容易拖垮主库 - 副本集里从节点延迟高时,读取到的统计结果可能滞后数秒甚至更久
-
db.collection.countDocuments()比db.collection.estimatedDocumentCount()准确,但代价是必须走 query plan —— 别被名字骗了,它不快
用原子更新实现计数器预聚合的实操要点
核心思路是把“查多少”变成“加/减多少”,用 $inc 在写入/状态变更时同步更新专用计数字段。这不是缓存,而是数据一致性可验证的状态快照。
- 为每个需高频统计的维度建独立字段,比如
stats.active_devices、stats.today_orders,别堆在同一个嵌套对象里——避免更新冲突和写放大 - 务必用
findAndModify()或findOneAndUpdate()配合{upsert: true},确保首次初始化不报错;例如设备上线时:db.devices.findOneAndUpdate( {device_id: "abc123"}, {$setOnInsert: {created_at: new Date()}, $inc: {"stats.active_devices": 1}}, {upsert: true} ) - 时间维度统计(如日/月计数)必须配合 TTL 索引或定时任务归档,否则
stats.day_20240520_orders字段会无限膨胀 - 注意事务边界:如果订单创建和库存扣减在同一个事务里,计数器更新必须也在该事务内,否则会出现「订单已建但计数没加」的中间态
如何安全地修复预聚合字段与实际数据的偏差
再严谨的写入逻辑也扛不住网络分区、应用崩溃或手动误操作。必须有兜底校验机制,不能只信计数器。
- 每天凌晨用低峰期跑一次对账脚本,比对
stats.today_orders和db.orders.countDocuments({date: "2024-05-20"}),差值超阈值(比如 > 0.1%)就告警并触发重建 - 重建不要用
updateMany()直接覆盖,先算出目标值存到临时字段stats.today_orders_corrected,确认无误后再原子 rename 字段名 —— 避免服务中途中断导致计数器归零 - 对账脚本本身要幂等:记录最后校验时间戳到单独集合(如
counter_audit_log),防止重复执行 - 别依赖
estimatedDocumentCount()做校验基准,它只反映 total docs,不含查询过滤逻辑
聚合管道里混用预聚合字段的注意事项
预聚合字段不是万能胶水,强行塞进复杂聚合可能引入语义错误。
- 如果要做「每个城市的活跃设备数 + 对应城市今日订单数」双维度关联,别试图用
$lookup把两个预聚合字段拼一起——它们更新时机不同步,结果可能错位;老老实实按 city 分组后分别$sum原始字段更可靠 - 在
$facet中同时用预聚合字段和实时$count,要注意时钟偏移:预聚合值反映的是最后一次写入时刻,而$count是当前快照,两者时间点不一致 - 开启 readConcern: "majority" 时,预聚合字段的可见性跟普通字段一致,但如果你在从节点读取计数器,得确认该节点复制延迟是否在业务容忍范围内
最麻烦的不是设计,是说服团队接受「统计值可以有秒级延迟」——只要这个延迟有明确上限、可监控、可对账,它就比每次请求都扫表更可控。










