Удобные Архитектурные Шаблоны

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

Например, Одна из торговых сетей Великобритании просто закрыла сайт онлайн-заказов.

, потому что не хватило мощности.

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

В этой статье я кратко расскажу о популярных практиках, которые позволят создать быстрый и отказоустойчивый сервис.

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

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

Горизонтальное масштабирование Самый простой и известный момент. Условно наиболее распространенными являются две схемы распределения нагрузки – горизонтальное и вертикальное масштабирование.

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

В секунду вы заказываете более мощные сервера или оптимизируете код. Для примера я возьму абстрактное облачное файловое хранилище, то есть некий аналог OwnCloud, OneDrive и так далее.

Стандартное изображение такой схемы приведено ниже, но оно лишь демонстрирует сложность системы.

Ведь нам нужно как-то синхронизировать сервисы.

Что произойдет, если пользователь сохранит файл с планшета, а затем захочет просмотреть его с телефона?

Удобные архитектурные шаблоны

Разница подходов: при вертикальном масштабировании мы готовы увеличивать мощность узлов, а при горизонтальном — добавлять новые узлы для распределения нагрузки.

CQRS Разделение ответственности за командный запрос Достаточно важный паттерн, поскольку позволяет разным клиентам не только подключаться к разным сервисам, но и получать одни и те же потоки событий.

Его преимущества не так очевидны для простого приложения, но они чрезвычайно важны (и просты) для загруженного сервиса.

Ее суть: входящие и исходящие потоки данных не должны пересекаться.

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

Для примера возьмем более-менее стандартную последовательность:

  1. Клиент отправил запрос на сервер.

  2. Сервер начал длительную обработку.

  3. Сервер ответил клиенту с результатом.

Представим, что в пункте 2 разорвалось соединение (или сеть переподключилась, или пользователь перешёл на другую страницу, разорвав соединение).

В этом случае серверу будет сложно отправить пользователю ответ с информацией о том, что именно было обработано.

При использовании CQRS последовательность будет немного другой:

  1. Клиент подписался на обновления.

  2. Клиент отправил запрос на сервер.

  3. Сервер ответил: «Запрос принят».

  4. Сервер ответил результатом по каналу из точки «1».



Удобные архитектурные шаблоны

Как видите, схема немного сложнее.

Более того, здесь отсутствует интуитивный подход «запрос-ответ».

Однако, как видите, разрыв соединения при обработке запроса не приведет к ошибке.

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

Интересно, что код обработки входящих сообщений становится одинаковым (не на 100%) как для событий, на которые повлиял сам клиент, так и для других событий, в том числе от других клиентов.

Однако на самом деле мы получаем дополнительный бонус за счет того, что однонаправленный поток можно обрабатывать в функциональном стиле (с использованием RX и тому подобных).

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

Для жирных программ это позволяет существенно сэкономить ресурсы на разработку и поддержку.

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

Таким образом, клиент может выбрать тот сервис, который ему удобен, а система внутри по-прежнему сможет корректно обрабатывать события.

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

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

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

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

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

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

Здесь есть некоторые послабления из-за уровня изоляции, но общая идея остается той же – можно жить в полностью гармонизированном мире.

Однако вернемся к исходной задаче.

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



Удобные архитектурные шаблоны

Важные особенности этого подхода:

  • Каждый входящий запрос помещается в одну очередь.

  • При обработке запроса сервис также может помещать задачи в другие очереди.

  • Каждое входящее событие имеет идентификатор (необходимый для дедупликации).

  • Очередь идеологически работает по схеме «только добавление».

    Из него нельзя удалять элементы или переставлять их.

  • Очередь работает по схеме FIFO (извините за тавтологию).

    Если вам нужно сделать параллельное выполнение, то на одном этапе вам следует переместить объекты в разные очереди.

Напомню, что мы рассматриваем случай онлайн-хранилища файлов.

В этом случае система будет выглядеть примерно так:

Удобные архитектурные шаблоны

Важно, что службы на схеме не обязательно означают отдельный сервер.

Даже процесс может быть таким же.

