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

Middle: питання на співбесіді з теми «Масштабування й продуктивність»

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

5 питань

Лавина кешу (cache stampede, thundering herd) - популярний ключ кешу закінчується, і сотні одночасних запитів не знаходять значення, всі разом ідуть у базу й обчислюють одне й те саме. База, що спокійно працювала завдяки кешу, раптом отримує навантаження, від якого падає. А поки вона повільна, ключ ще довше не з'являється в кеші.

Захист:

1. Блокування - обчислює лише один.

$value = Cache::get('report');

if ($value === null) {
    $value = Cache::lock('report:lock', 10)->block(5, function () {
        return Cache::remember('report', now()->plus(minutes: 10), fn () => buildReport());
    });
}

Один процес обчислює, інші чекають і отримують готове значення.

2. Stale-while-revalidate - віддавати застаріле, оновлювати у фоні. У Laravel - Cache::flexible():

$stats = Cache::flexible('dashboard:stats', [300, 900], fn () => computeStats());
  • перші 300 секунд значення свіже - віддається з кешу;
  • з 300 до 900 секунд - застаріле, але прийнятне: віддається одразу, а перерахунок запускається після відправки відповіді (лише один, із блокуванням);
  • після 900 секунд - обчислюється заново синхронно.

Користувачі майже ніколи не чекають на обчислення, а база не отримує лавини.

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

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

5. Прогрів кешу - фонова задача оновлює важливі ключі за розкладом, і користувацькі запити кеш ніколи не «пропускають».

Схожа проблема - холодний старт: після очищення всього кешу (деплой, перезапуск Redis) - лавина по всіх ключах одразу. Тому cache:clear на продакшені під навантаженням - ризикована операція.

Докладніше в документації: Laravel: stale-while-revalidate

У більшості вебзастосунків читань у десятки разів більше, ніж записів. Репліки дають змогу розподілити читання між кількома серверами бази, а всі записи лишити на основному (primary).

У Laravel - розділення з'єднань:

'mysql' => [
    'read' => ['host' => ['10.0.0.11', '10.0.0.12']],
    'write' => ['host' => ['10.0.0.10']],
    'sticky' => true,
    // ...
],

SELECT ідуть на репліки, усе інше - на основний сервер.

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

Типові наслідки:

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

Як з цим жити:

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

Що репліки не вирішують:

  • масштабування записів - усі записи все одно на одному сервері;
  • повільні запити - запит, що займає 10 секунд, займе їх і на репліці. Спершу індекси й оптимізація.

Корисне застосування окремої репліки: важкі звіти, аналітика, бекапи - щоб вони не впливали на основний трафік.

Докладніше в документації: Laravel: з'єднання для читання й запису

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

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

public function store(StoreOrderRequest $request)
{
    $order = Order::create($request->validated());

    ProcessOrder::dispatch($order);   // секунди роботи - у фоні

    return new OrderResource($order);   // відповідь за мілісекунди
}

Користувач отримує відповідь одразу, а обробка «розтягується» на кілька хвилин після піку.

Що дає черга:

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

Зворотний тиск (backpressure) - механізм, що сповільнює виробника, коли споживач не встигає. Без нього черга росте необмежено: пам'ять Redis закінчується, задачі виконуються з годинною затримкою, і система падає пізніше, але гірше.

Способи зворотного тиску:

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

Що варто пам'ятати: черга не прибирає роботу, лише переносить її в часі. Якщо середнє навантаження вище за пропускну здатність воркерів, черга росте вічно - потрібна більша потужність, а не довша черга.

Докладніше в документації: Azure Architecture: Queue-Based Load Leveling

Кожне з'єднання з базою коштує ресурсів на сервері бази: у PostgreSQL це окремий процес з власною пам'яттю (мегабайти), у MySQL - потік. Тому кількість з'єднань обмежена (max_connections), і її не можна просто підняти до тисяч - база витратить пам'ять і процесор на керування з'єднаннями замість запитів.

Як PHP-застосунок вичерпує з'єднання: кожен процес PHP-FPM (чи воркер черги, чи воркер Octane) тримає власне з'єднання.

4 вебсервери × 50 процесів PHP-FPM   = 200 з'єднань
3 сервери воркерів × 20 процесів      =  60
планувальник, Horizon, Pulse, cron     ~  20

Додали сервери під час піку - і наступний запит отримує too many connections. Причому з'єднань багато, а активних запитів у кожен момент - одиниці: більшість процесів PHP у цей час чекає на мережу, рендерить шаблон чи стоїть без діла.

Пул з'єднань - проміжний сервіс між застосунком і базою (PgBouncer для PostgreSQL, ProxySQL для MySQL, керовані пули в хмарах):

  • застосунок відкриває сотні «дешевих» з'єднань до пулу;
  • пул тримає невелику кількість справжніх з'єднань до бази (наприклад, 30) і видає їх на час транзакції;
  • база бачить рівно стільки з'єднань, скільки може ефективно обслужити.

Режими PgBouncer:

  • session - з'єднання закріплене за клієнтом на весь сеанс (мало виграшу);
  • transaction - з'єднання видається лише на час транзакції - найефективніший, але ламає функції, що живуть довше транзакції: SET на рівні сесії, advisory locks на сесію, LISTEN/NOTIFY, підготовлені запити поза протоколом;
  • statement - на кожен оператор, найобмеженіший.

Для Laravel з PgBouncer у режимі transaction - вимкнути емульовані/постійні підготовлені запити, якщо вони несумісні, і не покладатися на стан сесії між транзакціями.

Інші важелі:

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

Помилка, якої варто уникати: «вирішити» проблему, піднявши max_connections до тисяч - база почне деградувати від кількості з'єднань раніше, ніж від запитів.

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

Оцінка «на серветці» - швидкий розрахунок порядків величин до проєктування: скільки запитів на секунду, скільки даних, скільки серверів. Мета - не точність, а розуміння, чи це задача для одного сервера чи для розподіленої системи.

Приклад: сервіс коротких посилань.

1. Припущення:

  • 1 млн нових посилань на день;
  • переходів у 100 разів більше - 100 млн на день;
  • один запис - ~500 байтів (URL, код, метадані).

2. Запити на секунду (у добі ~86 400 с, для простоти ~100 000):

  • записи: 1 млн / 100 000 ≈ 10 на секунду;
  • читання: 100 млн / 100 000 ≈ 1000 на секунду;
  • піки - у 3-5 разів вище середнього: до 5000 читань на секунду.

3. Сховище:

  • 1 млн × 500 байтів = 500 МБ на день;
  • × 365 × 5 років ≈ ~1 ТБ за п'ять років.

4. Висновки:

  • 10 записів на секунду - тривіально для однієї бази;
  • 5000 читань на секунду на простий пошук за ключем - під силу одній базі з індексом, але кеш (Redis) перед нею майже повністю прибере навантаження: популярні посилання - мала частка всіх;
  • 1 ТБ за 5 років - одна база, без шардингу.

Корисні числа, які варто пам'ятати (порядки):

Операція Порядок
читання з пам'яті наносекунди
запит до Redis у тій самій мережі ~0,5-1 мс
простий запит до бази за індексом ~1 мс
запит між дата-центрами десятки мс
процес PHP на запит десятки мс і МБ пам'яті

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

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

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