Архитектуры на акторах: простые системы
Архитектуры на акторах:
Вступление (Actors / Objects; Complexity; When to Distribute; Kinds of Actors)
Монолиты (Control/Data Flow; Reactor / Proactor / Half-Sync/Half-Async; Request State)
Простые системы (Notation for Diagrams; Sharding; Layers; Services / Pipeline)
Системы с моделью (Hexagonal / MVC; Blackboard / Message Bus; Microkernel / Plug-ins)
Фрагментированные системы (Mesh; SOA; Hierarchy; Design Space)
What is not remembered never existed
(S. E. Lain)
В прошлой части посмотрели обработку событий внутри актора. Сейчас пробуем сложить несколько акторов вместе (или разрезать один актор на куски).
Нотация
Мы будем рисовать структурные диаграммы (какие компоненты существуют и как общаются в рантайме). По вертикали откладываем приближение логики к высокоуровневым бизнес-задачам (например, сверху — пользовательский код на Питоне, снизу — драйвера на ассемблере и железо), по горизонтали — доменные компоненты (например, для ОСи на разных частях оси х будут отображены диск, сеть, видяшка и звуковуха — в произвольном порядке), третье измерение — стопки шардов (одинаковых модулей). При этом иногда разбивку по горизонтали придется менять — например, на уровне ОС у нас будет разбивка по физический устройствам, а на верхнем уровне логики — по доменным сущностям. Горизонталь одна, а распределений нужно показать несколько.
Примерно такие диаграммы распространены для операционных систем и эмбеддеда, но я не видел в литературе явного указания значения осей.
Для каждой принципиально отличной (в описанных выше координатах) структурной диаграммы подумаем, как и зачем можно было бы такое чудо применить. А также — попробуем добавить краткое описание, как это в жизни работает, а когда наблюдаются существенно различающиеся имплементации данной архитектуры — рассмотрим варианты в деталях.
Мы не ограничиваемся строго акторами — иногда есть смысл ослабить модель, чтобы включить в рассмотрение похожие системы с синхронными взаимодействиями, если у них сильно выражена модульность. Тем более, что RPC, блокирующий у вызывающей стороны, реактивно обрабатывается принимающей стороной. То есть, для принимающей — является сообщением, а сама принимающая сторона вдруг оказывается актором, питающимся входящими событиями.
Шардинг
Нарезка одинаковыми частями (как у батона), между кусочками ничего нет — они не общаются. Поднимаем несколько одинаковых монолитов (инстансов, шардов), на которые раздаются запросы.
Почему сложно делать общение между шардами? Из-за конкурентного доступа к данным. На рисунке видно петлю, когда нулевой шард пытается что-то изменить в первом. Если первый в это же время начнет менять данные в нулевом — мы наступим на хвост САР теореме [DDIA]. Получаем распределенный race condition, а их лучше обходить стороной. Проблема возникает большей частью, когда в control- / data- flow есть петли или противонаправленные потоки (условие дедлока). Если мы хотим относительно легко создать систему, которую можно будет годами развивать — грабли надо обходить десятой дорогой. Race conditions — как раз грабли.
А вот если мы движемся по системе в одном направлении (Pipeline [POSA1]) — ок, здесь нет конкурентных обновлений, приносящих данные из будущего. Если мы храним данные вместе (Layers [POSA1]), и временами дергаем периферию чтобы у нее что-то выяснить — тоже норм, хоть и не супер быстро работает. Раз мы владеем всеми данными, то можем их защитить, или все сразу обновить. Консистентность состояния системы и снепшотов не страдает.
Конечно, идеальный случай — когда вообще ничего делать не надо. Зеленые стрелочки на картинках приближаются к идеалу — мы быстро обрабатываем событие, потому что можем (сами знаем, как правильно, и никого не нужно спрашивать).
Плюсы (кроме указанных для монолита):
- Можно масштабироваться до безобразия — сколько нагрузки, столько инстансов программы подняли. Если, конечно, она быстро поднимается. Ну или склонировали снепшот виртуалки.
- Можно позволить себе делать синхронные вызовы, что упростит код. В таком варианте (Reactor [POSA1]) каждый актор (процесс) обрабатывает один запрос от начала до конца — не проблема, так как акторов плодим сколько хотим.
- Отказоустойчивость — падение одного инстанса ничему не повредит. При условии, что его уронил не воспроизводимый баг в коде, и что мы не перепосылаем тот же запрос по кругу (как достойный метод положить все, что шевелится).
- Возможность канареечного деплоя — можно поднять один инстанс новой версии, оставить на день работать, и если не упадет и выдает правильные результаты — обновить остальные инстансы.
Минусы (кроме указанных для монолита):
- Неудобно шарить данные между шардами. Обычно шарды вообще не знают друг о друге (но смотрим ниже исключение — Leader/Followers). Не проблема для stateless шардов.
- Кто-то должен распределять шардам запросы. Вот он может навернуть все всерьез и надолго (единая точка отказа, но тоже смотрим ниже).
- Требует дополнительных усилий по администрированию (поднять и настроить шарды).
Итого: относительно бесплатная возможность масштабирования по всем параметрам, но она применима, только когда входящие запросы можно разделить на независимые группы (не работают с общим состоянием). Пример: файлохранилище, где у каждого пользователя его собственная папка. Идентификатор папки указывает, какой шард обработает запрос. При этом код остается таким же удобным, как в монолите. И с теми же проблемами при длительном росте проекта.
Общие названия: Instances.
Классика: Pooling [POSA3].
Бэкенд: Sharding, CGI/fCGI, FaaS.
Архитектура сходна с многопоточным реактором, только вместо потоков — независимые акторы (сервисы). Собственно, это различие и обусловило перечисленные плюсы и минусы.
В литературе описаны три варианта того, как распределяется работа между инстансами (но на самом деле нет):
Создание по запросу (эластичные инстансы)
Мы подозреваем, что инстансов будет много, но не знаем, сколько (инстанс обслуживает одного пользователя в многопользовательской системе). Или есть возможность размещать инстанс на пользовательском устройстве. В результате, шарды создаются при установлении соединения с пользователем, и удаляются при завершении соединения.
Примеры: фронтенд, объекты звонков на сервере телефонии, прокси для пользователей в мультиплеере. А также — наш реактор на корутинах, рассмотренный в прошлый раз (корутины как инстансы). Многопоточный stateless реактор тоже можно отнести сюда, считая инстансами акторов сами потоки — как обычно с архитектурой, все выворачивается любым удобным образом.
Leader / Followers [POSA2] (самоуправляемые инстансы)
Инстансы заранее подняты и объединены в список. Один инстанс (Leader) ждет, пока из сокета выпадет какашка. Другие инстансы ждут пинка. Когда какашка выпадает, лидер дает пинка следующему инстансу, и начинает обрабатывать свой запрос. Следующий инстанс, получивший пинка, садится на сокет ждать какашку, становясь лидером. Когда любой инстанс закончил со своим запросом — он становится в конец очереди на достижение лидерства.
Типа, круто, но не масштабируется на несколько серверов. Если проблемы в железе — бекенд мертв.
Load Balancer aka Dispatcher [POSA1] (внешнее распределение задач)
Кто-то внешний (nginx) распределяет запросы между заранее созданными инстансами. Все хорошо, можно раскидать инстансы по разным серверам. Но оно хорошо только до тех пор, пока живой балансировщик.
На самом деле, это уже слоистая система (следующий раздел): диспатчер и сервисы находятся на разных уровнях абстракции: диспатчер занимается, в основном, связью (байты, протоколы и железо), а сервисы — логикой.
Смеси
И тут не обошлось без комбинаций.
- Load Balancer распределяет запросы между серверами, на каждом из которых бежит система Leader/Followers. Позволяет полностью загрузить несколько серверов.
- Все инстансы на сервере ждут на одном объекте операционной системы или фреймворка. Когда приходит сообщение, оно достается только одному из них. В этом случае Load Balancer спрятан где-то в кишках фреймворка или ОС.
Слои
Нарезка на коржи, между ними — мягкий коричневый крем (асинхронный интерфейс). Режем монолит по высокоуровневости кода. Верхний слой командует солдатиками, нижний — отрисовывает их движения.
Плюсы:
- Не теряется связность на каждом уровне абстракции: один солдатик может синхронно говорить с другим. Довольно простой дебаг в рамках одного уровня.
- Отвязка высокоуровневого кода от низкоуровневого по логике: ИИ для солдатика пофиг, как его будут рендерить. И зависимость от рендерера (теоретически) не может протечь через асинхронный интерфейс в слой ИИ, с которым нет общих данных. Код стал понятнее (в идеальном мире).
- Отвязка высокоуровневой и низкоуровневой частей по свойствам (силам): солдатик может секунду размышлять над планом действий, а рендерер отрисовывает картинку каждые 15 мс.
- Параллельное выполнение всех частей (когда хватает ядер проца).
- Слои можно по-разному шардить, если на один из них больше нагрузка, и он не имеет значимого для логики системы состояния.
- Слои можно деплоить по-отдельности, если постараться (обновления в ОС как реальный пример).
- Более простая и быстрая обработка событий, затрагивающих только один слой: рендерер рендерит последнее, что прислали, не обращаясь напрямую к полной модели мира и к солдатикам.
Минусы:
- Очень плохо описывать и отлаживать сценарии, у которых логика или данные размазаны между слоями.
- Теряется скорость реакции или передачи данных по вертикали (когда для правильной реакции на событие нужно вовлечь несколько слоев).
- При росте проекта какой-то (вероятно — верхний) слой все равно станет слишком жирным — от роста сложности слои надолго не спасут.
Итого: имеем довольно дешевое «разделение труда» с энкапсуляцией для случаев, когда код может быть запутан на каждом уровне абстракции, но сами уровни относительно независимы (как логика игры и рендерер). Используется повсеместно (идея — простая, накосячить — сложно).
Общие названия: Layers [POSA1].
Бэкенд: n-Tier.
И снова есть пара вариантов, хотя вторые неочевидны):
Многоуровневая система
Обычно под слоистой системой понимают разбиение по уровню абстракции бизнес-логики. Например, для
Благодаря четким интерфейсам данная архитектура одинаково хорошо работает для объектов и акторов, и позволяет их смешивать: например, фронтенд и бэкенд могут общаться не блокируясь (как акторы), а обращение бек-энда к базе будет блокироваться (RPC как у объектов). Или наоборот.
Та же
На картинке мы пропустили load balancer так как он маленький (не несет особой бизнес-логики) и непонятно, куда его пихнуть: над бэкендом чтобы проще рисовать стрелочки, или в самый низ как слой без бизнес-логики. В дальнейшем также будем опускать детали — иначе в диаграммах нереально разбираться. Too much information is no information.
Собственно с акторами слои используются в эмбеддеде для разделения на «головной мозг» и «спинной мозг» (часто даже с разнесением на разные процессоры). Головной мозг отвечает за целеполагание, спинной — управляет железом. Или на MMI (общение с пользователем), SDK (логика среднего уровня) и FW (железозависимый код).
Пул обработчиков
Часто используется передача вычислительно тяжелых задач из основной логики в служебные потоки, которые:
- Могут сразу занять все свободные ядра процессора
- Могут бежать с более низким приоритетом, не мешая основному потоку
- Делают работу параллельно до победного конца, и асинхронно сообщают о результате вычислений
- Берутся за любую работу, не обладая собственным статическим состоянием или логикой
По сути — это одновременное применение слоев и шардинга. Управляющий слой с бизнес-логикой решает, что делать, а обработчики — служебные потоки — только распределяют выданную сверху задачу по свободному железу.
Пул навыворот
Мы уже рассмотрели корутины в разделе реакторов, но на самом деле (с одной из возможных точек зрения при одном из возможных уровней детализации) реактор на корутинах принадлежит к слоистым системам (на что указывает классическое название архитектуры: Half-Sync/Half-Async [POSA2]). Она позаимствована из операционных систем, где Sync layer — это пользовательские процессы, создаваемые юзером по желанию, а Async layer — само ядро ОС со стабильными структурой и потреблением ресурсов.
Бекенд с Load Balancer из шардинга (выше в тексте) — тоже пул навыворот: маленький низкоуровневый кусочек может по надобности создавать и уничтожать компонеты (CGI), содержащие всю бизнес-логику системы, и распределяет нагрузку между уже существующими шардами.
Еще схема напоминает движок оркестраторов [MP], но это — уже отклонение от нашей темы.
Сервисы
Нарезка кусочками (порционный торт), переложенными грязной бумагой (асинхронный интерфейс). Разрезали монолит по границам между субдоменами в надежде, что сценарии обычно крутятся внутри одного субдомена и редко перепрыгивают между субдоменами.
Плюсы:
- Есть шанс в несколько раз уменьшить сложность кода на проекте — если кусков получится с десяток.
- Удобно писать и отлаживать код внутри субдомена, даже когда высокоуровневая логика мешается с низкоуровневой.
- Отвязка субдоменов по логике (модульность); зависимостям тяжело пересекать асинхронный интерфейс.
- Субдомены отвязаны по свойствам: в одном можно имплементить реал-тайм сценарии, в другом — медленные.
- Параллельное выполнение кода во всех субдоменах.
- Возможность шардить субдомены независимо, если на них разная нагрузка и нет связки по состоянию.
- Возможность деплоить код для субдоменов независимо.
- Быстро работают сценарии, не пересекающие границу между субдоменами.
Минусы:
- Очень плохо описывать и отлаживать сценарии, у которых логика или данные размазаны между субдоменами.
- Если несколько сервисов зависят от одних данных, может потребоваться хранить и синхронизировать копии таких данных в каждом сервисе.
- Теряется скорость реакции или передачи данных по горизонтали (когда в обработку события или информации вовлечены несколько сервисов).
- Требуются усилия по администрированию сервисов и инфраструктуры.
Итого: позитивно, если в процессе обработки данных или событий можно выделить относительно независимые блоки. Иначе — возрастание сложности (и тормознутости) сценариев из-за асинхронных интерфейсов сможет окупиться только при очень большом и запутанном проекте — когда без хоть какого-то разделения разработка просто остановилась по причине размера и сложности кода, а разбивка на модули не помогает (Monolith Hell [MP]).
Другие разделения не дают возможности справиться с проектом такого размера: шардинг вообще не упрощает код (в каждом шарде сразу весь проект), а в слоистой системе обычно
Классика: Actors (telecom), Pipes and Filters [POSA1].
Бэкенд: Microservices [MP], Pipelines [DDIA].
Как обычно, варианты:
Акторы / Микросервисы (разделение по доменным областям)
Если наш домен делится на несколько субдоменов, мы можем хотеть надеяться, что бизнес-логика внутри каждого субдомена более связна, чем между субдоменами. То есть, бухгалтерии не надо знать, что творится внутри ХР — им достаточно уметь спросить должность, дату выхода на работу, и дату увольнения. Код каждого субдомена / сервиса не зависит от других, может разрабатываться отдельной командой и жить в своем репозитории. Запросы внутри субдомена (увольнение сотрудника из-за performance review у ХР, или TCP пакет с данными у сетевого драйвера) могут обрабатываться очень быстро и эффективно, пока они не требуют сложного взаимодействия с другими субдоменами (акторами). Например, ХР может сообщить бухгалтерии, что сотрудника уволили. Это простое взаимодействие: fire and forget, результат события никак изменить нельзя, обратной связи никто не ждет. А вот если у ХР появляется метрика, по которой нужно повысить зарплату не более чем 10% программистов, и при этом не выйти за бюджет $5000/mo, то начинается сложный обмен бумагами с бухгалтерией (ХР не помнит все зарплаты), причем оба отдела в это время заняты более важными делами, и в результате все так запутается и затянется, что зарплату никому не повысят. Такое взаимодействие слишком сложное для данной модели (независимые отделы).
Доменные акторы/микросервисы могут использоваться как для data flow (энтерпрайз) так и для control flow (телеком). Они идеальны, когда большинство сценариев обработки данных/событий решаются локально в пределах субдомена, чуть хуже — когда нужно еще рассылать нотификации соседям. В принципе, можно переправлять сообщение по цепочке кому-то умному.
А вот когда нам для какого-либо решения нужно собрать информацию от нескольких сервисов — начинается трэш, угар, и вьюшки [DDIA] с оркестраторами и хореографией [MP] (потому что все общение асинхронно, и пока мы готовим бумаги, чтобы уволить сотрудника за несоответствие политике партии, он сам находит новую работу с повышением. И что теперь делать с бумагами?). Дебажить такую систему нереально — каждый актор живет своим потоком, и сценарий будет состоять из кучи кусков, запускающихся в разных местах в разное время, а возможно — даже из разных репозиториев. А еще — распределенные сценарии будут адово тормозить. Если честно, умные люди называют такой случай «распределенный монолит» [MP] и рекомендуют вначале сделать и довести до ума обычный монолит [MP], потом — посмотреть, где в реальности проходят границы между субдоменами, и какие из них реально (а не в нашем представлении исходя из документации) мало взаимодействуют друг с другом [DDD], и только затем — резать этот монолит на микросервисы вот по вот этим вот самым местам слабой связности [MP].
Pipeline (pipes and filters)
Частный случай предыдущей системы, узкоспециализированный, но очень распространенный. Если у обычных доменных акторов ((микро-)сервисов) возможны взаимодействия всех со всеми, то пайплайн относительно линейный: каждый актор (их здесь любят называть «фильтры») получает из входящей трубы пакет данных, обрабатывает как хочет, и нотифицирует переработанным пакетом следующий фильтр (отправляет обработанные данные в исходящую трубу). Данные текут в одном направлении. Фильтры стараются делать простыми и отвечающими за один определенный логический шаг. Их можно независимо разрабатывать, деплоить, отлаживать, улучшать [DDIA]. Можно записать данные, которые проходили когда-то в пайплайне, а потом их скармливать разным пробным версиям одного фильтра, и сравнивать результаты и скорость обработки [DDIA]. Иногда пайплайн может ветвиться, крайне редко — менять форму в рантайме. Сам пайплайн обычно собирается фабрикой на старте, вероятно — исходя из конфиг файла.
Так как у фильтров одинаковый интерфейс (может отличаться тип данных), и они вообще ничего не знают друг о друге — то они полностью независимы (недостижимый идеал микросервисов).
Исключительно data flow вариант. Но если он подходит под требования — часто можно дальше не сильно думать. Кроме случаев, когда важна скорость ответа (приборы ночного зрения, очки виртуальной реальности, роботехника), а не пропускная способность.
Плюсы:
- исключительная простота разработки и поддержки.
- фильтры работают параллельно, загружая все свободные ядра процессора.
Минусы:
- нужно отслеживать, чтобы ни на каком этапе не скопилось слишком много данных, если один из фильтров тормозит. То есть — появляется потребность в обратной связи. В результате моська превратится в слона, если задача была слишком сложной для решения «в лоб» через pipeline.
- возможно избыточное копирование данных, и каждый пакет данных в каждый момент обрабатывается только одним ядром проца. Значит — монолит быстрее проработает каждый конкретный пакет, особенно если там применили service thread pool и разбивку пакета на части для вычислений на всех ядрах одновременно. С другой стороны — пайплайн эффективно распределяет нагрузку по доступному железу вообще без усилий или синхронизации в коде.
Если фильтры обрабатывают данные с разной скоростью, и данных много — нужен фидбек (congestion control), чтобы не насыпать в трубу настолько много отходов, что она захлебнется от недостатка памяти для хранения скопившихся промежуточных данных. Для этого можно дать фильтрам возможность отсылать в обратном направлении подтверждение на каждый пакет, или сообщения о том, сколько в очереди лежит пакетов, или номер пакета, который сейчас в работе. Получится интересная система, у которой пакеты данных (data flow) и пакеты управления (control flow) бегут в противоположных направлениях. Либо можно добавить надзирателя, собирающего статистику и устанавливающего скорость или качество / битрейт (звук, картинки, видео) входящего потока. Либо можно логику контроля скорости зашить в трубы.
Сравнение с пространством масштабирования
В литературе [MP] встречается scale cube как координаты масштабирования систем по производительности:
- Клонирование монолита (у нас — шардинг бекенда)
- Разделение по функциональности (микросервисы)
- Data partitioning (у нас — шардинг data layer)
Наши измерения эволюционирования (evolvability) систем как уменьшение связности:
- Разделение по уровню абстракции логики
- Разделение по субдоменам
- Шардинг
Если сравнить, то два наших измерения (по абстрактности и по субдоменам) соответствуют одному в data cube (по функциональности), а два из data cube (cloning, partitioning) — одному нашему (шардинг).
Кабанчик [DDIA] приводит 2 измерения масштабирования: scaling up (увеличение мощности процессора, рамы и диска) vs scaling out (распределение данных по нескольким машинам).
Вывод? Похоже, эти системы координат напрямую не связаны. Просто, больше чем 3 измерения в голове тяжело поместить. Хотя, какой-то вариант шардинга присутствует у всех.
Итого:
Мы научились превращать шарлотку в:
- нарезной батон (шардинг),
- слоеный пирог,
- тортик (сервисы) или
- сосиски (пайплайн).
В следующей части попробуем комбинировать эти варианты нарезки, чтобы собрать вишенки (плюсы архитектуры) и не замазаться кремом (асинхронными результатами жизнедеятельности).
Если предложите примеры хорошо описанных в интернете систем (когда понятно, как оно работает, и какие там есть модули / сервисы), — попробую их проанализировать в пятой части.
Литература
[DDD] Domain-Driven Design: Tackling Complexity in the Heart of Software. Eric Evans. Addison-Wesley (2003).
[DDIA] Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems. Martin Kleppmann. O’Reilly Media, Inc. (2017).
[EIP] Enterprise Integration Patterns. Gregor Hohpe and Bobby Woolf. Addison-Wesley (2003).
[MP] Microservices Patterns: With Examples in Java. Chris Richardson. Manning Publications (2018).
[POSA1] Pattern-Oriented Software Architecture Volume 1: A System of Patterns. Frank Buschmann, Regine Meunier, Hans Rohnert, Peter Sommerlad and Michael Stal. John Wiley & Sons, Inc. (1996).
[POSA2] Pattern-Oriented Software Architecture Volume 2: Patterns for Concurrent and Networked Objects. Douglas C. Schmidt, Michael Stal, Hans Rohnert, Frank Buschmann. John Wiley & Sons, Inc. (2000).
[POSA3] Pattern-Oriented Software Architecture Volume 3: Patterns for Resource Management. Michael Kircher, Prashant Jain. John Wiley & Sons, Inc. (2004).
Архитектуры на акторах:
Вступление (Actors / Objects; Complexity; When to Distribute; Kinds of Actors)
Монолиты (Control/Data Flow; Reactor / Proactor / Half-Sync/Half-Async; Request State)
Простые системы (Notation for Diagrams; Sharding; Layers; Services / Pipeline)
Системы с моделью (Hexagonal / MVC; Blackboard / Message Bus; Microkernel / Plug-ins)
Фрагментированные системы (Mesh; SOA; Hierarchy; Design Space)
55 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів