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 на продакшені під навантаженням - ризикована операція.
У більшості вебзастосунків читань у десятки разів більше, ніж записів. Репліки дають змогу розподілити читання між кількома серверами бази, а всі записи лишити на основному (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 до тисяч - база почне деградувати від кількості з'єднань раніше, ніж від запитів.
Оцінка «на серветці» - швидкий розрахунок порядків величин до проєктування: скільки запитів на секунду, скільки даних, скільки серверів. Мета - не точність, а розуміння, чи це задача для одного сервера чи для розподіленої системи.
Приклад: сервіс коротких посилань.
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 на запит | десятки мс і МБ пам'яті |
Чому це цінно на співбесіді: показує, що архітектурні рішення спираються на цифри. Часто розрахунок доводить, що «складна розподілена система» не потрібна - вистачить одного сервера з кешем.
Типові помилки: не враховувати піки (середнє оманливе), забувати про репліки й бекапи в обсязі сховища, ігнорувати співвідношення читань і записів.