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

Питання на співбесіді з Архітектура

Питання з реальних співбесід з відповідями: 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 для адреси, яка ніколи не існує окремо від замовлення), - і навпаки.

Докладніше в документації: Martin Fowler: Value Object

Анемічна модель - об'єкти домену містять лише дані (поля, геттери, сеттери), а вся поведінка живе в окремих сервісах. Модель - це просто структура для бази.

// анемічна модель
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 і чіткі назви допомагають.

Докладніше в документації: Martin Fowler: Domain Event

Гроші, час і статуси - три типи даних, які найчастіше моделюють «примітивами» (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) як запах коду: якщо значення має правила (формат, діапазон, операції), воно заслуговує на власний тип.

Докладніше в документації: Martin Fowler: Money

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

Вертикальне масштабування (scale up) - зробити сервер потужнішим: більше процесорів, пам'яті, швидші диски.

Горизонтальне (scale out) - додати ще сервери й розподілити навантаження між ними.

Вертикальне Горизонтальне
зміни в коді майже не потрібні застосунок має бути готовим
межа найпотужніший доступний сервер практично немає
відмовостійкість одна точка відмови відмова одного сервера не зупиняє систему
вартість дорожчає нелінійно лінійна, дешеві сервери
складність низька балансувальник, спільний стан, деплой на кілька машин

Почати варто з вертикального. Сучасний сервер з 32 ядрами й 128 ГБ пам'яті витримує дуже багато - для більшості Laravel-проєктів це роки росту без зміни архітектури. Передчасне горизонтальне масштабування додає складність без потреби.

Горизонтальне потрібне, коли:

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

Що треба зробити в застосунку, щоб масштабуватися горизонтально - прибрати стан з окремого сервера:

  • сесії - у Redis чи базі, а не у файлах;
  • завантажені файли - в S3/R2, а не на локальний диск;
  • кеш і блокування - у спільному Redis;
  • черги - Redis/SQS, воркери на окремих машинах;
  • планувальник - запуск на одному сервері (onOneServer()).

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

Докладніше в документації: System Design Primer

Застосунок без стану (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) на балансувальнику - обхідний шлях: користувача завжди відправляють на той самий сервер. Працює, але сервер стає точкою відмови для своїх користувачів, а навантаження розподіляється нерівномірно.

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

Докладніше в документації: Laravel: налаштування сесій

Балансувальник навантаження приймає запити клієнтів і розподіляє їх між кількома серверами застосунку. Для клієнта це одна адреса, а за нею - пул серверів.

Що він дає:

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

Балансувальник сам може стати точкою відмови - у продакшені їх резервують (пара з перемиканням чи керований хмарний сервіс).

Докладніше в документації: Load balancing

Cache-aside (ліниве кешування) - найпоширеніша стратегія: застосунок сам керує кешем.

  1. Шукаємо значення в кеші.
  2. Знайшли - повертаємо.
  3. Не знайшли - читаємо з бази, кладемо в кеш, повертаємо.
$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 спрощують групову інвалідацію.

Докладніше в документації: Azure Architecture: Cache-Aside

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, що посилається на нові файли, або навпаки. Старі хешовані файли варто зберігати якийсь час після деплою.

Докладніше в документації: MDN: HTTP-кешування

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії