Привет, Хабр! Я Сергей Гайдамаков. Уже 28 лет я занимаюсь проектированием и разработкой программных систем различного масштаба. Сейчас работаю в Т-Банке системным аналитиком и проектирую системы, которые в совокупности составляют большую распределенную систему. 

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

Распределенные системы

Распределенная система — это набор взаимодействующих узлов, которые воспринимаются как целостная система. 

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

В случае набора реплик MongoDB мы имеем однородные узлы, которые хранят одни и те же данные, а в случае неоднородных узлов может быть система, в которой, например, данные из PostgreSQL переносятся в Elastic Search для поиска информации.

Однородная распределенная система данных
Неоднородная распределенная система данных

CAP-теорема

CAP-теорема — это утверждение о том, что распределенная система может удовлетворять одновременно двум из трех свойств: consistency, availability и partition tolerance.

Про смысл CAP-теоремы написано много статей, поэтому, чтобы не повторяться, оставил несколько ссылок в конце статьи.

Свойство availability понять просто: система должна позволять сохранять данные, даже если они могут быть потеряны в случае сбоя, и читать данные, даже не самые последние. 

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

Когда речь заходит о базах данных и CAP-теореме, сразу вспоминается картинка, которая кочует из статьи в статью:

Базы данных на CAP-треугольнике

Результаты тестирования набора реплик MongoDB помогут найти реальное место, которое занимает MongoDB на этом CAP-треугольнике, и определить, каким свойствам она соответствует.

Свойство consistency — согласованность

Свойство consistency важно для корректной работы алгоритмов, которые получают и меняют данные, хранящиеся в распределенной системе. 

Взаимодействие с распределенной системой

На рисунке — пример взаимодействия, где сервис 1 изменяет объект А, потом асинхронно (например, через сообщение Kafka) вызывает сервис 2, который использует тот же самый объект А в своем алгоритме. Если распределенная система не будет обладать свойством consistency, то сервис 2 при чтении объекта А может получить не вторую, а первую версию объекта — и работа алгоритма «сломается».

Нужно учитывать, что после изменения объекта А в сервисе 1 и отправки уведомления в сервис 2 может произойти сбой в работе распределенной системы. Например, может пропасть сетевое взаимодействие между узлами, что приведет к разделению узлов, либо произойти аппаратный сбой, что вызовет перезагрузку сервисов распределенной системы, и так далее. 

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

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

А свойство consistency для распределенной системы означает, что система будет выдавать одни и те же данные даже после сбоя, что по смыслу ближе к свойству durability для транзакций, но на уровне не одного, а множества узлов.

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

Узел MongoDB

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

Структура узла MongoDB

Основной компонент управления данными в MongoDB — это WiredTiger Storage Engine, платформа для управления данными и создания NoSQL баз данных (колоночных, строковых). 

Внутри базы данных MongoDB фактически находится другая база данных! Как сказано на официальном сайте платформы, это высокопроизводительная масштабируемая NoSQL Open Source расширяемая платформа для управления данными. WiredTiger создана стартапом в 2012 году, приобретена MongoDB в 2014 году и используется с версии MongoDB 3.2.

WiredTiger Storage Engine управляет рядом абстракций: документ, схема, транзакция, журнал транзакций, кэш, курсор, блокировки и так далее. Поддерживает транзакции с уровнями изоляции read uncommitted, read committed и snapshot. При этом в MongoDB используется только уровень snapshot, который аналогичен уровню serializable read, только без блокировок при параллельных транзакциях. Конфликты изменения данных определяются в момент фиксации (commit) транзакции. 

MongoDB Engine — компонент, который реализует механизмы MongoDB для управления запросами, коллекциями и индексами, алгоритмы репликации и шардирования, инструменты обслуживания.

Другие элементы MongoDB:

  • Cache — область памяти с копией данных, вычитанных с диска или измененных в результате фиксации (commit) транзакций.

  • Checkpoint — процесс сохранения данных из кэша на диск, по умолчанию выполняется каждые 60 секунд. Процесс влияет на время восстановления системы в случае сбоя.

  • Snapshot — данные в памяти на определенный момент, относящиеся к транзакции. Когда транзакция начинается, выделяется область памяти и в нее копируются данные из кэша по мере чтения или обновления данных. При фиксации транзакции данные сбрасываются обратно в кэш с контролем конфликтов изменения данных — и в случае обнаружения конфликта транзакция отменяется. По умолчанию snapshot транзакции хранится еще в течение 5 минут после фиксации (настраивается в файле конфигурации), и к его данным можно обратиться.

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

  • Operation log — системная коллекция документов, которая содержит уже выполненные операции по изменению данных в результате транзакций и используется для репликации данных на secondary-узлы. Изменение данных возможно только на primary-узле набора реплик MongoDB, на котором в результате транзакций обновляется operation log. Secondary-узлы подписываются на изменение operation log, получают изменения в свою базу данных и на его основании обновляют данные в других коллекциях.

