Общая теория индексов — B-tree, типы, анти-паттерны — не зависит от конкретной БД, но у MongoDB есть механика, которая в реляционных индексах просто не встречается: индекс, растущий по элементам массива внутри документа. Здесь — как устроен multikey-индекс и во что обходится его поддержка, зачем нужно правило ESR при построении составных индексов, и как читать explain() в Mongo, чтобы отличить выигранный план от отвергнутого, а IXSCAN от COLLSCAN, не гадая.
В статье
- Single и compound-индексы — база, на которой строится специфика
- Multikey-индексы на массивах: механика и цена
- Правило ESR (Equality-Sort-Range) при построении составных индексов
- Partial, TTL, wildcard и text-индексы — когда каждый уместен
- Covered queries — как индекс отвечает на запрос без обращения к документу
- Чтение
explain(): winning и rejected planы,IXSCANпротивCOLLSCAN - Граница: общая теория индексов — в серии про индексы в базах данных
- Граница: гео-индексы — в статье про гео-поиск в MongoDB
Предыдущая статья серии — WiredTiger вглубь — закончилась на том, что индекс в MongoDB это отдельное B-дерево, живущее рядом с B-деревом самой коллекции. Здесь мы поднимаемся на уровень выше движка хранения: как эти B-деревья участвуют в планах запросов. Всё измеренное ниже снято на живом replica set (mongo:8.2.11, 3 узла) на сквозном датасете серии (seed=42: 50 000 пользователей, 5000 товаров, 200 000 заказов), стенд mongodb/indexes. Планы получены реальным db.runCommand({explain: {...}, verbosity: "executionStats"}) — не оценкой планировщика, а фактическим прогоном запроса с подсчётом просмотренных ключей и документов.
Single и compound-индексы — база
Одиночный индекс — B-дерево по одному полю: db.orders.createIndex({status: 1}). Составной (compound) — по нескольким полям в заданном порядке: db.orders.createIndex({status: 1, created_at: 1, total: 1}). Ключ такого индекса — конкатенация значений полей в порядке объявления, и это не косметика: порядок полей определяет, какие запросы индекс обслуживает эффективно, а какие — нет.
// одиночный — B-дерево по status
db.orders.createIndex({ status: 1 })
// составной — ключ это (status, created_at, total) в этом порядке
db.orders.createIndex({ status: 1, created_at: 1, total: 1 })Ключевое свойство составного индекса — префиксность. Индекс {status, created_at, total} обслуживает запросы по {status}, по {status, created_at} и по всем трём полям, но не по {created_at} в отрыве от status: искать по не-первому полю индекса всё равно что искать в телефонной книге по имени, когда она отсортирована по фамилии. Из префиксности растёт и правило ESR, и covered queries — обе темы ниже опираются на неё.
Общая теория B-дерева, кардинальность, селективность, анти-паттерны вроде индекса на низкоселективном поле — не специфика MongoDB и разобраны отдельно: типы индексов и их внутреннее устройство. Здесь — только то, чего в реляционных индексах не встречается или что в Mongo устроено иначе.
Multikey-индексы на массивах: механика и цена
Вот первое, чего в B-дереве реляционного индекса просто нет: индекс по полю-массиву. Если проиндексированное поле — массив, MongoDB создаёт отдельную запись индекса на каждый элемент массива. Такой индекс автоматически помечается как multikey (isMultiKey: true) — флаг ставит сам движок при первой вставке документа с массивом в этом поле, объявлять его не нужно.
Пример из стенда — массив тегов пользователя users.tags (["vip", "early-adopter", ...]) и индекс по нему:
db.users.createIndex({ tags: 1 }) // станет multikey автоматически
// запрос находит документы, где массив СОДЕРЖИТ "vip"
db.users.find({ tags: "vip" })Запрос {tags: "vip"} не сравнивает массив целиком — он matchит документ, в массиве которого есть элемент "vip". Индекс это умеет ровно потому, что в нём лежит по записи на элемент: "vip" — обычный ключ B-дерева, и поиск идёт через IXSCAN, а не через полный перебор коллекции. Explain живого прогона:
{
"queryPlanner": {
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "idx_tags_multikey",
"isMultiKey": true,
"multiKeyPaths": { "tags": ["tags"] }
}
}
},
"executionStats": {
"nReturned": 8847,
"totalKeysExamined": 8847,
"totalDocsExamined": 8847
}
}Верхняя стадия — IXSCAN(idx_tags_multikey) с isMultiKey: true, стадии COLLSCAN в плане нет. Просмотрено ключей — 8847, документов — 8847, возвращено — 8847. Равенство keysExamined == docsExamined == nReturned здесь не случайно и стоит понимать: тег "vip" встречается у конкретного пользователя максимум один раз (теги в датасете — перестановка без повторов), поэтому на каждого подходящего пользователя приходится ровно один ключ индекса, дедупликации не требуется.
Цена multikey. Механика «запись на элемент» — это и есть стоимость. Документ с массивом из 20 тегов порождает 20 записей в индексе; обновление такого массива правит все затронутые записи B-дерева. Отсюда практические ограничения движка, специфичные именно для multikey:
- Нельзя пересечь два массива в одном составном индексе. Индекс
{tags: 1, roles: 1}, где оба поля — массивы, MongoDB создать откажется: декартово произведение элементов взорвало бы число записей. Один из полей-массивов в составном multikey — можно, два — нет. - Multikey-индекс не покрывает запрос (covered query, см. ниже) по массивному полю: чтобы отдать элемент массива из индекса, движку всё равно нужен исходный документ, потому что индекс хранит элементы порознь и не знает исходной формы массива.
- Рост числа элементов линейно растит и объём индекса, и стоимость записи. Тот же корень, что и у роста документа из статьи про WiredTiger: неограниченно растущий массив бьёт не только по 16-МиБ лимиту документа, но и по индексу над ним.
Правило ESR (Equality-Sort-Range)
Это центральная тема статьи и лучший способ прочувствовать, зачем порядку полей в составном индексе уделяют столько внимания. ESR — мнемоника порядка полей: сначала поля из Equality-условий (==, $in), затем поле, по которому идёт Sort, и в конце поле Range-условия (>, <, $gt, $lt). Нарушишь порядок — и планировщик не сможет отдать отсортированный результат прямо из индекса, ему придётся сортировать в памяти отдельной блокирующей стадией SORT.
Проверим это на одном и том же запросе, форсируя через .hint() два индекса с одними и теми же тремя полями в разном порядке. Запрос: equality по status, range по total, сортировка по created_at.
// ESR-ВЕРНЫЙ: Equality(status) -> Sort(created_at) -> Range(total)
db.orders.createIndex({ status: 1, created_at: 1, total: 1 }, { name: "idx_esr_correct" })
// ESR-НЕВЕРНЫЙ: Range-поле (total) стоит ПЕРЕД Sort-полем (created_at)
db.orders.createIndex({ status: 1, total: 1, created_at: 1 }, { name: "idx_esr_wrong" })
// один и тот же запрос, форсируем каждый индекс по очереди
db.orders.find({ status: "paid", total: { $gt: 500 } })
.sort({ created_at: 1 })
.hint("idx_esr_correct") // затем "idx_esr_wrong"
ESR-верный индекс (status, created_at, total) — план без сортировки. Поскольку после equality-фиксации status="paid" индекс уже физически упорядочен по created_at, движок читает ключи в нужном порядке сразу:
{
"queryPlanner": {
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "idx_esr_correct"
}
}
},
"executionStats": {
"nReturned": 28759,
"totalKeysExamined": 33252,
"totalDocsExamined": 28759
}
}ESR-неверный индекс (status, total, created_at) — тот же результат, но с блокирующей стадией SORT наверху плана: range-поле total стоит перед sort-полем created_at, порядок по created_at в индексе не сохранён, и движок вынужден собрать весь результат и отсортировать его в памяти:
{
"queryPlanner": {
"winningPlan": {
"stage": "SORT",
"sortPattern": { "created_at": 1 },
"inputStage": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "idx_esr_wrong"
}
}
}
},
"executionStats": {
"nReturned": 28759,
"totalKeysExamined": 28759,
"totalDocsExamined": 28759
}
}nReturned совпадает — 28759 в обоих случаях: оба индекса ищут один и тот же результат, отличается только план его получения. Разница — наличие стадии SORT, и это не косметика. Блокирующий SORT в худшем случае буферизует весь результат в памяти (лимит 100 МиБ на стадию, дальше — ошибка или спилл на диск), тогда как потоковый IXSCAN отдаёт документы по мере чтения и не масштабируется в память. На больших выборках это разница между «работает» и «валится по памяти».
Честный компромисс — ESR это не бесплатный выигрыш. Сравните числа totalKeysExamined: ESR-верный индекс просмотрел 33252 ключа, ESR-неверный — 28759, на +15.6% больше (разница 4493 ключа). У idx_esr_correct keysExamined (33252) > docsExamined (28759) — часть просмотренных ключей отбрасывается фильтром внутри IXSCAN, не долетая до FETCH. Причина в самом порядке ESR: range-поле total стоит последним, после sort-поля created_at. Чтобы сохранить порядок по created_at, движок не может построить настолько же плотные границы (bounds) по total, как в индексе, где total идёт сразу за equality-полем. То есть ESR-верный индекс избегает блокирующей сортировки ценой более широкого прохода по индексу — торгует лишние просмотренные ключи на устранение SORT. Это классический документированный компромисс, а не аномалия: чуть более широкий, но потоковый IXSCAN почти всегда предпочтительнее узкого прохода плюс сортировки в памяти — но знать, что вы за это платите, стоит.
Partial, TTL, wildcard и text-индексы
Помимо обычных, MongoDB даёт несколько специализированных типов. Каждый уместен в своей нише — и у partial есть коварная ловушка, которую разберём на живых числах.
Partial-индекс и его тихая ловушка
Partial-индекс покрывает только подмножество документов, удовлетворяющих partialFilterExpression. Полезно, когда индексировать нужно лишь «горячую» часть коллекции — например, только крупные заказы:
db.orders.createIndex(
{ total: 1 },
{ name: "idx_total_partial", partialFilterExpression: { total: { $gt: 1000 } } }
)Индекс меньше и дешевле полного, потому что документов вне total > 1000 в нём физически нет. Планировщик выбирает его только когда может доказать, что предикат запроса — подмножество partialFilterExpression. Запрос total > 1500 (⊆ total > 1000) индекс использует, вернув 113550 документов (keys = docs = nReturned = 113550). А вот запрос total > 500, который не является подмножеством фильтра, планировщик сам разумно отвергает индекс и уходит в честный COLLSCAN на 172957 документов — это корректный полный результат.
Проблема начинается, когда total > 500 форсируют на partial-индекс через .hint():
// total > 500 НЕ является подмножеством partialFilterExpression (total > 1000)
db.orders.find({ total: { $gt: 500 } }).hint("idx_total_partial")Сервер не отклоняет несовместимый forced hint — explain проходит без ошибки. winningPlan реально ссылается на idx_total_partial, запрос отрабатывает — и возвращает 143896 документов вместо корректных 172957:
{
"queryPlanner": {
"winningPlan": {
"stage": "FETCH",
"inputStage": { "stage": "IXSCAN", "indexName": "idx_total_partial" }
}
},
"executionStats": {
"nReturned": 143896,
"totalDocsExamined": 143896
}
}29061 документ с 500 < total <= 1000 физически отсутствует в partial-индексе — и они молча теряются, без ошибки и без предупреждения. Это задокументированное, но контринтуитивное поведение: .hint() на partial-индекс — это контракт «я, разработчик, ручаюсь, что предикат совместим с partial-фильтром». Сервер совместимость не проверяет и при нарушении контракта тихо отдаёт неполные данные. Практический вывод: без hint планировщик ведёт себя безопасно (просто не рассматривает индекс-кандидат), а .hint() на partial-индекс безопасен только когда предикат запроса гарантированно — подмножество partialFilterExpression (в идеале — программно тот же самый фильтр). Форсировать partial-индекс «чтобы было быстрее» на произвольном запросе — прямой путь к тихо неверным ответам.
TTL-индекс
TTL (time-to-live) — индекс по полю-дате с опцией expireAfterSeconds. Фоновый TTL monitor периодически (по умолчанию ~раз в 60 с) удаляет документы, у которых <поле> + expireAfterSeconds уже в прошлом. Удобно для сессий, кэшей, временных данных:
db.ttl_demo.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 })С expireAfterSeconds: 0 документ удаляется, как только expires_at наступит. На стенде документ с expires_at на час в прошлом был удалён фоновым монитором за 40 с от вставки; документ с expires_at на час в будущем за то же окно остался нетронутым. Оговорка важна: 40 с — не гарантия, а факт конкретного прогона. Реальная задержка зависит от фазы TTL monitor относительно момента вставки (проход раз в ~60 с) — рассчитывать на точечное удаление «секунда в секунду» нельзя, TTL это про «удалится в течение примерно минуты после срока», не про точный таймер.
Wildcard-индекс
Wildcard-индекс {"$**": 1} (или по поддереву — {"attributes.$**": 1}) индексирует все поля документа или ветки, включая заранее неизвестные. Ниша — гибкие схемы, где набор атрибутов не фиксирован (каталог товаров с произвольными характеристиками, пользовательские метаданные). Цена — размер и стоимость записи: индексируется всё, поэтому wildcard не заменяет продуманный набор точечных индексов под известные запросы, а закрывает непредсказуемые пути. Для конкретного, заранее известного запроса обычный индекс почти всегда эффективнее.
Text-индекс
Text-индекс поддерживает полнотекстовый поиск по строковым полям ($text, стемминг, стоп-слова, веса полей). Для базового поиска по словам внутри Mongo он работает, но по возможностям это не полноценный поисковый движок. Полнотекстовый поиск как отдельная большая тема — с инвертированными индексами, релевантностью, анализаторами — разобран отдельно (полнотекстовый поиск в OpenSearch); здесь достаточно знать, что тип есть и закрывает несложные сценарии без внешней системы.
Граница: гео-индексы
Геопространственные индексы (2dsphere, 2d) — поиск по координатам, близости, попаданию в область — вынесены в отдельную статью «Гео-поиск в MongoDB»Скоро, где они разобраны вместе с альтернативами (PostGIS, Redis GEO, H3). Здесь их не дублируем.
Covered queries — ответ без обращения к документу
Covered query — запрос, на который индекс отвечает целиком, не читая ни одного документа из коллекции. Условие: и поля фильтра, и поля проекции — все входят в индекс, и _id явно исключён из проекции (если его нет в индексе). Тогда движку не нужна стадия FETCH — вся нужная информация уже в ключах индекса.
Пример из стенда — индекс {category: 1, price: 1} на товарах и запрос, которому хватает ровно этих двух полей:
db.products.createIndex({ category: 1, price: 1 }, { name: "idx_category_price_covering" })
// проекция запрашивает только category и price, _id ЯВНО исключён
db.products.find({ category: "electronics" }, { category: 1, price: 1, _id: 0 }){
"queryPlanner": {
"winningPlan": {
"stage": "PROJECTION_COVERED",
"inputStage": {
"stage": "IXSCAN",
"indexName": "idx_category_price_covering"
}
}
},
"executionStats": {
"nReturned": 340,
"totalKeysExamined": 340,
"totalDocsExamined": 0
}
}Диагностический признак covered query — totalDocsExamined: 0 при nReturned > 0 и стадия PROJECTION_COVERED в winningPlan вместо FETCH. Возвращено 340 документов, просмотрено 340 ключей индекса — и ноль обращений к самим документам. Это самый дешёвый из возможных планов: коллекции движок не касается вовсе.
Два ограничения, о которых легко забыть: covered query не работает по multikey-полю (индекс не хранит исходную форму массива — см. выше), и _id нужно явно исключить проекцией, если его нет в индексе, иначе движку придётся идти в документ за _id и покрытие сломается — FETCH вернётся, totalDocsExamined перестанет быть нулём.
Чтение explain(): winning и rejected planы, IXSCAN против COLLSCAN
Всё выше держалось на explain() — соберём теперь, как его читать системно. Вызов на живых данных с реальным прогоном запроса:
db.orders.find({ status: "paid" }).explain("executionStats")
// или низкоуровнево:
db.runCommand({ explain: { find: "orders", filter: { status: "paid" } },
verbosity: "executionStats" })Три уровня verbosity: queryPlanner (только выбранный план, без прогона), executionStats (план плюс реальные счётчики от фактического выполнения) и allPlansExecution (ещё и статистика отвергнутых планов). Для диагностики почти всегда нужен executionStats — оценки планировщика врут, реальные keysExamined/docsExamined — нет.
Что читать в первую очередь:
winningPlan.stageи деревоinputStage— план, который движок выбрал и выполнил. Стадии читаются изнутри наружу: самая вложенная выполняется первой.rejectedPlans— планы-кандидаты, которые планировщик рассмотрел и отбросил. Полезно, когда план не тот, что ожидался: видно, какие индексы вообще рассматривались.IXSCANпротивCOLLSCAN— главный водораздел.IXSCAN— проход по B-дереву индекса;COLLSCAN— полный перебор коллекции.COLLSCANна большой коллекции в горячем пути — почти всегда сигнал отсутствующего или неподходящего индекса (кроме случаев, когда вернуть надо почти всё — тогда скан честнее индекса).SORTв плане — блокирующая сортировка в памяти. Её отсутствие при наличииsort()в запросе означает, что порядок отдан индексом (ESR соблюдён) — то, за чем мы гнались выше.FETCH— обращение к документу после индекса. Его отсутствие (PROJECTION_COVERED) — covered query.
Три числа executionStats, по которым ставится диагноз:
| Метрика | Что означает | Идеал |
|---|---|---|
nReturned |
сколько документов вернул запрос | — |
totalKeysExamined |
сколько ключей индекса просмотрено | близко к nReturned |
totalDocsExamined |
сколько документов прочитано | близко к nReturned, а для covered — 0 |
Соотношение этих трёх — вся диагностика. totalDocsExamined много больше nReturned — движок читает лишние документы (слабая селективность плана, отсутствует нужный индекс). totalKeysExamined больше totalDocsExamined — часть ключей отбрасывается фильтром внутри IXSCAN (ровно случай ESR-верного индекса выше: 33252 ключа против 28759 документов — плата за устранение SORT). totalDocsExamined == 0 при nReturned > 0 — covered query, лучший исход. А COLLSCAN с totalDocsExamined, равным размеру коллекции, при маленьком nReturned — классический недостающий индекс.
Граница: общая теория индексов
Всё в этой статье — специфика движка индексов MongoDB: multikey поверх массивов, ESR-порядок полей составного индекса, partial/TTL/wildcard/text как типы Mongo, covered queries и чтение explain. Всё это стоит на общем фундаменте, который от конкретной БД не зависит: как устроено B-дерево, что такое селективность и кардинальность, почему индекс на низкоселективном поле бесполезен, когда индекс дороже полного скана, как индексы взаимодействуют с записью. Этот общий слой разобран в серии «Индексы в базах данных» — там же прямые параллели с B-tree-индексами PostgreSQL и других СУБД. Здесь мы намеренно на нём не задерживались, чтобы не переписывать целую серию.
Граница: гео-индексы
Геопространственные индексы (2dsphere/2d), запросы по близости и попаданию в полигон — отдельная большая тема, разобранная вместе с альтернативными подходами (PostGIS, Redis GEO, H3, Tile38) в статье «Гео-поиск в MongoDB»Скоро. В этой статье про индексы мы их сознательно не касались.
Следующая статья серии — aggregation pipeline вглубь: как индексы участвуют уже не в простых find, а в стадиях $match/$group/$lookup/$sort конвейера агрегации, где $match в начале пайплайна может опереться на индекс, а blocking-стадия $sort — упереться в тот же 100-МиБ лимит памяти, что и SORT из ESR-раздела. Живой стенд этой статьи — mongodb/indexes: все explain-планы и числа выше воспроизводятся одним bash ops/indexes-demo.sh.
Комментарии