Хабрахабр

Руководство по выживанию с MongoDB

Все хорошие стартапы либо быстро умирают, либо дорастают до необходимости масштабироваться. Мы смоделируем такой стартап, который сначала про фичи, а потом про перфоманс. Перфоманс будем улучшать с MongoDB — это популярное NoSQL-решение для хранения данных. С MongoDB легко стартовать, и многие проблемы имеют решения «из коробки». Однако, когда нагрузка растет, вылезают грабли, о которых вас заранее никто не предупреждал… до сегодняшнего дня!

image

Также был замечен в серверной части разработки MMORPG Skyforge. Моделирование проводит Сергей Загурский, который отвечает за инфраструктуру бэкенда вообще, и MongoDB в частности, в Joom. Под микроскопом — проект, который использует стратегию накопления для управления техническими долгом. Как сам себя описывает Сергей — «профессиональный набиватель шишек собственным лбом и граблями». В этой текстовой версии доклада на HighLoad++ будем двигаться в хронологическом порядке от возникновения проблемы до решения с помощью MongoDB.

Первые сложности

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

Если проблемы можно решить подобными простыми средствами — их так и надо решать.

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

Медленная запись

Это одна из проблем, с которой можно столкнуться. Что делать, если вы её повстречали, а методы выше не помогают? Ответ: режим гарантии durability в MongoDB по умолчанию. В трех словах он устроен так:

  • Мы пришли на primary реплику и сказали: «Пиши!».
  • Primary реплика записала.
  • После этого с нее прочитали secondary реплики и сказали primary: «Мы записали!».

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

К счастью, MongoDB — это такая БД, которая позволяет уменьшать гарантии durability на каждый отдельный запрос.

Для важных запросов мы можем оставить максимальные гарантии durability по умолчанию, а для некоторых запросов — уменьшить.

Классы запросов

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

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

Запись в журнал пишется в любом случае. Следующая гарантия, которая влияет на пропускную способность и на latency тоже — это отключение подтверждения записи в журнал. Если мы отключаем подтверждение записи в него, то не делаем две вещи: fsync на журнале и не ждем, когда он закончится. Журнал — один из основополагающих механизмов. Этим можно хорошо сэкономить ресурсы дисковой системы и получить кратный прирост пропускной способности, просто поменяв durability гарантии.

{w:1, j:false}

Самые «жёсткие» гарантии durability — это отключение любых подтверждений. Мы получим только подтверждение, что запрос дошел до primary реплики. Это сэкономит latency и никак не увеличит пропускную способность.

{w:0, j:false} — отключаем любые подтверждения.

Мы также получим разные другие вещи, например, запись не прошла из-за конфликта с уникальным ключом.

К каким операциям это применимо?

Расскажу про применение к сетапу в Joom. Кроме нагрузки от пользователей, в которой нет никаких послаблений durability, есть нагрузка, которую можно описать как фоновую batch-нагрузку: обновление, пересчет рейтингов, сбор данных аналитики.

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

Масштабирование чтения

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

Из secondary реплик будут приходить немного устаревшие данные — на 0,5–1 секунды. Читать можно, но есть нюансы. В большинстве случаев это нормально, но поведение secondary реплики отличается от поведения primary реплик.

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

Для пользовательских запросов secondary реплики не подходят — user experiences бодрым шагом идёт в мусорное ведро.

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

Количество соединений

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

Производительность деградирует нелинейно. Однако, чем больше параллельных конкурентных запросов, тем медленнее они выполняются. Количество соединений нужно ограничить непосредственно на MongoDB. Поэтому, если к нам прилетел спайк запросов, лучше обслужить 80%, чем не обслужить 100%.

В частности, connection pool на стороне MongoDB общий как для пользовательских, так и для служебных внутрикластерных подключений. Но есть баги, которые могут из-за этого доставить неприятности. Если приложение «съело» все соединения из этого пула, то в кластере может нарушиться целостность.

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

  • построить похожий индекс без уникальности;
  • удалить индекс с уникальностью;
  • построить индекс без уникальности вместо удаленного;
  • удалить временный.

Когда временный индекс еще достраивали на secondary, мы начали удалять уникальный индекс. В этот момент secondary MongoDB объявило о своей блокировке. Какие-то метаданные заблокировались, и в majority остановились все записи: они висели в connection pool и ждали, пока им подтвердят, что запись прошла. Все чтения на secondary тоже остановились, потому что был захвачен глобальный log.

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

Мораль истории: за количеством соединений надо следить.

Есть известная грабля MongoDB, на которую все равно настолько часто наступают, что я решил коротко по ней пройтись.

Не теряем документы

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

Размеры коллекций

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

  • По размеру конкретных документов, чтобы поиграться с их длиной: Object.bsonsize();
  • По среднему размеру документа в коллекции: db.c.stats().avgObjectSize.

Как повлиять на размер документа?