При взаимодействии клиента с набором реплик MongoDB используются следующие три параметра, которые управляют свойствами availability и consistency распределенной системы.

ReadPreference определяет, с какого узла нужно читать данные — primary, primaryPreferred, secondary, secondaryPreferred, nearest:

  • primary — данные будут читаться строго с primary-узла, а если он отсутствует (например, в результате сбоя), то будет ошибка чтения данных;

  • primaryPreferred — в случае отсутствия primary-узла данные могут читаться с secondary;

  • secondaryPreferred — данные читаются с secondary-узлов, но при увеличенной нагрузке на узлы могут читаться с primary-узла.

ReadConcern определяет, какие данные из набора реплик MongoDB нужно возвращать:

  • local — локальные данные, в данный момент хранящиеся в узле, к которому подключен клиент;

  • majority — данные, которые в данный момент сохранены на большинстве реплик;

  • linearizable — возвращаются данные с ожиданием применения на большинстве узлов всех изменений, сделанных к данному моменту (смысл параметра станет понятнее после соответствующих тестов);

  • snapshot — majority-данные на указанный момент.

WriteConcern — структура {w: integer | "majority", j: boolean}, которая определяет, когда возвращать клиенту подтверждение выполнения операции по изменению данных:

  •  {w: 1, j: true} — подтверждение после сохранения данных на primary-узел;

  •  {w: 2, j: true} — подтверждение после сохранения на primary и одном secondary-узле;

  • {w: "majority", j: true} — подтверждение после сохранения данных на большинстве узлов (общее число узлов / 2 + 1);

  • j (journal) — признак принудительного сохранения журнала запросов на диск (см. описание journal выше). Если j: false, то журнал запросов сохраняется на диск в соответствии с расписанием (по умолчанию каждые 100 мс), но есть риск потерять изменения в случае сбоя.

Из теории все, теперь к практике и выводам 😊

Тестовая конфигурация MongoDB

Все тесты выполнялись на ноутбуке под Windows. Запускали одновременно три инстанса MongoDB версии 7.0 на разных портах (29001, 29002, 29003), которые объединены в набор реплик. 

За счет параметров подключения можно получать данные как с конкретного узла (local client), так и с набора реплик в целом (replica set client), что будет использоваться в тестах.

Все используемые в тестах скрипты можно скачать с GitHub, ссылка есть в конце статьи.

Тестовая конфигурация MongoDB

Тестирование скорости от writeConcern

Проверим, как writeConcern влияет на скорость добавления и обновления данных. Функция тестирования WriteSpeedTestSuit(blockSize, count) выполняет insert и update документа размером blockSize байт count раз с разными writeConcern параметрами. 

Время добавления и обновления 10 документов размером 1 КБ
Время добавления и обновления 10 документов размером 1 МБ

Очевидные результаты теста:

1. С ростом n в параметре {w: n} растет время выполнения операций, так как требуется время, чтобы передать изменения на другие узлы.

2. j: true выполняется медленнее, чем j: false, так как требуется время, чтобы сохранить журнал запросов на диск.

3. С ростом объема сохраняемых данных растет время выполнения операций, так как растет нагрузка на дисковую подсистему.

А еще получили неожиданные и стабильно повторяющиеся результаты для следующих опций:

1. {w: "majority", j: false} — самая долгая опция для данных небольшого размера, когда минимально влияние дисковой подсистемы.

2. {w: "majority", j: true} — работает быстрее, чем {w:2, j: true}, хотя majority числа узлов в нашем случае равно 2.

Значение "majority" включает алгоритмы, гарантирующие сохранность данных после завершения операции даже в случае аппаратно-программных сбоев. Поэтому в первом случае система ожидает цикла сохранения журнала на диск (по умолчанию это происходит каждые 100 мс). Из-за этого выполнение операции долгое, хотя в документации описано другое поведение, которое не объясняет долгую работу. 

А во втором случае журнал сразу сохраняется на диск, но для {w: 2} ожидается репликация и применение операции к данным на узле. Для {w: "majority"} ожидается только репликация, без применения изменений к данным на узле, поэтому скорость операции выше. В MongoDB версии 8.0 этот алгоритм немного изменили — и, возможно, время будет совпадать.

Размер JSON-документа, который сохраняется в коллекции MongoDB, не должен превышать 16 МБ. Если мы запустим функцию WriteSpeedTestSuit для размера строки 16 МБ, то получим результат как на рисунке.

Время добавления и обновления 10 документов размером 16 МБ

Для writeConcern: {w: 0, j: false} клиент отправляет данные на сервер без ожидания ответа — и такой запрос выполняется успешно. Но для writeConcern: {w: 0, j: true} требуется получить от сервера подтверждение сохранения журнала запросов на диск. В этом случае клиент получает серверную ошибку о превышении максимального размера документа в 16 МБ.

Если неправильно выбрать значение writeConcern, то в угоду скорости работы можно потерять данные. Поэтому при разработке прикладной системы требуется выбирать нужные значения в зависимости от решаемой задачи и предъявляемых критериев (скорость выполнения или надежность).

