Индексы в Mongo вглубь: multikey, ESR, explain

Multikey-индексы на массивах, правило ESR (Equality-Sort-Range), partial/TTL/wildcard-индексы и чтение explain() в MongoDB — специфика движка индексов поверх общей теории B-tree

Общая теория индексов — B-tree, типы, анти-паттерны — не зависит от конкретной БД, но у MongoDB есть механика, которая в реляционных индексах просто не встречается: индекс, растущий по элементам массива внутри документа. Здесь — как устроен multikey-индекс и во что обходится его поддержка, зачем нужно правило ESR при построении составных индексов, и как читать explain() в Mongo, чтобы отличить выигранный план от отвергнутого, а IXSCAN от COLLSCAN, не гадая.

Библиотека индексов «Полдень»: маскот-лист с лупой «ЗАПРОС: IXSCAN ✓, COLLSCAN ✗»; каталог индексов с мультиключевым ящиком (массив tags → отдельные записи), бирки partial/TTL/wildcard; подиумы правила ESR — Equality→Sort→Range (1-2-3); табличка «ПОРЯДОК = СКОРОСТЬ (ESR RULE)»

В статье

  • 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.

Обсуждение в Telegram

Присоединиться →

Комментарии