Типы индексов: составные, 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 — лишних быть не должно.