Зручні архітектурні патерни

Привіт, Хабре!

У світлі поточних подій через коронавірус ряд інтернет-сервісів став отримувати збільшене навантаження. Наприклад, одна з торгових мереж у Великій Британії просто зупинила сайт із онлайн-замовленнями, тому що не вистачило потужностей. І далеко не завжди можна прискорити сервер, просто додавши потужніше обладнання, проте запити клієнтів обробляти треба (або вони підуть до конкурентів).

У цій статті я коротко розповім про популярні практики, які дозволять зробити швидкий та стійкий до відмови сервіс. Однак із можливих схем розробки я відібрав лише ті, якими зараз легко скористатися. Для кожного пункту у вас є вже готові бібліотеки, або є можливість вирішити завдання за допомогою хмарної платформи.

Горизонтальне масштабування

Найпростіший і найвідоміший пункт. Найчастіше зустрічаються дві схеми розподілу навантаження — горизонтальне і вертикальне масштабування. В першому випадку ви дозволяєте сервісам працювати паралельно, тим самим розподіляючи навантаження між ними. У другому ви замовляєте потужніші сервери або оптимізуєте код.

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

Стандартна картинка подібної схеми нижча, проте вона лише демонструє складність системи. Адже нам потрібно якось синхронізувати послуги. Що буде, якщо з планшета користувач зберіг файл, а потім хоче подивитися його з телефону?

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

CQRS

Розділення відповідальності командного запиту досить важливий патерн, оскільки він дозволяє різним клієнтам як підключатися до різних сервісів, а й отримувати однакові потоки подій. Його бонуси не настільки очевидні для простого додатку, проте він украй важливий (і простий) для навантаженого сервісу. Його суть: вхідні та вихідні потоки даних не повинні перетинатися. Тобто ви не можете надіслати запит і чекати на відповідь, натомість ви відправляєте запит до сервісу A, проте отримуєте відповідь у сервісі Б.

Першим бонусом цього підходу є можливість розриву з'єднання (у широкому сенсі цього слова) у процесі виконання довгого запиту. Наприклад візьмемо більш-менш стандартну послідовність:

  1. Клієнт надіслав запит на сервер.
  2. Сервер запустив довгу обробку.
  3. Сервер відповів клієнту результатом.

Припустимо, що в пункті 2 стався обрив зв'язку (або мережа перепідключилася, або користувач перейшов на іншу сторінку, обірвавши з'єднання). У цьому випадку серверу буде складно надіслати відповідь користувачу з інформацією, що саме обробилося. Застосовуючи CQRS, послідовність буде трохи іншою:

  1. Клієнт підписався на поновлення.
  2. Клієнт надіслав запит на сервер.
  3. Сервер відповів "запит прийнятий".
  4. Сервер відповів результатом через канал із пункту «1».

Зручні архітектурні патерни

Як видно, схема трохи складніша. Більше того, інтуїтивний підхід request-response тут відсутній. Однак, як видно, обрив зв'язку в процесі обробки запиту не приведе до помилки. Більше того, якщо насправді користувач підключено до сервісу з декількох пристроїв (наприклад, з мобільного телефону та планшета), можна зробити так, щоб відповідь приходила на обидва пристрої.

Що цікаво, код обробки вхідних повідомлень стає однаковим (не на 100%) як для подій, на які вплинув сам клієнт, так і для інших подій, у тому числі від інших клієнтів.

Однак насправді ми отримуємо додаткові бонуси у зв'язку з тим, що односпрямований потік можна обробляти у функціональному стилі (використовуючи RX та аналоги). І ось це вже серйозний плюс, так як насправді додаток можна зробити повністю реактивним, причому ще із застосуванням функціонального підходу. Для жирних програм це може суттєво заощадити ресурси на розробку та підтримку.

Якщо об'єднати цей підхід із горизонтальним масштабуванням, то бонусом ми маємо можливість надсилати запити на один сервер, а отримувати відповіді від іншого. Таким чином, клієнт може сам вибирати зручний для нього сервіс, а система всередині все одно зможе коректно обробити події.

Пошук подій

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

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

Важливо розуміти, що для класичних баз даних часто застосовується сувора узгодженість, де кожен вузол має однакову інформацію (подібне часто досягається у разі, коли транзакція вважається встановленою лише після відповіді другого сервера). Тут є деякі послаблення через рівні ізоляції, проте загальна суть залишається такою самою — ви можете жити у цілком узгодженому світі.

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

