Этапы конвейера: $match, $group, $project, $sort
Конвейер агрегации — это последовательность этапов, где каждый получает документы от предыдущего и передаёт результат дальше.
Aggregation pipeline — массив этапов (stages), который MongoDB выполняет по порядку: документы коллекции «текут» сквозь $match, $group, $project, $sort и другие операторы, постепенно превращаясь в итоговый отчёт.
Базовый поиск через find() умеет фильтровать и сортировать, но не умеет считать суммы по группам, перестраивать форму документа или соединять данные. Всё это — работа конвейера агрегации. Он напоминает конвейер на заводе: на входе сырьё (все документы коллекции), каждый этап что-то с ними делает, на выходе — готовый отчёт.
Этот урок — про четыре самых частых этапа и про главную идею оптимизации: чем раньше вы выбрасываете лишние документы и поля, тем меньше работы у всего конвейера.
Зачем это на практике
Любой отчёт по данным — это конвейер. Реальные задачи, где find() бессилен, а агрегация решает за один запрос:
- Выручка по категориям: сгруппировать заказы по категории товара и просуммировать суммы.
- Средний чек по дням: посчитать
$avgпо полю суммы в разрезе даты. - Топ-10 авторов по числу статей: сгруппировать, посчитать, отсортировать, ограничить.
- Витрина для дашборда: оставить только нужные поля и переименовать их под фронтенд.
Дальше все примеры — в mongosh (оболочка MongoDB). Этот код не запускается в браузере, поэтому кнопки «Запустить» под ним нет — читайте его как образец синтаксиса.
$match — фильтрация документов
$match отбирает документы по условию — синтаксис тот же, что у find(). Это первый кандидат на роль начального этапа: он уменьшает поток до того, как начнётся дорогая группировка.
db.orders.aggregate([
{ $match: { status: "paid", total: { $gt: 1000 } } }
])
Здесь останутся только оплаченные заказы дороже 1000. Операторы сравнения те же: $gt, $gte, $lt, $lte, $in, $ne. Внешне выглядит как обычный фильтр, но внутри конвейера у него особая роль: если $match стоит первым и поле проиндексировано, MongoDB использует индекс и читает не всю коллекцию.
$group — группировка и аккумуляторы
$group — сердце агрегации. Он собирает документы в группы по ключу _id и считает по каждой группе агрегаты с помощью аккумуляторов. Ключ _id здесь — это «по чему группируем» (не идентификатор документа!).
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: {
_id: "$category",
revenue: { $sum: "$total" },
avgCheck: { $avg: "$total" },
orders: { $sum: 1 }
} }
])
Здесь заказы группируются по $category (доллар перед именем — это ссылка на значение поля). По каждой категории считаются три величины. Один из результатов:
{ "_id": "books", "revenue": 154000, "avgCheck": 770, "orders": 200 }
Главные аккумуляторы:
| Аккумулятор | Что делает |
$sum | сумма значений; $sum: 1 — счётчик документов в группе |
$avg | среднее арифметическое |
$min / $max | минимум и максимум |
$push | собирает значения в массив (все, включая повторы) |
$addToSet | собирает в массив только уникальные значения |
$first / $last | первое/последнее значение в группе (зависит от порядка) |
Чтобы посчитать итог по всей коллекции без разбивки, ставят _id: null:
db.orders.aggregate([
{ $group: { _id: null, total: { $sum: "$total" }, count: { $sum: 1 } } }
])
$push удобен, когда нужно собрать список: например, по каждому автору — массив заголовков его статей. Но осторожно: огромные массивы раздувают документ группы и память.
$project и $addFields — форма документа
$project задаёт, какие поля останутся в выходных документах, и может вычислять новые. Значение 1 включает поле, 0 исключает. По умолчанию всегда присутствует _id — его убирают явно через _id: 0.
db.users.aggregate([
{ $project: {
_id: 0,
name: 1,
email: 1,
fullName: { $concat: ["$firstName", " ", "$lastName"] }
} }
])
Разница между $project и $addFields:
$project— «белый список»: оставляет только перечисленные поля (плюс что вычислили).$addFields(синоним$set) — добавляет новые поля, не трогая существующие.
db.orders.aggregate([
{ $addFields: { withVat: { $multiply: ["$total", 1.2] } } }
])
Здесь к каждому заказу добавится поле withVat, а все остальные поля сохранятся. Используйте $project, когда нужно урезать документ до нескольких полей; $addFields — когда нужно просто дописать вычисленное.
$sort — сортировка
$sort упорядочивает поток: 1 — по возрастанию, -1 — по убыванию. Часто идёт в паре с $limit, чтобы получить «топ-N».
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$category", revenue: { $sum: "$total" } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
])
Это классический «топ-5 категорий по выручке»: отфильтровали оплаченные, сгруппировали, отсортировали по убыванию выручки, взяли пять первых.
Как это работает под капотом
Порядок этапов — это не косметика, а план выполнения. MongoDB обрабатывает документы строго по списку, и от позиции этапа зависит, сколько данных пройдёт через каждый шаг.
Ключевая оптимизация — ранний $match. Если $match стоит первым, оптимизатор может задействовать индекс и прочитать с диска лишь нужные документы. Если же $match оказался после $group, то группировка уже обработала всю коллекцию, и фильтрация по результату ничего не сэкономила. Сравните два конвейера, дающих одинаковый ответ:
// ХОРОШО: сначала отсекли лишнее по индексу, потом группируем малый поток
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$category", revenue: { $sum: "$total" } } }
])
// ПЛОХО: сгруппировали ВСЕ заказы, потом отбросили часть групп
db.orders.aggregate([
{ $group: { _id: "$category", revenue: { $sum: "$total" }, st: { $first: "$status" } } },
{ $match: { st: "paid" } }
])
Современные версии MongoDB умеют сами переставлять $match ближе к началу, если это безопасно, но полагаться на автомат не стоит: пишите конвейер так, чтобы фильтры и проекции стояли как можно раньше. То же касается $project с исключением тяжёлых полей — убрать ненужное в начале значит уменьшить объём данных, который перекладывается между этапами.
Частые ошибки
- Забыли доллар перед полем.
_id: "category"сгруппирует всё в одну группу со строкой-литералом «category». Правильно —_id: "$category": доллар означает «значение этого поля». - $match после $group. Фильтр по сырым полям ставьте до группировки; после
$groupдоступны только поля результата (_idи аккумуляторы), и индекс уже не помогает. - Путают $project и $addFields. Если в
$projectперечислить только пару полей, всё остальное исчезнет — иногда неожиданно для автора. - $sort без индекса на больших данных. Сортировка крупного потока в памяти упирается в лимит и требует записи на диск. Сортируйте по индексированному полю или сокращайте поток ранним
$match. - Огромный $push. Сбор всех значений группы в массив может раздуть документ до предела 16 МБ. Если нужен список — подумайте, не достаточно ли счётчика или топ-N.
Итоги
- Конвейер — массив этапов; документы проходят их по порядку, и порядок влияет на скорость.
$matchфильтрует (какfind); ставьте его первым, чтобы использовать индекс и уменьшить поток.$groupгруппирует по_idи считает аккумуляторами$sum,$avg,$min/$max,$push,$addToSet.$projectоставляет/вычисляет поля (белый список),$addFieldsдобавляет новые, не трогая прочие.$sort+$limitдают «топ-N»; ссылка на значение поля — всегда через$.