Питання на співбесіді з Архітектура
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Єдина мова - спільний словник, яким користуються і бізнес, і розробники: в розмовах, документації, тікетах і в коді. Одне поняття - одна назва, і ця назва означає те саме для всіх.
Проблема, яку вона розв'язує. Менеджер каже «замовлення оформлене», бухгалтер - «рахунок виставлено», а в коді є Order зі статусом processing і таблиця invoices_tmp. Кожна розмова потребує «перекладу», і в перекладі губляться деталі: розробник реалізує не те, що мав на увазі бізнес.
Як це виглядає в коді:
// без єдиної мови
$order->status = 3;
$order->save();
$this->mailer->sendType2($order);
// з єдиною мовою
$order->confirm();
// всередині: перевірка правил підтвердження, подія OrderConfirmed
Назви класів, методів і подій (confirm, OrderConfirmed, cancel, refund) - ті самі слова, що їх вимовляють експерти предметної області. Код можна обговорювати з бізнесом майже дослівно.
Як її будувати:
- слухати експертів і записувати терміни, а не вигадувати технічні замінники;
- глосарій у документації проєкту - з визначеннями й прикладами;
- уточнювати неоднозначності: якщо «клієнт» означає і того, хто купує, і того, хто платить, - це два різні поняття, і їм потрібні різні назви;
- оновлювати код, коли змінюється розуміння: перейменування класу - нормальна частина роботи, а не «косметика».
Важливе обмеження: єдина мова діє в межах одного обмеженого контексту. «Товар» у каталозі (опис, фото, характеристики) і «товар» на складі (кількість, полиця, вага) - різні моделі, і силоміць зводити їх до одного класу Product - типова помилка великих систем.
Ознака проблеми: у команді є «перекладачі» - люди, без яких розробники й бізнес не розуміють одне одного.
Докладніше в документації: Martin Fowler: Ubiquitous Language
Сутність має ідентичність, що зберігається протягом усього життя: користувач лишається тим самим користувачем, навіть якщо змінив ім'я, email і адресу. Дві сутності з однаковими полями, але різними id - різні об'єкти.
Об'єкт-значення не має ідентичності - він визначається лише своїми значеннями. Дві суми «100 гривень» - одна й та сама сума; немає сенсу питати «яка саме з них».
| Сутність | Об'єкт-значення | |
|---|---|---|
| рівність | за ідентифікатором | за всіма значеннями |
| зміна | змінюється з часом | незмінний: «зміна» = новий об'єкт |
| приклади | User, Order, Invoice |
Money, Email, Address, DateRange |
Об'єкт-значення в PHP:
final readonly class Money
{
public function __construct(
public int $amount, // у копійках
public string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Сума не може бути від\'ємною');
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Різні валюти');
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}
Що дають об'єкти-значення:
- валідація в одному місці: некоректний
Emailчи від'ємну суму неможливо створити - не треба перевіряти скрізь, де вони використовуються; - поведінка поруч з даними: додавання грошей, перевірка перетину періодів;
- незмінність (
readonly) - об'єкт можна безпечно передавати й кешувати; - виразність:
function charge(Money $amount)замістьfunction charge(int $amount, string $currency).
У Laravel об'єкти-значення зберігають у моделях через власні касти (CastsAttributes): Money з двох колонок price_amount і price_currency.
Пастка: робити сутністю те, що насправді значення (окрема таблиця addresses з id для адреси, яка ніколи не існує окремо від замовлення), - і навпаки.
Анемічна модель - об'єкти домену містять лише дані (поля, геттери, сеттери), а вся поведінка живе в окремих сервісах. Модель - це просто структура для бази.
// анемічна модель
class Order extends Model {}
class OrderService
{
public function cancel(Order $order): void
{
if ($order->status === 'shipped') {
throw new DomainException('Відправлене замовлення не скасувати');
}
$order->status = 'cancelled';
$order->cancelled_at = now();
$order->save();
}
}
Мартін Фаулер назвав це антипатерном: об'єкти виглядають як об'єктна модель, але насправді це процедурний код - дані окремо, функції окремо.
Проблеми:
- правила розпорошені: перевірку «чи можна скасувати» хтось повторить в іншому сервісі, контролері, команді - і забуде одну з умов;
- будь-хто може зламати інваріанти:
$order->status = 'cancelled'можна написати будь-де, оминувши правила; - сервіси розростаються до «божественних» класів на тисячі рядків.
Багата модель - поведінка поруч з даними:
class Order extends Model
{
public function cancel(string $reason): void
{
if ($this->status === OrderStatus::Shipped) {
throw new OrderAlreadyShipped($this);
}
$this->status = OrderStatus::Cancelled;
$this->cancelled_at = now();
$this->cancellation_reason = $reason;
}
}
Правило скасування - в одному місці, і викликати його можна лише через метод з назвою з єдиної мови.
Чи завжди анемічна модель погана? Ні:
- CRUD-застосунки без складної логіки - анемічна модель простіша й чесніша;
- «тонкі» моделі + actions (окремий клас на дію:
CancelOrder) - популярний у Laravel підхід. Він не багата модель у класичному сенсі, але логіка кожної операції зібрана в одному місці, і це вже краще за розмазаний код.
Критерій: якщо одні й ті самі правила повторюються в кількох місцях або бізнес-інваріанти порушуються «випадково» - логіці час переїхати ближче до даних.
Докладніше в документації: Martin Fowler: Anemic Domain Model
Доменна подія - факт, що вже стався в предметній області і важливий для бізнесу: OrderPlaced, PaymentReceived, SubscriptionCancelled. Назва - дієслово в минулому часі, бо подію не можна «скасувати» - лише відреагувати на неї.
final class OrderPlaced
{
public function __construct(
public readonly int $orderId,
public readonly int $customerId,
public readonly int $totalAmount,
public readonly CarbonImmutable $placedAt,
) {}
}
Навіщо вони:
1. Розв'язати зв'язки між частинами системи. Оформлення замовлення не повинно знати про лист клієнту, нарахування бонусів, оновлення аналітики й сповіщення складу:
// без подій: оформлення знає про все
$order->save();
$mailer->sendConfirmation($order);
$loyalty->addPoints($order);
$warehouse->reserve($order);
// з подією: оформлення лише повідомляє факт
OrderPlaced::dispatch($order->id, ...);
// окремі слухачі: лист, бонуси, склад - кожен незалежно
Новий обробник (наприклад, вебхук партнеру) додається без зміни коду оформлення.
2. Явно назвати важливі моменти бізнес-процесу - події стають частиною єдиної мови.
3. Інтеграція між контекстами чи сервісами - подія передається в чергу чи брокер повідомлень.
4. Журнал і аудит - історія того, що відбувалося.
У Laravel - події й слухачі:
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void { /* ... */ }
}
Що варто знати:
- подія - про факт, а не команда:
OrderPlaced, а неSendOrderEmail; - подія після коміту транзакції: якщо слухач у черзі отримає подію до коміту, він не знайде замовлення в базі. У Laravel - інтерфейс
ShouldDispatchAfterCommitдля подій чиafterCommitдля слухачів; - дані в події - ідентифікатори й значущі значення, а не ціла модель, що може змінитися до обробки;
- приховані залежності: коли подій і слухачів багато, важко зрозуміти, що відбувається після дії.
php artisan event:listі чіткі назви допомагають.
Гроші, час і статуси - три типи даних, які найчастіше моделюють «примітивами» (float, string, int) і які дають найбільше помилок.
Гроші:
- не
float:0.1 + 0.2≠0.3. Сума в мінімальних одиницях (копійках) цілим числом абоdecimalу базі; - валюта завжди поруч з сумою: 100 - це гривні чи долари? Додавання сум у різних валютах має бути помилкою, а не мовчазним результатом;
- об'єкт-значення
Money(власний чи бібліотекаmoneyphp/money,brick/money) збирає правила в одному місці: додавання, порівняння, округлення, розподіл без втрат (100 грн на 3 частини - 33,34 + 33,33 + 33,33, а не три по 33,33 з загубленою копійкою).
Час:
- мить у часі -
CarbonImmutableв UTC; у місцевий час перетворювати лише для показу; - дата без часу (день народження, дата події) - окремий тип, а не
DateTimeз опівнічним часом, який зміщується в іншому поясі; - період - об'єкт
DateRangeз початком і кінцем і методамиcontains(),overlaps()замість пари змінних і перевірок, розкиданих по коду; - незмінні дати (
CarbonImmutable,Date::use(CarbonImmutable::class)) - щоб$start->addDay()не змінював оригінал непомітно; - «зараз» як залежність: код, що порівнює з поточним часом, легше тестувати, якщо час можна підмінити (
Carbon::setTestNow,$this->travelTo()).
Статуси:
- енум замість рядків і чисел:
OrderStatus::Paidзамість'paid'чи3; - дозволені переходи - у моделі чи енумі (
canTransitionTo()), а не вifпо всьому коду; - без суперечливих прапорців:
is_paid,is_shipped,is_cancelledдозволяють неможливі комбінації - один статус робить їх невиразними.
У Laravel все це підтримується з коробки: касти енумів ('status' => OrderStatus::class), власні касти для Money, immutable_datetime.
Загальна ідея - «примітивна одержимість» (primitive obsession) як запах коду: якщо значення має правила (формат, діапазон, операції), воно заслуговує на власний тип.
Моноліт - один застосунок, одна кодова база, один процес розгортання, одна база даних. Більшість 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: основи спостережуваності
Вертикальне масштабування (scale up) - зробити сервер потужнішим: більше процесорів, пам'яті, швидші диски.
Горизонтальне (scale out) - додати ще сервери й розподілити навантаження між ними.
| Вертикальне | Горизонтальне | |
|---|---|---|
| зміни в коді | майже не потрібні | застосунок має бути готовим |
| межа | найпотужніший доступний сервер | практично немає |
| відмовостійкість | одна точка відмови | відмова одного сервера не зупиняє систему |
| вартість | дорожчає нелінійно | лінійна, дешеві сервери |
| складність | низька | балансувальник, спільний стан, деплой на кілька машин |
Почати варто з вертикального. Сучасний сервер з 32 ядрами й 128 ГБ пам'яті витримує дуже багато - для більшості Laravel-проєктів це роки росту без зміни архітектури. Передчасне горизонтальне масштабування додає складність без потреби.
Горизонтальне потрібне, коли:
- вертикальне вперлося в межу чи стало непропорційно дорогим;
- потрібна відмовостійкість - навіть невеликий сервіс з вимогою «працювати під час оновлення сервера» потребує щонайменше двох екземплярів;
- навантаження стрибкоподібне (розпродажі, пікові години) - простіше додати й прибрати сервери.
Що треба зробити в застосунку, щоб масштабуватися горизонтально - прибрати стан з окремого сервера:
- сесії - у Redis чи базі, а не у файлах;
- завантажені файли - в S3/R2, а не на локальний диск;
- кеш і блокування - у спільному Redis;
- черги - Redis/SQS, воркери на окремих машинах;
- планувальник - запуск на одному сервері (
onOneServer()).
Різні шари масштабуються по-різному: вебсервери горизонтально прості (вони без стану), а база даних - найскладніша частина: її зазвичай спершу масштабують вертикально, потім репліками для читання, і лише в крайньому разі - шардингом.
Застосунок без стану (stateless) - будь-який запит може обробити будь-який сервер, бо жоден сервер не зберігає даних, потрібних для наступних запитів. Тоді балансувальник може розподіляти запити довільно, а сервери - додавати, прибирати й перезапускати без втрати даних.
Що зазвичай «прилипає» до сервера в Laravel-застосунку:
1. Сесії. Драйвер file зберігає сесії на локальному диску. Запит на інший сервер - користувач «розлогінений».
Рішення: SESSION_DRIVER=redis чи database.
2. Завантажені файли. Storage::disk('local') пише на диск конкретного сервера - файл, завантажений на сервер A, недоступний на сервері B.
Рішення: спільне сховище - S3, R2, MinIO (FILESYSTEM_DISK=s3).
3. Кеш. Драйвер file чи array - у кожного сервера свій кеш: інвалідація на одному не діє на інших, а атомарні блокування (Cache::lock) не працюють між серверами.
Рішення: CACHE_STORE=redis.
4. Черги. Драйвер sync виконує задачі в запиті; database - працює, але під навантаженням краще Redis чи SQS.
5. Планувальник. schedule:run на кожному сервері запустить задачу N разів.
Рішення: ->onOneServer() (потребує спільного кешу для блокування) або окремий сервер для планувальника.
6. Локальні змінні середовища й файли конфігурації - однакові на всіх серверах, деплой з одного артефакту (Docker-образ).
7. Ліміти частоти (throttle) - лічильники в кеші: з локальним кешем кожен сервер рахує окремо, і реальний ліміт множиться на кількість серверів.
«Липкі сесії» (sticky sessions) на балансувальнику - обхідний шлях: користувача завжди відправляють на той самий сервер. Працює, але сервер стає точкою відмови для своїх користувачів, а навантаження розподіляється нерівномірно.
Перевірка готовності: запустити два екземпляри за балансувальником і пройти основні сценарії - вхід, завантаження файлу, черги, кеш. Те, що зламається, і є прихованим станом.
Балансувальник навантаження приймає запити клієнтів і розподіляє їх між кількома серверами застосунку. Для клієнта це одна адреса, а за нею - пул серверів.
Що він дає:
- масштабування - навантаження ділиться між серверами;
- відмовостійкість - перевірки стану (health checks) виключають несправний сервер з пулу;
- оновлення без простою - сервери оновлюються по черзі, поки решта обслуговує запити;
- завершення TLS - шифрування обробляється на балансувальнику, а не на кожному сервері.
Рівні балансування:
- L4 (транспортний) - розподіляє TCP-з'єднання за IP і портом, не дивлячись у вміст. Швидко й просто;
- L7 (прикладний) - бачить HTTP: може маршрутизувати за шляхом (
/api- на одні сервери,/- на інші), заголовками, cookie, кешувати, стискати.
Алгоритми розподілу:
- round robin - по черзі; найпростіший, добрий для однакових серверів і схожих запитів;
- weighted round robin - потужніші сервери отримують більше запитів;
- least connections - на сервер з найменшою кількістю активних з'єднань; кращий, коли запити дуже різні за тривалістю;
- IP hash / consistent hashing - той самий клієнт потрапляє на той самий сервер (корисно для кешів на сервері, але це «липкість»);
- random with two choices - вибрати два випадкові сервери й узяти менш завантажений: просто й ефективно у великих пулах.
Health checks - балансувальник регулярно запитує, наприклад, /up (у Laravel 11+ такий маршрут налаштовано за замовчуванням). Сервер, що не відповідає, тимчасово виключається.
Приклади: Nginx, HAProxy, Caddy, Traefik, хмарні (AWS ALB/NLB), Cloudflare Load Balancing.
Що треба налаштувати в застосунку за балансувальником: довірені проксі (trustProxies), щоб Laravel бачив справжній IP клієнта й протокол https, а не адресу балансувальника; спільні сесії, кеш і файли - сервери мають бути без стану.
Балансувальник сам може стати точкою відмови - у продакшені їх резервують (пара з перемиканням чи керований хмарний сервіс).
Cache-aside (ліниве кешування) - найпоширеніша стратегія: застосунок сам керує кешем.
- Шукаємо значення в кеші.
- Знайшли - повертаємо.
- Не знайшли - читаємо з бази, кладемо в кеш, повертаємо.
$stats = Cache::remember("dashboard:stats:{$teamId}", now()->plus(minutes: 10), function () use ($teamId) {
return Order::where('team_id', $teamId)->selectRaw('count(*) as total, sum(amount) as revenue')->first();
});
При зміні даних - інвалідувати кеш (видалити ключ), щоб наступне читання взяло свіжі дані:
Cache::forget("dashboard:stats:{$teamId}");
Інші стратегії:
- write-through - при записі в базу одночасно оновлюється кеш. Кеш завжди актуальний, але кожен запис дорожчий, а в кеш потрапляють і дані, які ніхто не читає;
- write-behind - запис спершу в кеш, у базу - пізніше пакетами. Швидко, але ризик втрати даних;
- read-through - кеш сам завантажує дані з бази (зазвичай у спеціалізованих системах).
Як обрати TTL (час життя):
- як довго застарілі дані прийнятні для бізнесу? Курс валют - хвилина, список категорій - година чи доба, статистика для дашборду - кілька хвилин;
- як часто змінюються дані і чи є надійна інвалідація при змінах. З інвалідацією TTL може бути довгим - він лише страховка;
- ціна обчислення: дороге обчислення варто кешувати довше;
- випадковий розкид (jitter) - щоб тисячі ключів, створених одночасно, не застаріли в одну мить і не вдарили по базі разом.
Що кешувати: дорогі обчислення й запити, що повторюються; дані, однакові для багатьох користувачів. Що не кешувати: дешеві запити за первинним ключем, дані, що мають бути абсолютно точними (баланс при оплаті).
Найскладніше в кешуванні - інвалідація: кожне місце, що змінює дані, має знати, які ключі скинути. Теги кешу (Cache::tags(['team:5'])->flush()) у Redis спрощують групову інвалідацію.
CDN (content delivery network) - мережа серверів у різних країнах, що кешують вміст ближче до користувачів. Запит з Києва обслуговує вузол у Варшаві, а не сервер у Франкфурті чи Вірджинії.
Що дає CDN:
- швидкість - менша мережева затримка, особливо для далеких користувачів;
- розвантаження сервера - кешовані запити взагалі не доходять до застосунку;
- стійкість - CDN поглинає піки трафіку й частину DDoS-атак;
- оптимізації - стиснення, HTTP/3, перетворення зображень.
Що кешувати на CDN:
1. Статичні ресурси з хешем у назві (app-3f9a1c.js, logo-8b2e.png) - «назавжди»:
Cache-Control: public, max-age=31536000, immutable
2. Публічні сторінки для гостей (статті, каталог, лендинги) - короткий термін плюс фонове оновлення:
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
s-maxage - лише для спільних кешів (CDN), браузер його ігнорує.
3. Публічні відповіді API, однакові для всіх (довідники, курси).
Що НЕ кешувати:
- сторінки для автентифікованих користувачів - інакше один користувач побачить персональні дані іншого. Найнебезпечніша помилка з CDN;
- відповіді з
Set-Cookie; - форми з CSRF-токеном у HTML (токен «прилипне» до кешованої сторінки).
Як розрізняти гостей і автентифікованих: правило CDN «обходити кеш, якщо є cookie сесії» плюс заголовок Cache-Control: private, no-store від застосунку для персональних відповідей. Головне джерело правди - заголовки відповіді від застосунку, а не наявність cookie в запиті.
Інвалідація. Хешовані ресурси не потребують інвалідації (нова версія - нова назва). Для сторінок - короткий TTL або очищення через API CDN після публікації.
Пастка деплою: CDN може тримати старий HTML, що посилається на нові файли, або навпаки. Старі хешовані файли варто зберігати якийсь час після деплою.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії