Skip to content

System Design

System Design

Проблеми та рішення, з якими стикаємось при розвитку додатку

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

  • MVP - стадія прототипу
    • Достатньо сервера з додатками (фронтенд та бекенд в якості API), бази даних.
    • Для зберігання файлів, картинок потрібно користуватись стороннім сервісом Amazon S3 або аналогічним.
    • Можна масштабуватись тільки за рахунок вертикального масштабування - більше місця, більше оперативної пам'яті, кращий процесор.
    • Індекси на потрібні колонки.
  • Бекапи - Backups
    • Щоб не втратити дані - потрібно робити бекапи. Або обрати зовнішнє рішення, яке буде це робити самостійно.
    • Потрібно перевірити процес відновлення з бекапів, оскільки вони можуть бути не робочі. Для цього періодично можна тестувати відновлення системи після інциденту.
  • Доступність, Відказостійкість - Availability, Fault Tolerance - Load Balancer
    • Коли збільшується трафік, одного сервера може почати не вистачати. Тоді необхідно додати один або кілька інших, та налаштувати розподілення трафіку між ними.
    • Для розподілення трафіку потрібно скористатись Load balancer (Nginx). Найпростіший принцип розподілення - Round-Robin.
  • Час відповіді - Performance - Cache, Database Replica, CDN
    • Якщо вузьким місцем стає база даних, потрібно налаштувати кешування найбільш часто запитуваних даних.
    • Якщо кешування недостатньо - потрібно налаштувати репліку, та перевести на неї read запити. Також потрібно налаштувати механізм master-slave, якщо одна з них вийде з ладу.
    • Якщо клієнти додатку розкидані географічно (користувачі із Сінгапуру незадоволені, що хост зі Штатів довго відповідає), можна скоротити час очікування відповіді, якщо перенести сервери ближче до кінцевих споживачів.
    • Також можна налаштувати кешування, CDN (Cloudflare).
  • Аналітика - Analytics
    • Аналітичні запити зазвичай дістають велику кількість даних, мають кілька JOIN запитів, тож завжди повинні виконуватись на репліці. Оскільки ці великі запити вимагають великої кількості CPU, що може заблокувати основний додаток, а також вони забивають кеш, який база даних використовували для обслуговування додатку.
    • Якщо аналітичних даних багато, можливо потрібна денормалізація або NoSQL база даних.
  • База даних - Database
    • Для оптимізації роботи бази даних, якщо вже є кешування та репліки, можна використати
      • Денормалізація
      • Партиціонування
      • Шардування
  • Мікросервіси - Microservices
    • Впровадження мікросервісів ускладнює архітектуру додатку, сповільнює розробку. Для забезпечення транзакцій потрібно впроваджувати окремі концепції.
  • Пікові навантаження
    • Якщо ми очікуємо маркетингову сесію, рекламу, свята - тобто піковий онлайн може бути 10х від звичайного, потрібно налаштувати політики автоскейлінгу.
    • Перед піком можна прогріти сервіси, тобто запустити додаткові сервіси так, щоб навантаження було близько 10% від максимуму. Це допоможе краще справитись з ростом навантаження.

Робота в конкурентному середовищі

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

Основні підходи до контролю конкурентного доступу — песимістичне та оптимістичне блокування, кожне з яких має свої trade-off’и.

Вибір між песимістичним і оптимістичним підходом завжди залежить від:

  • частоти конфліктів
  • вимог до latency
  • допустимості повторних спроб
  • характеру бізнес-операцій

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

  • Песимістичне блокування - коли блокуються певні рядки, й інші користувачі, які намагаються щось зробити з ними, пропускаються. Такий вид блокування може не підходити у випадку великої кількості запитів до одного ресурсу, оскільки може створювати затримки. Песимістичне блокування гарантує, що жоден інший процес не змінить рядок, поки він заблокований. Це підходить для сценаріїв із високою критичністю даних (наприклад, фінансові транзакції), але може створювати затримки при великій кількості запитів. Також потрібно використовувати коли конкуренція за запис настільки велика, що оптимістичний підхід призведе до постійних retry циклів.
  • Оптимістичне блокування - блокування за допомогою версіонування, таймстемпів, логічних годинників. Базується на припущенні, що конфлікти трапляються рідко. Ми дозволяємо паралельні операції, але перевіряємо версію або таймстемп перед записом. Якщо версія змінилася — транзакція відхиляється, і користувач має повторити операцію. Краще використовувати у системах з інтенсивним читанням (наприклад, бронювання квитків, редагування статей, e-commerce каталоги), де ймовірність того, що два користувачі редагують один запис одночасно, є низькою.

