Раздел 36 · MongoDB с нуля

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

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы собирали витрину каталога одним конвейером. На девяти книгах любой конвейер мгновенный. На миллионе продаж тот же конвейер может читать всю коллекцию, держать в памяти сотни мегабайт и пересчитываться при каждом открытии страницы. Сегодня разберём три рычага: индексы в начале конвейера, память для тяжёлых этапов и сохранение готового результата в коллекцию через $out и $merge.

Склад и сборщик

Сборщик на складе получил заказ: «все учебники». Если товары лежат как попало, он обойдёт весь склад. Если у склада есть картотека по разделам, он подойдёт к стеллажу «учебники» и возьмёт только их. Но картотека помогает только в начале. Когда сборщик уже сложил коробки на тележку и пересчитывает их по авторам, картотека склада ему не нужна: он работает с тележкой.

Конвейер агрегации работает так же. Индексы коллекции помогают только первым этапам, которые читают саму коллекцию. Всё, что после $group или $unwind, работает с потоком документов в памяти, и индексов там нет.

Подготовим данные

В песочнице explain нет, поэтому все планы в этом уроке сняты с настоящего MongoDB 8. Повтори их на своём сервере из урока про установку. Создадим коллекцию продаж на 100 тысяч документов:

use perf
const genres = ['роман', 'повесть', 'фантастика', 'учебник']
const docs = []
for (let i = 0; i < 100000; i++) docs.push({
  _id: i, book_id: i % 9 + 1, genre: genres[i % 4], qty: i % 3 + 1,
  day: new Date(Date.UTC(2026, 0, 1) + (i % 270) * 86400000)
})
db.sales.insertMany(docs)

Полный вывод explain для конвейера длинный, как мы видели в уроке про explain. Напишем функцию, которая достаёт главное: цепочку этапов плана и два счётчика.

const stats = (pipeline) => {
  const e = db.sales.explain('executionStats').aggregate(pipeline)
  const c = e.stages ? e.stages[0].$cursor : e
  const stages = []
  let node = c.queryPlanner.winningPlan.queryPlan ?? c.queryPlanner.winningPlan
  while (node) { stages.push(node.stage); node = node.inputStage }
  return { plan: stages.join(' <- '), keys: c.executionStats.totalKeysExamined, docs: c.executionStats.totalDocsExamined }
}

Цепочка читается справа налево: самый правый этап работает первым. keys это сколько записей индекса прочитано, docs сколько документов.

Индекс помогает только началу

Продажи учебников по книгам. Индексов, кроме _id, пока нет:

stats([{ $match: { genre: 'учебник' } }, { $group: { _id: '$book_id', sold: { $sum: '$qty' } } }])
{ plan: 'GROUP <- COLLSCAN', keys: 0, docs: 100000 }

COLLSCAN прочитал все сто тысяч документов, чтобы найти четверть из них. Создадим индекс по жанру:

db.sales.createIndex({ genre: 1 })
stats([{ $match: { genre: 'учебник' } }, { $group: { _id: '$book_id', sold: { $sum: '$qty' } } }])
{ plan: 'GROUP <- FETCH <- IXSCAN', keys: 25000, docs: 25000 }

Теперь сервер читает индекс, достаёт только 25 тысяч учебников и группирует их. Это тот же IXSCAN, что и у find в уроке про индексы: первый $match конвейера работает как фильтр обычного запроса.

А вот $match по полю, которое посчитал $group:

stats([{ $group: { _id: '$book_id', sold: { $sum: '$qty' } } }, { $match: { sold: { $gt: 22000 } } }])
{ plan: 'GROUP <- COLLSCAN', keys: 0, docs: 100000 }

Поля sold в коллекции нет, оно появляется только после группировки. Индексу тут не за что зацепиться, сервер читает всё. Если фильтр можно выразить через поля исходных документов, ставь его до $group.

Оптимизатор переставляет этапы

Перед запуском сервер переписывает конвейер, если от этого не меняется ответ. Два примера.

$match после $set, который не трогает жанр:

stats([{ $set: { total: { $multiply: ['$qty', 100] } } }, { $match: { genre: 'учебник' } }])
{ plan: 'FETCH <- IXSCAN', keys: 25000, docs: 25000 }

Сервер передвинул $match в начало и воспользовался индексом. Поле total фильтру не нужно, поэтому перестановка безопасна.

$match по _id после группировки по жанру:

stats([{ $group: { _id: '$genre', sold: { $sum: '$qty' } } }, { $match: { _id: 'учебник' } }])
{ plan: 'GROUP <- FETCH <- IXSCAN', keys: 25000, docs: 25000 }

Здесь _id группы это и есть жанр. Сервер понял, что фильтр по группе можно превратить в фильтр по документам, и тоже пошёл через индекс.

Оптимизатор помогает, но его работу надо проверять. Правило из урока про этапы остаётся: отбирай рано, и смотри в explain, что получилось.

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.