Работаете с распределенными системами или только пытаетесь к ним подобраться? Проектировать их с нуля бывает сложно и страшно: чтобы учесть все нюансы, нужен определенный багаж знаний.
На помощь приходит Reactive Manifesto — документ, который обобщил опыт целого ряда компаний по созданию распределенных систем. Манифест формулирует главные принципы, на которые стоит опираться во время их проектирования и эксплуатации.
Меня зовут Андрей Василевский, я системный архитектор в Lamoda Tech. В статье на примерах из своей работы я покажу, как применять Reactive Manifesto. Материал будет полезен тем, кто только начал изучать распределенные системы, хочет закрепить теорию или тем, кто хочет структурировать проектирование микросервисов в своей компании.
Распределенные системы: в чем сложность
За 13 лет развития платформы Lamoda наши процессы сильно усложнились, нагрузка возросла, увеличилось количество систем. Как и многие наши коллеги и конкуренты, мы трансформируем наши монолитные системы в микросервисы на языке Go.
Микросервисы — типичный представитель распределенных систем. Само собой, они не лишены и типичных недостатков:
Сеть
Помните, что сеть никогда не является надежным каналом коммуникации: всегда есть риск потери сообщений.
Интерфейсы коммуникации
В распределенной системе особое внимание уделяется проектированию интерфейсов коммуникации. От них зависят границы ответственности компонентов, их взаимодействие. Кроме этого, интерфейс должен быть понятен другим людям. Если коммуникация асинхронная - всё ещё сложнее.CAP-теорема
Теорема, описывающая фундаментальные ограничения распределенных систем. Ее суть состоит в том, что при нарушении связи между узлами системы (network partition) мы идем на компромисс между консистентностью (здесь — linearizability) и доступностью системы. В статье мы еще вернемся к этой теореме.
Сложность
Как вы считаете, уменьшаем ли мы сложность системы, трансформируя монолит в микросервисы? Краткого ответа здесь не будет, так что оставлю ссылку на статью Влада Хононова на эту тему.
Инфраструктура
Поддержка больших распределенных систем требует соответствующих инструментов и экспертизы. Чтобы обеспечивать их работу, может понадобится всё то, что вы так любите: облака, балансировщики, оркестраторы, брокеры сообщений и прочее. Это сложно и дорого.
Несмотря на недостатки, такой сдвиг парадигмы является не столько трендом, сколько необходимостью. Микросервисы обеспечивают компании возможность развивать процессы, масштабировать команды и улучшать метрики разработки. Например, команда Lamoda Tech только за последние полгода выросла на сотню инженеров.
При этом, когда компания стремительно развивается, становится сложнее управлять развитием систем, и растет риск ошибок. Иногда эти ошибки могут стоить бизнесу миллионы рублей. Поэтому необходимо хорошо понимать, как такие системы строить, и делиться знаниями внутри команды.
На помощь приходит Reactive Manifesto.
Что это такое
Reactive Manifesto — это документ, который обобщает подходы к проектированию систем, способных справиться с сегодняшними вызовами: минимальной задержкой, постоянной (почти) доступностью и обработкой огромного количества данных.
Reactive Manifesto описывает четыре ключевые характеристики таких систем. Они должны быть отзывчивыми, устойчивыми, эластичными и основанными на обмене сообщениями Message Driven. Системы, обладающие такими характеристиками, следуя документу, называются реактивными (reactive).
Отзывчивость (responsiveness) — прогнозируемое время ответа и отказа
Устойчивость (resilience) — устойчивость к отказам. Изоляция отказов в конкретном участке системы.
Эластичность (elasticity) — система реагирует на изменения в нагрузке, изменяя потребление своих ресурсов.
Message Driven — система основана на обмене сообщениями.
Цель манифеста — объединить и обобщить принципы проектирования реактивных систем, которые так или иначе открывали для себя IT-компании, прошедшие этот путь.
Предлагаю рассмотреть каждую характеристику подробнее, а также дополнить её примерами из нашего опыта.
Отзывчивость
Система обеспечивает консистентное время ответа, особенно в аварийных ситуациях.
Система деградирует предсказуемым образом — обеспечивает минимальный уровень сервиса либо отвечает ошибкой.
Посмотрим, что делает систему отзывчивой (или наоборот) на примере из нашей практики.
Представим себе Stock System, Orders Systems и Goods Management Systems. Это абстрактный набор разных систем:
Orders Systems — создают заказы;
Goods Management Systems — управляют товародвижением;
Stock system — управляет остатками и резервацией товаров.
Эту коммуникацию можно разделить на два независимых потока данных: поток заказов и поток товарных остатков. На схеме также отразим, что Stock system хранит свои данные в любой реляционной БД, обращаясь к ней через Connection Pooler.
Перейдем к практическому кейсу. Предположим, что к нам приехала крупная партия товара и резко увеличилась нагрузка в потоке данных об остатках — сразу в 10 раз. В базе данных происходит что-то нехорошее, и на запросы обновления товарных остатков она начинает отвечать дольше обычного.
Эти операции занимают весь пул соединений в Connection pooler: образуется слишком много долгих запросов. Из-за отсутствия свободных соединений в Connection pooler системы, которые отправляют резервы, начинают ждать ответа слишком долго либо вовсе отменяют заказы из-за таймаута. Это происходит потому, что пул занят другими операциями.
Получается, что два потока, которые казались нам независимыми в начале (и которые мы проектировали именно таким образом), стали влиять друг на друга: если на одном из них мы увеличиваем нагрузку кратно, то другой поток данных начинает при этом деградировать, и наоборот.
Самое неприятное то, что мы теряем запросы, часть из них отваливается с ошибкой. Система не обеспечивает предсказуемый уровень сервиса.
Такая система не может считаться отзывчивой.
Любая коммуникация между сервисами связана с выделением ресурсов (будь то соединения, потоки, файловые дескрипторы и так далее).
Продолжая обрабатывать запросы в условиях нехватки ресурсов, «во что бы то ни стало», сервисы только усугубляют свое положение — не обеспечивают консистентное время ответа из-за ожидания свободных ресурсов.
Как решить эту проблему?
SLA как часть контракта
SLA (Service Level Agreement) — соглашение об уровне сервиса, которое должна обеспечивать система или ее компонент. Такое соглашение обычно описывает ряд метрик и их целевые значения (SLO): например, за какое время сервис должен отвечать, какое максимальное количество запросов должен обрабатывать.
SLA как часть контракта означает, что когда мы проектируем сервис и проектируем его контракты (будь то API, спецификации сообщений, Topic в Kafka), SLA описывается сразу вместе с контрактами, не откладывая его в долгий ящик.
Мы описываем SLA на этапе проектирования системы и далее ориентируемся на него при реализации.
Поставь себе дедлайн
Нет никакого смысла бесконечно ждать соединение из пула. Кажется, что такая банальная ситуация может быть решена с помощью тайм-аута, и всё тут.
Однако если операция комплексная, и в рамках неё мы параллельно запускаем другие системные вызовы (например, запрос в другую систему или чтение с диска), то удобно иметь инструмент, который задаст некоторый тайм-аут для всех вложенных операций.
Так как мы используем Go для создания микросервисов, нам здесь помогает пакет context. Каждая операция и все её этапы ограничены общим контекстом с конкретным дедлайном.
Если операция не завершается до этого дедлайна, откатываемся и сообщаем системе-потребителю об ошибке.
Похожий полезный механизм также есть в gRPC.
Устойчивость
Системы должны быть устойчивыми перед отказами.
Изолируем отказы в определенных границах.
Обеспечиваем доступность системы.
Посмотрим на пример создания заказа. Пусть он будет разделен на три шага: резервация товара, создание платежа, а также сохранение модели заказа в базе данных.
Если совсем упростить, это может выглядеть так.
Взаимодействие компонентов здесь без сети, разумеется, не обходится. Значит, где-то может случится тайм-аут, то есть по какой-то причине ответ от сервиса не придет за ожидаемое время.
Даже база данных может отказать, и в итоге мы сломаемся на любом из этих шагов. Еще хуже эту ситуацию делает то, что мы не получим ответ на запрос за заданное время, а значит, не будем знать, был ли запрос доставлен. Похожая ситуация описывается в Задаче двух генералов.
Первое, что приходит в голову, — сделать гарантию доставки сообщений для обеспечения устойчивости. Для этого в Lamoda Tech мы часто обращаемся к нашей внутренней библиотеке — Outboxer, о которой рассказывали в отдельной статье. Часто эту работу за нас делает и брокер сообщений. Главное не забыть гарантированно доставить сообщение до брокера.
Но этого может быть недостаточно. Давайте усложним задачу и представим, что при создании платежа Payment system общается с внешними провайдерами эквайринга А и Б. И иногда случается так, что по некой причине конкретный провайдер оказывается недоступен.
А если в создании заказа участвует не три системы, как в примере выше, а десять? Как обеспечить устойчивость Order System, если ненадежных узлов так много?
Если процесс, который связан с коммуникацией по сети, настолько сложный, нужно убедиться, что отказ одного компонента не приведет к полной остановке создания заказов.
Как решить эту проблему?
На помощь приходит изоляция. Для обеспечения изоляции отказов необходимы границы, в первую очередь в бизнес-процессах. Для этого мы применяем доменно-ориентированное проектирование (DDD).
Основным паттерном DDD является ограниченный контекст.
Выделение ограниченных контекстов приводит к локализации отказов внутри них за счет:
необходимости четко соблюдать границы контекстов и проектировать коммуникацию на основе кооперации, учитывающей обработку отказов;
влияния на коммуникацию команд разработки.
Мы берем нашу замечательную Lamoda, постоянно анализируем все процессы, которые в ней происходят, и далее разделяем их на те самые ограниченные контексты.
Ограниченные контексты взаимодействуют друг с другом через публичные контракты: например, обмениваясь сообщениями через нашу шину Events Bus (не путать с ESB). Именно строго определенные контракты позволяют скрыть приватные данные контекстов, чтобы не размывать их границы, а также инкапсулировать логику обработки сообщений.
Мы проектируем контракты таким образом, чтобы обработка аварий и исключений была явной — в виде отдельных сообщений и обменов, и была заложена в бизнес-процесс.
Вернёмся к нашему примеру с созданием заказа и обработкой аварий от провайдеров эквайринга. Для такой ситуации определим публичное событие в домене платежей, которое будет говорить о недоступности указанного провайдера. Другие контексты и системы, например, Order System, в контексте заказов могут отреагировать на это событие отключением соответствующих методов оплаты до тех пор, пока провайдер не «оживёт».
Вызовы к провайдерам в Payment System будем осуществлять через Circuit breaker — паттерн, который позволит нам избежать бесконечных ошибок в случае, когда внешняя система недоступна после N попыток обратиться к ней.
Таким образом мы решаем сразу несколько проблем:
Изящно обрабатываем аварийную ситуацию на уровне бизнес-процесса: отключаем метод оплаты вместо того, чтобы бесконечно отвечать пользователям ошибкой.
Изолируем аварию провайдера эквайринга внутри контекста платежей.
Уменьшаем связанность Order System с происходящим внутри других контекстов.
Message Driven
Коммуникация в распределенной системе должна быть основана на обмене сообщениями:
Так мы достигнем слабой связанности, автономности и изоляции компонентов.
Обеспечим Location transparency — коммуникацию между компонентами, которая не зависит от топологии системы (локации, адреса, серверы, количества инстансов и прочее).
Кажется, что рецепт счастья здесь прост:
Брокер сообщений решает за нас проблему с изменяемой топологией системы. Нам не нужно хранить адреса других узлов системы, брокер сам доставит сообщение адресату.
Обмен сообщениями часто подразумевает возможность получения сообщения несколькими адресатами. Таким образом, отправляя сообщение, можно не указывать адресата явно. Если еще больше сместиться к Event Driven Architecture и построить коммуникацию вокруг обработки событий, связанность между компонентами будет еще меньшей.
Как всегда есть «но»: этот тоже подход не лишен сложностей. Вновь обратимся к примеру про обработку заказов.
Представим сущность заказа. Ее формирует целый ряд систем: это системы доставки, склада и платежей, контакт-центр и другие. Однако, в распределенной системе это скорее выглядит так:
Каждая система оперирует локальной проекцией модели заказа и обменивается сообщениями об изменениях с другими системами. Если что-то происходит при сборке заказа, склад может опубликовать сообщение о потере позиции и необходимости отмены заказа.
Другие системы реагируют на это сообщение и изменяют не «общее состояние», а свою, локальную, модель заказа. Такое положение особенно актуально для микросервисной архитектуры, где применяется паттерн Database per service.
Предположим, что один из клиентов Lamoda хочет изменить адрес доставки заказа. А мы в этот момент потеряли связь с системой склада и не знаем, был ли уже отгружен и отправлен заказ. Без этой информации мы не можем принять решение, можно ли изменить адрес доставки или нет. Как обработать эту ситуацию?
Давайте вспомним ещё одно важное свойство распределенных систем.
Для пользователя распределенная система выглядит единым целым. Ему все равно, что происходит у вас под капотом.
Пользователю важно получить ответ по запросу, и ему не нужно знать, что именно под капотом сломалось. А нам — нужно. Как нам сохранить иллюзию того, что пользователь взаимодействует с монолитной системой?
Выбор между доступностью и консистентностью
Варианты поведения системы при отсутствии связи между компонентами (нодами) описывает CAP-теорема. Согласно теореме, на выбор можно поддержать только одно из свойств: либо консистентность, либо доступность.
Если мы делаем выбор в пользу консистентности, то модель заказа в наших системах всегда будет актуальной, целостной и непротиворечивой. Но нам придется жертвовать доступностью.
В случае отсутствия связи с системой склада, например, мы не сможем обработать запрос на изменение адреса доставки заказа, ведь нам нужно убедится в том, что заказ ещё не был отгружен. Иначе мы переведем систему в неконсистентное состояние.
При выборе доступности мы обработаем изменение адреса доставки заказа в тех системах, с которыми есть связь. В остальных системах модель заказа останется в неконсистентном состоянии.
Сложности здесь добавляет теорема PACELC: даже если система работает нормально в отсутствии сетевого разделения, приходится выбирать между задержкой (latency) и потерей консистентности.
Однако здесь есть некоторые оговорки.
Во-первых, C в CAP не имеет ничего общего с C из ACID. Под C в CAP следует понимать linearizability:
Если операция B началась после того, как операция А успешно завершилась, тогда операция B должна увидеть систему в том же состоянии, в котором она была после завершения операции А или более актуальном состоянии.
Применим это к нашей ситуации с моделью заказа. Если модель заказа изменилась в одном из компонентов системы, следующая операция в любом другом компоненте системы должна происходить уже с актуальным состоянием заказа. Со стороны это выглядит так, как будто синхронизация модели заказа происходит мгновенно. Это очень «дорогое» требование для распределенных систем и требует больших затрат на синхронизацию (помним о PACELC).
Во-вторых, linearizability (она же strict consistency) является самой строгой моделью консистентности. Однако есть и другие, обеспечить которые проще. О них поговорим ниже.
В-третьих, на деле, реальные системы нельзя дифференцировать по CAP-теореме на CP- и AP-системы, так как в каждой реализации есть свои тонкие компромиссы. Об этом хорошо рассказывает Martin Kleppmann в своей «книге с кабанчиком».
И всё же CAP-теорема может быть полезной, чтобы помнить о фундаментальных ограничениях распределенных систем.
Выбор между доступностью и консистентностью можно определить еще на этапе анализа процессов. В этом помогает Event Storming и его Pivotal events. Поворотные события (pivotal events) — это такие точки бизнес-процесса, которые кардинально меняют ход работы с сущностью. В каждой точке можно делать свой выбор того, что важнее поддерживать в распределенной системе: доступность или консистентность.
В части процессов сильная консистентность не требуется, и мы полагаемся на модель согласованности в конечном счете (eventual consistency).
В других процессах, где важна сильная консистентность, мы применяем паттерн Saga в разных вариантах: с помощью оркестрации или хореографии.
Главное достоинство Saga — простота и понятность протокола.
Однако есть и недостатки. В случае Saga с оркестратором появляется явная точка отказа в виде самого оркестратора, к тому же нужно постоянно следить за энтропией этого сервиса, чтобы он не превратился в большой комок грязи.
Saga, построенная на хореографии, плоха тем, что так как нет единой точки координации, то отследить состояние бизнес-процесса (особенно в случае аварии) становится значительно сложнее.
Эластичность
Система реагирует на изменение нагрузки, управляя своими ресурсами.
Как обеспечить эту характеристику?
Коммуникация
Чтобы обеспечить эластичность на основании коммуникации, можно применить механизм Backpressure. Есть несколько вариантов его реализации. Самый предпочтительный, на мой взгляд — с помощью отправки сообщения оповестить запрашивающую систему о невозможности обрабатывать запросы.
Throttling и Rate limiting также помогают управлять входящей нагрузкой в системе.
Инструменты инфраструктуры
Здесь всё сильно зависит от того, что есть в вашем окружении.
Говоря на примере Kubernetes, основными инструментами для обеспечения эластичности будут лимиты и Autoscaling.
Перейдем к выводам
Reactive Manifesto — полезный документ, который учит нас мыслить в парадигме реактивных систем. Мы прошлись по каждой характеристике таких систем и выделили для себя ключевые способы их достижения.
Но, к сожалению, этот манифест не серебряная пуля. На примерах мы убедились в том, что проектировать такие системы всё ещё сложно, и нужен определенный багаж знаний и опыта, чтобы делать это хорошо. А его нельзя получить только лишь из одного документа.
Чтобы извлечь максимум пользы из Reactive Manifesto, в Lamoda Tech мы сформировали свои гайдлайны и стандарты разработки, опираясь на манифест и на практическое дополнение к нему — Reactive Principles. Это помогает нам распространять экспертизу о проектировании распределенных систем внутри нашей компании, особенно во время стремительного роста.
Делюсь моей шпаргалкой по Reactive Principles выше. А также хочу узнать ваше мнение: будет ли полезно рассказать ещё про System Design на кейсах из моего опыта?
Комментарии (3)
Pro2021
29.03.2024 12:51Здравствуйте.
Возник ряд вопросов:
1) Почему Вы рассматриваете только RDBMS в качестве хранилища?
2) Почему не рассматриваете KVDB в качестве хранилища?
3) Почему не рассматриваете IMDB + backend хранилище? Это бы решило много проблем с масштабированием и CAP.
4) Почему вы рассматриваете работу с RDBMS только в синхронном режиме и не рассматриваете асинхру и bulk? Несмотря на то, что именно из-за этого возникают все ваши проблемы.
5) "Предположим, что к нам приехала крупная партия товара и резко увеличилась нагрузка в потоке данных об остатках — сразу в 10 раз. В базе данных происходит что-то нехорошее, и на запросы обновления товарных остатков она начинает отвечать дольше обычного". Это свидетельствует о том, что структура данных и работа с ними ведутся не корректно и не оптимально. Почему Вы предпочитаете делать все что угодно, вплоть до запуска человека на Марс, только не устраняете эти проблемы?
6) Какой "паттерн" Вы используете для отката распределенных транзакций в случае отказа одной из подсистем? Какой "паттерн" используется для подтверждения распределенных транзакций?
7) Каким образом при масштабировании вы решаете проблемы консистентности? Например, split brain syndrome.
Спасибо.
zlumyo
А не поделитесь, в чём вы рисуете такие симпатичные иллюстрации?
vasilisq Автор
Конечно, это excalidraw)