Производительность агрегаций
Скорость агрегации определяется тем, как рано вы уменьшаете данные и используете ли индексы на первых этапах.
Правило производительности: фильтруйте и сортируйте до группировки, опирайтесь на индексы в начале конвейера и убирайте лишние поля как можно раньше — тогда каждый следующий этап обрабатывает минимум данных.
Конвейер, дающий правильный ответ, может быть быстрым или мучительно медленным — при одинаковом результате. Разница в том, сколько документов и байт проходит через этапы и подключаются ли индексы. Этот урок — про инструменты и приёмы, которые превращают тяжёлую агрегацию в быструю: ранний $match/$sort, индексы, explain и работа с лимитом памяти.
Зачем это на практике
- Отчёт «завис»: агрегация по большой коллекции идёт секундами — надо понять, читается ли весь набор.
- Ошибка по памяти: этап превысил лимит и упал — нужно решить, индекс это или
allowDiskUse. - Дашборд тормозит: один и тот же конвейер хочется ускорить, не меняя результат.
- Проверка индекса: убедиться, что начальный
$matchреально использует индекс, а не сканирует коллекцию.
Команды — в mongosh, без исполнения в браузере.
$match и $sort до $group
Главный приём — двигать сокращающие этапы к началу. $match отсекает документы, $sort с $limit оставляет только верхушку — оба резко уменьшают поток для дорогой группировки. Сравните:
// МЕДЛЕННО: группируем все заказы за всю историю, потом фильтруем
db.orders.aggregate([
{ $group: { _id: "$category", revenue: { $sum: "$total" }, d: { $max: "$createdAt" } } },
{ $match: { d: { $gte: ISODate("2026-01-01") } } }
])
// БЫСТРО: сначала отсекли по дате (по индексу), группируем малую часть
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$category", revenue: { $sum: "$total" } } }
])
Во втором варианте $match стоит первым и (при индексе на createdAt) читает только нужные заказы. В первом — группировка перемалывает всю коллекцию, и фильтр уже ничего не экономит.
Индексы на ранних этапах
Индекс помогает агрегации только в начале конвейера — пока документы ещё «как в коллекции». Как только прошёл $group, $project с вычислениями или $unwind, данные становятся промежуточными, и индексы коллекции к ним неприменимы. Поэтому индексами можно ускорить ровно два начальных этапа:
- начальный
$match— индексный поиск вместо полного сканирования; - начальный
$sort— упорядоченное чтение по индексу вместо сортировки в памяти.
Если $match и $sort стоят первыми и по их полям есть подходящий составной индекс, MongoDB читает уже отфильтрованным и отсортированным — самый дешёвый сценарий. Стоит этим этапам уехать вглубь конвейера — и индекс «отключается».
$project пораньше — меньше байт в потоке
Между этапами перекладываются не только документы, но и все их поля. Если документ тяжёлый (большие тексты, вложенные массивы), а для отчёта нужны два-три поля, выгодно сбросить лишнее в начале:
db.articles.aggregate([
{ $match: { published: true } },
{ $project: { authorId: 1, views: 1 } },
{ $group: { _id: "$authorId", totalViews: { $sum: "$views" } } }
])
После $project по конвейеру едут крошечные документы с двумя полями вместо громоздких статей с телом. На больших данных это заметно снижает нагрузку на память и ускоряет группировку. Правило: уберите ненужные поля сразу, как только они перестали быть нужны.
explain — читаем план агрегации
Догадки о скорости проверяются командой explain. Для конвейера её вызывают так:
db.orders.explain("executionStats").aggregate([
{ $match: { status: "paid" } },
{ $group: { _id: "$category", revenue: { $sum: "$total" } } }
])
В выводе ищите ключевые признаки. В разделе плана начального $match поле stage покажет, как читались данные:
| Значение | Что значит |
IXSCAN | чтение по индексу — хорошо |
COLLSCAN | полное сканирование коллекции — обычно повод добавить индекс |
В executionStats сравните totalDocsExamined (сколько документов прочитали) с nReturned (сколько вернули). Если осмотрели миллион, а вернули сотню — индекса по фильтру не хватает. Цель — чтобы число осмотренных было близко к числу нужных.
Как это работает под капотом
У этапов, которые накапливают данные ($group, $sort без поддержки индекса), есть жёсткий лимит оперативной памяти на этап — по умолчанию 100 МБ. Если этап пытается удержать больше, агрегация падает с ошибкой вроде «exceeded memory limit». Это защита: один запрос не должен съесть всю память сервера.
Выхода два, и порядок важен. Сначала — уменьшить данные: ранний $match, ранний $project, индекс под $sort, чтобы сортировка шла по индексу и вообще не копила документы в памяти. И только если данные принципиально большие (тяжёлая аналитика, разовый пересчёт) — разрешить выгрузку на диск:
db.orders.aggregate(
[ { $sort: { total: -1 } }, { $group: { _id: "$category", n: { $sum: 1 } } } ],
{ allowDiskUse: true }
)
allowDiskUse: true разрешает этапам использовать временные файлы на диске вместо падения по лимиту. Это спасает от ошибки, но диск намного медленнее памяти — поэтому это «последнее средство», а не замена оптимизации. Сначала индексы и сокращение потока, и только потом allowDiskUse.
Частые ошибки
- $match/$sort в конце. Сокращающие этапы в хвосте не используют индекс и не уменьшают работу группировки — двигайте их в начало.
- allowDiskUse вместо индекса. Включить выгрузку на диск проще, но это маскирует проблему: правильнее добавить индекс и уменьшить поток, а диск оставить для действительно больших объёмов.
- Не смотрят explain.
COLLSCANв начале конвейера на большой коллекции — почти всегда сигнал, что нужен индекс; безexplainэто незаметно. - Тяжёлые документы тащат до $group. Если не сбросить лишние поля ранним
$project, по конвейеру едут мегабайты ненужного и быстрее упирается лимит памяти. - Индекс есть, но этап не первый. Индекс ускоряет только начальный
$match/$sort; после$group/$unwindон бесполезен.
Итоги
- Двигайте
$matchи$sortв начало: они уменьшают поток до дорогого$group. - Индексы помогают только начальным
$match/$sort; после группировки и разворачивания они не работают. - Ранний
$projectубирает тяжёлые поля и снижает объём данных между этапами. explain("executionStats")показываетIXSCANпротивCOLLSCANи сравнивает осмотренные/возвращённые документы.- Лимит памяти этапа — 100 МБ; сначала оптимизируйте (индексы, сокращение потока), и лишь затем включайте
allowDiskUse.