Статьи

Эффективное индексирование в MongoDB 2.6

Осмар Оливо, менеджер по продукции в MongoDB

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

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

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

В следующих разделах мы углубимся в пересечение индекса и его применение в приложениях.

Пример — телефонная книга

Давайте возьмем пример телефонной книги со следующей схемой.

{
    FirstName
    LastName
    Phone_Number
    Address
}

Если бы мне пришлось искать слова «Смит, Джон», как бы я проиндексировал следующий запрос, чтобы он был максимально эффективным?

db.phonebook.find({ FirstName : “John”, LastName : “Smith” })

Я мог бы использовать индивидуальный индекс по FirstName и искать всех «Джонс».

Это будет выглядеть примерно так: sureIndex ({FirstName: 1})

Мы выполняем этот запрос и получаем 200 000 Джона Смита. Однако, глядя на вывод объяснения () ниже, мы видим, что мы сканировали 1 000 000 «Джонс» в процессе поиска 200 000 «Джон Смитс».

> db.phonebook.find({ FirstName : "John", LastName : "Smith"}).explain()
{
    "cursor" : "BtreeCursor FirstName_1",
    "isMultiKey" : false,
    "n" : 200000,
    "nscannedObjects" : 1000000,
    "nscanned" : 1000000,
    "nscannedObjectsAllPlans" : 1000101,
    "nscannedAllPlans" : 1000101,
    "scanAndOrder" : false,
    "indexOnly" : false,
    "nYields" : 2,
    "nChunkSkips" : 0,
    "millis" : 2043,
    "indexBounds" : {
        "FirstName" : [
            [
                "John",
                "John"
            ]
        ]
    },
    "server" : "Oz-Olivo-MacBook-Pro.local:27017"
}

Как насчет создания индивидуального индекса на LastName?

Это будет выглядеть примерно так, как sureIndex ({LastName: 1})

При выполнении этого запроса мы возвращаем 200 000 «Джон Смитс», но наш вывод объяснения говорит, что мы сейчас отсканировали 400 000 «Смит». Как мы можем сделать это лучше?

db.phonebook.find({ FirstName : "John", LastName : "Smith"}).explain()
{
    "cursor" : "BtreeCursor LastName_1",
    "isMultiKey" : false,
    "n" : 200000,
    "nscannedObjects" : 400000,
    "nscanned" : 400000,
    "nscannedObjectsAllPlans" : 400101,
    "nscannedAllPlans" : 400101,
    "scanAndOrder" : false,
    "indexOnly" : false,
    "nYields" : 1,
    "nChunkSkips" : 0,
    "millis" : 852,
    "indexBounds" : {
        "LastName" : [
            [
                "Smith",
                "Smith"
            ]
        ]
    },
    "server" : "Oz-Olivo-MacBook-Pro.local:27017"
}

Итак, мы знаем, что в нашей телефонной книге содержится 1 000 000 записей «Джон», 400 000 записей «Смит» и 200 000 записей «Джон Смит». Есть ли способ, которым мы можем сканировать только те 200 000, которые нам нужны?

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

ensureIndex( {  LastName : true, FirstName : 1  } ) 

db.phonebook.find({ FirstName : "John", LastName : "Smith"}).explain()
{
    "cursor" : "BtreeCursor LastName_1_FirstName_1",
    "isMultiKey" : false,
    "n" : 200000,
    "nscannedObjects" : 200000,
    "nscanned" : 200000,
    "nscannedObjectsAllPlans" : 200000,
    "nscannedAllPlans" : 200000,
    "scanAndOrder" : false,
    "indexOnly" : false,
    "nYields" : 0,
    "nChunkSkips" : 0,
    "millis" : 370,
    "indexBounds" : {
        "LastName" : [
            [
                "Smith",
                "Smith"
            ]
        ],
        "FirstName" : [
            [
                "John",
                "John"
            ]
        ]
    },
    "server" : "Oz-Olivo-MacBook-Pro.local:27017"
}

Глядя на объяснение этого, мы видим, что индекс отсканировал только 200 000 соответствующих документов, поэтому мы получили идеальный успех.

Помимо сложных индексов

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

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

{
      Fname
      LName
      SSN
      Age
      Blood_Type
      Conditions : [] 
      Medications : [ ]
      ...
      ...
}

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

Найдите мне пациента с Blood_Type = O в возрасте до 50 лет

db.patients.find( {   Blood_Type : “O”,  Age : {   $lt : 50  }     } )

Найдите мне всех пациентов старше 60 лет на лекарствах X

db.patients.find( { Medications : “X” , Age : { $gt : 60} })

Найдите меня всех пациентов с диабетом на лекарства Y

db.patients.find( { Conditions : “Diabetes”, Medications : “Y” } )

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

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

Например, глядя на предыдущий пример:

db.patients.find( {   Blood_Type : “O”,  Age : {   $lt : 50  }     } )

Неэффективно находить всех пациентов с BloodType: O (которых может быть миллионы), а затем запоминать каждый документ, чтобы найти пациентов с возрастом <50 или наоборот.

Вместо этого планировщик запросов находит всех пациентов с bloodType: O, использующих индекс для BloodType, и всех пациентов с возрастом <50, использующих индекс по возрасту, а затем только вытягивает пересечение этих двух наборов результатов в память. Планировщику запросов нужно только разместить подмножества индексов в памяти, а не извлекать все документы. Это, в свою очередь, приводит к меньшему количеству подкачки и меньшему перерасходу содержимого памяти, что в целом приводит к повышению производительности.

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

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

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

Найдите мне всех пациентов с диабетом и высоким кровяным давлением

db.patients.find( {  Conditions : { $all : [ “Diabetes”, “High Blood Pressure” ]  }    }  )

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

Нужны ли нам сложные индексы?

Для ясности, составная индексация ВСЕГДА будет более производительной, если вы знаете, к чему вы будете обращаться, и можете ее создать заранее. Кроме того, если ваш рабочий набор полностью находится в памяти, вы не сможете воспользоваться какими-либо преимуществами пересечения индексов, поскольку он в первую очередь основан на уменьшении ввода-вывода. Но в более специальном случае, когда невозможно предсказать форму запросов, а рабочий набор намного больше, чем доступная память, пересечение индекса автоматически вступит во владение и выберет наиболее эффективный путь.