Junior: питання на співбесіді з теми «Розподілені системи»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Моноліт - один застосунок, одна кодова база, один процес розгортання, одна база даних. Більшість 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: основи спостережуваності