Типы индексов: составные, multikey, текстовые, геопространственные

Индекс — это отсортированная структура, которая позволяет MongoDB находить документы, не перебирая всю коллекцию.

Индекс — компактная копия одного или нескольких полей, хранящаяся в виде B-дерева. Поиск по индексу — это спуск по дереву за время порядка log(N), а не линейный просмотр всех N документов.

Когда коллекция маленькая, разницы нет: MongoDB прочитает все документы быстрее, чем вы моргнёте. Но как только коллекция вырастает до сотен тысяч и миллионов документов, запрос без индекса начинает читать с диска каждый документ, чтобы проверить условие. Это и есть COLLSCAN — полный просмотр коллекции, главный враг производительности. Индекс превращает такой перебор в точечный поиск.

В этом уроке мы разберём все основные типы индексов: одиночные и составные, multikey по массивам, текстовые для полнотекстового поиска, геопространственные для координат, а также свойства unique и partial, которые меняют поведение индекса.

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

Правильный индекс — это разница между ответом за 2 миллисекунды и за 2 секунды. На проде медленный запрос не просто раздражает пользователя: он держит соединение, нагружает диск и тормозит соседние запросы. Каждый тип индекса решает свою задачу — поиск по равенству, по диапазону, по словам в тексте, по близости на карте. Знать их набор нужно, чтобы под каждый частый запрос подобрать структуру, которая его обслужит.

Одиночные и составные индексы

Одиночный (single-field) индекс строится по одному полю. Число 1 означает возрастающий порядок, -1 — убывающий. Для одиночного индекса направление почти не важно: B-дерево читается в обе стороны.

// индекс по одному полю
db.users.createIndex({ email: 1 })

// теперь этот запрос использует индекс
db.users.find({ email: "[email protected]" })

Составной (compound) индекс охватывает несколько полей сразу и хранит их в одном дереве, отсортированном сначала по первому полю, затем по второму и так далее. Это как телефонный справочник, отсортированный по фамилии, а внутри одинаковых фамилий — по имени.

// составной индекс: сперва по status, внутри — по createdAt убыванию
db.orders.createIndex({ status: 1, createdAt: -1 })

// обслуживается индексом целиком
db.orders.find({ status: "paid" }).sort({ createdAt: -1 })

Ключевое свойство составного индекса — префикс. Индекс { status: 1, createdAt: -1 } умеет обслуживать запросы по status и по status + createdAt, но НЕ по одному только createdAt: первое поле пропустить нельзя. Порядок полей — отдельная большая тема, ей посвящён следующий урок про правило ESR.

Multikey: индексы по массивам

Если индексируемое поле содержит массив, MongoDB автоматически создаёт multikey-индекс: в дерево добавляется отдельная запись на каждый элемент массива. Никакого специального синтаксиса не нужно — тип определяется по данным.

{
  "_id": 1,
  "title": "MongoDB за выходные",
  "tags": ["nosql", "database", "backend"]
}
// тот же createIndex, но поле — массив → индекс станет multikey
db.articles.createIndex({ tags: 1 })

// найдёт документ, если "nosql" есть СРЕДИ элементов массива
db.articles.find({ tags: "nosql" })

У multikey есть важное ограничение: в составном индексе не более одного поля может быть массивом. Индекс { tags: 1, comments: 1 }, где оба поля — массивы, создать нельзя, потому что число записей было бы произведением длин массивов и взрывообразно росло.

Текстовый индекс

Текстовый индекс (text) включает полнотекстовый поиск по словам: разбивает строки на токены, отбрасывает стоп-слова и приводит к основам. Он нужен, когда пользователь ищет «по смыслу», а не по точному совпадению.

// текстовый индекс по двум полям сразу
db.articles.createIndex({ title: "text", body: "text" })

// поиск по словам через оператор $text
db.articles.find({ $text: { $search: "индексы mongodb" } })

На коллекцию допускается только один текстовый индекс, но он может покрывать несколько полей. Для подсветки релевантности используют метаполе { score: { $meta: "textScore" } } в проекции и сортировке. Для серьёзного полнотекстового поиска (морфология, фасеты, опечатки) в проде чаще берут Atlas Search на базе Lucene, но встроенного $text хватает для простых сценариев.

Геопространственные индексы

Для координат используют индекс 2dsphere — он понимает геометрию на сфере (Земле) и поддерживает запросы «найди рядом» и «найди внутри полигона». Данные хранят в формате GeoJSON.

{
  "name": "Кофейня",
  "location": { "type": "Point", "coordinates": [37.6173, 55.7558] }
}
db.places.createIndex({ location: "2dsphere" })

// заведения в радиусе 1000 метров от точки, по возрастанию расстояния
db.places.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [37.6173, 55.7558] },
      $maxDistance: 1000
    }
  }
})

Координаты в GeoJSON идут в порядке [долгота, широта] — это частый источник путаницы, потому что в речи мы говорим «широта, долгота». Запрос $near сразу возвращает результаты отсортированными по близости.

Уникальные и частичные индексы

Свойство unique превращает индекс в ограничение целостности: вставка документа с дублирующим значением будет отвергнута с ошибкой E11000. Это серверная гарантия — её нельзя обойти в обход приложения.

// двух пользователей с одним email не будет
db.users.createIndex({ email: 1 }, { unique: true })

Частичный (partial) индекс покрывает не все документы коллекции, а только те, что подходят под фильтр. Он экономит память и часто комбинируется с unique, когда уникальность нужна лишь для части документов.

// индексируем только активные заказы — он меньше и точнее
db.orders.createIndex(
  { customerId: 1 },
  { partialFilterExpression: { status: { $eq: "active" } } }
)

Частичный индекс — современная замена устаревшему sparse: он гибче, потому что условие задаётся выражением, а не просто «поле существует».

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

Все индексы MongoDB — это B-деревья (точнее, B+-деревья), хранящиеся отдельно от самих документов. Каждая запись индекса содержит ключ (значение поля) и указатель на документ. При вставке, обновлении или удалении документа MongoDB обязана обновить все индексы, затронутые изменением, — поэтому индексы ускоряют чтение, но замедляют запись. Это фундаментальный компромисс: каждый лишний индекс — это дополнительная работа на каждый insert и update.

Индексы живут в оперативной памяти, пока туда помещаются (working set). Если суммарный размер активных индексов превышает доступную RAM, движок WiredTiger начинает подкачивать страницы с диска, и выигрыш от индекса тает. Поэтому держать сотню индексов «на всякий случай» — плохая идея: они занимают память, тормозят запись и могут вытеснять друг друга из кэша.

Посмотреть все индексы коллекции и их размер можно так:

db.orders.getIndexes()              // список индексов
db.orders.totalIndexSize()          // суммарный размер в байтах
db.orders.dropIndex("status_1")     // удалить ненужный

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

  • Индекс на низкоселективное поле. Индекс по полю с двумя значениями (например, isDeleted: true/false) почти бесполезен: он всё равно указывает на половину коллекции. Индексы окупаются на полях с большим разбросом значений.
  • Два поля-массива в одном индексе. MongoDB запретит создание multikey-индекса, где более одного поля — массив. Нужно либо разнести их по разным индексам, либо пересмотреть схему.
  • Путаница в порядке координат. В GeoJSON сначала идёт долгота, потом широта. Перепутанные координаты «телепортируют» точку в океан, и $near ничего не находит.
  • Уникальный индекс на существующих дублях. Если в коллекции уже есть документы с повторяющимся значением, создание unique-индекса упадёт с ошибкой. Сначала чистят данные, потом строят индекс.

Итоги

  • Одиночный индекс — по одному полю; составной — по нескольким, с важным свойством префикса.
  • Multikey возникает автоматически на массивах; в одном составном индексе массив может быть только один.
  • Текстовый индекс ($text) даёт полнотекстовый поиск; на коллекцию он один.
  • Геопространственный 2dsphere работает с GeoJSON и запросами $near / $geoWithin; координаты — [долгота, широта].
  • unique — серверное ограничение целостности; partial индексирует лишь часть документов и экономит память.
  • Каждый индекс ускоряет чтение, но замедляет запись и ест RAM — лишних быть не должно.
Проверьте себя
1. Что произойдёт, если построить индекс по полю, которое в документах содержит массив?
AMongoDB вернёт ошибку — массивы индексировать нельзя
BАвтоматически создастся multikey-индекс с отдельной записью на каждый элемент массива
CПроиндексируется только первый элемент массива
DИндекс будет хранить массив целиком как единое значение
2. Запросы по какому из полей сможет обслужить составной индекс { status: 1, createdAt: -1 }?
AТолько по createdAt
BПо любому из двух полей по отдельности
CПо status, а также по status вместе с createdAt (правило префикса)
DТолько по обоим полям одновременно
3. Чем частичный (partial) индекс полезнее, чем индекс на всю коллекцию?
AОн индексирует только документы, подходящие под фильтр, поэтому меньше и экономит память
BОн всегда быстрее на запись благодаря сжатию
CОн не требует обновления при изменении документов
DОн автоматически делает поле уникальным