$facet是处理多分支独立统计的最优解,因其支持并行子管道、逻辑隔离、避免字段膨胀;但须严控各子管道内存(≤100mb)与最终bson体积(≤16mib),且子管道间无法共享中间结果。

$facet 是解决复杂条件分支统计最直接有效的手段,但必须严格控制子管道输出体积,否则会直接报错。
为什么不用 $cond + $group 而选 $facet
当需要同时统计“60岁以上用户数”“30–59岁用户数”“近7天活跃用户数”“总注册数”等多个互不重叠、逻辑独立的指标时,用 $cond 嵌套在 $group 里会导致:字段膨胀、可读性差、无法单独加索引优化、且一旦某一分支出错整个聚合失败。而 $facet 让每个分支完全隔离,各自走自己的 $match → $count 或 $sum 流程。
常见错误现象:"errmsg" : "Exceeded memory limit for $facet" 或 "BSONObj size: 16777216 (0x1000000) is invalid" —— 这不是数据量大,而是某个子管道没做前置过滤,把全表文档拖进内存了。
- 每个子管道都应以
$match开头,且尽可能利用索引(如{ age: 1, createdAt: -1 }) - 避免在子管道里用
$lookup或未限制的$unwind,它们极易触发 100 MB 内存上限 - 如果只需要计数,优先用
$count而非$group+$sum: 1,前者更轻量
子管道之间不能共享中间结果
$facet 的每个子管道接收的是**完全相同的原始输入文档集**,彼此不可见。你不能写一个子管道先算出“高价值用户ID列表”,再让另一个子管道用这个列表去 $match。这种依赖关系必须拆到 $facet 外面处理。
典型误用场景:
- 想在一个子管道里
$group出 top 10 用户,另一个子管道统计这些用户的订单总额 —— 不行,得先用$facet输出 top10 ID 数组,再用$unwind+$lookup后续处理 - 试图在不同子管道里复用同一个
$project字段别名 —— 每个子管道是独立作用域,字段名只在自己内部生效 - 期望子管道 A 的
$sort影响子管道 B 的执行顺序 —— 完全无关,排序只作用于本子管道输出数组
性能与体积的硬性边界必须手动盯紧
$facet 不支持 allowDiskUse: true,所有子管道都在内存中完成,且受两个硬限制约束:
- 每个子管道中间结果 ≤ 100 MB(不是最终输出,是任意阶段产生的临时文档)
- 最终输出文档 ≤ 16 MiB(BSON 限制,超了直接报错
"BSONObj size ... is invalid")
这意味着:即使你用 $limit: 10 控制了输出条数,但如果中间 $project 把每个文档撑到 2MB,10 条就超限了。真实项目中容易被忽略的点:
- 子管道里用了
$addFields拼接长文本或嵌套对象,没做截断 - 用
$push收集大量子文档(比如“每个用户的最近50条操作日志”),数组一膨胀就爆 - 忘记
$project显式剔除不需要的字段,原始文档带 20 个字段,子管道却只用其中 2 个
Laravel-MongoDB 中 facet() 方法的坑
如果你用的是 laravel-mongodb 包,它的 facet() 是语法糖,底层仍调用原生聚合。但要注意:
- 传入的子管道数组必须是纯 PHP 数组,不能混用 Query Builder 对象(比如
User::where(...)->count()返回的是整数,不能塞进 facet 子管道) - 子管道内不能直接调用
->match()链式方法,必须写成完整数组结构:['$match' => ['age' => ['$gte' => 60]]] - 返回结果是嵌套数组,
$result['senior'][0]['total']这种写法要加空值判断,因为子管道可能无匹配文档,返回空数组
真正难的不是怎么写 $facet,而是怎么预估每个子管道的内存开销——它不像 SQL 可以 explain,得靠 explain("executionStats") 看 usedDisk 和 memUsage,但注意:这里显示的内存是整个聚合,不是单个子管道。实际压测时,建议逐个关闭子管道,观察体积变化。











