RE:NODE

Агрегация в MongoDB: $match, $group, $lookup и индексы

Автор Команда RE:NODE
18 мин чтения


Aggregation pipeline - это массив стадий. Каждая стадия получает документы, которые выдала предыдущая, меняет их и передаёт дальше. $match фильтрует, $group сворачивает много документов в один на каждый ключ, $lookup присоединяет другую коллекцию, а порядок, в котором вы их расставили, определяет, будет ли запрос использовать индекс, сколько памяти ему нужно и закончится ли он за 40 миллисекунд или упадёт с ошибкой памяти. Коротко: сначала фильтруйте, потом группируйте, соединяйте в последнюю очередь и смотрите в explain(), прежде чем доверять любому pipeline на коллекции, которая будет расти.

В этом руководстве мы шаг за шагом собираем один pipeline на небольшом наборе данных магазина, объясняем, что делает каждая распространённая стадия, показываем, какие стадии могут использовать индекс, а какие нет, и разбираем лимит в 100 МБ, о который спотыкаются, когда отчёт впервые запускается на реальных данных. Предполагается, что вы уже умеете подключаться и выполнять запросы; если нет, начните с строк подключения MongoDB для начинающих.

Что такое pipeline и чем он отличается от find()#

find() возвращает документы в том виде, в каком они хранятся, при желании отфильтрованные и урезанные по полям. aggregate() может вернуть то, чего никогда не было в хранилище: суммы по клиентам, количество заказов по дням, заказы вместе с клиентом, который их сделал. Всё, что в SQL вы написали бы через GROUP BY, JOIN или оконную функцию, в MongoDB проходит через pipeline.

Pipeline запускается так: db.collection.aggregate([ stage1, stage2, ... ]). Документы проходят через стадии по порядку, и каждая стадия видит только то, что выдала предыдущая. Из этого следуют два вывода, и к одному из них сводится большинство проблем с pipeline:

  • Стадия может использовать только те поля, которые к этому моменту ещё существуют. Если ранний $project или $group убрал поле, более поздний $match по нему ничего не найдёт, и никакая ошибка об этом не сообщит.
  • Стадии, которым нужен весь вход целиком, прежде чем они что-то выдадут ($group, $sort без индекса, $bucket), держат документы в памяти. Стадии, пропускающие документы по одному ($match, $project, $addFields, $limit), почти ничего не стоят.

В примерах ниже две коллекции. В orders один документ на заказ, в customers один на аккаунт:

javascript
db.orders.insertMany([  { customerId: 1, status: "paid", total: 42.5,    createdAt: ISODate("2026-10-01T09:12:00Z"),    items: [{ sku: "A-100", qty: 2, price: 10 }, { sku: "B-200", qty: 1, price: 22.5 }] },  { customerId: 2, status: "paid", total: 15,    createdAt: ISODate("2026-10-02T14:40:00Z"),    items: [{ sku: "A-100", qty: 1, price: 10 }, { sku: "C-300", qty: 1, price: 5 }] },  { customerId: 1, status: "refunded", total: 10,    createdAt: ISODate("2026-10-03T08:05:00Z"),    items: [{ sku: "A-100", qty: 1, price: 10 }] }])db.customers.insertMany([  { _id: 1, name: "Nino", plan: "pro" },  { _id: 2, name: "Dato", plan: "free" }])

Выполняйте их в mongosh. Те же документы стадий без изменений работают из драйвера; в Node.js, Python и Go pipeline - это просто массив объектов.

$match: фильтруйте первым и используйте индекс#

$match принимает тот же синтаксис запроса, что и find(). Это самая важная стадия pipeline, потому что только она может использовать индекс и только она может удешевить всё, что идёт после неё.

javascript
db.orders.aggregate([  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } }])

$match в самом начале pipeline планируется так же, как фильтр в find(): если индекс покрывает status и createdAt, MongoDB просматривает диапазон индекса, а не всю коллекцию. $match дальше по pipeline работает с результатом предыдущих стадий, поэтому индекс коллекции использовать не может вообще. Отсюда правило "match как можно раньше".

