Освоение индексирования MongoDB для оптимальной производительности запросов

Раскройте оптимальную производительность запросов в MongoDB, освоив методы индексирования. Это подробное руководство охватывает одно-полевые, составные и многоключевые индексы, объясняет силу покрывающих запросов и помогает использовать `explain()` для анализа производительности. Изучите лучшие практики для ускорения операций чтения, снижения нагрузки на базу данных и создания более отзывчивого приложения. Обязательное чтение для любого разработчика MongoDB, ориентированного на настройку производительности.

Освоение индексирования MongoDB для оптимальной производительности запросов

Индексирование MongoDB становится интересным, когда база данных уже недостаточно мала для удачных догадок. Запрос, который казался мгновенным в разработке, может стать болезненным в продакшене, как только коллекция достигает миллионов документов, панель мониторинга добавляет сортировку или конечная точка API начинает фильтровать по нескольким полям одновременно.

Цель не в том, чтобы индексировать каждое поле. Обычно это замедляет запись, потребляет память и диск, а важные запросы все равно остаются неохваченными. Цель — понять те немногие формы запросов, от которых на самом деле зависит ваше приложение, а затем построить индексы, соответствующие этим формам.

Понимание индексов MongoDB

По своей сути индекс похож на указатель в книге. Вместо того чтобы читать всю книгу, чтобы найти тему, вы обращаетесь к отсортированному справочнику и переходите близко к нужной странице. Индексы MongoDB помогают планировщику запросов находить соответствующие документы без сканирования всей коллекции. Без полезного индекса MongoDB может выполнить сканирование коллекции, проверяя документы один за другим, пока не найдет совпадения.

Сканирование коллекции не всегда плохо. Сканирование крошечной коллекции может быть нормальным. Выполнение административного отчета раз в месяц может быть нормальным. Но сканирование коллекции внутри пути высоконагруженного запроса — это другое дело. Оно конкурирует с обычными операциями чтения и записи, ухудшается по мере роста данных и часто проявляется как непредсказуемая задержка.

Как работают индексы

MongoDB обычно использует индексы типа B-дерева для обычных полевых индексов. Важная практическая деталь заключается в том, что индексированные значения хранятся в порядке. Этот порядок помогает MongoDB с фильтрами равенства, фильтрами диапазона и сортировкой, когда форма запроса совпадает с индексом.

Например, индекс по { email: 1 } идеален для:

db.users.findOne({ email: "[email protected]" })

Он не полезен для:

db.users.find({ lastLoginAt: { $lt: ISODate("2025-01-01") } })

Этому второму запросу нужен индекс, начинающийся с lastLoginAt, или ему придется сканировать.

Когда использовать индексы

Индексы наиболее полезны для полей, которые часто используются в:

  • Критериях запроса (find(), findOne()): Поля, используемые в документе filter ваших запросов.
  • Критериях сортировки (sort()): Поля, используемые для упорядочивания результатов ваших запросов.
  • Поле _id: По умолчанию MongoDB создает индекс по полю _id, обеспечивая уникальность и быстрый поиск по ID.

Однако индексы также имеют стоимость:

  • Место на диске: Индексы потребляют дисковое пространство.
  • Производительность записи: Индексы необходимо обновлять при вставке, обновлении или удалении документов, что может замедлить операции записи.
  • Нагрузка на память: Часто используемые страницы индексов конкурируют за кэш. Слишком много больших индексов могут затруднить удержание рабочего набора в памяти.

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

Создание и управление индексами

MongoDB предоставляет метод createIndex() для создания индексов и getIndexes() для просмотра существующих. Метод dropIndex() используется для их удаления.

Базовое создание индекса

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

db.collection.createIndex( { fieldName: 1 } );

Пример: Индексирование поля username в возрастающем порядке:

db.users.createIndex( { username: 1 } );

Просмотр индексов

Чтобы увидеть индексы в коллекции:

db.collection.getIndexes();

Пример: Просмотр индексов в коллекции users:

db.users.getIndexes();

Это вернет массив определений индексов, включая индекс по умолчанию _id.

В загруженной коллекции создавайте индексы обдуманно. Современные версии MongoDB поддерживают онлайн-сборку индексов во многих распространенных случаях, но сборка индексов все равно потребляет ресурсы ЦП, дисковый ввод-вывод и память. В производственных системах планируйте крупные сборки индексов на периоды меньшей активности и следите за задержкой репликации, если вы используете набор реплик.

Удаление индексов

Чтобы удалить индекс:

db.collection.dropIndex( "indexName" );

Вы можете найти indexName в выводе getIndexes(). В качестве альтернативы вы можете удалить индекс, указав индексированное поле(я) в том же формате, что и createIndex():

db.collection.dropIndex( { fieldName: 1 } );

Пример: Удаление индекса username:

