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.