Питання на співбесіді: Розподілені системи
Питання з реальних співбесід з відповідями: 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 для кожного сервісу, централізовані логи, трасування, обробка мережевих збоїв;
- більшість успішних систем на мікросервісах починалися як моноліти.
Коли мікросервіси виправдані:
- багато команд, що заважають одна одній у спільній кодовій базі й релізах;
- частини з дуже різними вимогами до масштабування чи технологій;
- незалежні цикли релізів критичні для бізнесу.
Модульний моноліт - найкращий компроміс для більшості зростаючих проєктів: чіткі межі, як у мікросервісах, без мережевих і операційних витрат. Якщо колись знадобиться виділити модуль у сервіс, межі вже готові.
Розподілений моноліт - найгірший варіант: сервіси, які мусять розгортатися разом, викликають одне одного синхронно ланцюжками й ділять базу. Ціна мікросервісів без їхніх переваг.
Синхронна взаємодія - сервіс надсилає запит (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.
Звідки беруться дублікати:
- воркер отримав джобу й успішно виконав її (надіслав лист);
- перед підтвердженням обробки (видаленням із черги) воркер упав, з'єднання обірвалося чи перевищено тайм-аут;
- черга вважає джобу необробленою й видає її знову - лист надіслано двічі.
Також дублікати виникають, коли відправник повторює відправку після тайм-ауту, не знаючи, що перша спроба дійшла.
Наслідок для коду: обробник має бути ідемпотентним - повторна обробка того самого повідомлення не повинна мати додаткового ефекту.
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, що перестає надсилати запити до несправного сервісу на певний час.
Спостережуваність - можливість зрозуміти, що відбувається всередині системи, за даними, які вона видає назовні. Особливо важлива для розподілених систем: один запит користувача проходить через кілька сервісів, черг і баз, і помилку треба знайти серед них усіх.
Три основні типи сигналів:
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 (запобіжник) - обгортка над викликом, що відстежує помилки й перестає надсилати запити, коли сервіс явно несправний. Три стани:
- закритий (closed) - запити проходять, помилки рахуються;
- відкритий (open) - після N помилок поспіль чи частки помилок понад поріг виклики одразу відхиляються без звернення до сервісу. Застосунок отримує миттєву помилку й використовує запасний варіант;
- напіввідкритий (half-open) - після паузи пропускається кілька пробних запитів. Успіх - назад у закритий стан; помилка - знову відкритий.
Що це дає:
- швидкий збій замість очікування тайм-ауту - потоки й воркери застосунку не блокуються;
- сервіс, що відновлюється, отримує паузу від лавини запитів;
- запасна поведінка: показати кешовані дані, приховати блок рекомендацій, поставити операцію в чергу на пізніше.
Bulkhead (перегородка) - ізоляція ресурсів, щоб проблема в одній частині не «затопила» всю систему (назва - від перегородок у корпусі корабля):
- окремі пули з'єднань і воркерів для різних залежностей: повільний сервіс звітів не вичерпує з'єднання, потрібні для оплат;
- окремі черги для різних типів джоб: тисяча повільних генерацій PDF не блокує відправку листів підтвердження;
- ліміти паралельності на виклики конкретного сервісу.
У Laravel-застосунку:
- окремі черги й супервізори Horizon для різних типів робіт - найпростіший bulkhead;
- middleware джоб
ThrottlesExceptions- фактично запобіжник для джоб: після кількох винятків джоби відкладаються на час замість безперервних спроб; - запобіжник для HTTP реалізують через кеш (лічильник помилок і прапорець «відкрито» в Redis) чи бібліотеки;
- короткі тайм-аути - основа: без них жоден з патернів не допоможе.
Що важливо налаштувати: пороги (скільки помилок і за який час), тривалість відкритого стану, що вважається помилкою (тайм-аути й 5xx - так, 404 і 422 - ні), і моніторинг: запобіжник, що відкрився, - подія, про яку має дізнатися команда.
Принцип «база даних на сервіс»: кожен сервіс сам володіє своїми даними. Інші сервіси не читають і не пишуть його таблиці напряму - лише через його 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 тощо).
Правило вибору: хореографія - для простих процесів з кількома кроками; оркестрація - для складних, де важлива видимість і керування процесом.
Двофазний коміт (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 - інструмент мислення, а не класифікатор баз даних.
Інтуїтивно здається: у кожної події є мітка часу, тож порядок подій очевидний. У розподіленій системі це не так.
Чому годинник ненадійний:
- годинники різних серверів розходяться - на мілісекунди чи більше навіть з 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