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 (запобіжник) - обгортка над викликом, що відстежує помилки й перестає надсилати запити, коли сервіс явно несправний. Три стани:
- закритий (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