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

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 для кожного сервісу, централізовані логи, трасування, обробка мережевих збоїв;
  • більшість успішних систем на мікросервісах починалися як моноліти.

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

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

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

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

Докладніше в документації: 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: основи спостережуваності