В прошлом уроке мы собирали витрину каталога одним конвейером. На девяти книгах любой конвейер мгновенный. На миллионе продаж тот же конвейер может читать всю коллекцию, держать в памяти сотни мегабайт и пересчитываться при каждом открытии страницы. Сегодня разберём три рычага: индексы в начале конвейера, память для тяжёлых этапов и сохранение готового результата в коллекцию через $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 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.