db.users.dropIndex( "username_1" ); // Используя имя индекса
// ИЛИ
db.users.dropIndex( { username: 1 } ); // Используя определение индекса

Перед удалением индекса проверьте, использует ли его что-то еще:

db.users.aggregate([{ $indexStats: {} }])

Это показывает счетчики доступа с момента запуска сервера. Нулевой счетчик — это подсказка, а не абсолютное доказательство. Сервер мог быть недавно перезапущен, или запрос может выполняться только во время еженедельной задачи. Для важных систем комбинируйте $indexStats, поиск по коду приложения, журналы запросов и короткий период наблюдения.

Составные индексы

Составные индексы включают несколько полей. Порядок полей в составном индексе критичен. MongoDB использует составные индексы для запросов, которые включают несколько полей в предложениях filter или sort.

Когда использовать составные индексы

Составные индексы наиболее эффективны, когда ваши запросы часто фильтруют или сортируют по комбинации полей. Индекс может удовлетворять запросы, которые соответствуют полям в том же порядке, в котором они определены в индексе, или префиксу индекса.

Пример: Рассмотрим коллекцию orders с полями userId, orderDate и status. Если вы часто запрашиваете заказы по конкретному пользователю и сортируете их по дате, составной индекс по { userId: 1, orderDate: 1 } будет очень полезен.

db.orders.createIndex( { userId: 1, orderDate: 1 } );

Этот индекс может эффективно поддерживать запросы типа:

  • db.orders.find( { userId: "user123" } ).sort( { orderDate: -1 } )
  • db.orders.find( { userId: "user123", orderDate: { $lt: ISODate() } } )

Однако он может быть не таким эффективным для запросов, которые фильтруют только по orderDate, если userId также не указан, или если поля находятся в другом порядке.

Порядок полей имеет значение

Порядок полей в составном индексе определяет, какие шаблоны запросов он может хорошо поддерживать. Полезное практическое правило: сначала поля равенства, затем поля сортировки, затем поля диапазона. Это часто называют правилом ESR: равенство, сортировка, диапазон. Это руководство, а не закон, но оно предотвращает множество неудачных проектов индексов.

Предположим, ваша страница заказов выполняет этот запрос:

db.orders.find({
  tenantId: "t1",
  status: "paid",
  createdAt: { $gte: ISODate("2025-01-01") }
}).sort({ createdAt: -1 })

Разумным индексом может быть:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })

tenantId и status — это фильтры равенства. createdAt поддерживает сортировку и диапазон. Если вместо этого вы создадите { createdAt: -1, status: 1, tenantId: 1 }, MongoDB все равно может использовать его в некоторых случаях, но обычно он менее соответствует этому запросу.

Для запросов, которые сортируют результаты, порядок полей в индексе должен соответствовать порядку полей в операции sort() для оптимальной производительности. Если запрос включает как фильтр, так и сортировку, и индекс соответствует полям фильтра, он также может использоваться для сортировки без отдельного сканирования коллекции для сортировки.

Составные индексы также могут обслуживать префиксные запросы. Индекс по { tenantId: 1, status: 1, createdAt: -1 } может помочь запросу только по tenantId или по tenantId плюс status. Обычно он не может сильно помочь запросу только по status, потому что status не является ведущим полем.

Покрывающие запросы

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

Как достичь покрывающих запросов

Чтобы достичь покрывающего запроса, убедитесь, что:

  1. У вас есть индекс, который включает все поля, используемые в фильтре запроса.
  2. Вы включаете только эти индексированные поля (или их подмножество) в свою проекцию.

Пример: Рассмотрим коллекцию employees с полями name, age и city. Если у вас есть индекс { city: 1, age: 1 } и вы хотите получить имена и возраст сотрудников в определенном городе, вы можете создать покрывающий запрос:

db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } ).explain()

В этом запросе city находится в индексе, а name и age включены в проекцию. Если бы индекс также содержал name и age, это был бы покрывающий запрос.

Давайте уточним индекс и запрос для настоящего покрывающего запроса:

// Создаем индекс, который включает все поля, необходимые для запроса и проекции
db.employees.createIndex( { city: 1, age: 1, name: 1 } );

// Теперь запрос, который фильтрует по городу и проецирует имя и возраст, может быть покрыт
db.employees.find( { city: "New York" }, { name: 1, age: 1, _id: 0 } )

Когда вы запускаете explain("executionStats") для этого запроса, покрытый план должен проверять ключи индекса без извлечения полных документов из коллекции. Во многих планах объяснения это означает, что вы увидите IXSCAN без стадии FETCH, и totalDocsExamined должно быть 0. Вывод explain варьируется в зависимости от версии MongoDB и формы запроса, поэтому сосредоточьтесь на фактических стадиях плана и проверенных счетчиках, а не ищите одну точную метку.