Важно другое: идеологически эти вещи разделены таким образом, что можно легко применить горизонтальное масштабирование.

А для двух пользователей схема будет выглядеть так (разным цветом обозначены сервисы, предназначенные для разных пользователей):

Удобные архитектурные шаблоны

Бонусы от такой комбинации:

  • Службы обработки информации разделены.

    Очереди также разделены.

    Если нам нужно увеличить пропускную способность системы, то нам просто нужно запустить больше сервисов на большем количестве серверов.

  • Когда мы получаем информацию от пользователя, нам не нужно ждать полного сохранения данных.

    Наоборот, нам нужно просто ответить «ок» и потом постепенно начинать работать.

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

  • В качестве примера я добавил службу дедупликации, которая пытается объединить идентичные файлы.

    Если в 1% случаев это работает долго, клиент этого почти не заметит (см.

    выше), что является большим плюсом, поскольку от нас больше не требуется быть на 100% быстрыми и надежными.

Однако сразу видны недостатки:
  • Наша система утратила строгую последовательность.

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

    Еще одним следствием является отсутствие общего времени в системе.

    То есть невозможно, например, отсортировать все события просто по времени прихода, так как часы между серверами могут быть не синхронны (причём одинаковое время на двух серверах — это утопия).

  • Никакие события теперь нельзя просто откатить (как это можно сделать с базой данных).

    Вместо этого вам нужно добавить новое событие – компенсационное событие , что изменит последнее состояние на необходимое.

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

    Однако и ошибочная фиксация, и откат останутся в истории.

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

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

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

Формально это не означает, что данные уже доступны на других устройствах (например, сервис дедупликации может перестроить индекс).

Однако через некоторое время клиент получит уведомление в стиле «файл X сохранен».

Как результат:

  • Увеличивается количество статусов отправки файлов: вместо классического «файл отправлен» мы получаем два: «файл добавлен в очередь на сервере» и «файл сохранен в хранилище».

    Последнее означает, что другие устройства уже могут начать получать файл (с поправкой на то, что очереди работают с разной скоростью).

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

    Как следствие этого: в отличие от классического запроса-ответа, клиент может быть перезапущен во время обработки файла, но сам статус этой обработки будет корректным.

    Более того, этот пункт работает, по сути, «из коробки».

    Как следствие: мы теперь более терпимы к неудачам.

Шардинг Как описано выше, системам источников событий не хватает строгой последовательности.

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

Подойдя к нашей проблеме, мы можем:

  • Разделяйте файлы по типам.

    Например, можно декодировать изображения/видео и выбрать более эффективный формат.

  • Отдельные аккаунты по странам.

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



Удобные архитектурные шаблоны

Если вы хотите перенести данные из одного хранилища в другое, то стандартных средств уже недостаточно.

К сожалению, в этом случае вам нужно остановить очередь, сделать миграцию, а затем запустить ее.

В общем случае данные не могут передаваться «на лету», однако, если очередь событий хранится полностью, и у вас есть снимки предыдущих состояний хранения, то мы можем воспроизвести события следующим образом:

  • В Event Source каждое событие имеет свой идентификатор (в идеале неубывающий).

    Это значит, что мы можем добавить в хранилище поле — id последнего обработанного элемента.

  • Дублируем очередь, чтобы все события можно было обрабатывать для нескольких независимых хранилищ (первое — то, в котором уже хранятся данные, а второе — новое, но еще пустое).

    Вторая очередь, конечно, пока не обрабатывается.

  • Запускаем вторую очередь (то есть начинаем воспроизведение событий).

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

Как видите, у нас не было и нет строгой последовательности в нашей системе.

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

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

Таким образом, продолжая наш пример об онлайн-хранилище файлов, такая архитектура уже дает нам ряд бонусов:

  • Мы можем динамически перемещать объекты ближе к пользователям.

    Таким образом вы сможете улучшить качество обслуживания.

  • Мы можем хранить некоторые данные внутри компаний.

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

    С помощью шардинга мы можем легко это поддержать.

    А задача еще проще, если у заказчика есть совместимое облако (например, Самостоятельное размещение Azure ).

  • И самое главное, что нам не придется этого делать.

    Ведь для начала нас бы вполне устроило одно хранилище для всех аккаунтов (чтобы быстро начать работу).

    И ключевая особенность этой системы в том, что она хоть и расширяема, но на начальном этапе достаточно проста.

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

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

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

В результате эти операции выполняются быстрее (условный nginx обслуживает файлы быстрее и дешевле, чем Java-сервер).

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

Самый простой и стандартный пример статического контента — набор скриптов и изображений для сайта.

С ними все просто — они известны заранее, затем архив загружается на CDN-серверы, откуда раздается конечным пользователям.

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

Вернёмся к нашей задаче (онлайн-хранилище файлов), в которой нам нужно раздавать файлы пользователям.

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

Основным недостатком такого подхода является то, что статический контент (а файл с определенной ревизией по сути является статическим контентом) распространяется тем же сервером, на котором находится бизнес-логика.

Вместо этого вы можете сделать следующую диаграмму:

  • Сервер предоставляет URL-адрес для загрузки.

    Он может иметь вид file_id + ключ, где ключ — это мини-цифровая подпись, дающая право доступа к ресурсу в течение следующих 24 часов.

  • Файл распространяется простым nginx со следующими параметрами:
    • Кэширование контента.

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

    • Проверка ключа в момент создания соединения
  • Дополнительно: потоковая обработка контента.

    Например, если мы сжимаем все файлы в сервисе, то разархивацию мы можем сделать прямо в этом модуле.

    Как следствие: операции ввода-вывода выполняются там, где им и место.

    Архиватор на Java легко выделит много дополнительной памяти, но переписывание сервиса с бизнес-логикой в условные выражения Rust/C++ также может оказаться неэффективным.

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



Удобные архитектурные шаблоны

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

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

Еще пример (для подкрепления): если вы работали с Jenkins/TeamCity, то знаете, что оба решения написаны на Java. Оба они представляют собой процесс Java, который обеспечивает как оркестрацию сборки, так и управление контентом.

В частности, у них обоих есть задачи типа «перенести файл/папку с сервера».

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

Все эти задачи различаются по нагрузке ввода-вывода.

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

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

Однако, если мы вернемся к нашей системе, мы получим аналогичную диаграмму:

Удобные архитектурные шаблоны

Как видите, система стала радикально сложнее.

Теперь это не просто мини-процесс, хранящий файлы локально.

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

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

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

Как я сказал в самом начале, сейчас ряд интернет-сервисов начал получать повышенную нагрузку.

А некоторые из них просто начали переставать корректно работать.

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

То есть вместо отсрочки доставки, вместо предложения клиентам «запланировать доставку на ближайшие месяцы», система просто сказала «идите к конкурентам».

Фактически, это цена низкой производительности: убытки возникнут именно тогда, когда прибыль будет самой высокой.

Заключение Все эти подходы были известны ранее.

Тот же ВК уже давно использует идею хостинга статического контента для отображения изображений.

Многие онлайн-игры используют схему шардинга для разделения игроков на регионы или для разделения игровых локаций (если сам мир один).

Подход Event Sourcing активно используется в электронной почте.

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

Что ж, горизонтальное масштабирование уже довольно давно используется во многих сервисах.

Однако самое главное — все эти паттерны стало очень легко применять в современных приложениях (если они, конечно, уместны).

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

CQRS стал намного проще, хотя бы благодаря развитию таких библиотек, как RX. Около 10 лет назад редкий веб-сайт мог это поддержать.

Event Sourcing также невероятно легко настроить благодаря готовым контейнерам с Apache Kafka. 10 лет назад это было бы новшеством, сейчас это обычное дело.

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

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

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

И самое главное: пожалуйста, не используйте эти подходы, если у вас простое приложение.

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

Теги: #облачные сервисы #Инженерные системы #архитектура #Высокая производительность #Анализ и проектирование систем #облако

Вместе с данным постом часто просматривают: