$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, чтобы чтение и фильтрация выполнялись один раз.