Производительность агрегаций

Скорость агрегации определяется тем, как рано вы уменьшаете данные и используете ли индексы на первых этапах.

Правило производительности: фильтруйте и сортируйте до группировки, опирайтесь на индексы в начале конвейера и убирайте лишние поля как можно раньше — тогда каждый следующий этап обрабатывает минимум данных.

Конвейер, дающий правильный ответ, может быть быстрым или мучительно медленным — при одинаковом результате. Разница в том, сколько документов и байт проходит через этапы и подключаются ли индексы. Этот урок — про инструменты и приёмы, которые превращают тяжёлую агрегацию в быструю: ранний $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.
Проверьте себя
1. Почему $match и $sort выгодно ставить до $group?
AОни уменьшают поток данных и могут использовать индекс, так что дорогая группировка обрабатывает меньше документов
BПосле $group синтаксис $match запрещён
C$group обязан быть последним этапом конвейера
DПорядок этих этапов не влияет на скорость
2. Что в выводе explain обычно сигнализирует о нехватке индекса для начального $match на большой коллекции?
AСтадия COLLSCAN — полное сканирование коллекции
BСтадия IXSCAN — чтение по индексу
CПоле allowDiskUse: true
DНаличие этапа $project
3. Когда уместно включать allowDiskUse: true?
AКогда данные действительно большие и этап превышает лимит памяти 100 МБ, и только после попытки оптимизировать индексами и ранним $match/$project
BВсегда — это бесплатно ускоряет любую агрегацию
CЧтобы заставить $match использовать индекс
DЧтобы $facet смог передавать данные между ветками