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

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

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

Как мы можем сохранить наши ресурсы во время операции запроса?

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

Индексы используют структуры данных, называемые b-деревом, они делают поиск чрезвычайно быстрым. Кроме того, для достижения кратчайшего времени обработки MongoDB пытается полностью уместить индексы в ОЗУ. Таким образом система может избежать чтения индекса с диска.

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

Чтобы определить индекс, вам просто нужно указать набор атрибутов в определенном порядке (1 для вставки полей в порядке возрастания, -1 для убывания). MongoDB позаботится о их сортировке в структуре данных b-tree в соответствии с порядком, который вы указали для атрибутов. Первое поле атрибута - это корень дерева, а последнее - листья.

Как сделать индекс для сложного запроса?

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

Когда вы создаете индекс для запроса, это практическое правило помогает вам определить порядок полей в индексе:

  • Поля, по которым выполняются условия равенства запросов.
  • Поля для индексации должны отражать порядок сортировки запроса.
  • Поля, представляющие диапазон данных для доступа.

Но это правило подходит не для всех случаев.

Ваша структура данных играет очень важную роль в определении индекса

Для целей этой статьи рассмотрим коллекцию ‘audit’ (имитирующую нашу коллекцию аудита, в которой регистрируются операции пользователей на нашей платформе).

Каждый документ в коллекции «аудит» имеет следующую структуру:

{
   _id: ObjectId
   tenant: string,
   type: Array[string],
   status: string,
   removed: boolen,
   silent: false, 
   targetId: string,
   ...
}

Предположим, нам нужно поддерживать экспорт части аудита, отфильтрованной по диапазону дат (с использованием _id), статусу пользователя и типу действия.

db.audit.find({
  "_id": {
    "$gte":ObjectId("5e26775e0000000000000000"),
    "$lte": ObjectId("5e4f55de19383d7303d1d8b5")
  },
  "type": {
    "$in": ["TYPE1", "TYPE2", "TYPE3, "TYPE4", "TYPE5", "TYPE6",           "TYPE7", "TYPE8", "TYPE9", "TYPE10", "TYPE11", "TYPE12", "TYPE13","TYPE14","TYPE15","TYPE16"]
  },
  "silent": false,
  "status": {"$in": ["COMPLETE"]},
  "removed": false,
  "tenant": "tenant_1"
}, {_id :-1}).limit(50)

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

_id_1
tennantsilent: false,statussilent: false,silentsilent: false,_id_-1
tennant_1_silent_1_targetId_1__id
tennant_1_silent_i_userId_1__id
...

Если мы выполним запрос, мы увидим, что его выполнение занимает 50 мс. Но не существует индекса, который удовлетворял бы описанным выше правилам. Почему этот запрос быстрый? Чтобы понять, почему мы должны выполнить команду .explain () и это позволит нам увидеть, что она использует индекс ‘tennantsilent: false,statussilent: false,silentsilent: false,_id_-1’.

Глядя только на время выполнения, может быть недостаточно для проверки производительности запроса.

Чтобы проверить, эффективно ли выполняется запрос, вам нужно использовать команду объяснять () и проверить связь между «nReturned», то есть количеством документов, соответствующих запросу. и totalDocsExamined - количество документов, проанализированных в процессе запроса. В этом случае наш результат был:

{
 seek: 14
 nReturned: 50
 totaldocumentsExamined: 178
 totalkeysExamined: 178
}

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

Фактически, если мы хотим проверить реальную мощность индекса, мы можем просто запустить count () в предыдущем запросе. Время выполнения составляет 30,1 секунды. Используемый индекс был таким же, почему такая разница?

Чтобы выполнить подсчет с этим индексом, MongoDB извлекает с диска (или кеширует) любой документ, содержащийся в этом индексе, потому что в этом запросе есть некоторые поля, которые не включены в индекс. Кроме того, не все отсканированные документы соответствуют запросу. Как следствие, мы видим, что загрузка ЦП составляет около 10%, а количество операций ввода-вывода в секунду на диске очень высокое (~ 1660 операций ввода-вывода в секунду).

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

Следуя ранее описанному правилу, правильный индекс для этого запроса будет:

tenantsilent: false,silentsilent: false,removedsilent: false,statussilent: false,typesilent: false,_id_1

Но если мы снова запустим count (), мы все равно увидим время выполнения 30,1 секунды. Запустив объяснение (), мы замечаем, что планировщик запросов игнорирует новый индекс, который мы создали, следуя советам документации, и продолжает использовать предыдущий.

Почему?

Анализируя отвергнутый план объяснения, мы замечаем, что в нашем индексе много «поиска» (перемещение курсора в указателе во время предварительного сканирования b-дерева), что отрицательно влияет на эффективность запроса. Наша интерпретация заключается в том, что фильтрация по дате, применяемая к любому элементу предложения $ in, вызывает большое количество «поиска» и генерирует операцию, которая оценивается планировщиком запросов как слишком затратная.

Что заставляет нас думать, что мы правы в отношении нашей гипотезы, так это то, что если мы изменим наш запрос, уменьшив количество элементов для оператора $ in в фильтре «type» до пяти элементов, планировщик запросов выберет «правильный» индекс и время выполнения снижается примерно до 60 мс.

На следующем графике поясняется структура индекса.

Правильный индекс для нашего варианта использования

Предыдущий индекс неверен для нашего случая и распределения данных.

Чтобы сделать наш запрос максимально быстрым, мы изменили индекс на:

tenantsilent: false,silentsilent: false,#removedsilent: false,statussilent: false,_idsilent: false,type_1

В этом примере данные влияют на построение индекса.

Наши данные распределены за 4 года, скорость добавления в эту коллекцию постоянна с течением времени. Наш запрос должен фильтровать данные в диапазоне от 1 до 30 дней. Когда мы создаем индекс, если мы поменяем местами позиции «type» и «_id», данные, проанализированные запросом, должны быть ниже, чем раньше.

Следующая диаграмма объясняет выбор пути во время исследования индекса.

Если мы сейчас запустим команду объяснения (), мы увидим следующее:

{
 seek: 1
 nReturned: 50
 totaldocumentsExamined: 0
 totalkeysExamined: 50
}

Теперь «поиск» равен 1. Просмотр индекса проще и приводит к более быстрому запросу.

«TotalkeysExamined» теперь имеет соотношение с «nReturned», равным 1. В худшем случае, когда мы фильтруем только по одному «типу», «totalkeysExamined» может быть высоким, но в нашем случае производительность с использованием этого индекса приемлемо (время ответа на запрос 130 мс), поскольку это не запрос, ориентированный на клиента, а административный с довольно низкой частотой доступа.

Этот запрос также будет нормально работать без вставки поля «тип» в индекс, потому что данные, анализируемые во время запроса, уменьшаются (ниже загрузка ЦП). Однако, чтобы еще больше повысить производительность и избежать извлечения данных с диска и потребления ресурсов, мы все равно вставили его, чтобы получить значение «totaldocumentsExamined: 0»

Наличие всех полей в индексе и наличие достаточного количества ОЗУ для этого также является лучшей практикой, рекомендованной MongoDB для уменьшения использования диска.

На следующем графике мы можем увидеть производительность двух индексов. С последним индексом количество проверенных ключей в индексе все еще велико. Этот показатель показывает, что запрос может лучше работать с другим индексом, даже если использование ресурсов кластера (ЦП и IOPS диска) было уменьшено. Два теста были выполнены с очисткой кэширования кластера (объект уже загружен с диска).

  • Левый график: два запроса выполняются со старым индексом
  • Правый график: два запроса выполняются с новым индексом

Можем ли мы сделать лучше?

Да, могли. Это компромисс между эффективностью и стоимостью. Мы могли бы добавить оба индекса, но для этого потребовалось бы больше ресурсов. Например, наш индекс использует 1,4 ГБ памяти с фактическими данными, компромисс между использованием ресурсов и производительностью запроса хорош, как и сейчас.

В будущем мы можем быть вынуждены добавить еще один индекс, особенно если наши данные изменят его с перекосом.

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