В одном случае оптимизатор помогает. Если вы пишете $match после $project или $addFields, а условие затрагивает только поля, которые они не меняли, MongoDB сам переносит $match вперёд. Если же условие зависит от поля, вычисленного предыдущей стадией, перенести его нельзя, и полагаться на это не стоит: пишите pipeline в том порядке, в котором хотите его выполнить.

Две детали, о которые обжигаются:

  • Обычный $match сравнивает поле со значением, а не с другим полем. Пути к полям вроде "$total" читаются только в выражениях. Используйте $expr, когда нужно сравнить два поля одного документа: { $match: { $expr: { $gt: ["$total", "$limit"] } } }. В общем случае $match с $expr не может использовать индекс для такого сравнения, так что применяйте его осознанно.
  • Даты сравниваются как даты, только если хранятся как даты. Если createdAt вставили строкой ISO, условие $gte: ISODate(...) ничего не найдёт. Выполните db.orders.findOne() и посмотрите на тип, прежде чем писать диапазон.

$group: один документ на ключ#

$group сворачивает все входящие документы с одинаковым выражением _id в один выходной документ и считает по группе аккумуляторы. Выручка по клиентам из оплаченных заказов:

javascript
db.orders.aggregate([  { $match: { status: "paid" } },  { $group: {      _id: "$customerId",      orders: { $sum: 1 },      revenue: { $sum: "$total" },      average: { $avg: "$total" },      lastOrder: { $max: "$createdAt" }  } },  { $sort: { revenue: -1 } }])

_id группы - это то, по чему вы группируете. Путь к полю ("$customerId"), целый вложенный документ для составного ключа ({ customer: "$customerId", day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } }) или null, чтобы свернуть весь вход в один документ, так считают общий итог.

Вот аккумуляторы, которые понадобятся чаще всего:

АккумуляторРезультат
$sumСумма поля или 1 на документ для подсчёта
$avgСреднее значение поля, отсутствующие значения игнорируются
$min, $maxНаименьшее или наибольшее значение в группе
$first, $lastЗначение из первого или последнего документа (имеет смысл только после $sort)
$pushМассив всех значений, дубликаты сохраняются
$addToSetМассив уникальных значений

$push и $addToSet опаснее остальных. Они держат в памяти каждое значение каждой группы, а готовая группа - это один документ с ограничением размера 16 МБ. $group с _id: "$customerId" и $push: "$$ROOT" на большой коллекции - стандартный способ упереться сразу в оба лимита. Если вам нужны только три последних заказа каждого клиента, используйте аккумуляторы top-N ($topN, $firstN) в версии, где они есть, а не складывайте всё в массив и не обрезайте его потом.

Отчёт по датам - та же стадия с другим ключом. Заказы по дням:

javascript
db.orders.aggregate([  { $match: { createdAt: { $gte: ISODate("2026-10-01"), $lt: ISODate("2026-11-01") } } },  { $group: {      _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },      orders: { $sum: 1 }  } },  { $sort: { _id: 1 } }])

Ключ - строка в UTC, если не передать timezone. Если отчёты нужны людям из одного часового пояса, передайте его (timezone: "Asia/Tbilisi"), иначе заказ, сделанный в 01:00 по местному времени, попадёт в предыдущий день.

$project, $addFields, $unwind и $count#

Четыре небольшие стадии делают большую часть переформатирования вокруг $match и $group.

$project оставляет, убирает или вычисляет поля. { $project: { _id: 0, customerId: 1, total: 1 } } вернёт только эти два поля. $addFields (и его псевдоним $set) добавляет вычисляемые поля, сохраняя все остальные, что обычно и нужно в середине pipeline.

$unwind превращает поле-массив в по одному документу на элемент. Так вы агрегируете по items:

javascript
db.orders.aggregate([  { $match: { status: "paid" } },  { $unwind: "$items" },  { $group: {      _id: "$items.sku",      units: { $sum: "$items.qty" },      revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }  } },  { $sort: { revenue: -1 } },  { $limit: 5 }])