У меня неспецифичные ответы на этот вопрос. Первый - длинное название поля увеличивает размер документа. В каждом документе копируются все названия полей, поэтому если в документе длинное название поля, то размер названия нужно прибавить к размеру каждого документа. Если у вас коллекция с огромным количеством маленьких документов на несколько полей, то называйте поля короткими именами: «A», «B», «CD» — максимум две буквы. На диске это компенсируется сжатием, но в кэше все хранится как есть.

Например, таким полем может быть язык. Второй совет — иногда некоторые поля с низким cardinality можно вынести в название коллекции. Так мы уменьшим размеры документов и можем уменьшить количество и размер индексов — сплошная экономия! Если у нас есть коллекция с переводами на русский, английский, французский и поле с информацией о хранимом языке — значение этого поля можно вынести в название коллекции. Это не всегда можно провернуть, потому что иногда есть индексы внутри документа, которые не будут работать, если коллекцию разнести по разным коллекциям.

Если в ваших данных имеется естественный уникальный ключ — поместите его прямо в поле_id. Последний совет по размеру документов — используйте поле _id. Он отлично индексируется. Даже если ключ составной — используйте составной id. В некоторых случаях так может случиться в Go. Есть только одна маленькая грабля — если у вас marshaller иногда меняет порядок полей, то id с одинаковыми значениями полей, но с разным порядком будут считаться разными id с точки зрения уникального индекса в MongoDB.

Размеры индексов

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

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

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

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

Удаление документов

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

Особенно это плохо влияет на latency на secondary. Если все-таки получилось так, что требуется удалять много документов, обязательно делайте троттлинг, иначе массовое удаление документов скажется на latency чтения и будет неприятно.

Мы так много раз через это проходили, что троттлинг угадывается с третьего, четвертого раза. Стоит сделать какую-то «ручку», чтобы крутить троттлинг — с первого раза очень тяжело подобрать уровень. Изначально предусмотрите возможность его подкрутить.

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

Когда придет это время, MongoDB удалит этот документ автоматически. Еще один способ удаления документов — TTL-индекс — это индекс, в котором индексируется поле, в котором лежит Mongo timestamp, в котором содержится дата смерти документа.

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

Шардирование

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

Если вы сомневаетесь, что вам нужно шардирование — значит оно вам не нужно.

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

Например, плохая идея использовать запросы со skip, особенно если у вас много документов. Некоторые вещи с шардированием просто плохо работают. Вы отдаете команду: «Skip 100 000 документов».

А это мы вернем пользователю». MongoDB считает так: «Первый, второй, третий… стотысячный, пошли дальше.

В шардированной — все 100 000 документов она действительно прочитает и передаст в шардирующий прокси — в mongos, который уже на своей стороне как-то отфильтрует и отбросит первые 100 000. В нешардированной коллекции MongoDB выполнит операцию где-то внутри себя. Неприятная особенность, о которой следует помнить.

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

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

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

Как устроено шардирование в MongoDB

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

Ключ шардирования

Вы все-таки решили, что надо шардировать? Хорошо, первый вопрос — как выбрать ключ шардирования. У хорошего ключа несколько параметров: высокий cardinality, немутабельность и он хорошо ложится в частые запросы.

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

Я уже упоминал коллекцию переводов — translations. Приведу пример неудачного ключа шардирования. Например, коллекция поддерживает 100 языков и мы шардируем по языку. В ней есть поле language, которое хранит язык. Но это не самое плохое — может быть, для этих целей cardinality достаточно. Это плохо — cardinality количество возможных значений всего 100 штук, что мало. На несчастный шард, на котором находится английский язык, приходит в три раза больше запросов, чем на все остальные вместе взятые. Хуже, что как только мы пошардировали по языку, мы тут же узнаем, что у нас англоязычных пользователей в 3 раза больше, чем остальных.

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

Балансировка

Мы подходим к шардированию, когда у нас назрела необходимость в нем — наш кластер MongoDB поскрипывает, похрустывает своими дисками, процессором — всем, чем можно. Куда деваться? Некуда, и мы героически шардируем пяток коллекций. Шардируем, запускаем, и внезапно узнаем, что балансировка не бесплатна.

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

А вот вторая — удаление — совсем неприятная, потому что уложит на лопатки шард и так страдающий под нагрузкой. Шард у нас перегружен, в нем лежат все коллекции, но первая часть операции для него легкая.

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

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

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

К шардингу нужно тщательно готовиться.

HighLoad ++ Siberia — это возможность для разработчиков из Сибири послушать доклады, поговорить на хайлоад-темы и окунуться в среду «где все свои», не летая за три тысячи километров в Москву или Питер. HighLoad++ Siberia 2019 наступит в Новосибирске уже 24 и 25 июня. Подписывайтесь, чтобы быть в курсе. Из 80 заявок Программный комитет одобрил 25, а обо всех остальных изменениях в программе, анонсах докладов и других новостях мы рассказываем в нашей рассылке.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»