Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Розподілені системи

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

Моноліт - один застосунок, одна кодова база, один процес розгортання, одна база даних. Більшість Laravel-проєктів - моноліти, і це нормально.

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

app/Modules/Billing   - публічний API: BillingFacade, події InvoicePaid
app/Modules/Catalog
app/Modules/Shipping

Мікросервіси - окремі застосунки, що розгортаються незалежно, мають власні бази даних і спілкуються мережею (HTTP, черги, брокери повідомлень).

Моноліт Модульний моноліт Мікросервіси
розгортання одне одне незалежне для кожного сервісу
транзакції звичайні звичайні розподілені (саги, кінцева узгодженість)
виклики функції функції мережа (затримки, збої)
масштабування весь застосунок весь застосунок окремо кожен сервіс
операційна складність низька низька висока: моніторинг, трасування, деплой

Чому «спершу моноліт» (Мартін Фаулер, Monolith First):

  • на початку межі предметної області невідомі - неправильно розрізані мікросервіси дорого переробляти, а в моноліті це рефакторинг;
  • мікросервіси вимагають зрілої інфраструктури: CI/CD для кожного сервісу, централізовані логи, трасування, обробка мережевих збоїв;
  • більшість успішних систем на мікросервісах починалися як моноліти.

Коли мікросервіси виправдані:

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

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

Розподілений моноліт - найгірший варіант: сервіси, які мусять розгортатися разом, викликають одне одного синхронно ланцюжками й ділять базу. Ціна мікросервісів без їхніх переваг.

Докладніше в документації: Martin Fowler: Monolith First

Синхронна взаємодія - сервіс надсилає запит (HTTP, gRPC) і чекає відповіді, перш ніж продовжити:

Замовлення → HTTP → Склад: «зарезервуй товар» → чекає → «зарезервовано» → продовжує

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

Замовлення → подія OrderPlaced → черга → Склад обробить пізніше
                                      → Сповіщення обробить пізніше

Синхронна - переваги й недоліки:

  • проста модель: запит - відповідь, легко зрозуміти й налагоджувати;
  • результат одразу - потрібно, коли відповідь потрібна користувачу прямо зараз (ціна, наявність, перевірка прав);
  • часова зв'язаність: якщо склад недоступний чи повільний, оформлення замовлення теж падає чи гальмує;
  • ланцюжки викликів множать затримки й імовірність збою: п'ять сервісів з доступністю 99,9% кожен дають ланцюжок з ~99,5%.

Асинхронна - переваги й недоліки:

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

Як обирати:

  • запит від користувача, що потребує відповіді (прочитати дані, перевірити) - синхронно, з тайм-аутами й запасним варіантом;
  • реакція на подію (надіслати лист, оновити аналітику, синхронізувати пошуковий індекс) - асинхронно;
  • довгі операції (генерація звіту, обробка відео) - асинхронно з відповіддю 202 Accepted і статусом.

У межах одного Laravel-застосунку той самий вибір: синхронний виклик сервісу чи dispatch() джоби в чергу. Асинхронність через черги - перший крок до розподіленої архітектури навіть у моноліті.

Докладніше в документації: Microsoft: взаємодія в мікросервісній архітектурі

Системи обміну повідомленнями дають одну з гарантій доставки:

  • щонайбільше один раз (at-most-once) - повідомлення може загубитися, але дубліката не буде;
  • щонайменше один раз (at-least-once) - повідомлення не загубиться, але може прийти кілька разів;
  • рівно один раз (exactly-once) - ідеал, який у розподілених системах на практиці досягається лише в обмежених умовах.

Більшість черг і брокерів (Laravel Queue з Redis/SQS/database, RabbitMQ, Kafka в типовій конфігурації) працюють за моделлю at-least-once.

Звідки беруться дублікати:

  1. воркер отримав джобу й успішно виконав її (надіслав лист);
  2. перед підтвердженням обробки (видаленням із черги) воркер упав, з'єднання обірвалося чи перевищено тайм-аут;
  3. черга вважає джобу необробленою й видає її знову - лист надіслано двічі.

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

Наслідок для коду: обробник має бути ідемпотентним - повторна обробка того самого повідомлення не повинна мати додаткового ефекту.

public function handle(): void
{
    $payment = Payment::find($this->paymentId);

    if ($payment->isCaptured()) {
        return;   // уже оброблено - нічого не робимо
    }

    $this->gateway->capture($payment);
    $payment->markCaptured();
}

Способи досягти ідемпотентності:

  • перевірка стану перед дією (як вище);
  • унікальний ключ обробленого повідомлення в базі (processed_messages з унікальним індексом);
  • ідемпотентні операції за природою: «встановити статус paid» замість «додати 100 до балансу»;
  • ключ ідемпотентності у викликах зовнішніх API (Stripe приймає Idempotency-Key).

Налаштування Laravel, що впливають на дублікати:

  • retry_after у з'єднанні черги має бути більшим за timeout джоби, інакше черга видасть джобу іншому воркеру, поки перший ще працює;
  • tries, backoff - скільки разів і з якою паузою повторювати;
  • ShouldBeUnique - не ставити в чергу джобу, якщо така сама вже чекає.

Головна думка: «рівно один раз» - не властивість черги, а результат at-least-once доставки + ідемпотентної обробки.

Докладніше в документації: Microservices.io: обмін повідомленнями

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

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

Http::connectTimeout(3)   // встановлення з'єднання
    ->timeout(10)          // уся відповідь
    ->get('https://inventory.internal/api/stock/42');

Значення - з урахуванням очікуваного часу відповіді, а не «з запасом на всяк випадок»: 10 секунд для виклику, що зазвичай займає 100 мс, означає, що користувач чекатиме 10 секунд при кожній проблемі.

Повторні спроби - для тимчасових помилок:

Http::timeout(5)
    ->retry([200, 500, 1000], when: fn (Throwable $e) =>
        $e instanceof ConnectionException
        || ($e instanceof RequestException && $e->response->serverError()))
    ->get($url);

Що повторювати, а що ні:

  • так: тайм-аути з'єднання, помилки мережі, 502, 503, 504, 429 (з урахуванням Retry-After);
  • ні: 400, 401, 403, 404, 422 - повтор дасть той самий результат;
  • обережно: неідемпотентні операції (POST створення, списання коштів) - лише з ключем ідемпотентності, інакше повтор створить дублікат.

Правила повторів:

  • експоненційна затримка (200 мс, 400 мс, 800 мс...) замість миттєвих повторів;
  • випадковий розкид (jitter) - щоб тисячі клієнтів після збою не повторювали синхронно й не «добили» сервіс, що відновлюється;
  • обмеження кількості спроб і загальний бюджет часу на операцію;
  • повтори не на кожному рівні: якщо клієнт повторює 3 рази, сервіс A повторює виклик B 3 рази, а B - виклик C 3 рази, один збій C перетворюється на 27 запитів (шторм повторів).

Для фонових операцій - повтори через чергу: tries і backoff у джобі, а не цикл повторів усередині HTTP-запиту.

Коли повтори не допомагають (сервіс лежить надовго) - потрібен circuit breaker, що перестає надсилати запити до несправного сервісу на певний час.

Докладніше в документації: Azure: патерн Retry

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

Три основні типи сигналів:

1. Логи - записи про окремі події з контекстом:

{"level":"error","message":"Payment capture failed","order_id":42,"provider":"liqpay","trace_id":"4bf92f35..."}

Структуровані (JSON), з ідентифікаторами сутностей і запиту - щоб їх можна було шукати й фільтрувати.

2. Метрики - числові показники в часі: кількість запитів, частка помилок, час відповіді (перцентилі p95, p99), довжина черги, використання пам'яті. Дешеві для зберігання, ідеальні для графіків і сповіщень.

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

Як їх пов'язати - ідентифікатор кореляції / трасування. Перший сервіс генерує trace_id (стандарт W3C Trace Context, заголовок traceparent), передає його в кожен виклик і подію, і кожен сервіс пише його в логи. Тоді за одним ідентифікатором видно весь шлях запиту.

// додати контекст до всіх логів цього запиту
Log::withContext(['trace_id' => $request->header('X-Request-Id') ?? (string) Str::uuid()]);

OpenTelemetry - відкритий стандарт і набір SDK для збору всіх трьох сигналів у єдиному форматі. Дані можна відправляти в будь-яку систему (Grafana, Jaeger, Datadog, Honeycomb), не прив'язуючись до постачальника.

Що вимірювати насамперед (RED для сервісів): Rate (кількість запитів), Errors (помилки), Duration (тривалість). Для ресурсів (черги, бази) - використання, насичення, помилки.

У Laravel-екосистемі: Pulse (метрики застосунку, повільні запити, черги), Telescope (детальне налагодження, не для продакшену під навантаженням), Nightwatch, інтеграції з Sentry, OpenTelemetry-пакети для PHP.

Різниця з моніторингом: моніторинг відповідає на заздалегідь відомі питання («чи живий сервіс?»), спостережуваність - на нові («чому саме ці запити повільні з понеділка?»).

Докладніше в документації: OpenTelemetry: основи спостережуваності

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

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

public function handle(OrderPaid $message): void
{
    DB::transaction(function () use ($message) {
        $inserted = DB::table('processed_messages')->insertOrIgnore([
            'message_id' => $message->id,
            'consumer' => 'loyalty.points',
            'processed_at' => now(),
        ]);

        if ($inserted === 0) {
            return;   // уже оброблено
        }

        LoyaltyAccount::forCustomer($message->customerId)->addPoints($message->points);
    });
}

Транзакція гарантує: або повідомлення позначено і бали нараховано, або ні те, ні інше. Окремий запис «оброблено» після дії (не в транзакції) залишає вікно для дубліката.

Підхід 2 - ідемпотентна операція за природою. Замість «додати 100 балів» - «встановити бали за замовлення 42 у 100»:

LoyaltyEntry::updateOrCreate(
    ['order_id' => $message->orderId],
    ['points' => $message->points],
);

Повтор перезаписує те саме значення.

Підхід 3 - перевірка стану. «Якщо замовлення вже позначено оплаченим - нічого не робити». Працює, якщо стан однозначно показує, що дію виконано.

Підхід 4 - ключ ідемпотентності в зовнішньому виклику. Якщо споживач викликає платіжну систему, передати ключ (наприклад, id повідомлення) - провайдер сам відкине повтор.

Складні випадки:

  • побічні ефекти поза базою (лист, SMS, виклик API) не відкочуються транзакцією. Якщо лист надіслано, а позначка «оброблено» не збереглася, повтор надішле ще раз. Рішення - ключ ідемпотентності в провайдера чи окремий крок із власною позначкою;
  • порядок повідомлень: дублікат старого повідомлення може прийти після нового - перевіряти версію чи час сутності;
  • очищення журналу: таблиця оброблених повідомлень росте - старі записи видаляють після періоду, за який дублікати вже неможливі.

У Laravel для джоб у черзі: ShouldBeUnique запобігає дублікатам у черзі, але не замінює ідемпотентності обробника - джоба все одно може виконатися двічі після збою воркера.

Докладніше в документації: Microservices.io: Idempotent Consumer

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

Звідки береться:

  • репліки бази: запис на основний сервер, читання з репліки, що відстає на мілісекунди чи секунди;
  • асинхронна обробка: замовлення збережене, а пошуковий індекс, кеш і дашборд оновлюються подіями з черги;
  • мікросервіси: кожен сервіс має свою базу, дані синхронізуються подіями;
  • кеші й CDN.

Чому з нею миряться: це ціна доступності, продуктивності й незалежності частин системи. Сильна узгодженість між сервісами вимагала б розподілених транзакцій, які повільні й крихкі.