$unwind размножает документы, поэтому ставьте его после $match и следите за разрастанием: коллекция из миллиона заказов по десять позиций превращается в десять миллионов документов, проходящих через следующую стадию. Документы, у которых массива нет или он пуст, отбрасываются, если не написать { $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }.

$count - это стадия, которая выдаёт один документ с числом входных документов: { $count: "paidOrders" }. $sort, $limit и $skip работают как в find(), с одним отличием, которое важно дальше: $sort, за которым сразу идёт $limit, оптимизируется в сортировку top-k, где в памяти держатся только k документов.

$lookup: соединение коллекций#

$lookup - это left outer join в MongoDB. Для каждого входящего документа он достаёт подходящие документы из другой коллекции и прикрепляет их массивом:

javascript
db.orders.aggregate([  { $match: { status: "paid" } },  { $lookup: {      from: "customers",      localField: "customerId",      foreignField: "_id",      as: "customer"  } },  { $unwind: "$customer" },  { $project: { total: 1, "customer.name": 1, "customer.plan": 1 } }])

as всегда массив, даже когда совпал ровно один документ, поэтому дальше идёт $unwind. Без него вы получите customer: [ { ... } ], что один раз удивляет почти всех. Если ни один документ не совпал, массив пуст, и $unwind выше отбросит такой заказ. Добавьте preserveNullAndEmptyArrays: true, если заказ должен пережить отсутствующего клиента.

Три правила держат $lookup быстрым:

  1. Индексируйте внешнее поле. Для каждого входящего документа $lookup выполняет запрос к другой коллекции. По _id индекс уже есть. По customerId в orders (когда вы соединяете в обратную сторону) его нет, и без индекса каждый поиск сканирует всю коллекцию: тысяча входящих документов против ста тысяч внешних - это сто миллионов сравнений.
  2. Сначала сократите вход. Каждый документ, дошедший до $lookup, стоит одного запроса. $match и $limit ставьте до него, никогда не после.
  3. Соединяйте в направлении от малой стороны. Начать с нескольких документов, переживших фильтр, и искать по индексу в большой коллекции дёшево. Наоборот - нет.

Когда нужно отфильтровать или переформатировать присоединяемые документы, используйте форму с pipeline, передавая локальные поля через let:

javascript
db.customers.aggregate([  { $match: { plan: "pro" } },  { $lookup: {      from: "orders",      let: { cid: "$_id" },      pipeline: [        { $match: { $expr: { $eq: ["$customerId", "$$cid"] }, status: "paid" } },        { $project: { _id: 0, total: 1, createdAt: 1 } },        { $sort: { createdAt: -1 } },        { $limit: 3 }      ],      as: "recentOrders"  } }])

Использование индексов внутри $lookup в форме с pipeline ограниченнее, чем в простой форме. Равенство через $expr по полю может использовать индекс, но проверьте это через explain(), а не предполагайте; об этом ниже. Для соединения, которое вы выполняете постоянно, ответ часто не в более быстром $lookup, а в другой схеме: встраивайте то, что всегда читается вместе, как описано в статье схема и индексы MongoDB. $lookup - хороший инструмент для отчётов и дорогой для горячего пути веб-запроса.

Присоединённый массив тоже лежит внутри одного документа, поэтому на него действует лимит документа в 16 МБ. Если присоединять к клиенту все когда-либо сделанные им заказы без $limit, рано или поздно запрос упадёт на клиенте с самым большим числом заказов.

Какие стадии используют индекс#

Индекса могут коснуться только стадии в начале pipeline, и не все:

СтадияИспользование индекса
$match в началеДа, как в find()
$sort в начале или сразу после $match с индексомДа, если порядок даёт индекс
$groupНет, но $sort с $first или $last может использовать индекс по ключу сортировки
$lookupПоиски во внешней коллекции используют её индексы
$project, $addFields, $unwindНет - они преобразуют документы
Любая стадия после $group или $projectНет - это уже не документы коллекции

Логика простая: индекс - это структура над хранимыми документами. Как только стадия меняет форму потока, указывать индексу не на что. Поэтому порядок "match, sort, group, lookup" - порядок по умолчанию, а pipeline, который начинается с $project или $addFields и только потом фильтрует, - тревожный знак.

