Статьи

Разработка схемы MongoDB в масштабе

У меня была недавняя возможность выступить с докладом в MongoDB в Сиэтле о разработке схем в Scale . По сути, это краткое исследование того, какие шаги предпринимали сотрудники Службы мониторинга MongoDB (MMS) для развития своей схемы, а также некоторые количественные сравнения производительности между различными схемами. Учитывая, что одним из моих самых читаемых постов в блоге по-прежнему является блокировка записи MongoDB , я решил, что читатели моего блога также будут заинтересованы в количественном сравнении.

MongoDB Мониторинг

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

Так что же такое MMS? MongoDB Monitoring Service — это бесплатная услуга, предоставляемая 10gen всем пользователям MongoDB для мониторинга нескольких ключевых показателей производительности на их установках MongoDB. Вот как это работает:

  • Вы загружаете небольшой скрипт, который запускаете на своих собственных серверах, который будет периодически загружать статистику производительности в MMS.
  • Вы получаете доступ к отчетам через веб-сайт MMS. Вы можете график поминутной производительности любой из метрик, а также увидеть исторические тенденции.

Едим свою собачью еду

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

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

Построение системы, подобной MMS

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

Чтобы все было компактно, я решил хранить статистику за день в одном документе MongoDB. Основная схема следующая:

{
    _id: "20101010/metric-1",
    metadata: {
        date: ISODate("2000-10-10T00:00:00Z"),
        metric: "metric-1" },
    daily: 5468426,
    hourly: {
        "00": 227850,
        "01": 210231,
        ...
        "23": 20457 },
    minute: {
        "0000": 3612,
        "0001": 3241,
        ...
        "1439": 2819 }
}

Здесь мы сохраняем дату и показатель, которые мы храним, в свойстве «метаданные», чтобы мы могли легко запросить его позже. Обратите внимание, что дата и имя метрики также встроены в поле _id (это будет важно позже). Фактические данные метрики хранятся в ежедневных, часовых и минутных свойствах.

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

def record_hit(coll, dt, measure):
    sdate = dt.strftime('%Y%m%d')
    metadata = dict(
        date=datetime.combine(
            dt.date(),
            time.min),
        measure=measure)
    id='%s/%s' % (sdate, measure)
    minute = dt.hour * 60 + dt.minute
    coll.update(
        { '_id': id, 'metadata': metadata },
        { '$inc': {
                'daily': 1,
                'hourly.%.2d' % dt.hour: 1,
                'minute.%.4d' % minute: 1 } },
        upsert=True)

Чтобы использовать это для записи «попадания» на наш веб-сайт, мы просто назвали бы его с нашей коллекцией, текущей датой и обновляемым показателем:

>>> record_hit(db.daily_hits, datetime.utcnow(), '/path/to/my/page.html')

Измерение производительности

Чтобы измерить производительность этого подхода, я создал кластер с двумя серверами в Amazon EC2: один сервер для запуска MongoDB и один для запуска кода моего теста для выполнения множества вызовов record_hit (), имитирующих разное время дня для наблюдения за производительностью. в течение нескольких 24-часовых периодов. Вот что я нашел:

Начальная производительность схемы

Ой! По какой-то причине мы видим, что производительность нашей системы неуклонно снижается с 3000-5000 операций записи в секунду до 200-300 операций записи в секунду в течение дня. Это, оказывается, происходит потому, что наше обновление «на месте» фактически не было на месте.

Растущие документы

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

После того, как вы делаете перерастают ваш отступы, однако, MongoDB должен переместить документ в другое место. Когда ваш документ увеличивается, это занимает больше времени (больше байтов для копирования и все такое). Таким образом, документы, которые растут, растут и растут, действительно снижают производительность с MongoDB. И это именно то, что мы имеем здесь. Рассмотрим первый раз, когда мы вызываем record_hit в течение дня. После этого документ выглядит следующим образом:

