Этапы конвейера: $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»; ссылка на значение поля — всегда через $.
Проверьте себя
1. Почему $match выгодно ставить первым этапом конвейера?
AПервым этапом MongoDB может использовать индекс и прочитать только нужные документы, уменьшив поток для всех следующих этапов
B$match работает только если он первый, в других позициях он игнорируется
CПорядок этапов не влияет на производительность, $match можно ставить где угодно
DПервый этап выполняется на сервере, а остальные на клиенте
2. Что вернёт { $group: { _id: "$category", n: { $sum: 1 } } }?
AПо одному документу на каждую категорию с количеством документов в поле n
BСумму всех значений поля total по категориям
CОдин документ с общим числом записей в коллекции
DОшибку, потому что $sum принимает только имя поля, а не число
3. Чем $project отличается от $addFields?
A$project оставляет только перечисленные поля (белый список), а $addFields добавляет новые поля, не удаляя существующие
B$project удаляет поля, а $addFields их не меняет вообще
CЭто полные синонимы и работают одинаково
D$addFields умеет вычислять выражения, а $project — нет