Зручні архітектурні патерни

Важливі особливості цього підходу:

  • Кожен вхідний запит міститься в одну чергу.
  • У процесі обробки запиту сервіс може також поміщати завдання до інших черг.
  • Кожна вхідна подія має ідентифікатор (який необхідний для дедуплікації).
  • Черга ідеологічно працює за схемою "append only". З неї не можна видаляти елементи або переставляти їх.
  • Черга працює за схемою FIFO (вибачте за тавтологію). Якщо необхідно зробити паралельне виконання, слід в одному з етапів перекладати об'єкти в різні черги.

Нагадаю, що ми розглядаємо випадок онлайн-файлового сховища. У цьому випадку система виглядатиме приблизно так:

Зручні архітектурні патерни

Важливо, що послуги на діаграмі не обов'язково означають окремий сервер. Навіть процес може бути той самий. Важливо інше: ідеологічно ці речі розділені таким чином, щоб легко було застосувати горизонтальне масштабування.

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

Зручні архітектурні патерни

Бонуси від подібної комбінації:

  • Сервіси обробки інформації поділені. Черги також поділені. Якщо нам потрібно збільшити пропускну здатність системи, то треба лише запустити більше сервісів на більшій кількості серверів.
  • Коли ми отримуємо інформацію від користувача, нам не обов'язково чекати на повне збереження даних. Навпаки, нам достатньо відповісти "ок", а потім поступово розпочати роботу. Заодно чергу згладжує піки, тому що додавання нового об'єкта відбувається швидко, а повного проходу по всьому циклу користувачеві чекати не обов'язково.
  • Наприклад, я додав сервіс дедуплікації, який намагається об'єднувати однакові файли. Якщо він довго працює в 1% випадків, клієнт цього практично не помітить (див. вище), що є великим плюсом, тому що від нас вже не потрібна стовідсоткова швидкість та надійність.

Однак одразу ж видно й мінуси:

  • У нашої системи зникла сувора узгодженість. Це означає, що якщо, наприклад, підписатися до різних сервісів, то теоретично можна отримати різний стан (оскільки один із сервісів може не встигнути прийняти повідомлення від внутрішньої черги). Як ще одне наслідок, система тепер не має спільного часу. Тобто не можна, наприклад, сортувати всі події просто за часом приходу, тому що годинник між серверами може не бути синхронним (більше того, однаковий час на двох серверах — це утопія).
  • Жодні події не можна тепер просто відкотити (як можна було б зробити з базою даних). Натомість необхідно додати нову подію. compensation event, який змінюватиме останній стан на необхідне. Як приклад із подібної області: без перезапису історії (що погано в ряді випадків) в git не можна відкотити комміт, проте можна зробити спеціальний rollback commit, Що по суті просто поверне старий стан. Однак в історії збережеться і хибний коміт, і rollback.
  • Схема даних може змінюватися від релізу до релізу, проте старі події тепер не вдасться оновити новий стандарт (бо події у принципі не можна змінювати).

Як видно, Event Sourcing добре вживається з CQRS. Більше того, реалізувати систему з ефективними та зручними чергами, проте без поділу потоків даних, уже само по собі складно, адже доведеться додавати точки синхронізації, які нівелюватимуть весь позитивний ефект від черг. Застосовуючи обидва підходи відразу, необхідно скоригувати код роботи програми. У нашому випадку, при відправці файлу на сервер, у відповіді приходить лише "ок", що означає лише, що "операція додавання файлу збережена". Формально це не означає, що дані вже доступні на інших пристроях (наприклад, сервіс дедуплікації може перебудовувати індекс). Однак через деякий час клієнту прийде повідомлення у стилі "файл Х збережений".

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

  • Число статусів відправки файлів збільшується: замість класичного "файл відправлений" ми отримуємо два: "файл доданий у чергу на сервері" та "файл збережений у сховищі". Останнє означає, що інші пристрої можуть почати отримувати файл (з поправкою на те, що черги працюють з різною швидкістю).
  • Через те, що інформація про відправлення тепер надходить різними каналами, нам необхідно вигадувати рішення, щоб набувати статусу обробки файлу. Як наслідок з цього: на відміну від класичного request-response, клієнт може бути перезапущений у процесі обробки файлу, проте статус цієї обробки буде коректний. Причому цей пункт працює, по суті, із коробки. Як наслідок: ми тепер більш толерантні до відмови.

