$facet, $bucket, $graphLookup

Продвинутые этапы решают задачи, неподъёмные для базового конвейера: много отчётов за один проход, гистограммы и обход иерархий.

$facet — этап, который прогоняет один и тот же входной поток сразу через несколько независимых подконвейеров и возвращает все их результаты в одном документе.

Базовых этапов хватает для большинства отчётов, но есть задачи, где они неуклюжи: посчитать несколько разных срезов сразу, разложить значения по диапазонам, обойти дерево категорий любой глубины. Для этого в MongoDB есть специализированные этапы. Этот урок — про $facet, $bucket/$bucketAuto, $graphLookup и про то, как сохранить тяжёлый результат через $merge/$out.

Зачем это на практике

  • Дашборд одним запросом: топ-категории, распределение по ценам и общий итог — за один проход по данным.
  • Гистограмма цен: сколько товаров в диапазонах 0–500, 500–1000, 1000+.
  • Дерево категорий: собрать все подкатегории заданной ветки любой вложенности.
  • Ночной отчёт: посчитать агрегат и сохранить в отдельную коллекцию для быстрого чтения витриной.

Код — в mongosh, без запуска в браузере.

$facet — несколько отчётов за один проход

Иногда нужно из одних и тех же данных получить сразу несколько разных срезов. Можно сделать несколько запросов, но это несколько проходов по коллекции. $facet делает это за один: внутри него — объект, где каждый ключ это отдельный подконвейер.

db.products.aggregate([
  { $match: { inStock: true } },
  { $facet: {
      topCategories: [
        { $group: { _id: "$category", n: { $sum: 1 } } },
        { $sort: { n: -1 } },
        { $limit: 5 }
      ],
      priceStats: [
        { $group: { _id: null, avg: { $avg: "$price" }, max: { $max: "$price" } } }
      ],
      total: [ { $count: "count" } ]
  } }
])

На входе у всех трёх веток одни и те же отфильтрованные товары. Результат — один документ с тремя полями-массивами:

{
  "topCategories": [ { "_id": "books", "n": 120 }, { "_id": "toys", "n": 80 } ],
  "priceStats": [ { "_id": null, "avg": 640, "max": 9900 } ],
  "total": [ { "count": 350 } ]
}

Это идеально для дашбордов: одним запросом отдаёте фронтенду готовый набор виджетов. Важно: внутри $facet подконвейеры не видят результатов друг друга — каждый работает с исходным потоком независимо.

$bucket и $bucketAuto — гистограммы

$bucket раскладывает документы по заданным границам — как раскладывают монеты по ячейкам. Вы задаёте поле, массив границ и что считать в каждой корзине.

db.products.aggregate([
  { $bucket: {
      groupBy: "$price",
      boundaries: [0, 500, 1000, 5000],
      default: "5000+",
      output: { count: { $sum: 1 }, avgPrice: { $avg: "$price" } }
  } }
])

Границы [0, 500, 1000, 5000] задают корзины [0,500), [500,1000), [1000,5000); всё, что не попало (цена ≥ 5000 или меньше 0), уходит в корзину default. Без default документ за границами вызовет ошибку. Результат:

{ "_id": 0,    "count": 140, "avgPrice": 270 }
{ "_id": 500,  "count": 120, "avgPrice": 720 }
{ "_id": 1000, "count": 70,  "avgPrice": 2100 }
{ "_id": "5000+", "count": 20, "avgPrice": 7300 }

Если границы выбирать вручную лень, есть $bucketAuto: вы говорите «хочу N корзин», и MongoDB сама подбирает границы так, чтобы документы распределились примерно поровну.

db.products.aggregate([
  { $bucketAuto: { groupBy: "$price", buckets: 4 } }
])

$bucket хорош, когда границы заданы бизнесом (ценовые сегменты), $bucketAuto — для разведочного анализа, когда хочется равномерных групп.

$graphLookup — рекурсивные иерархии

Деревья и графы (категории с подкатегориями, сотрудники с подчинёнными, цепочки «родитель → потомок») плохо ложатся на обычный $lookup: он соединяет на один уровень, а иерархия — на много. $graphLookup рекурсивно идёт по связи, пока находит совпадения.

db.categories.aggregate([
  { $match: { name: "Электроника" } },
  { $graphLookup: {
      from: "categories",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "parentId",
      as: "descendants",
      maxDepth: 5
  } }
])

Чтение по шагам: стартуем со значения _id категории «Электроника» (startWith); ищем документы, где parentId равен этому _id (connectToField); для каждого найденного берём его _id (connectFromField) и снова ищем детей — и так до глубины maxDepth. Все потомки любого уровня собираются в массив descendants. maxDepth ограничивает рекурсию и защищает от зацикливания при битых данных.

$merge и $out — материализация результата

Тяжёлая агрегация, которую читают часто, — кандидат на материализацию: посчитать заранее и сохранить в коллекцию. Два финальных этапа умеют писать результат конвейера в коллекцию.

ЭтапПоведение
$outполностью заменяет целевую коллекцию результатом конвейера
$mergeсливает результат: вставляет новые, обновляет совпавшие по ключу (умеет инкрементальное обновление)
db.orders.aggregate([
  { $group: { _id: "$category", revenue: { $sum: "$total" } } },
  { $merge: { into: "category_report", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])

Такой конвейер можно гонять по расписанию, а витрина пусть читает готовую коллекцию category_report мгновенным find(). $merge гибче $out: он не сносит всю коллекцию, а аккуратно обновляет — поэтому подходит для регулярного пересчёта.

Как это работает под капотом

$facet не делает магии «всё бесплатно»: он действительно прогоняет входной поток через каждый подконвейер, поэтому суммарная работа равна сумме веток. Экономия в том, что чтение и предварительные этапы (например, $match до $facet) выполняются один раз. Поэтому ставьте общий $match перед $facet, а не повторяйте его в каждой ветке.

$graphLookup на каждом уровне выполняет поиск по connectToField — без индекса на этом поле обход дерева быстро становится дорогим. Всегда индексируйте поле связи. А $facet «ломает» оптимизацию индексов для сортировки внутри веток: индексный $sort работает только до $facet, внутри веток сортировка идёт в памяти.

Частые ошибки

  • $bucket без default. Документ, не попавший ни в одну границу, вызывает ошибку выполнения — почти всегда нужен default.
  • Ждут общения между ветками $facet. Подконвейеры независимы и не видят результатов друг друга; передать данные между ними нельзя.
  • $graphLookup без maxDepth и без индекса. При циклах в данных обход может «уехать» очень глубоко, а без индекса на connectToField — ещё и медленно.
  • Путают $out и $merge. $out затирает коллекцию целиком (опасно для живой витрины); для инкрементального обновления берут $merge.
  • Повторяют $match внутри каждой ветки $facet. Общий фильтр выносите до $facet — иначе чтение дублируется.

Итоги

  • $facet даёт несколько независимых отчётов из одного входного потока — основа дашбордов одним запросом.
  • $bucket раскладывает по заданным границам (нужен default), $bucketAuto сам подбирает N равных корзин.
  • $graphLookup рекурсивно обходит иерархии по connectFromField/connectToField; ограничивайте maxDepth и индексируйте поле связи.
  • $out заменяет коллекцию целиком, $merge сливает результат инкрементально — для материализации витрин.
  • Общий $match ставьте до $facet, чтобы чтение и фильтрация выполнялись один раз.
Проверьте себя
1. Что особенного делает $facet по сравнению с обычными этапами?
AПрогоняет один входной поток сразу через несколько независимых подконвейеров и возвращает все результаты в одном документе
BСоединяет две коллекции по равенству ключей
CСортирует документы по нескольким полям одновременно
DРекурсивно обходит дерево связанных документов
2. Зачем в $bucket указывают параметр default?
AЧтобы документы, не попавшие ни в одну заданную границу, складывались в отдельную корзину, а не вызывали ошибку
BЧтобы задать имя коллекции для результата
CЧтобы отсортировать корзины по убыванию
DЧтобы ограничить глубину рекурсии
3. Чем $merge отличается от $out при сохранении результата агрегации?
A$merge сливает результат с целевой коллекцией (вставляет новые, обновляет совпавшие), а $out заменяет коллекцию целиком
B$merge работает только в памяти, $out — на диске
C$out умеет инкрементально обновлять, а $merge всегда перезаписывает
DЭто синонимы, разницы нет