Я присоединился к Uber два года назад в качестве мобильного разработчика, имеющего некоторый опыт разработки бекенда. Здесь я занимался разработкой функционала платежей в приложении — и по ходу дела переписал само приложение. После чего я перешёл в менеджмент разработчиков и возглавил саму команду. Благодаря этому я смог гораздо ближе познакомиться с бэкендом, поскольку моя команда несёт ответственность за многие системы нашего бэкенда, позволяющие осуществлять платежи.
До моей работы в Uber у меня не было опыта работы с распределёнными системами. Я получил традиционное образование в Computer Science, после чего с десяток лет занимался full-stack разработкой. Поэтому, пусть я и мог рисовать различные диаграммы и рассуждать о компромиссах (tradeoffs) в системах, к тому моменту я недостаточно хорошо понимал и воспринимал концепции распределённости — такие, например, как согласованность (consistency), доступность (availability) или идемпотентность (idempotency).
В данном посте я собираюсь рассказать про несколько концепций, которые мне потребовалось изучить и применить на практике при построении крупномасштабной высокодоступной распределённой системы платежей, которая сегодня работает в Uber. Это система с нагрузкой до нескольких тысяч запросов в секунду, в которой критическая функциональность платежей должна работать корректно даже в тех случаях, когда перестают работать отдельные части системы.
Полный ли это список? Скорее всего, нет. Однако, если бы лично я сам узнал про эти концепции раньше, это сделало бы мою жизнь гораздо проще.
Итак, давайте приступим к нашему погружению в SLA, согласованность, долговечность данных, сохранность сообщений, идемпотентность и некоторые другие вещи, которые мне потребовалось выучить на своей новой работе.
SLA
В больших системах, которые обрабатывают миллионы событий в день, некоторые вещи просто по определению обязаны пойти не по плану. Вот почему прежде, чем погружаться в планирование системы, нужно сделать самый важный шаг — принять решение о том, что для нас означает «здоровая» система. Степень «здоровья» должна быть чем-то таким, что на самом деле можно измерить. Общепринятым способом измерения «здоровья» системы являются SLA (service level agreements). Вот некоторые из самых распространённых видов SLA, с которыми мне доводилось сталкиваться на практике:
- Доступность (Availability): процент времени, который сервис является работоспособным. Пусть существует искушение достичь 100% доступности, достижение этого результата может оказаться по-настоящему сложным занятием, да ещё вдобавок и весьма дорогостоящим. Даже крупные и критичные системы вроде сети карт VISA, Gmail или интернет-провайдеров не имеют 100% доступности — за годы они накопят секунды, минуты или часы, проведённые в даунтайме. Для многих систем, доступность в четыре девятки (99.99%, или примерно 50 минут даунтайма в год) считается высокой доступностью. Для того, чтобы добраться до этого уровня, придётся изрядно попотеть.
- Точность (Accuracy): является ли допустимой потеря данных или их неточность? Если да, то какой процент является приемлимым? Для системы платежей, над которой я работал, этот показатель должен был составлять 100%, поскольку данные терять было нельзя.
- Пропускная способность/мощность (Capacity): какую нагрузку должна выдерживать система? Этот показатель обычно выражается в запросах в секунду.
- Задержка (Latency): за какое время система должна отвечать? За какое время должны быть обслужены 95% и 99% запросов? В подобных системах обычно многие из запросов являются «шумом», поэтому задержки p95 и p99 находят более практическое применение в реальном мире.
Почему SLA нужны при создании крупной системы платежей? Мы создаём новую систему, заменяющую существующую. Чтобы убедиться в том, что мы всё делаем правильно, и что наша новая система будет «лучше», чем её предшественница, мы использовали SLA, чтобы определить наши ожидания от неё. Доступность была одним из самых важных требований. Как только мы определили цель, нам было необходимо разобраться с компромиссами в архитектуре, чтобы достичь этих показателей.
Горизонтальное и вертикальное масштабирование
По мере роста бизнеса, который использует нашу свежесозданную систему, нагрузка на неё будет лишь увеличиваться. В определенный момент, существующая установка будет неспособна выдержать дальнейшее увеличение нагрузки, и нам потребуется увеличить допустимую нагрузку. Две общепринятые стратегии масштабирования — это вертикальное или горизонтальное масштабирование.
Горизонтальное масштабирование заключается в добавлении большего количества машин (или узлов) в систему для увеличения пропускной способности (capacity). Горизонтальное масштабирование — это самый популярный способ масштабирования распределённых систем.
Вертикальное масштабирование — это по сути «купить машину побольше/посильнее» — (виртуальная) машина с большим числом ядер, лучшей вычислительной мощностью и большей памятью. В случае с рапределёнными системами, вертикальное масштабирование обычно менее популярно, поскольку оно может быть более дорогостоящим, чем масштабирование горизонтальное. Однако, некоторые известные большие сайты, вроде Stack Overflow, успешно масштабировались вертикально для соответствия нагрузке.
Почему стратегия масштабирования имеет смысл, когда вы создаёте крупную платёжную систему? Мы на раннем этапе решили, что мы будем строить систему, которая будет масштабироваться горизонтально. Несмотря на то, что вертикально масштабирование допустимо использовать в некоторых случаях, наша система платежей к тому моменту уже достигла прогнозируемой нагрузки и мы с пессимизмом отнеслись к предположению о том, что единственный супер-дорогой мейнфрейм сможет выдержать эту нагрузку сегодня, не говоря уже о будущем. Помимо этого, в нашей команде было несколько человек, которые работали в крупных поставщиках платёжных услуг и имели негативный опыт попытки масштабироваться вертикально даже на самых мощных машинах, которые можно было купить за деньги в те годы.
Согласованность (consistency)
Доступность любой из систем важна. Распределённые системы часто строятся из машин, чья доступность по отдельности ниже, чем доступность всей системы. Пусть наша цель построить систему с доступностью в 99.999% (даунтайм составляет примерно 5 минут/год). Мы используем машины/ноды, которые в среднем имеют доступность в 99.9% (они находятся в даунтайме примерно 8 часов/год). Прямым путём достижения нужного нам показателя доступности является добавление ещё нескольких таких машин/узлов в кластер. Даже если некоторые из узлов будут «в дауне», другие будут продолжать оставаться в строю и общая доступность системы будет выше, чем доступность её индивидуальных компонентов.
Согласованность — это ключевая проблема в высокодоступных системах. Система согласована, если все узлы видят и возвращают одни и те же данные в одно и то же время. В отличие от нашей предыдущей модели, когда мы добавляли больше узлов для достижения большей доступности, удостовериться в том, что система остается согласованной, далеко не так тривиально. Чтобы убедиться в том, что каждый узел содержит одну и ту же информацию, они должны отправлять сообщения друг другу, чтобы постоянно быть сихронизированными. Однако, сообщения, отправленные ими друг другу, могут быть не доставлены — они могут потеряться и некоторые из узлов могут быть недоступными.
Согласованность — это концепция, на осознание которой у меня ушло больше всего времени, прежде чем я понял её и оценил по достоинству. Существует несколько видов согласованности, самым широко используемым в распределённых системах является сильная согласованность (strong consistency), слабая согласованность (weak consistency) и согласованность в конечном счёте (eventual consistency). Вы можете прочитать полезный практический разбор преимуществ и недостатков каждой из моделей в данной статье. Обычно, чем слабее требуемый уровень согласованности, тем быстрее может работать система — но тем вероятнее, что она вернет не самый последний набор данных.
Почему согласованность стоит учитывать при создании крупной платёжной системы? Данные в системе должны быть согласованы. Но насколько согласованы? Для некоторых частей системы подойдут только сильно согласованные данные. Например, нам необходимо сохранить в сильно согласованном виде информацию о том, что платеж был инициирован. Для других частей системы, которые не являются настолько важными, согласованность в конечном счёте можно считать разумным компромиссом.
Хорошо иллюстрирует это вывод списка недавних транзакций: они могут быть реализованы при помощи согласованности в конечном счёте (eventual consistency) — то есть, последняя транзакция может появиться в некоторых частях системы лишь некоторое время спустя, но благодаря этому запрос списка вернет результат с меньшей задержкой или потребует меньше ресурсов для выполнения.
Долговечность данных (data durability)
Долговечность означает то, что как только данные будут успешно добавлены в хранилище данных, они будут доступны нам в будущем. Это будет справедливо даже в том случае, если узлы системы уйдут в оффлайн, в них произойдёт сбой или данные узлов будут повреждены.
Различные распределённые базы данных имеют разные уровни долговечности данных. Некоторые из них поддерживают data durability на уровне машины/узла, другие делают это на уровне кластера, а некоторые вообще не предоставляют этой функциональности «из коробки». Некоторая форма репликации обычно используется для увеличения долговечности — если данные хранятся на нескольких узлах и один из узлов перестаёт работать, данные по-прежнему будут доступны. Вот хорошая статья, поясняющая, почему достижение долговечности в распределённых системах может стать серьёзным вызовом.
Почему долговечность данных имеет значение при построении платёжной системы? Если данные являются критическими (например, это платежи), то мы не можем позволить себе терять их во многих из частей нашей системы. Распределённые хранилища данных, которые мы построили, должны были поддерживать долговечность данных на уровне кластера — так что даже если инстансы будут «падать», завершенные транзакции будут сохраняться. В наши дни, большинство распределённых сервисов хранения данных — вроде Cassandra, MongoDB, HDFS или Dynamodb — все поддерживают долговечность на различных уровнях и все могут быть сконфигурированы для предоставления долговечности уровня кластера.
Сохранность сообщений (message persistence) и долговечность (durability)
Узлы в распределённых системах выполняют вычисления, хранят данные и отправляют сообщения друг другу. Ключевой характеристикой отправки сообщений является то, насколько надёжно эти сообщения прибудут. Для критически важных систем зачастую имеется требование того, чтобы ни одно из сообщений не было потеряно.
В случае распределенных систем, обмен сообщениями (messaging) обычно выполняется при помощи некоторого распределенного сервиса сообщений — RabbitMQ, Kafka или других. Эти брокеры сообщений могут поддерживать (или настроены так, что станут поддерживать) различные уровни надежности доставки сообщений.
Сохранность сообщения означает, что когда на узле, обрабатывающем сообщение, происходит отказ, то сообщение по прежнему будет доступно для обработки после того, как проблема разрешится. Долговечность сообщений обычно используется на уровне очереди сообщений. При помощи долговечной очереди сообщений, если очередь (или узел) уходят в оффлайн когда сообщение отослано, оно попрежнему получит сообщение когда оно вернётся в онлайн. Хорошая подробная статья по данному вопросу доступна по ссылке.
Почему сохранность и долговечность сообщений имеют значение при построении крупных платёжных систем? У нас были сообщения, которые мы не могли позволить себе потерять — например, сообщение о том, что человек инициировал платёж по оплате поездки. Это означало, что система обмена сообщениями, которую нам предстояло использовать, должна была работать без потерь: каждое сообщение должно было быть единожды доставлено. Однако, создание системы которая доставляет каждое сообщение ровно один раз нежели хотя бы один раз — это задачи, значительно различающиеся по своей трудности. Мы решили реализовать систему обмена сообщениями, которая доставляет хотя бы единожды, и выбрали шину сообщений (messaging bus), поверх которой мы решили её построить (мы остановили свой выбор на Kafka, создав кластер без потерь, который требовался в нашем случае).
Идемпотентность
В случае с распределёнными системами, может пойти не так всё, что угодно — соединения могут отваливаться посередине или запросы могут выпадать по тайм-ауту. Клиенты будут часто повторять эти запросы. Идемпотентная система гарантирует, что чтобы ни произошло, и сколько бы раз конкретный запрос ни выполнялся, действительное выполнение этого запроса происходишь всего один раз. Хороший пример — это осуществление платежа. Если клиент создает запрос на оплату, запрос успешен, но если клиент попадает в тайм-аут, то клиент может повторить тот же самый запрос. В случае с идемпотентной системой, с человека, производящего оплату, не будут дважды списаны деньги; а вот для не-идемпонетной системы это вполне возможное являение.
Проектирование идемпотентных распределённых систем требует некоего вида распределённой стратегии блокировки. Здесь в игру вступают концепции, которые мы обсуждали ранее. Скажем, мы намереваемся реализовать идемпотентность при помощи оптимистической блокировки во избежание параллельных обновлений. Для того, чтобы мы могли прибегнуть к оптимистической блокировке, система должна быть строго согласованной — благодаря чему во время выполнения операции мы можем проверить, начата ли другая операция, используя некую форму версионирования.
Существует множество способов достижения идемпотентности, и каждый конкретный выбор будет зависеть от ограничений системы и типа производимой операции. Проектирование идемпотентных подходов это достойный вызов для разработчика — достаточно взглянуть на посты Бена Надела, в которых он рассказывает о различных стратегиях, которые он использовал, которые включают в себя и распределённые блокировки, и ограничения (constraints) базы данных. Когда вы проектируете распределённую систему, идемпотентность может легко оказаться одной из частей, которую вы упустили из своего внимания. В нашей практике мы сталкивались со случаями, в которых моя команда «погорела» на том, что не убедилась в наличии корректной идемпотентности для некоторых ключевых операций.
Почему идемпотентность имеет значение при построении крупной платёжной системы? Самое главное: для избежания двойных списаний и двойных возвратов средств. Учитывая, что наша система обмена сообщениями имеет доставку типа «хотя бы раз, без потерь», мы должны предполагать, что все сообщения могут быть доставлены несколько раз и системы должны гарантировать идемпотентность. Мы приняли решения обрабатывать это при помощи версионирования и оптимистической блокировки, где наши системы реализуют идемпотентное поведение используя строго согласованное хранилище в качестве своего источника данных.
Шардинг и кворум
Распределённые системы часто должны хранить гораздо больше данных, чем может позволить себе один отдельный узел. Так как же нам сохранить набор данных на нужном количестве машин? Самой популярной техникой для этого является шардинг. Данные горизонтально партиционируются при помощи некоего хеша, присвоенного партиции. Пусть многие распределённые базы данных сегодня реализуют шардинг у себя «под капотом», он сам по себе является интересной темой, которую стоит изучить — особенно решардинг. У Foursquare в 2010 году был 17-часовой даунтайм из-за попадания на краевой случай шардинга, после чего компания поделилась интересным пост-мортемом, проливающим свет на корень проблемы.
Во многих распределённых системах есть данные или вычисления, которые реплицируются между несколькими узлами. Чтобы убедиться в том, что операции выполняются согласованно, определяется подход с голосованием, при котором для признания операции успешной нужно, чтобы определённое количество узлов получило один и тот же результат. Этот процесс называется кворумом.
Почему кворум и шардинг имеют смысл при построении крупной платёжной системы в Uber? Обе эти концепции являются простыми и используются практически повсеместно. Я познакомился с ними тогда, когда мы настраивали репликацию в Cassandra. Cassandra (и другие распределённые системы) использует кворум и местный кворум (local quorum) для того, чтобы обеспечить согласованность между кластерами.
Модель акторов
Привычный словарь, который мы используем для описания практик программирования — вещи вроде переменных, интерфейсов, вызова методов — предполагают системы из одной машины. Когда мы говорим о распределённых системах, то мы должны использовать другие подходы. Распространенным способом описания таких систем является модель акторов, в рамках который код видится нам в терминах коммуникации. Эта модель является популярной в силу того, что она совпадает с ментальной моделью того, как мы представляем себе, например, взаимодействие людей в организации. Другой, не менее популярный способ описания распределённых систем — это CSP, взаимодействующие последовательные процессы.
Модель акторов базируется на акторах, которые отправляют друг другу сообщения и реагируют на них. Каждый актор может делать ограниченный набор вещей — создавать других акторов, отправлять сообщения другим или решать, что сделать со следующим сообщением. При помощи нескольких простых правил, мы можем достаточно хорошо описать сложные распределённые системы, которые могут восстанавливать себя после того, как актор «падает». Если вы не знакомы с данным подходом, то я рекомендую вам статью Модель акторов за 10 минут за авторством Брайна Сторти. Для многих языков существуют библиотеки или фреймворки, реализующие модель акторов. Например, в Uber мы используем Akka для некоторых из наших систем.
Почему имеет смысл применить модель акторов в крупной платёжной системе? В разработке нашей системы принимало участие много инженеров, большинство из которых уже имело опыт работы над распределёнными системами. Мы решили следовать стандартной распределённой модели вместо того, чтобы заниматься «велосипедами» и изобретать свои собственные распределённые принципы.
Реактивная архитектура
При построении больших распределённых систем, целью обычно является сделать их отказоустойчивыми, эластичными и масштабируемыми. Будь то система платежей или какая-либо другая высоконагруженная система, паттерны достижения желаемого могут быть одними и теми же. Те, кто занимается подобными системами, регулярно обнаруживают и распространяют лучшие практики их построения — и реактивная архитектура является подобным популярным и широко используемым паттерном.
Для ознакомления с реактивной архитектурой, я предлагаю прочитать Реактивный манифест (на русском) и посмотреть 12-минутное видео по ссылке.
Почему реактивную архитектуру имеет смысл применять, если вы создаёте крупную платёжную систему? Akka, — библиотека, которую мы использовали для создания большей части нашей новой платежной системы, — находится под сильным влиянием реактивной архитектуры. Многие из наших инженеров, занимавшихся построением этой системы, уже были знакомы с лучшими практиками реактивного программирования. Следуя реактивным принципам — создавая отзывчивую, отказоустойчивую, эластичную систему, основанную на сообщениях (message-driven), мы пришли к этому выводу естественным путём. Возможность иметь модель, на которую можно положиться и при помощи которой можно оценить прогресс разработки и его направление, оказалась крайне полезной, и я буду полагаться на неё в дальнейшем при создании новых систем.
Заключение
Мне повезло принять участие в перестраивании широкомасштабной, распределённой и критически важной системы: той, что позволяет работать с платежами в Uber. Работая в этом окружении, я познакомился со многими распределёнными концепциями, которые мне не доводилось использовать ранее. Я собрали их здесь в надежде на то, что остальные найдут мой рассказ полезным для старта изучения распределённых систем или узнают для себя что-нибудь новое.
Данный пост был посвящен исключительно планированию и архитектуре подобных систем. Существует множество различных тонкостей, о которых стоит рассказать — про построение, деплой и миграцию высоконагруженных систем, — а ещё про их надёжную эксплуатацию; все эти темы я собираюсь поднять в последующих постах.
Автор: HotWaterMusic