Песимістичне блокування (Pessimistic Locking)

  • Блокує рядки або таблиці наперед, ще до зміни даних (рядкове блокування, наприклад, SELECT FOR UPDATE в PostgreSQL). Поки транзакція А тримає блокування, транзакція Б, яка намагається вибрати ті самі рядки через FOR UPDATE, буде чекати завершення транзакції А (або впаде з таймаутом, якщо налаштовано NOWAIT).
  • Ідеально підходить для критичних даних (фінансові транзакції), де не можна допустити "race conditions".
  • Мінімізує race condition, але погіршує масштабованість, знижує пропускну здатність системи (throughput), тримає з'єднання з БД відкритим довше.
  • Може призводити до дедлоків (deadlocks) при складних транзакціях.
  • Погано підходить для систем із великою кількістю паралельних запитів до одного ресурсу.
SELECT * FROM orders
WHERE id = 42
FOR UPDATE;
# Django example with pessimistic locking
with transaction.atomic():
    order = Order.objects.select_for_update().get(id=42)
    order.status = "paid"
    order.save()

Оптимістичне блокування (Optimistic Locking)

  • Не блокує рядки наперед, а перевіряє, чи не були дані змінені кимось іншим перед збереженням.
  • Реалізується через version, updated_at, timestamp або логічні лічильники. Транзакція читає дані без блокувань, а при оновленні додається умова WHERE, перевіряючи, чи версія в базі збігається з тією, яку прочитали (якщо версія не співпадає — конфлікт, транзакція скасовується).
  • Дає кращу пропускну здатність під навантаженням - висока швидкодія при читанні (read-heavy systems).
  • Потребує обробки конфліктів під час save/update, вимагає реалізації логіки повторних спроб (retry mechanism) на рівні коду застосунку.
  • Найкраще працює, коли конфлікти трапляються рідко - при високій конкуренції (high contention) багато транзакцій будуть скасовуватися і перезапускатися, що витрачає ресурси CPU.
UPDATE orders
SET status = 'paid', version = version + 1
WHERE id = 42 AND version = 3;
# Optimistic locking example
rows_updated = Order.objects.filter(id=42, version=3).update(
    status="paid",
    version=F("version") + 1,
)

if rows_updated == 0:
    raise ConcurrencyError("Order was modified by another transaction")

Links

Data Consistency (Консистентність у мікросервісній архітектурі)

Консистентність у мікросервісній архітектурі означає, що всі сервіси узгоджено відображають стан системи, навіть якщо відбуваються збої чи часткові відмови. У мікросервісній архітектурі неможливо покладатися на класичні ACID-транзакції між сервісами, тому консистентність досягається через eventual consistency, контроль побічних ефектів і правильні механізми відновлення після збоїв.

Тримати систему в консистентному стані в мікросервісній архітектурі можна кількома підходами, що враховують розподілену природу сервісів і можливі збої мережі.

  • Ретраї (повторні спроби)
  • Try-Confirm/Cancel (TCC)
  • Saga
  • Двохфазний коміт (2PC)

Кожен із цих підходів має свої плюси та мінуси і обирається залежно від характеру навантаження та критичності бізнес-процесів. Наприклад, для систем із високою частотою записів і відмовостійкістю більше підходять Saga і ідемпотентні ретраї, а для критичних транзакцій з низьким трафіком — 2PC або TCC.

Ретраї (Retries)

Ретраї (Retries) - повторні запити - найпростіший спосіб боротьби з тимчасовими збоями мережі або недоступністю сервісів. Ретраї допомагають виправити тимчасові помилки, але вони мають бути ідемпотентними. Це гарантує безпечне відновлення без дублювання дій.

Ідемпотентність - це властивість операції, яка гарантує, що повторне виконання запиту дає той самий результат, що й однократне виконання, без зміни результату або зайвих ефектів. Ідемпотентність зазвичай реалізується через idempotency key або перевірку унікального бізнес-ідентифікатора операції. Наприклад, клієнт генерує унікальний Idempotency-Key (UUID) і передає його в заголовку запиту. Сервер зберігає цей ключ (наприклад, у Redis) разом із результатом першого успішного виконання. Якщо приходить запит з тим самим ключем, сервер не виконує логіку знову, а повертає збережений результат.

Try-Confirm/Cancel (TCC) Pattern

TCC - розбиває операцію на три фази: спроба резервування (Try), підтвердження (Confirm) або скасування (Cancel). Якщо в фазі Confirm виникає помилка або збій, виконується Cancel, який відкочує зміни.

Приклад реалізації

  • Try: Резервуємо ресурси (наприклад, позначаємо товар як "pending" або заморожуємо суму на карті). Це не фінальна зміна, а лише резерв.
  • Confirm: Якщо всі "Try" пройшли успішно, переводимо статус у "Sold" або списуємо гроші. Ця операція має бути ідемпотентною і не може зафейлитись (в теорії).
  • Cancel: Якщо десь помилка, скасовуємо резерви (розморожуємо гроші).

Недоліки TCC

  • Складна реалізація на рівні кожного сервісу
  • Потрібно тримати стан резервацій
  • Збільшує кількість запитів до сервісів (мінімум 2x)
  • Потрібна логіка для звільнення "застряглих" резервацій

Saga Pattern

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

Два типи Saga

  • Choreography (Хореографія) - сервіси спілкуються через події (Message Broker: Kafka/RabbitMQ). Сервіс А кидає подію "Created", В слухає її тощо. Складність: важко відстежити статус транзакції, можливі циклічні залежності.
  • Orchestration (Оркестрація) - є окремий сервіс (Orchestrator), який каже кожному сервісу, що робити.

Недоліки Saga

  • Вимагає написання логіки "скасування" (Undo) для кожної дії.
  • Компенсації теж можуть падати, тому потрібні ретраї й для них.
  • Складність у розумінні flow процесу

Двофазний коміт (Two-Phase Commit)

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

Фази

  • Фаза 1 (Prepare): Координатор запитує всі бази даних: "Чи готові ви зробити коміт?". Ресурси блокуються.
  • Фаза 2 (Commit): Якщо всі відповіли "Так", координатор каже "Коміт". Якщо хоч один "Ні" — "Rollback".

Недоліки 2PC

  • Блокуючий протокол — ресурси заблоковані під час всієї транзакції
  • Single point of failure — якщо координатор впаде, ресурси залишаться заблокованими.
  • Погана масштабованість — не підходить для high-load систем
  • Висока латентність — потрібні синхронні виклики до всіх учасників

Порівняння підходів та рекомендації

  • Ретраї + Ідемпотентність
    • Найпростіший підхід, використовується завжди як базовий рівень захисту
    • Підходить для більшості сценаріїв з eventual consistency
    • Мінімальна складність коду
  • TCC
    • Використовується для бізнес-критичних операцій з резервуванням ресурсів
    • Приклади: бронювання готелів, резервування товарів у кошику
    • Середня складність реалізації
  • Saga
    • Найкращий вибір для складних багатокрокових бізнес-процесів
    • Приклади: оформлення замовлення, onboarding користувачів
    • Висока складність, але хороша масштабованість
  • 2PC
    • Використовується тільки якщо дійсно потрібна сильна консистентність
    • Приклади: фінансові транзакції між рахунками
    • Не підходить для high-load систем