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

Питання на співбесіді: Масштабування й продуктивність

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

14 питань

Вертикальне масштабування (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-кешування

Лавина кешу (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: оцінки

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

Стратегії вибору шарду:

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

Чому шардинг - останній засіб:

  • запити між шардами: JOIN, агрегати, сортування по всіх даних стають розподіленими - їх збирає застосунок;
  • транзакції між шардами - без звичних гарантій ACID; потрібні саги чи двофазний коміт;
  • унікальність і ідентифікатори - автоінкремент на кожному шарді дасть дублікати; потрібні UUID/Snowflake;
  • перебалансування - додати шард означає перенести частину даних під навантаженням;
  • гарячі шарди - великий клієнт чи популярний ключ перевантажує один сервер;
  • операційна складність - бекапи, міграції схеми, моніторинг на N серверах;
  • вибір ключа майже незворотний - помилка дорого коштує через роки.

Що спробувати раніше:

  1. індекси, оптимізація запитів, усунення N+1;
  2. вертикальне масштабування бази (сучасні сервери - сотні ядер і терабайти пам'яті);
  3. кеш і репліки для читання;
  4. секціонування (partitioning) великих таблиць усередині однієї бази;
  5. винесення окремих доменів у власні бази (функціональне розділення) - лог подій, аналітика, пошук;
  6. архівація старих даних.

Коли шардинг виправданий: записи чи обсяг даних справді перевищують можливості найпотужнішого сервера, або вимоги до ізоляції клієнтів (SaaS з великими орендарями). Альтернатива - розподілені бази з вбудованим шардингом (Vitess, Citus, CockroachDB), що беруть частину складності на себе.

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

Класична задача системного дизайну. Важливо не одне «правильне» рішення, а структура міркувань: вимоги → оцінки → API → дані → вузькі місця.

1. Вимоги.

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

2. Оцінки (див. попередні розрахунки): ~10 записів і ~1000-5000 читань на секунду, ~1 ТБ за 5 років.

3. API:

POST /api/links        {"url": "https://..."} → {"code": "aB3xK9q"}
GET  /aB3xK9q          → 301/302 Location: https://...

301 чи 302: 301 кешують браузери - менше навантаження, але зникає статистика повторних переходів. Для аналітики - 302.

4. Генерація коду - найцікавіша частина:

  • хеш URL (перші символи base62 від SHA-256) - однакові URL дають однаковий код, але можливі колізії - потрібна перевірка;
  • лічильник + base62 - унікально без колізій; 7 символів base62 = 62^7 ≈ 3,5 трлн кодів. Але послідовні коди передбачувані - перемішати (бієкція, шифрування лічильника);
  • випадковий код + перевірка унікальності - простіше, при великій заповненості зростає ймовірність колізії.

5. Сховище: таблиця code → url з унікальним індексом на code. Пошук за ключем - ідеальний випадок для будь-якої бази; масштаб невеликий для шардингу.

6. Масштабування читань: кеш (Redis) перед базою: популярні посилання становлять малу частку, і кеш з LRU покриє більшість переходів. Плюс CDN/edge для редиректів.

7. Статистика переходів: не писати в базу синхронно на кожен перехід - події в чергу чи потік, агрегування пакетами.

8. Безпека й зловживання: перевірка URL на фішинг і шкідливі сайти, ліміти створення, заборона внутрішніх адрес.

Чого чекає інтерв'юер: уточнення вимог, обґрунтування вибору генерації кодів, розуміння, що основне навантаження - читання, і що кеш вирішує його дешевше за шардинг.

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

SLI (service level indicator) - вимірювана характеристика якості сервісу з погляду користувача:

  • доступність: частка успішних запитів (не 5xx);
  • затримка: частка запитів, швидших за 300 мс;
  • свіжість даних: частка оновлень, що з'явилися протягом хвилини.

SLO (service level objective) - цільове значення SLI за період:

  • «99,9% запитів до API успішні за 30 днів»;
  • «95% сторінок відкриваються швидше за 500 мс».

SLA (agreement) - договірне зобов'язання перед клієнтами з наслідками (компенсаціями). SLA зазвичай м'якший за внутрішній SLO, щоб мати запас.

Бюджет помилок - допустима «ненадійність», що випливає з SLO:

SLO 99,9% за 30 днів → 0,1% невдалих запитів
                      → ~43 хвилини повної недоступності на місяць

Як бюджет помилок керує рішеннями:

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

Це прибирає вічну суперечку «розробка хоче швидше, експлуатація хоче стабільніше» - є об'єктивне число.

Чому не 100%:

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

Практичні поради:

  • SLI - з погляду користувача, а не серверів: «процесор на 40%» - не SLI, «90% запитів швидші за 200 мс» - SLI;
  • перцентилі, а не середні: середня затримка ховає повільний «хвіст», який бачать реальні користувачі;
  • кілька SLO на ключові сценарії (вхід, оформлення замовлення, пошук), а не один на весь сервіс;
  • сповіщення за швидкістю витрачання бюджету (burn rate), а не за кожен окремий збій - менше хибних тривог.

Для невеликого проєкту досить почати з одного SLO доступності й одного затримки для головних сторінок - і вимірювати їх (Pulse, APM, uptime-моніторинг).

Докладніше в документації: Google SRE Book: Service Level Objectives

Каскадна відмова - збій одного компонента поширюється на інші й валить усю систему. Типовий сценарій:

  1. сервіс рекомендацій сповільнився (відповідає за 30 секунд замість 50 мс);
  2. процеси PHP, що чекають на нього, не звільняються;
  3. пул процесів вичерпано - уся сторінка товару, а за нею й увесь сайт перестає відповідати;
  4. користувачі оновлюють сторінку - навантаження зростає, повторні спроби добивають систему.

Причина - не відмова рекомендацій, а те, що від них синхронно залежала критична частина.

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

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

Механізми:

1. Тайм-аути на всі зовнішні виклики - короткі, розраховані на нормальну відповідь, а не на «стандартні 30 секунд»:

Http::timeout(2)->connectTimeout(1)->get($url);

2. Запобіжник (circuit breaker) - після серії помилок перестати викликати сервіс на якийсь час і одразу повертати запасний варіант. Сервіс отримує час відновитися, а запити - швидку відповідь замість очікування.

3. Запасні відповіді (fallback) - кешоване значення, порожній блок, спрощена версія.

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

5. Обмеження й скидання навантаження (load shedding) - при перевантаженні відмовляти частині запитів одразу (503), щоб решта обслуговувалася нормально, - замість того щоб повільно обслуговувати всіх.

6. Повтори з експоненційною затримкою й розкидом - і з обмеженою кількістю; бездумні повтори множать навантаження на сервіс, що й так не справляється.

7. Асинхронність - некритичне (аналітика, листи, синхронізації) у черги, щоб збій отримувача не впливав на відповідь користувачу.

8. Прапорці функцій - швидко вимкнути важку функцію під час інциденту без деплою.

Перевірка: навмисно «вимикати» залежності в тестовому середовищі (chaos testing) і дивитися, що відбувається з головними сценаріями.

Докладніше в документації: Google SRE Book: каскадні відмови