Для pipeline, который фильтрует по равенству и сортирует, действуют те же правила составных индексов, что и для find(): сначала поля равенства, затем сортировка, затем диапазон. Как упорядочить поля, объясняет разбор правила ESR. Для отчёта по оплаченным заказам с датами выше:

javascript
db.orders.createIndex({ status: 1, createdAt: -1 })

обслуживает и $match по status, и диапазон по createdAt, и отдаёт документы уже отсортированными по дате, если pipeline этого требует.

Как читать explain() для pipeline

Не гадайте, использовался ли индекс. Спросите:

javascript
db.orders.explain("executionStats").aggregate([  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } },  { $group: { _id: "$customerId", revenue: { $sum: "$total" } } }])

В выводе найдите входную стадию. IXSCAN с именем индекса означает, что индекс использован. COLLSCAN означает, что прочитан каждый документ. Сравните totalDocsExamined и nReturned на первой стадии: 500 000 просмотренных документов ради 800 возвращённых значат, что фильтр не селективен для имеющегося индекса или индекса нет. В pipeline с $lookup вывод explain показывает доступ к внешней коллекции отдельно, и COLLSCAN там - это недостающий индекс по foreignField.

Если первая стадия читается как $cursor с блоком queryPlanner, это та часть, которая передана движку запросов, и только её может обслужить индекс. Всё, что перечислено после, выполнялось уже в слое агрегации.

Простой explain() (без уровня детализации) только строит план и никогда не выполняет pipeline, это безопасный выбор для боевой базы. executionStats pipeline выполняет, поэтому на большой коллекции запускайте его на копии или с $limit, который в обычной работе вам не нужен.

Лимит 100 МБ и allowDiskUse#

Каждая блокирующая стадия pipeline может использовать до 100 мегабайт оперативной памяти. Блокирующие стадии - те, которым нужен весь вход, прежде чем выдать результат: $group, $sort (когда порядок не даёт индекс), $bucket, $bucketAuto, $setWindowFields на больших партициях и некоторые другие. Стадии, которые пропускают документы потоком, такие как $match, $project и $addFields, в лимит не входят. Лимит действует на каждую стадию, а не на весь pipeline, поэтому pipeline с $group и следующим за ним $sort может использовать по 100 МБ на каждую.

Когда стадия превышает его, поведение зависит от версии сервера и от опции allowDiskUse:

  • В MongoDB 6.0 и новее серверная настройка allowDiskUseByDefault равна true, если развёртывание её не отключило, поэтому стадия, переросшая 100 МБ, пишет временные файлы на диск и продолжает работу, но медленнее.
  • В более ранних версиях или там, где allowDiskUseByDefault равна false, стадия падает с ошибкой вроде Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting или Exceeded memory limit for $group, but didn't allow external sort. Передайте allowDiskUse: true в опциях, чтобы разрешить сброс на диск.
javascript
db.orders.aggregate(  [    { $group: { _id: "$customerId", orders: { $push: "$_id" } } },    { $sort: { _id: 1 } }  ],  { allowDiskUse: true })

В драйвере та же опция передаётся в аргументе options метода aggregate(). Точное написание смотрите в документации вашего драйвера, оно следует соглашениям языка.

allowDiskUse - это страховочная сетка, а не решение. Он превращает жёсткий отказ в медленный запрос: стадия пишет отсортированные куски в каталог _tmp внутри пути к базе и затем сливает их, что стоит дискового ввода-вывода и на небольшом тарифе конкурирует со всем остальным, что делает сервер. Ему также нужно свободное место в каталоге данных. Pipeline, который регулярно сбрасывает данные на диск, о чём-то вам говорит, и обычные средства по порядку такие:

  1. Перенесите $match раньше или добавьте нужный ему индекс, чтобы до блокирующей стадии доходило меньше документов.
  2. Уберите ненужные поля через $project перед $group или $sort. Стадия держит документы целиком, и документ в 4 КБ с нужным вам полем в 40 байт обходится в сто раз дороже, чем должен.
  3. Поставьте $limit сразу после $sort, чтобы сохранялись только первые несколько.
  4. Замените $push: "$$ROOT" и подобное небольшим аккумулятором, который хранит одно число.
  5. Для отчёта, который вы запускаете ежедневно по миллионам документов, предвычислите его в сводную коллекцию через $merge (или $out) по расписанию и читайте сводку.

