explain() и покрывающие запросы

explain() показывает, КАК MongoDB выполняет запрос: какой индекс выбран, сколько ключей и документов прочитано и нет ли полного перебора.

explain() — диагностический метод, который вместо результата запроса возвращает план его выполнения: выбранный индекс, стадии обработки и, в режиме executionStats, точные счётчики прочитанных ключей и документов.

Создать индекс — половина дела. Без проверки вы не знаете, использует ли его запрос на самом деле. Бывает, что индекс есть, а MongoDB всё равно делает полный перебор: мешает неудачный порядок полей, операция, несовместимая с индексом, или приведение типов. explain() — главный инструмент, который снимает догадки и показывает реальную картину.

В этом уроке научимся читать ключевые поля executionStats, отличать хороший план (IXSCAN) от плохого (COLLSCAN) и строить покрывающие запросы, которые отвечают вообще не заглядывая в документы.

Зачем это на практике

Когда прод тормозит, первый вопрос — какой запрос медленный и почему. explain("executionStats") даёт точные числа: сколько документов реально прочитано, сколько времени ушло, был ли перебор всей коллекции. Это превращает оптимизацию из гадания в инженерию: вы видите проблему в цифрах и проверяете, что новый индекс действительно помог.

Как запустить explain

Метод .explain() вешается на курсор. Для реальных метрик нужен режим "executionStats" — он не только строит план, но и выполняет запрос, собирая счётчики.

db.orders.find({ status: "paid" }).explain("executionStats")

Ответ — большой JSON. Нас интересуют две части: winningPlan (какой план выбран) внутри queryPlanner и executionStats (что произошло при выполнении).

IXSCAN против COLLSCAN

Самое важное поле — stage в выигравшем плане. Оно говорит, как MongoDB добывала документы:

СтадияЧто значитОценка
COLLSCANполный перебор коллекции, документ за документомплохо на больших данных
IXSCANобход индекса (B-дерева)хорошо
FETCHподъём полного документа по указателю из индексанорма, но не бесплатно
SORTсортировка в памяти (индекс не обеспечил порядок)тревожный знак

Если в плане только COLLSCAN и коллекция большая — нужного индекса нет или запрос не может его использовать. Связка IXSCAN → FETCH — типичный здоровый план: нашли по индексу, подняли документы. Стадия SORT намекает на нарушение ESR из прошлого урока.

Главные счётчики executionStats

В блоке executionStats три числа решают почти всё:

  • nReturned — сколько документов запрос вернул (полезный результат).
  • totalKeysExamined — сколько записей индекса MongoDB просмотрела.
  • totalDocsExamined — сколько документов MongoDB прочитала с диска/из памяти.

Идеал — когда все три числа близки: totalKeysExamined ≈ totalDocsExamined ≈ nReturned. Это значит, что индекс привёл точно к нужным документам без лишнего чтения.

Тревога — когда totalDocsExamined сильно больше nReturned. Например, вернули 10 документов, а прочитали 100 000 — индекс не отсёк лишнее, движок поднимал документы зря. Самый яркий случай: при COLLSCAN totalKeysExamined = 0 (индекс не трогали вообще), а totalDocsExamined равно размеру всей коллекции.

// фрагмент executionStats хорошего запроса по индексу
{
  "nReturned": 42,
  "totalKeysExamined": 42,
  "totalDocsExamined": 42,
  "executionTimeMillis": 1
}
// плохой запрос: полный перебор коллекции
{
  "nReturned": 42,
  "totalKeysExamined": 0,
  "totalDocsExamined": 1000000,
  "executionTimeMillis": 820
}

Покрывающие запросы

Покрывающий запрос (covered query) — это запрос, на который MongoDB отвечает, читая только индекс, не поднимая сами документы. Условие — все поля и фильтра, и проекции должны присутствовать в одном индексе. Тогда вся нужная информация уже есть в дереве, и стадия FETCH вообще не выполняется.

Чтобы запрос стал покрывающим, в проекции нужно вернуть только индексированные поля и обязательно исключить _id (если его нет в индексе), потому что по умолчанию _id возвращается всегда.

// индекс по двум полям
db.users.createIndex({ city: 1, age: 1 })

// проекция просит только city и age, _id явно выключен
db.users.find(
  { city: "Москва" },
  { _id: 0, city: 1, age: 1 }
)

В explain признак покрывающего запроса — стадия PROJECTION_COVERED и, что важнее, totalDocsExamined: 0 при ненулевом nReturned. Ноль прочитанных документов означает, что ответ собран целиком из индекса. Это самый быстрый из возможных планов: чтения с диска документов нет вовсе.

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

Как это работает под капотом

Когда MongoDB видит запрос, планировщик собирает все индексы-кандидаты, которые могут помочь, и строит для каждого план. Затем он запускает короткую «гонку»: прогоняет планы на ограниченном числе документов и выбирает тот, что быстрее даёт первые результаты. Победитель — winningPlan, проигравшие лежат в rejectedPlans. Выбор кэшируется по форме запроса (query shape), чтобы не повторять гонку каждый раз.

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

Частые ошибки

  • Забыли executionStats. Простой .explain() без аргумента показывает только план, без счётчиков прочитанного. Для диагностики всегда добавляйте "executionStats".
  • Не исключили _id. Запрос «почти покрывающий», но _id не в индексе и не выключен в проекции — покрытие ломается, появляется FETCH.
  • Читают только executionTimeMillis. Время зависит от прогрева кэша и нагрузки. Надёжнее смотреть на totalDocsExamined относительно nReturned — это стабильная метрика эффективности.
  • Радуются IXSCAN, игнорируя соотношение. Даже с IXSCAN запрос может читать на порядки больше документов, чем возвращает, если индекс низкоселективный. Смотрите на числа, а не только на название стадии.

Итоги

  • explain("executionStats") возвращает план и реальные счётчики выполнения вместо результата запроса.
  • COLLSCAN — полный перебор (плохо на больших данных), IXSCAN — обход индекса (хорошо), SORT — сортировка в памяти (тревога).
  • Здоровый запрос: totalKeysExamined ≈ totalDocsExamined ≈ nReturned; большой отрыв totalDocsExamined от nReturned — лишнее чтение.
  • Покрывающий запрос отвечает только из индекса: totalDocsExamined: 0, стадия PROJECTION_COVERED, без FETCH.
  • Чтобы запрос был покрывающим, проекция должна содержать только индексированные поля и явно исключать _id.
Проверьте себя
1. Какой режим explain() нужен, чтобы увидеть реальное число прочитанных документов и время выполнения?
AqueryPlanner — режим по умолчанию
BexecutionStats — он выполняет запрос и собирает счётчики
CallPlansExecution достаточно только для rejectedPlans
DЛюбой режим показывает эти числа одинаково
2. Запрос вернул 50 документов, но в executionStats totalDocsExamined = 200000. О чём это говорит?
AЭто идеальный покрывающий запрос
BИндекс не отсёк лишнее — движок прочитал 200000 документов, чтобы отдать 50 (вероятен COLLSCAN или низкоселективный индекс)
CЗапрос точно использовал составной индекс по ESR
DЭто нормально: totalDocsExamined всегда намного больше nReturned
3. Что характеризует покрывающий запрос (covered query) в выводе explain?
AСтадия FETCH присутствует, но выполняется очень быстро
BtotalDocsExamined = 0 при ненулевом nReturned — ответ собран только из индекса, без чтения документов
CtotalKeysExamined = 0, потому что индекс не нужен
DОбязательно присутствует стадия SORT