Руководство по выживанию с MongoDB
Все хорошие стартапы либо быстро умирают, либо дорастают до необходимости масштабироваться. Мы смоделируем такой стартап, который сначала про фичи, а потом про перфоманс. Перфоманс будем улучшать с MongoDB — это популярное NoSQL-решение для хранения данных. С MongoDB легко стартовать, и многие проблемы имеют решения «из коробки». Однако, когда нагрузка растет, вылезают грабли, о которых вас заранее никто не предупреждал… до сегодняшнего дня!
Моделирование проводит Сергей Загурский, который отвечает за инфраструктуру бэкенда вообще, и MongoDB в частности, в Joom. Также был замечен в серверной части разработки MMORPG Skyforge. Как сам себя описывает Сергей — «профессиональный набиватель шишек собственным лбом и граблями». Под микроскопом — проект, который использует стратегию накопления для управления техническими долгом. В этой текстовой версии доклада на HighLoad++ будем двигаться в хронологическом порядке от возникновения проблемы до решения с помощью MongoDB.
Первые сложности
Мы моделируем стартап, который набивает шишки. Первый этап жизни — в наш стартап запускаются фичи и, неожиданно, приходят пользователи. На наш маленький-маленький MongoDB-сервер сваливается нагрузка, о которой мы даже не мечтали. Но мы же в облаке, мы же стартап! Мы делаем самые простые вещи из возможных: смотрим запросы — ой, а у нас тут вся коррекция вычитывается для каждого пользователя, тут индексы построим, там железа добавим, а здесь закэшируем.
Всё — живем дальше!
Если проблемы можно решить подобными простыми средствами — их так и надо решать.
А вот дальнейший путь успешного стартапа — это медленное, мучительное оттягивание момента горизонтального масштабирования. Попытаюсь дать советы, как пережить этот период, добраться до масштабирования и не наступить на грабли.
Медленная запись
Это одна из проблем, с которой можно столкнуться. Что делать, если вы её повстречали, а методы выше не помогают? Ответ: режим гарантии durability в MongoDB по умолчанию. В трех словах он устроен так:
- Мы пришли на primary реплику и сказали: «Пиши!».
- Primary реплика записала.
- После этого с нее прочитали secondary реплики и сказали primary: «Мы записали!».
В тот момент, когда большинство secondary реплик это сделали, запрос считается выполненным и управление возвращается в драйвер в приложении. Такие гарантии позволяют быть уверенными, что когда управление вернулось в приложение, durability никуда не денется, даже если MongoDB ляжет, кроме совсем уже страшных катастроф.
К счастью, MongoDB — это такая БД, которая позволяет уменьшать гарантии durability на каждый отдельный запрос.
Для важных запросов мы можем оставить максимальные гарантии durability по умолчанию, а для некоторых запросов — уменьшить.
Классы запросов
Первый слой гарантий, который мы можем снять — не ждать подтверждение записи большинством реплик. Это сэкономит latency, но никак не добавит пропускной способности. Но иногда latency — это то, что нужно, особенно, если кластер чуть перегружен и secondary реплики работают не так быстро, как хотелось бы.
{w:1, j:true}
Если мы пишем записи с такими гарантиями, то в момент, когда получаем управление в приложение, уже не знаем, будет ли запись жива после какой-то аварии. Но, обычно, она все-таки жива.
Следующая гарантия, которая влияет на пропускную способность и на 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 реплик.
На secondary есть процесс применения oplog, которого нет на primary реплике. Этот процесс не то, чтобы разработан под низкую latency — просто разработчики MongoDB на этом не заморачивались. При некоторых условиях процесс применения oplog с первичной на secondary может давать задержки до 10 с.
Для пользовательских запросов secondary реплики не подходят — user experiences бодрым шагом идёт в мусорное ведро.
На нешардированных кластерах это спайки заметны меньше, но все равно есть. Шардированные кластеры страдают, потому что на применение oplog особенно сильно влияет удаление, а удаление — это часть работы балансировщика. Балансировщик смачно, со вкусом удаляет документы десятками тысяч за короткий промежуток времени.
Количество соединений
Следующий фактор для размышлений — ограничение по количеству соединений на инстансах MongoDB. По умолчанию никаких ограничений нет, кроме ресурсов ОС — можно подключаться пока она разрешает.
Однако, чем больше параллельных конкурентных запросов, тем медленнее они выполняются. Производительность деградирует нелинейно. Поэтому, если к нам прилетел спайк запросов, лучше обслужить 80%, чем не обслужить 100%. Количество соединений нужно ограничить непосредственно на MongoDB.
Но есть баги, которые могут из-за этого доставить неприятности. В частности, 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. Он отлично индексируется. Есть только одна маленькая грабля — если у вас marshaller иногда меняет порядок полей, то id с одинаковыми значениями полей, но с разным порядком будут считаться разными id с точки зрения уникального индекса в MongoDB. В некоторых случаях так может случиться в Go.
Размеры индексов
Индекс хранит копию полей, которые в него входят. Размер индекса состоит из тех данных, которые проиндексированы. Если мы пытаемся проиндексировать большие поля, то готовьтесь к тому, что размер индекса будет большим.
Сильно раздувает индексы второй момент: поля-массивы в индексе мультиплицируют другие поля из документа в этом индексе. Будьте осторожны с большими массивами в документах: либо не индексируйте что-то еще к массиву, либо поиграйтесь с порядком перечисления полей в индексе.
Порядок полей имеет значение, особенно, если одно из полей индекса — массив. Если поля отличаются по cardinality, и в одном поле количество возможных значений сильно отличается от количества возможных значений в другом, то имеет смысл их выстраивать по увеличению cardinality. Можно легко сэкономить 50% размера индекса, если поменять местами поля с разным cardinality. Перестановка полей может дать и более значимое уменьшение размера.
Иногда, когда поле содержит большое значение, нам не нужно сравнивать это значение больше-меньше, а достаточно сравнения по четкому равенству. Тогда индекс по полю с тяжелым содержимым можно заменить на индекс по hash от этого поля. В индексе будут храниться копии hash, а не копии этих полей.
Удаление документов
Я уже упоминал, что удаление документов — это неприятная операция и лучше не удалять, если возможно. Когда разрабатываете дизайн схемы данных, постарайтесь предусмотреть либо минимум удаления отдельных данных, либо удаление целых коллекций. удалять их было можно целыми коллекциями. Удаление коллекций — это дешевая операция, а удаление тысяч отдельных документов — тяжелая операция.
Если все-таки получилось так, что требуется удалять много документов, обязательно делайте троттлинг, иначе массовое удаление документов скажется на latency чтения и будет неприятно. Особенно это плохо влияет на latency на secondary.
Стоит сделать какую-то «ручку», чтобы крутить троттлинг — с первого раза очень тяжело подобрать уровень. Мы так много раз через это проходили, что троттлинг угадывается с третьего, четвертого раза. Изначально предусмотрите возможность его подкрутить.
Если вы удаляете больше 30% большой коллекции, то переложите живые документы в соседнюю коллекцию, а старую коллекцию удалите целиком. Понятно, что есть нюансы, потому что со старой на новую коллекцию переключается нагрузка, но по возможности перекладывайте.
Еще один способ удаления документов — TTL-индекс — это индекс, в котором индексируется поле, в котором лежит Mongo timestamp, в котором содержится дата смерти документа. Когда придет это время, MongoDB удалит этот документ автоматически.
TTL-индекс удобен, но в реализации нет троттлинга. MongoDB не заботится о том, чтобы эти удаления затроттлить. Если вы попытаетесь удалить миллион документов одновременно — на несколько минут у вас будет неработоспособный кластер, который занимается только удалением и больше ничем. Чтобы этого не происходило, добавьте какой-то рандом, размажьте TTL настолько, насколько позволяет ваша бизнес-логика и спецэффекты на latency. Размазывание TTL обязательно, если у вас естественные причины в бизнес-логике, которые концентрируют удаление в один момент времени.
Шардирование
Мы пытались отсрочить этот момент, но он настал — нам все-таки приходится масштабироваться горизонтально. Применительно к MongoDB — это шардирование.
Если вы сомневаетесь, что вам нужно шардирование — значит оно вам не нужно.
Шардирование усложняет жизнь разработчика и девопса разнообразными способами. В компании мы называем это налогом на шардирование. Когда мы шардируем коллекцию, то удельная производительность коллекции снижается: в MongoDB требуется поддерживать отдельный индекс для шардирования, и нужно передавать дополнительные параметры в запрос, чтобы он мог эффективнее исполняться.
Некоторые вещи с шардированием просто плохо работают. Например, плохая идея использовать запросы со skip
, особенно если у вас много документов. Вы отдаете команду: «Skip 100 000 документов».
MongoDB считает так: «Первый, второй, третий… стотысячный, пошли дальше. А это мы вернем пользователю».
В нешардированной коллекции MongoDB выполнит операцию где-то внутри себя. В шардированной — все 100 000 документов она действительно прочитает и передаст в шардирующий прокси — в mongos, который уже на своей стороне как-то отфильтрует и отбросит первые 100 000. Неприятная особенность, о которой следует помнить.
С шардированием обязательно будет усложняться код — во многие места придется протащить ключ шардирования. Это не всегда удобно, и не всегда возможно. Некоторые запросы пойдут либо broadcast, либо multicast, что тоже не добавляет масштабируемости. Подходите к выбору ключа по которому пойдет шардирование аккуратнее.
В шардированных коллекциях ломается операция count
. Она начинает возвращать число больше, чем в действительности — может соврать в 2 раза. Причина лежит в процессе балансировки, когда документы переливаются с одного шарда на другой. Когда документы перелились на соседний шард, а на исходном еще не удалились — count
их все равно посчитает. Разработчики MongoDB не называют это багом — это такая фича. Не знаю, будут они это чинить или нет.
Шардированный кластер гораздо тяжелее в администрировании. Девопсы перестанут с вами здороваться, потому что процедура снятия бэкапа становится радикально сложнее. При шардировании необходимость автоматизации инфраструктуры мигает как пожарная сигнализация — то, без чего можно было и обойтись раньше.
Как устроено шардирование в MongoDB
Есть коллекция, мы ее хотим как-то раскидать по шардам. Для этогоMongoDB делит коллекцию на чанки с помощью ключа шардирования, пытаясь поделить их в пространстве шард-ключа на равные кусочки. Дальше включается балансировщик, который старательно раскладывает эти чанки по шардам в кластере. Причем балансировщику все равно, сколько эти чанки весят и сколько в них документов, так как балансировка идет в чанках поштучно.
Ключ шардирования
Вы все-таки решили, что надо шардировать? Хорошо, первый вопрос — как выбрать ключ шардирования. У хорошего ключа несколько параметров: высокий cardinality, немутабельность и он хорошо ложится в частые запросы.
Естественный выбор ключа шардирования — это первичный ключ — поле id. Если вам подходит для шардирования поле id, то лучше прямо по нему и шардировать. Это отличный выбор — у него и cardinality хороший, он и немутабельный, но, а насколько хорошо ложится в частые запросы — это ваша бизнес-специфика. Отталкивайтесь от вашей ситуации.
Приведу пример неудачного ключа шардирования. Я уже упоминал коллекцию переводов — translations. В ней есть поле language, которое хранит язык. Например, коллекция поддерживает 100 языков и мы шардируем по языку. Это плохо — cardinality количество возможных значений всего 100 штук, что мало. Но это не самое плохое — может быть, для этих целей cardinality достаточно. Хуже, что как только мы пошардировали по языку, мы тут же узнаем, что у нас англоязычных пользователей в 3 раза больше, чем остальных. На несчастный шард, на котором находится английский язык, приходит в три раза больше запросов, чем на все остальные вместе взятые.
Поэтому надо учитывать, что иногда шард-ключ естественным образом тяготеет к неравномерному распределению нагрузки.
Балансировка
Мы подходим к шардированию, когда у нас назрела необходимость в нем — наш кластер MongoDB поскрипывает, похрустывает своими дисками, процессором — всем, чем можно. Куда деваться? Некуда, и мы героически шардируем пяток коллекций. Шардируем, запускаем, и внезапно узнаем, что балансировка не бесплатна.
Балансировка проходит несколько стадий. Балансировщик выбирает чанки и шарды, откуда и куда будет переносить. Дальнейшая работа идет в две фазы: сначала документы копируются с источника в цель, а потом документы, которые были скопированы, удаляются.
Шард у нас перегружен, в нем лежат все коллекции, но первая часть операции для него легкая. А вот вторая — удаление — совсем неприятная, потому что уложит на лопатки шард и так страдающий под нагрузкой.
Проблема усугубляется тем, что если мы балансируем много чанков, например, тысячи, то с настройками по умолчанию все эти чанки сначала копируются, а потом приходит удалятор и начинает их скопом удалять. В этот момент на процедуру уже не повлиять и приходится только грустно наблюдать за происходящим.
Поэтому если вы подходите к тому, чтобы шардировать перегруженный кластер, вам нужно планировать, так как балансировка занимает время. Желательно это время брать не в прайм-тайм, а в периоды низкой нагрузки. Балансировщик — отключаемая запчасть. Можно подойти к первичной балансировке в ручном режиме, отключать балансировщик в прайм-тайм, и включать, когда нагрузка снизилась, чтобы позволить себе больше.
Если возможности облака все еще позволяют масштабироваться вертикально, то лучше шард-источник заранее улучшить по железу, чтобы все эти спецэффекты немного уменьшить.
К шардингу нужно тщательно готовиться.
HighLoad++ Siberia 2019 наступит в Новосибирске уже 24 и 25 июня. HighLoad ++ Siberia — это возможность для разработчиков из Сибири послушать доклады, поговорить на хайлоад-темы и окунуться в среду «где все свои», не летая за три тысячи километров в Москву или Питер. Из 80 заявок Программный комитет одобрил 25, а обо всех остальных изменениях в программе, анонсах докладов и других новостях мы рассказываем в нашей рассылке. Подписывайтесь, чтобы быть в курсе.