Два ограничения не связаны с цифрой 100 МБ, и allowDiskUse на них не влияет. Один документ не может превышать 16 МБ, будь то сохранённый документ, слишком выросшая группа или результат $lookup. А $facet, который запускает несколько подпайплайнов на одном и том же входе, держит свой вывод в одном документе, так что весь его результат должен уместиться в 16 МБ.

Лимит 100 МБ также не означает, что серверу нужно 100 МБ свободной памяти на каждую стадию: это потолок, а небольшой $group использует несколько килобайт. Но на тарифе с 1 ГБ памяти несколько пользователей, одновременно запускающих крупные отчёты, могут занять больше, чем есть у кэша WiredTiger и операционной системы. Раздел о размерах в статье схема и индексы MongoDB объясняет, как кэш и остальной процесс делят память.

Разбор pipeline: от медленного к быстрому#

Возьмём месячный отчёт о выручке с именем и тарифом клиента, написанный так, как его проще всего написать:

javascript
db.orders.aggregate([  { $lookup: { from: "customers", localField: "customerId",               foreignField: "_id", as: "customer" } },  { $unwind: "$customer" },  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } },  { $group: { _id: "$customer.plan", revenue: { $sum: "$total" }, orders: { $sum: 1 } } },  { $sort: { revenue: -1 } }])

Он возвращает верный ответ и неправ тремя способами. $match стоит после $lookup, поэтому соединение выполняется для каждого заказа за всё время. Поля в $match принадлежат заказам, так что оптимизатор часто может передвинуть его вперёд, но такую перестановку лучше сделать самому. А $lookup копирует весь документ клиента в каждый заказ перед $group, которому нужен только plan.

Переписанный вариант:

javascript
db.orders.aggregate([  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } },  { $group: { _id: "$customerId", revenue: { $sum: "$total" }, orders: { $sum: 1 } } },  { $lookup: { from: "customers", localField: "_id",               foreignField: "_id", as: "customer" } },  { $unwind: "$customer" },  { $group: { _id: "$customer.plan", revenue: { $sum: "$revenue" }, orders: { $sum: "$orders" } } },  { $sort: { revenue: -1 } }])

Фильтр использует индекс { status: 1, createdAt: -1 }. Первый $group сводит поток к одному документу на платящего клиента до соединения, так что $lookup выполняется один раз на клиента, а не на заказ. Второй $group суммирует их по тарифам. Выполните explain("executionStats") для обоих вариантов, и разница будет видна в totalDocsExamined и во времени каждой стадии.

Безопасный запуск агрегаций на живой базе#

Несколько привычек не дадут отчёту уронить вместе с собой сайт.

  • Сначала пробуйте на ограниченном входе. Пока разрабатываете, ставьте { $limit: 1000 } сразу после $match и убирайте, когда результат верный.
  • Задайте лимит времени. maxTimeMS в команде остановит убежавший pipeline: db.orders.aggregate([...], { maxTimeMS: 20000 }). Отчёт, который не укладывается в двадцать секунд, не должен быть веб-запросом.
  • Находите и останавливайте то, что выполняется. db.currentOp() показывает операции, а db.killOp(opid) останавливает одну из них, как описано в разделе о профайлере в статье схема и индексы MongoDB.
  • Заведите отдельного пользователя для отчётов. Дайте ему роль read на одну базу, чтобы опечатка в pipeline не добралась до $out или $merge в боевую коллекцию. Какую роль выбрать, рассказывает статья пользователи и роли MongoDB.
  • Делайте backup перед любым `$out` или `$merge`. $out заменяет целевую коллекцию, когда pipeline заканчивается. Сначала сделайте mongodump, если целевая коллекция вам дорога.