Тестирование согласованности данных для writeConcern

Следующая группа тестов позволит проверить данные на отдельных узлах в наборе реплик сразу после выполнения транзакции с заданным writeConcern и узнать, какие данные возвращает распределенная система в целом для указанного readConcern.

Функция ConsistencyTestGenerator(writeConcern, readConcern, blockSize, count) в рамках одной транзакции обновляет один и тот же документ count раз с увеличением номера версии. Номера версий документа читаются с каждого узла с параметром readConcern: "local", а номер версии из набора реплик читается с readConcern, указанным в параметре функции (столбец replset). Если номер версии документа отличается от нового, то перед числом ставится минус.

На рисунках — результаты выполнения тестов при разных значениях writeConcern с параметром readConcern: "majority".

Данные на узлах набора реплик для writeConcern: {w: 1, j: false}
Данные на узлах набора реплик для writeConcern: {w: 1, j: true}
Данные на узлах набора реплик для writeConcern: {w: 2, j: false}
Данные на узлах набора реплик для writeConcern: {w: 2, j: true}
Данные на узлах набора реплик для writeConcern: {w: 3, j: false}
Данные на узлах набора реплик для writeConcern: {w: 3, j: true}
Данные на узлах набора реплик для writeConcern: {w: "majority", j: false}
Данные на узлах набора реплик для writeConcern: {w: "majority", j: true}

Тесты показывают ожидаемые результаты:

1. По мере роста n в параметре {w: n} уменьшается число узлов набора реплик, содержащих устаревшие (несогласованные) данные, но при этом растет время фиксации транзакции (столбец duration).

2. При {w: 3, j: true} после фиксации транзакции получается полная согласованность данных на всех узлах.

3. При {w: "majority", j: false} и {w: "majority", j: true} набор реплик (столбец replset) всегда возвращает новые данные, хотя на некоторых узлах могут находиться устаревшие данные. При этом для j: true большее число узлов имеет устаревшие данные, так как транзакция с таким значением выполняется немного быстрее, чем j: false (смотрите результаты предыдущих тестов выше).

И теперь о неочевидных результатах: при {w: 3, j: false} можно получить устаревшие данные с набора реплик, хотя на всех узлах находятся уже новые данные! Параметр writeConcern: {w: "majority"} обеспечивает дополнительную гарантию получения согласованных данных из набора реплик.

Тестирование readConcern

Теперь с помощью функции ConsistencyTestGenerator можно сравнить время получения данных (столбец get_time) для разных readConcern.

Длительность получения данных для readConcern: "local"
Длительность получения данных для readConcern: "majority"
Длительность получения данных для readConcern: "linearizable" и writeConcern: {w: 1, j: false}

Результаты тестов позволяют понять смысл опции readConcern: "linearizable", у которой время получения данных значительно больше, чем для readConcern: "local" или readConcern: "majority". С опцией "linearizable" происходит ожидание репликации данных на большинство узлов набора реплик и только после этого выдается результат. Это позволяет упорядочить получение данных в соответствии с запущенными транзакциями, то есть получить линеаризуемость.

Но если теперь установить writeConcern: {w: 3, j: true} и readConcern: "linearizable", то время фиксации транзакции увеличится, а вот время на получение данных значительно сократится.

Длительность получения данных для readConcern: "linearizable" и writeConcern: {w: 3, j: true}

Такое поведение ожидаемо, так как в момент чтения все данные на узлах уже согласованы и чтение выполняется без ожидания репликации данных. Но, в отличие от readConcern: "local" или readConcern: "majority", для обеспечения линеаризуемости есть накладные расходы по ожиданию меток синхронизации узлов, поэтому время на получение данных все же больше.

При разработке систем с использованием MongoDB нужно учитывать, что размер изменений в рамках одной транзакции не должен превышать 16 МБ. Это связано с тем, что изменения должны быть записаны в системную коллекцию oplog, а размер документа не может превышать 16 МБ. Если в функции ConsistencyTestGenerator указать больший размер изменяемого документа, можем поймать сообщение об ошибке.

Ошибка фиксации транзакции с изменениями более 16 МБ

Заключение

В статье мы:

1. Определили ключевые компоненты узла MongoDB, проверили ограничения в 16 МБ по размеру сохраняемого в коллекции документа и суммарных изменений в рамках транзакции.

2. Проверили влияние writeConcern на состояние данных в узлах набора реплик после выполнения операции.

3. Проверили влияние readConcern на получаемые данные в зависимости от состояния данных в узлах набора реплик.

В следующей статье рассмотрим работу набора реплик MongoDB в различных аварийных ситуациях с разными параметрами writeConcern и readConcern. А еще посмотрим сценарии потери данных в наборе реплик. Получив результаты тестирования, можно будет сделать вывод о CAP/PACELC-свойствах, которые достижимы в наборе реплик MongoDB, а также предложить параметры управления CAP-свойствами неоднородных распределенных систем.

Полезные материалы