字段冗余、嵌套过深、索引错配、滥用$unwind和$lookup是mongodb聚合性能四大主因;应精简字段、扁平化结构、按聚合路径设计文档、严格对齐索引与$match条件,并审慎使用展开与关联操作。

字段冗余与嵌套深度直接影响$unwind和$group效率
文档里塞太多没用的字段,尤其是深层嵌套对象或大数组,会让$unwind后文档数量爆炸式增长。比如一个订单文档含 items 数组(平均 20 条),再带 logs 字段(几 KB JSON),$unwind 后不仅生成 20 倍文档数,每条还拖着完整 logs 复制体——内存和传输开销直接翻车。
- 设计阶段就明确聚合路径:如果只按
items.sku分组统计,就别把items.description、items.image_url等无关字段留在同一数组里 - 对高频聚合字段做扁平化处理:把
user.profile.city提到根级city,避免每次 $project 都要写"city": "$user.profile.city" - 大文本、二进制、日志类字段单独拆到副集合,用
$lookup按需关联,而不是全量随主文档加载
索引字段必须与$match前置位置严格对齐
文档设计决定索引是否能被聚合真正用上。比如你设计了 { status: "string", createdAt: "date", tags: ["string"] },但聚合里写 {$match: { "tags.0": "urgent" }},哪怕给 tags 建了索引,MongoDB 也大概率走不了——因为索引无法高效支持数组位置访问。
- 等值查询字段(如
status)优先建单字段索引;复合查询时,把等值字段放复合索引最左,范围字段(如createdAt)靠右 - 避免在
$match中依赖计算字段:比如文档存的是updatedAt,但聚合里写{$match: { $expr: { $gt: [{ $dateDiff: ... }, 86400] } }},这种没法走索引,得先全量读再算 - 时间范围查询尽量用
$gte/$lt而非$gt/$lte,前者更容易命中索引边界
数组字段是否该展开,取决于下游是否真需要逐项处理
$unwind 不是万能钥匙,而是性能放大器。它把一条文档变成 N 条,后续所有阶段都要处理 N 倍数据。很多场景其实根本不需要展开——比如只想统计“含标签 'vip' 的订单数”,用 $in 或 $elemMatch 就够了,硬上 $unwind 反而慢。
- 先问自己:是要统计数组长度?取第一个元素?还是遍历每个子项做分组?只有最后一种才必须
$unwind - 如果只是过滤含某值的数组文档,用
{$match: { tags: "vip" }}或{$match: { tags: { $elemMatch: { $eq: "vip" } } }},比$unwind+$match快得多 - 真要展开时,务必紧跟
$project:比如$unwind后只保留item.sku和item.qty,立刻丢掉item.details这类大字段
避免用文档结构“模拟关系”,导致$lookup失控
把用户地址存在订单文档里(嵌入式),和单独建 addresses 集合再 $lookup 关联,性能表现天差地别。前者看似省事,但一旦要按“城市”聚合所有订单,就得扫描全部订单文档并提取嵌入字段;后者只要给 addresses.city 建索引,$lookup 就能快速定位。
- 高频更新或独立查询的字段(如用户偏好、设备信息)不要嵌入主文档,单独建集合 +
$lookup -
$lookup的from集合必须有对应索引:比如pipeline里$match用userId,那users集合就得有{ userId: 1 }索引 - 嵌入式设计只适用于:数据极少变更、几乎不单独查询、且聚合时只读不改——比如订单状态流转历史,存为数组没问题;但用户联系方式频繁改,就不该嵌入
$unwind 膨胀、$lookup 全表扫——这些坑在数据量上来后才暴露,但重构成本远高于初期设计权衡。