На RE:NODE к тарифу MongoDB прилагаются сгенерированные для вас учётные данные и слоты backup, которые можно запускать вручную или по расписанию на вкладке Schedules, - это разумный способ сделать backup перед pipeline, который пишет данные. Память тарифа - жёсткий лимит: сервер, у которого она закончилась, останавливается и чисто перезапускается, а не уходит в swap, так что очень большой $group в памяти на маленьком тарифе - повод сначала переосмыслить pipeline, а не повышать тариф. Тарифы перечислены на странице хостинга баз данных MongoDB.

Устранение неполадок#

`Exceeded memory limit for $group, but didn't allow external sort.` $group перешёл за 100 МБ. Передайте { allowDiskUse: true }, а затем сделайте настоящее исправление: фильтруйте раньше, убирайте неиспользуемые поля через project, не складывайте через $push документы целиком.

`Sort exceeded memory limit of 104857600 bytes`. Тот же лимит для $sort, за которым нет индекса. Добавьте индекс, соответствующий сортировке, или поставьте после неё $limit.

`BSONObj size: ... is invalid` или "document exceeds maximum size 16777216". Группа или массив из $lookup стали больше 16 МБ. Ограничьте то, что собираете, соединяйте более узким pipeline или перестройте данные.

Pipeline ничего не возвращает и не выдаёт ошибки. Обычно поля уже нет на этой стадии, $unwind применён к отсутствующему полю или дата хранится строкой. Сократите pipeline до первой стадии, проверьте вывод и добавляйте стадии по одной.

`$lookup` очень медленный. Выполните explain("executionStats") и поищите COLLSCAN во внешней коллекции. Проиндексируйте foreignField и сократите число входящих документов.

`$match` медленный, несмотря на индекс. Вероятно, это не первая стадия, или в фильтре несовпадение типов (число против строки), или он начинается с отрицания либо регулярного выражения с ведущим подстановочным знаком. Посмотрите границы IXSCAN в explain().

Результаты различаются между запусками. $group не возвращает группы в каком-либо определённом порядке, а $first и $last зависят от порядка входа. Если результат зависит от порядка, поставьте $sort раньше.

FAQ#

Быстрее ли aggregation pipeline, чем find()?

Сам по себе нет. Для простого фильтра find() и pipeline только с $match выполняют одну и ту же работу. Pipeline быстрее, чем та же задача в коде приложения, потому что группировка и соединение происходят внутри сервера, а по сети идёт только результат.

Использует ли $match индекс?

Да, когда это первая стадия или перед ним только стадии, через которые оптимизатор может его перенести. $match после $group, $project с переименованием полей или $unwind работает с преобразованными документами и не может использовать индекс коллекции.

Что делает allowDiskUse?

Позволяет блокирующей стадии писать временные файлы на диск при превышении лимита памяти 100 МБ, вместо того чтобы падать. Запрос завершается, но медленнее. В MongoDB 6.0 и новее серверное значение по умолчанию уже разрешает это, если администратор не изменил allowDiskUseByDefault.

Можно ли соединить больше двух коллекций?

Да, цепочкой стадий $lookup, но каждая добавляет запрос на каждый входящий документ, так что стоимость умножается. Если в горячем запросе таких стадий больше двух-трёх, данным, скорее всего, место в одном документе.

Чем $lookup отличается от SQL JOIN?

Это left outer join, который возвращает совпадения массивом внутри каждого документа, а не дополнительными строками. Он выполняет один поиск на каждый входящий документ и плохо подходит для полного соединения двух очень больших коллекций. SQL-базы построены именно для этого; если ваши данные в основном реляционные, сначала прочитайте PostgreSQL или MongoDB.

Когда лучше предвычислять, а не агрегировать?

Когда один и тот же дорогой отчёт читают намного чаще, чем меняются данные. Запускайте pipeline по расписанию с $merge в сводную коллекцию, а приложение пусть читает её. Дашборд, который при каждой загрузке страницы заново агрегирует миллион документов, - обычная ошибка.

Это было полезно?