Sharding

Як уже описувалося вище, в системах з event sourcing відсутня строга узгодженість. Отже ми можемо використовувати кілька сховищ без будь-якої синхронізації між ними. Наближаючись до нашого завдання, ми можемо:

  • Розділяти файли за типами. Наприклад, картинки/відео можна декодувати та вибирати більш ефективний формат.
  • Розділяти облікові записи по країнах. Через багато законів подібне може знадобитися, проте ця схема архітектури дає таку можливість автоматично.

Зручні архітектурні патерни

Якщо ви хочете перенести дані з одного сховища до іншого, то тут вже стандартними засобами не обійтися. На жаль, у такому разі необхідно зупинити чергу, зробити міграцію, а потім запустити її. Загалом «на льоту» дані не перенести, проте, якщо черга подій зберігається повністю, і у вас є зліпки попередніх станів сховища, то ми можемо перепрограти події таким чином:

  • У Event Source кожна подія має свій ідентифікатор (в ідеалі - не зменшується). А значить, у сховище ми можемо додати поле — id останнього обробленого елемента.
  • Дублюємо чергу, щоб усі події могли оброблятися для кількох незалежних сховищ (перше – це те, в якому вже зараз зберігаються дані, а друге – нове, проте поки що порожнє). Друга черга, звісно, ​​поки що не обробляється.
  • Запускаємо другу чергу (тобто починаємо перепрогравання подій).
  • Коли нова черга буде відносно порожня (тобто середня різниця в часі між додаванням елемента і його вилученням буде прийнятна), можна починати перемикати читачів на нове сховище.

Як видно, у нас у системі як не було, так і немає суворої узгодженості. Є лише еventual constistency, тобто гарантія того, що події обробляються в однаковому порядку (проте, можливо, з різною затримкою). І, користуючись цим, ми можемо порівняно легко перенести дані без зупинки системи на інший кінець земної кулі.

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

  • Ми можемо переміщати об'єкти ближче до користувачів, причому динамічно. Тим самим можна підвищити якість сервісу.
  • Ми можемо зберігати частину даних усередині компаній. Наприклад, Enterprise користувачі часто вимагають зберігати їх дані в підконтрольних датацентрах (щоб уникнути витоків даних). За рахунок sharding ми можемо легко це підтримати. І завдання ще спрощується, якщо замовник має сумісну хмару (наприклад, Azure self hosted).
  • А найважливіше – ми можемо цього не робити. Адже для початку нас цілком би влаштувало одне сховище для всіх акаунтів (щоб швидше почати працювати). І ключова особливість цієї системи — хоч і розширювана, на початковому етапі вона досить проста. Просто не треба одразу писати код, що працює з мільйоном окремих незалежних черг тощо. Якщо знадобиться, це можна зробити в майбутньому.

Хостинг статичного вмісту

Цей пункт може здатися очевидним, однак він все одно необхідний для більш-менш стандартного навантаженого додатка. Його суть проста: весь статичний контент лунає не з того ж сервера, де знаходиться додаток, а зі спеціальних виділених саме під цю справу. Як наслідок, ці операції виконуються швидше (умовний nginx віддає файли і оперативніше і менш витратно, ніж Java-сервер). Плюс архітектура CDN (Content Delivery Network) дозволяє розташовувати наші файли ближче до кінцевих користувачів, що позитивно позначається на зручності роботи з сервісом.

Найпростіший і стандартний приклад статичного контенту – це набір скриптів та картинок для сайту. З ними все просто - вони відомі заздалегідь, далі архів вивантажується на CDN сервери, звідки лунають кінцевим користувачам.

Однак насправді для статичного контенту можна застосувати підхід чимось схожим на лямбду архітектуру. Повернімося до нашого завдання (онлайн сховище файлів), в якому нам необхідно роздавати файли користувачам. Найпростіше рішення в лоб - зробити сервіс, який для кожного запиту користувача робить всі необхідні перевірки (авторизація і т.д.), а потім завантажує файл безпосередньо з нашого сховища. Головний мінус такого підходу — статичний контент (а файл з певною ревізією, по суті, є статичним контентом) лунає тим самим сервером, що містить бізнес-логіку. Натомість, можна зробити наступну схему:

  • Сервер видає URL-адресу для скачування. Він може бути виду file_id + key, де key - міні-цифровий підпис, що дає право на доступ до ресурсу на найближчу добу.
  • Роздачею файлу займається простий nginx з наступними опціями:
    • Кешування контенту. Так як цей сервіс може перебувати на окремому сервері, ми залишили собі заділ на майбутнє з можливістю зберігати всі останні завантажені файли на диску.
    • Перевірка ключа під час створення з'єднання
  • Опціонально: потокове оброблення контенту. Наприклад, якщо ми стискуємо всі файли в сервісі, то можна зробити розархівування у цьому модулі. Як наслідок: IO операції зроблено там, де їм саме місце. Архіватор на Java легко виділятиме багато зайвої пам'яті, проте переписувати сервіс з бізнес-логікою на умовні Rust/C++ може виявитися теж неефективним. У нашому випадку використовуються різні процеси (або навіть сервіси), а тому можна досить ефективно розділити бізнес логіку і IO операції.

Зручні архітектурні патерни

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

Як приклад (для закріплення): якщо ви працювали з Jenkins/TeamCity, то знаєте, що обидва рішення написані на Java. Обидва вони є Java-процесом, який займається як оркестрацією білдів, і менеджментом контенту. Зокрема, вони мають завдання виду "передати файл/папку з сервера". Як приклад: видача артефактів, передача вихідного коду (коли агент не завантажує код безпосередньо з репозиторію, а за нього це робить сервер), доступ до логів. Всі ці завдання відрізняються навантаженням на IO. Тобто виходить, що сервер, який відповідає за складну бізнес-логіку, заодно має вміти ефективно проштовхувати через себе великі потоки даних. І що найцікавіше, подібну операцію можна зделегувати тому ж nginx'у за рівною тією ж схемою (хіба що в запит слід додавати ключ даних).

Однак якщо повернутися до нашої системи, то виходить така схема:

Зручні архітектурні патерни

Як бачимо, система радикально ускладнилася. Тепер це не просто міні-процес, який зберігає файли локально. Тепер потрібно вже не найпростіша підтримка, контроль за версіями API і т.д. Тому після того, як усі діаграми намальовані, найкраще детально оцінити, чи варто розширюваність таких витрат. Однак якщо ви хочете мати можливість розширювати систему (у тому числі й для роботи з ще більшим числом користувачів), доведеться піти на подібні рішення. Зате, як наслідок, система архітектурна готова до збільшення навантаження (майже кожен компонент можна клонувати для горизонтального масштабування). Систему можна оновлювати без її зупинки (просто деякі операції трохи загальмуються).

Як я вже казав на самому початку, зараз низка інтернет-сервісів стала отримувати збільшене навантаження. І деякі з них просто почали припиняти правильно працювати. По суті, системи відмовили саме в той момент, коли бізнес якраз повинен робити гроші. Тобто замість відкладеної доставки замість пропозиції клієнтам «розплануйте поставку на найближчі місяці», система просто сказала «йдіть до конкурентів». Власне, це і є ціна низької продуктивності: втрати відбудуться саме тоді, коли прибуток був би найвищим.

Висновок

Всі ці підходи були відомі раніше. Той самий VK давно вже використовує ідею Static Content Hosting для видачі картинок. Купа онлайн-ігор використовують Sharding схему для поділу гравців по регіонах або для поділу ігрових локацій (якщо сам світ єдиний). Event Sourcing підхід активно використовується електронною поштою. Більшість додатків трейдерів, де безперервно приходять дані, насправді побудовані на підході CQRS, щоб мати можливість фільтрувати отримані дані. Та й горизонтальне масштабування досить давно застосовується у багатьох сервісах.

Однак, що найважливіше, всі ці патерни стало дуже легко застосовувати в сучасних додатках (якщо вони доречно, звичайно). Хмари пропонують Sharding та горизонтальне масштабування відразу, що набагато легше, ніж замовляти різні виділені сервери у різних датацентрах самостійно. CQRS став набагато легшим хоча б через розвиток бібліотек, таких як RX. Років 10 тому рідкісний web-сайт зміг би підтримати таке. Event Sourcing налаштовується теж неймовірно легко за рахунок готових контейнерів з Apache Kafka. Років 10 тому це було б інновацією, зараз це буденність. Аналогічно і з Static Content Hosting: через зручніші технології (у тому числі й тому, що є докладна документація та велика база відповідей), подібний підхід став ще простішим.

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

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

Джерело: habr.com

Додати коментар або відгук