Як жити з нею в інтерфейсі:

  • читати свої записи з основного джерела: після збереження профілю показувати дані з відповіді на збереження чи з основної бази, а не з репліки (у Laravel - 'sticky' => true для з'єднань з репліками);
  • оптимістичне оновлення: інтерфейс одразу показує зміну, не чекаючи, поки вона пройде всіма системами;
  • чесні стани: «Замовлення приймається», «Оплата обробляється» замість миттєвого «Готово», якщо підтвердження приходить пізніше;
  • оновлення в реальному часі: коли фонова обробка завершилася - подія через WebSocket оновлює екран.

Як жити з нею в бізнес-процесах:

  • з'ясувати допустиму затримку з бізнесом: лічильник переглядів може відставати на хвилину, баланс рахунку при списанні - ні;
  • компенсуючі дії замість заборон: якщо товар продано двічі через затримку синхронізації складу - процес повернення коштів чи заміни, а не спроба гарантувати неможливе;
  • ідемпотентність і порядок обробки подій, щоб усі частини дійшли до однакового стану;
  • узгодження (reconciliation): періодична перевірка й виправлення розбіжностей між системами.

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

Типова помилка: не помічати кінцевої узгодженості, доки користувачі не почнуть скаржитися на «зниклі» зміни після збереження.

Докладніше в документації: Martin Fowler: компроміси мікросервісів

Проблема подвійного запису. Сервіс має зробити дві речі: зберегти зміну в базі і опублікувати подію в брокер повідомлень. Це дві різні системи, і атомарно зробити обидві дії неможливо:

DB::transaction(fn () => $order->markPaid());
$broker->publish(new OrderPaid($order->id));   // а якщо брокер недоступний чи процес впав тут?
  • база оновилася, подія не опублікована - інші сервіси ніколи не дізнаються про оплату;
  • якщо спершу публікувати, а потім зберігати, - подія є, а зміна в базі відкотилася.

Transactional Outbox: подія записується в таблицю тієї ж бази, у тій самій транзакції, що й зміна даних:

DB::transaction(function () use ($order) {
    $order->markPaid();

    DB::table('outbox')->insert([
        'id' => (string) Str::uuid7(),
        'type' => 'order.paid',
        'payload' => json_encode(['order_id' => $order->id]),
        'created_at' => now(),
    ]);
});

Тепер зміна і запис про подію або збережені обидва, або жоден.

Окремий процес-ретранслятор (relay) читає таблицю outbox і публікує записи в брокер, позначаючи опубліковані:

  • опитування таблиці - простий варіант (команда в планувальнику чи постійний воркер);
  • читання журналу змін бази (CDC, наприклад Debezium) - без навантаження опитуванням, з мінімальною затримкою.

Що варто врахувати:

  • at-least-once: ретранслятор може опублікувати подію, впасти до позначки «опубліковано» і опублікувати знову. Споживачі мають бути ідемпотентними - ідентифікатор запису outbox стає ідентифікатором повідомлення;
  • порядок: публікувати в порядку створення, якщо споживачам важлива послідовність;
  • очищення: опубліковані записи видаляти чи архівувати;
  • моніторинг затримки: записи, що висять неопублікованими, - сигнал проблеми з брокером чи ретранслятором.

Аналог у межах Laravel-застосунку: джоба, поставлена в чергу database у тій самій транзакції, або afterCommit() / ShouldDispatchAfterCommit - щоб подія й джоба відправлялися лише після успішного коміту. Але afterCommit не захищає від падіння процесу між комітом і відправкою в зовнішню чергу - Outbox захищає.

Дзеркальний патерн на стороні споживача - Inbox: записувати отримані повідомлення в таблицю для дедуплікації й надійної обробки.

Докладніше в документації: Microservices.io: Transactional Outbox

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

Circuit Breaker (запобіжник) - обгортка над викликом, що відстежує помилки й перестає надсилати запити, коли сервіс явно несправний. Три стани:

  1. закритий (closed) - запити проходять, помилки рахуються;
  2. відкритий (open) - після N помилок поспіль чи частки помилок понад поріг виклики одразу відхиляються без звернення до сервісу. Застосунок отримує миттєву помилку й використовує запасний варіант;
  3. напіввідкритий (half-open) - після паузи пропускається кілька пробних запитів. Успіх - назад у закритий стан; помилка - знову відкритий.

Що це дає:

  • швидкий збій замість очікування тайм-ауту - потоки й воркери застосунку не блокуються;
  • сервіс, що відновлюється, отримує паузу від лавини запитів;
  • запасна поведінка: показати кешовані дані, приховати блок рекомендацій, поставити операцію в чергу на пізніше.

Bulkhead (перегородка) - ізоляція ресурсів, щоб проблема в одній частині не «затопила» всю систему (назва - від перегородок у корпусі корабля):

  • окремі пули з'єднань і воркерів для різних залежностей: повільний сервіс звітів не вичерпує з'єднання, потрібні для оплат;
  • окремі черги для різних типів джоб: тисяча повільних генерацій PDF не блокує відправку листів підтвердження;
  • ліміти паралельності на виклики конкретного сервісу.

У Laravel-застосунку:

  • окремі черги й супервізори Horizon для різних типів робіт - найпростіший bulkhead;
  • middleware джоб ThrottlesExceptions - фактично запобіжник для джоб: після кількох винятків джоби відкладаються на час замість безперервних спроб;
  • запобіжник для HTTP реалізують через кеш (лічильник помилок і прапорець «відкрито» в Redis) чи бібліотеки;
  • короткі тайм-аути - основа: без них жоден з патернів не допоможе.

Що важливо налаштувати: пороги (скільки помилок і за який час), тривалість відкритого стану, що вважається помилкою (тайм-аути й 5xx - так, 404 і 422 - ні), і моніторинг: запобіжник, що відкрився, - подія, про яку має дізнатися команда.

Докладніше в документації: Martin Fowler: Circuit Breaker

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

Чому не спільна база:

  • прихована зв'язаність: сервіс B читає таблицю сервісу A. Тепер A не може змінити схему (перейменувати колонку, розділити таблицю), не зламавши B. Незалежні релізи - головна перевага мікросервісів - зникають;
  • порушення інваріантів: B пише в таблиці A в обхід його бізнес-логіки й валідації;
  • спільна точка відмови й навантаження: важкий звіт B гальмує всі сервіси;
  • неможливо обрати сховище під задачу (пошук, графи, часові ряди).

Ізоляція може бути на різних рівнях: окремий сервер бази, окрема схема чи окремі таблиці з правами доступу лише для власника. Головне - дисципліна, що чужі таблиці не чіпають.

Як отримати чужі дані:

1. Запит до API власника - коли потрібні актуальні дані в момент запиту:

Замовлення → GET /customers/42 → Клієнти

Мінус - синхронна залежність і затримка.

2. Локальна копія через події - сервіс зберігає потрібну частину чужих даних, оновлюючи її з подій:

Клієнти: CustomerRenamed → Замовлення оновлює customer_name у своїй таблиці

Швидке читання без залежності від доступності іншого сервісу, але дані кінцево узгоджені.

3. API-композиція / BFF - окремий шар збирає дані з кількох сервісів для екрана.

4. CQRS-проєкції - окрема модель читання, що агрегує події кількох сервісів для звітів і пошуку.

Складності, які з'являються:

  • немає JOIN між сервісами - об'єднання даних у коді чи проєкціях;
  • немає транзакцій між сервісами - саги й кінцева узгодженість;
  • дублювання даних - свідоме, з відомим власником і способом оновлення;
  • звітність - зазвичай окреме аналітичне сховище, куди дані збираються з усіх сервісів.

Аналог у модульному моноліті: модуль не звертається до моделей і таблиць іншого модуля - лише через його публічний сервіс чи події. Це готує до можливого розділення без переписування.

Докладніше в документації: Microservices.io: Database per Service

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

Приклад - оформлення замовлення:

1. Замовлення: створити (статус pending)       компенсація: скасувати
2. Склад: зарезервувати товар                   компенсація: зняти резерв
3. Платежі: списати кошти                       компенсація: повернути кошти
4. Замовлення: підтвердити

Якщо оплата не пройшла (крок 3) - знімається резерв (компенсація 2) і скасовується замовлення (компенсація 1).

Компенсація - не відкат. Інші частини системи вже могли побачити проміжні стани (резерв товару, замовлення pending). Компенсація - нова бізнес-операція («повернути кошти»), а не магічне повернення в минуле.

Хореографія - кожен сервіс реагує на події інших:

OrderCreated → Склад резервує → StockReserved → Платежі списують → PaymentCompleted → Замовлення підтверджує
  • плюси: немає центрального координатора, сервіси слабо зв'язані, просто для 2-3 кроків;
  • мінуси: логіку процесу ніде не видно цілком - вона розподілена по слухачах; важко відповісти «на якому кроці зараз замовлення?»; ризик циклічних залежностей подій.

Оркестрація - окремий оркестратор (сервіс чи об'єкт процесу) керує кроками, надсилаючи команди й чекаючи відповідей:

Оркестратор: «Склад, зарезервуй» → OK → «Платежі, спиши» → помилка → «Склад, зніми резерв» → «Замовлення, скасуй»
  • плюси: процес описаний в одному місці, стан саги зберігається явно, легко моніторити й змінювати;
  • мінуси: оркестратор - додатковий компонент і ризик «знати забагато».

Що обов'язково для саг:

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

Інструменти: у межах Laravel - ланцюжки й пакети джоб (Bus::chain, Bus::batch) і явна модель стану процесу; для складних процесів - рушії робочих процесів (Temporal тощо).

Правило вибору: хореографія - для простих процесів з кількома кроками; оркестрація - для складних, де важлива видимість і керування процесом.

Докладніше в документації: Microservices.io: Saga

Двофазний коміт (2PC) - протокол атомарної транзакції на кількох учасниках (базах, сервісах) під керуванням координатора.

Фаза 1 - підготовка (prepare): координатор питає кожного учасника «чи можеш закомітити?». Учасник виконує зміни, блокує ресурси, записує все необхідне для коміту й відповідає «готовий» чи «ні».

Фаза 2 - коміт: якщо всі відповіли «готовий», координатор надсилає «комітьте»; якщо хоч один «ні» - «відкотіть».

Результат - усі учасники або закомітили, або відкотили.

Чому в мікросервісах його уникають:

1. Блокування й доступність. Між фазами учасники тримають блокування і чекають рішення координатора. Якщо координатор упав після фази 1, учасники в стані «готовий» не можуть ні закомітити, ні відкотити самостійно - ресурси заблоковані, доки координатор не відновиться. Одна несправна ланка зупиняє всіх.

2. Затримки. Кожна транзакція - щонайменше два мережеві обміни з кожним учасником, а блокування тримаються весь цей час. Пропускна здатність падає.

3. Зв'язаність. Усі учасники мають підтримувати той самий протокол (XA) і бути доступні одночасно. Це суперечить ідеї незалежних сервісів з різними технологіями й сховищами.

4. Підтримка. Брокери повідомлень, NoSQL-бази, зовнішні API (платіжні системи) зазвичай не беруть участі в 2PC. Транзакцію «база + Kafka + Stripe» двофазним комітом не зробити.

5. Теорема CAP на практиці: при розриві мережі 2PC жертвує доступністю заради узгодженості - для більшості вебсистем це неприйнятна ціна.

Що використовують натомість:

  • саги з компенсуючими діями;
  • Transactional Outbox для атомарного «зміна + подія»;
  • ідемпотентні споживачі і кінцева узгодженість;
  • перегляд меж: якщо операція постійно вимагає атомарності між двома сервісами - можливо, це один сервіс (чи один агрегат), розрізаний неправильно.

Де 2PC (і схожі протоколи) все ж живуть: усередині розподілених баз даних (Spanner, CockroachDB використовують його варіанти з консенсусом), у класичних корпоративних системах з XA-транзакціями між кількома базами одного постачальника. На рівні архітектури вебсервісів - майже ніколи.

Для співбесіди: важливо не лише описати фази, а й пояснити проблему блокування при збої координатора - саме вона робить 2PC непридатним для слабко зв'язаних систем.

Докладніше в документації: Patterns of Distributed Systems: Two-Phase Commit

CAP (Ерік Брюер): розподілена система з реплікацією даних не може одночасно гарантувати всі три властивості:

  • C - узгодженість (consistency): кожне читання бачить останній запис (лінеаризовність);
  • A - доступність (availability): кожен запит до робочого вузла отримує відповідь;
  • P - стійкість до розділення мережі (partition tolerance): система працює, коли частина вузлів не може зв'язатися з іншими.

Як розуміти правильно. «Вибрати два з трьох» - спрощення. Розділення мережі в реальних системах трапляються, тож P не обирають - з ним живуть. Справжній вибір - що робити під час розділення:

  • CP: відмовити частині запитів (повернути помилку), але не віддати застарілі дані - так поводяться системи на консенсусі (etcd, ZooKeeper), основна база з синхронною реплікацією;
  • AP: відповідати всім, але дані на різних вузлах можуть тимчасово розходитися - Cassandra, DynamoDB у режимі кінцевої узгодженості, DNS.

PACELC (Даніель Абаді) доповнює CAP тим, що відбувається без збою:

if Partition → вибір між Availability і Consistency
Else         → вибір між Latency і Consistency

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

Як це допомагає на практиці:

  • Postgres/MySQL з асинхронними репліками: читання з репліки - швидко, але можливо застаріле (PA/EL-поведінка для читань з реплік). Звідси «прилипання» читань до основного сервера після запису;
  • Redis як кеш - типово жертвує узгодженістю заради швидкості;
  • синхронна реплікація для фінансових даних - узгодженість ціною затримки й доступності;
  • налаштовувані системи (Cassandra, DynamoDB) дозволяють обирати рівень узгодженості на кожен запит.

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

Обмеження CAP: теорема говорить про дуже конкретну модель (лінеаризовність, повна доступність) - реальні системи мають багато проміжних рівнів узгодженості (читання своїх записів, монотонні читання, причинна узгодженість). CAP - інструмент мислення, а не класифікатор баз даних.

Докладніше в документації: PACELC

Інтуїтивно здається: у кожної події є мітка часу, тож порядок подій очевидний. У розподіленій системі це не так.

Чому годинник ненадійний:

  • годинники різних серверів розходяться - на мілісекунди чи більше навіть з NTP;
  • годинник може стрибати назад при синхронізації NTP чи переході віртуальної машини;
  • мітка часу ставиться в різних місцях: на клієнті, на сервері при отриманні, в базі при записі;
  • мережа змінює порядок: повідомлення, відправлене раніше, може прийти пізніше.

Типова помилка:

Сервіс A о 10:00:00.120: ProfileUpdated(name="Оля")
Сервіс B о 10:00:00.100: ProfileUpdated(name="Олена")   ← годинник B відстає на 50 мс

Правило «перемагає пізніша мітка часу» (last write wins) тихо втрачає справжню останню зміну.

Що використовують замість фізичного часу:

1. Версії сутності - лічильник, що збільшується з кожною зміною в джерелі істини:

CustomerRenamed { customer_id: 7, version: 15, name: "Оля" }

Споживач застосовує подію, лише якщо version більша за збережену, - запізнілі й дубльовані події ігноруються.

2. Логічні годинники Лампорта - лічильник, який кожен вузол збільшує при події й синхронізує з отриманими повідомленнями (max(local, received) + 1). Дають порядок, узгоджений з причинністю: якщо подія A спричинила B, лічильник A менший.

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

4. Гібридні логічні годинники (HLC) - поєднують фізичний час із логічним лічильником; використовуються в розподілених базах (CockroachDB).

5. Порядок у брокері повідомлень: Kafka гарантує порядок у межах партиції - події однієї сутності публікують з тим самим ключем (order_id), і вони обробляються послідовно.

6. Одне джерело істини для порядку - послідовність у базі власника даних (автоінкремент, sequence) чи запис усіх змін однієї сутності через один сервіс.

Практичні правила:

  • мітки часу - для показу людям і приблизного аналізу, а не для вирішення конфліктів;
  • для конкурентних змін однієї сутності - оптимістичне блокування з версією (WHERE version = ?);
  • події, що мають застосовуватися в порядку, - з ключем партиціювання за сутністю й номером версії.

Докладніше в документації: Patterns of Distributed Systems: Lamport Clock