{
    _id: ...,
    metadata: {...},
    daily: 1,
    hourly: { "00": 1 }, 
    minute: { ""0000": 1 }
}

Затем мы записываем попадание в течение второй минуты дня, и наш документ увеличивается:

{
    _id: ...,
    metadata: {...},
    daily: 2,
    hourly: { "00": 2 }, 
    minute: { ""0000": 1, "0001": 1 }
}

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

Фиксация с предварительным распределением

Решением проблемы растущих документов является предварительное распределение. Тем не менее, мы бы предпочли не размещать все документы сразу (это может вызвать большую нагрузку на сервер), и мы бы предпочли не планировать документы для предварительного распределения в течение дня (это просто боль). Таким образом, решение, которое выбрал 10gen, состояло в том, чтобы случайным образом (с малой вероятностью) предварительно выделять завтрашний документ каждый раз, когда мы записываем сегодня хит .

В разработанной мной системе это предварительное распределение выполняется в начале record_hit:

def record_hit(coll, dt, measure):
    if PREALLOC and random.random() < (1.0/2000.0):
        preallocate(coll, dt + timedelta(days=1), measure)
    # ... 

Наша функция preallocate не так интересна, поэтому я просто покажу общую идею здесь:

def preallocate(coll, dt, measure):
    metadata, id = # compute metadata and ID
    coll.update( 
       { '_id': id },
       { '$set': { 'metadata': metadata },
         '$inc': { 
             'daily': 0,
             'hourly.00': 0,
             # ...
             'hourly.23': 0,
             'minute.0000': 0,
             # ...
             'minute.1439: 0 } },
       upsert=True)

Здесь следует отметить две важные вещи:

  • Наша функция preallocate безопасна . Если по какой-то причине мы вызовем preallocate для даты / метрики, в которой уже есть документ, ничего не изменится.
  • Даже если preallocate никогда не вызывается, record_hit все еще функционально корректен, поэтому нам не нужно беспокоиться о малой вероятности, которую мы получим за целый день без предварительного выделения документа.

Теперь с этими изменениями мы видим гораздо лучшую производительность:

Производительность с предварительным распределением

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

  • Предварительное распределение означает, что наши документы никогда не растут, поэтому они никогда не перемещаются
  • Благодаря предварительному распределению в течение дня у нас нет «полуночной проблемы», когда все наши пользователи в конечном итоге вставляют новый документ и увеличивают нагрузку на сервер.

У нас, однако, есть любопытная тенденция к снижению производительности в течение дня (хотя и гораздо менее резкая, чем раньше). Откуда это пришло?

Формат хранения MongoDB

Чтобы выяснить снижение производительности в течение дня, нам нужно кратко остановиться на фактическом формате, который MongoDB использует для хранения данных на диске (и в памяти), BSON . Обычно нам не нужно беспокоиться об этом, так как драйвер pymongo так хорошо конвертирует все в нативные типы Python, но в этом случае BSON представляет нам проблему с производительностью.

Хотя документы MongoDB, такие как наш небольшой встроенный документ, представлены в Python как dict (это хеш-таблица поиска с постоянной скоростью), BSON фактически хранит документы в виде списка ассоциаций . Таким образом, вместо того, чтобы иметь хорошую хэш-таблицу на минуту, у нас есть нечто, похожее на следующее:

minute = [
    [ "0000", 3612 ],
    [ "0001", 3241 ],
    # ...
    [ "1439", 2819 ] ]

Теперь, чтобы фактически обновить определенную минуту, сервер MongoDB выполняет что-то вроде следующих операций (psuedocode, при этом игнорируется множество особых случаев):

inc_value(minute, "1439", 1)

def inc_value(document, key, value)
    for entry in document:
        if entry[0] == key:
            entry[1] += value
            break

Производительность этого алгоритма, в отличие от нашей красивой хеш-таблицы O (1), фактически равна O (N) по количеству записей в документе. В случае минутного документа MongoDB должен фактически выполнить 1439 сравнений, прежде чем он найдет соответствующий слот для обновления.

Исправление нисходящего тренда с иерархией

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

{ _id: "20101010/metric-1",
  metadata: {
    date: ISODate("2000-10-10T00:00:00Z"),
    metric: "metric-1" },
  daily: 5468426,
  hourly: {
    "0": 227850,
    "1": 210231,
    ...
    "23": 20457 },
  minute: {
    "00": {        
        "0000": 3612,
        "0100": 3241,
        ...
    }, ...,
    "23": { ..., "1439": 2819 }
}

Наши подпрограммы record_hit и preallocate также должны немного измениться:

def record_hit_hier(coll, dt, measure):
    if PREALLOC and random.random() < (1.0/1500.0):
        preallocate_hier(coll, dt + timedelta(days=1), measure)
    sdate = dt.strftime('%Y%m%d')
    metadata = dict(
        date=datetime.combine(
            dt.date(),
            time.min),
        measure=measure)
    id='%s/%s' % (sdate, measure)
    coll.update(
        { '_id': id, 'metadata': metadata },
        { '$inc': {
                'daily': 1,
                'hourly.%.2d' % dt.hour: 1,
                ('minute.%.2d.%.2d' % (dt.hour, dt.minute)): 1 } },
        upsert=True)

def preallocate(coll, dt, measure):
    '''Once again, simplified for explanatory purposes'''
    metadata, id = # compute metadata and ID
    coll.update( 
       { '_id': id },
       { '$set': { 'metadata': metadata },
         '$inc': { 
             'daily': 0,
             'hourly.00': 0,
             # ...
             'hourly.23': 0,
             'minute.00.00': 0,
             # ...
             'minute.00.59': 0,
             'minute.01.00': 0,
             # ...
             'minute.23.59': 0 } },
       upsert=True)

После того, как мы добавили иерархию и повторили наш эксперимент, мы получили хорошую производительность на уровне, которую мы хотели бы увидеть:

Производительность с иерархическими минутами

Вывод

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

  • Растущие документы — очень плохая вещь для работы. Избегайте этого, если это вообще возможно.
  • Осведомленность о спецификации BSON и представление данных могут быть весьма полезны при диагностике проблем с производительностью.
  • Чтобы получить максимальную производительность вашей системы, вам необходимо запустить ее (или весьма представительный помощник). На самом деле просмотр результатов настройки производительности в графической форме невероятно полезен при планировании ваших усилий.

Исходный код для всех этих обновлений доступен в моем репозитории mongodb-sdas Github , и я приветствую любые отзывы либо там, либо здесь, в комментариях. В частности, мне бы хотелось услышать о любых проблемах с производительностью, с которыми вы столкнулись, и о том, как вы их обошли. И, конечно же, если у вас действительно непонятная проблема, я всегда готов проконсультироваться по электронной почте на Arborian.com .