Покрывающие запросы полезны для горячих путей чтения, таких как автозаполнение, небольшие представления списков или проверки разрешений. Они менее полезны, если проекция включает большие поля, много полей или поля, которые постоянно меняются. Добавление слишком большого количества полей в индекс только для покрытия запроса может создать громоздкий индекс, который ухудшает производительность записи.

Другие важные типы индексов

MongoDB предлагает различные типы индексов для конкретных случаев использования:

Многоключевые индексы

Многоключевые индексы автоматически создаются при индексировании поля массива. Они позволяют запрашивать элементы внутри массивов.

Пример: Если у вас есть коллекция products с полем массива tags ["electronics", "gadgets"]:

db.products.createIndex( { tags: 1 } );

Этот индекс будет поддерживать запросы типа db.products.find( { tags: "electronics" } ).

Массивы требуют особой осторожности в составных индексах. Многоключевой индекс хранит записи для элементов массива, что может быстро увеличить размер индекса. MongoDB также имеет ограничения, касающиеся составных многоключевых индексов, когда более одного индексированного поля могут содержать массивы в одном документе. Если ваша модель данных имеет несколько массивов и сложные фильтры, протестируйте точный запрос с репрезентативными данными, прежде чем предполагать, что составной индекс будет вести себя так же, как индекс по скалярным полям.

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

Текстовые индексы поддерживают эффективный поиск строкового содержимого в документах. Они используются для запросов текстового поиска с помощью оператора $text.

db.articles.createIndex( { content: "text" } );

Это позволяет выполнять поиск типа: db.articles.find( { $text: { $search: "database performance" } } ).

Текстовые индексы полезны для базового текстового поиска, но они не являются полноценной поисковой платформой. Если вам нужна расширенная настройка релевантности, устойчивость к опечаткам, фасетный поиск, подсветка или поведение поиска с учетом языка, MongoDB Atlas Search или специализированная поисковая система могут быть лучшим выбором.

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

Геопространственные индексы используются для эффективного запроса географических данных с помощью операторов $near, $geoWithin и $geoIntersects.

db.locations.createIndex( { loc: "2dsphere" } ); // Для индекса 2dsphere

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

Уникальные индексы обеспечивают уникальность для поля или комбинации полей. Если вставляется или обновляется дублирующееся значение, MongoDB вернет ошибку.

db.users.createIndex( { email: 1 }, { unique: true } );

Для производственных пользовательских таблиц нормализуйте перед обеспечением уникальности. Адреса электронной почты являются распространенным примером. Если ваше приложение обрабатывает [email protected] и [email protected] как одного и того же пользователя, храните нормализованное поле, такое как emailLower, и поместите туда уникальный индекс. Не полагайтесь только на код приложения для предотвращения дубликатов при параллелизме.

Частичные индексы

Частичные индексы индексируют только документы, соответствующие выражению фильтра. Они полезны, когда запрос фокусируется на подмножестве коллекции.

db.orders.createIndex(
  { tenantId: 1, createdAt: -1 },
  { partialFilterExpression: { status: "open" } }
)

Это может помочь, если ваше приложение часто читает открытые заказы, а закрытые заказы составляют большую часть коллекции. Индекс меньше, потому что он исключает документы, не соответствующие частичному фильтру. Запрос должен включать совместимое условие, чтобы MongoDB его использовал.

TTL индексы

TTL индексы автоматически удаляют документы по истечении заданного времени. Они обычно используются для сессий, временных токенов или короткоживущих событий.

db.sessions.createIndex(
  { expiresAt: 1 },
  { expireAfterSeconds: 0 }
)

Удаление TTL не происходит мгновенно в точное время истечения. MongoDB удаляет просроченные документы в фоновом режиме. Используйте его для очистки, а не для точного контроля безопасности, когда токен должен стать недействительным немедленно. Ваше приложение все равно должно проверять срок действия во время чтения.

Анализ производительности с помощью explain()

Понимание того, как MongoDB выполняет ваши запросы, имеет решающее значение для их оптимизации. Метод explain() предоставляет информацию о плане выполнения запроса, включая то, использовался ли индекс и как.

db.collection.find( {...} ).explain( "executionStats" );

Ключевые поля, на которые стоит обратить внимание в выводе explain():

  • winningPlan.stage: Указывает стадию плана выполнения (например, COLLSCAN для сканирования коллекции, IXSCAN для сканирования индекса).
  • executionStats.totalKeysExamined: Количество проверенных ключей индекса.
  • executionStats.totalDocsExamined: Количество проверенных документов.

Хороший план выполнения будет иметь totalDocsExamined, близкое к количеству возвращенных документов или равное ему, и totalKeysExamined, значительно меньшее общего количества документов в коллекции. Если totalDocsExamined очень высок или используется COLLSCAN, это говорит о том, что индекс отсутствует или используется неэффективно.

Вот быстрый способ, которым я читаю план explain:

  1. Ищите COLLSCAN. Если это горячий путь и коллекция большая, это обычно первая проблема.
  2. Ищите IXSCAN, за которым следует FETCH. Выборка нормальна, когда запросу нужны поля вне индекса, но чрезмерная проверка документов означает, что индекс недостаточно селективен.
  3. Сравните nReturned, totalKeysExamined и totalDocsExamined. Возврат 20 документов после проверки 25 ключей — это нормально. Возврат 20 документов после проверки 500 000 ключей — нет.
  4. Следите за сортировкой в памяти. Если MongoDB приходится сортировать большой набор результатов после фильтрации, может помочь составной индекс, поддерживающий сортировку.

Используйте реалистичные фильтры при тестировании. План explain для tenantId: "demo" может не соответствовать большому арендатору с миллионами документов. Распределение данных имеет значение.

Практическое руководство по проектированию индексов

Представьте приложение с коллекцией tickets. Агенты поддержки используют страницу очереди с этими фильтрами:

db.tickets.find({
  tenantId: "acme",
  status: "open",
  assigneeId: "u123"
}).sort({ updatedAt: -1 }).limit(50)

Начните с формы запроса, а не со списка полей. Коллекция мультитенантная, агенты обычно фильтруют по статусу и исполнителю, а пользовательский интерфейс сортирует по самым новым обновлениям в начале. Практичный индекс:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  assigneeId: 1,
  updatedAt: -1
})

Теперь рассмотрим другую страницу: менеджеры просматривают все открытые тикеты, независимо от исполнителя:

db.tickets.find({
  tenantId: "acme",
  status: "open"
}).sort({ updatedAt: -1 }).limit(100)

Предыдущий индекс может использовать префикс { tenantId, status }, но assigneeId стоит перед updatedAt, поэтому он может не так хорошо поддерживать сортировку для этого запроса менеджера. Возможно, вам понадобится второй индекс:

db.tickets.createIndex({
  tenantId: 1,
  status: 1,
  updatedAt: -1
})

Это нормальный компромисс. Один индекс редко обслуживает каждый экран идеально. Задача состоит в том, чтобы поддерживать важные пути, не создавая кучу перекрывающихся индексов, которые все замедляют запись.

Лучшие практики индексирования MongoDB

  • Индексируйте только то, что нужно: Избегайте создания индексов для полей, которые редко запрашиваются или сортируются. Каждый индекс добавляет накладные расходы.
  • Используйте составные индексы с умом: Правильно упорядочивайте поля на основе шаблонов запросов. Сначала учитывайте наиболее селективные поля.
  • Стремитесь к покрывающим запросам: Если производительность чтения критична, проектируйте индексы для покрытия общих операций чтения.
  • Отслеживайте использование индексов: Регулярно проверяйте использование индексов с помощью explain() и db.collection.aggregate([{ $indexStats: {} }]), чтобы выявить неиспользуемые или неэффективные индексы.
  • Учитывайте селективность индекса: Индексы по полям с низкой кардинальностью (мало уникальных значений) могут быть не такими эффективными, как по полям с высокой кардинальностью.
  • Держите индексы небольшими: Избегайте включения больших полей или массивов в индексы, если это не absolutely необходимо для покрывающих запросов.
  • Тестируйте свои индексы: Всегда тестируйте влияние новых индексов как на производительность чтения, так и записи в реалистичных условиях нагрузки.
  • Удаляйте избыточные индексы осторожно: Если у вас есть { a: 1, b: 1 }, отдельный индекс { a: 1 } может быть избыточным для многих рабочих нагрузок. Подтвердите использование перед удалением.
  • Проектируйте под реальные экраны и задачи: Индексы должны соответствовать поведению приложения: поиск при входе, страница очереди, фильтр отчета, сканирование фоновым рабочим процессом.
  • Пересматривайте после изменений схемы: Новое поле, новый порядок сортировки или новая модель арендатора могут сделать старый индекс менее полезным.

Что такое хорошее индексирование на практике

Хорошее индексирование MongoDB обычно незаметно. Важные запросы проверяют примерно то количество данных, которое они возвращают. Сортировки не выливаются в дорогостоящую работу. Запись не обременяется дюжиной спекулятивных индексов. Когда новая функция добавляет новую форму запроса, вы тестируете ее с помощью explain("executionStats"), прежде чем она станет инцидентом в продакшене.

Практическая привычка проста: соберите реальный запрос, спроектируйте наименьший полезный индекс для этой формы запроса, протестируйте с репрезентативными данными и продолжайте проверять использование индекса с течением времени. Эта привычка сделает для производительности MongoDB больше, чем запоминание каждого типа индекса.