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

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

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

Горизонтальное масштабирование — это просто, верно?

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

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

Пока это не так.

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

Так заканчивается мир

Не с треском, а со свистом.

Давайте посмотрим, как этот тип масштабирования может сломаться и внести гейзенбаги в вашу систему.

Масштабирование в действии

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

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

function addUserToTeam(userId, teamId) {
    if (Teams.findOne({ userIds: userId })) {
        throw new Error("Already on a team!");
    }
    Teams.update({ _id: teamId }, { $push: { userIds: userId } });
}

Это кажется относительно простым и отлично сработало в наших небольших закрытых бета-тестах.

Здорово! Со временем наше приложение Team Joiner™ становится очень популярным.

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

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

Объединив наши силы

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

Представьте себе сценарий, в котором пользователь Сью пытается присоединиться к команде А. Одновременно пользователь-администратор Джон замечает, что Сью нет в команде, и решает помочь, назначив ее в команду Б.

Запрос Сью полностью обрабатывается сервером А, а запрос Джона полностью обрабатывается сервером Б.

Сервер А начинает с проверки того, входит ли Сью в команду. Не она. Сразу после этого сервер B также проверяет, входит ли Сью в команду. Не она. На данный момент оба сервера думают, что они готовы добавить Сью в свою команду. Сервер А назначает Сью команде А, выполняя ее запрос. Тем временем сервер B назначает Сью команде B, выполняя запрос Джона.

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

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

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

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

Ознакомьтесь с началом Доклада Натана Геральда на конференции ElixirConf EU в этом году, чтобы узнать обо всех фантастических способах отказа распределенных систем.

Разрешение конфликтов

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

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

При создании распределенных систем вы должны быть готовы к работе с данными, которые могут быть несовместимыми или устаревшими. Конфликты должны быть ожидаемым результатом, заложенным в систему и стратегически запланированным.

Это одна из причин, по которой я тяготею к подходу Event Sourcing для моего последнего проекта Inject Detect.

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

Мы углубимся в детали, связанные с этим типом решения, в следующих постах.

Последние мысли

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

Моя цель — показать, что работать с распределенными системами неожиданно сложно. В тот момент, когда вы добавляете второй ЦП или запускаете новый экземпляр сервера, вы вступаете в дивный новый (но на самом деле не такой уж новый) мир вычислений, который требует от вас более глубокого рассмотрения каждой строки кода, который вы пишете.

Я призываю вас пересмотреть проекты и написанный вами код, которые существуют в распределенной среде. Вы когда-нибудь сталкивались со странными ошибками, которые не можете объяснить? Есть ли какие-то условия гонки, которые вы никогда не рассматривали?

Готово ли ваше текущее приложение к горизонтальному масштабированию? Уверены ли вы?

В будущем я надеюсь написать больше полезных статей о решении подобных проблем. Следите за новостями о том, как можно использовать Event Sourcing для создания надежных, бесконфликтных распределенных систем!