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

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

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

5 питань

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

Підхід 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