← Valtive QA Quiz quiz.valtive.io/notes/performance valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-інженерів і SDET, яких на співбесіді питають про навантажувальне тестування: від джуніора, що вперше пише k6-скрипт, до сеньйора, який готує систему до Black Friday і пояснює, чому впав p99. Гайд покриває всі 40 питань квізу Performance і додає те, що зазвичай питають поверх нього: закон Літтла, відкриту й закриту моделі навантаження, coordinated omission, аналіз вузьких місць і Core Web Vitals.
For QA engineers and SDETs who get asked about load testing in interviews: from a junior writing a first k6 script to a senior preparing a system for Black Friday and explaining why p99 went up. The guide covers all 40 questions of the Performance quiz and adds what interviewers usually ask on top: Little's law, open and closed workload models, coordinated omission, bottleneck analysis and Core Web Vitals.
Як користуватисьHow to use it
Фільтруй за рівнем або розділом. Відповідь іде за схемою «Що це → Навіщо → Приклад → Підводні камені → Уточнення», а під нею — k6-скрипт, конфіг, SQL або розрахунок із числами, який варто вміти відтворити на дошці. Останній розділ — сценарії рівня senior. Після прочитання закрий текст і перекажи відповідь за блоком 🗣 Як читати вголос: так тренується жива розмова з інтерв'юером, а не впізнавання знайомих слів.
Filter by level or section. Each answer follows "What it is → Why → Example → Pitfalls → Follow-up", and below it sits a k6 script, a config, SQL or a worked calculation you should be able to reproduce on a whiteboard. The last section holds senior-level scenarios. After reading, close the text and retell the answer using the 🗣 How to say it out loud block: that trains a live conversation with the interviewer rather than recognising familiar words.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · Метрики і перцентиліANCHOR 1 · Metrics and percentiles
Середнє ховає біль. Дивись на p95 і p99 разом із пропускною здатністю та часткою помилок, і пам'ятай закон Літтла: конкурентність = інтенсивність × час у системі.
The average hides the pain. Look at p95 and p99 together with throughput and error rate, and remember Little's law: concurrency = arrival rate × time in the system.
ЯКІР 2 · Типи навантаженняANCHOR 2 · Load types
Кожен тип тесту відповідає на своє питання: load — чи тримаємо очікуване, stress і breakpoint — де межа, spike — чи переживемо стрибок, soak — чи не деградуємо з часом. Профіль навантаження будується з продакшн-даних.
Every test type answers its own question: load — do we hold the expected traffic, stress and breakpoint — where is the limit, spike — do we survive a jump, soak — do we degrade over time. The load profile is built from production data.
ЯКІР 3 · Вузькі місця і гейти в CIANCHOR 3 · Bottlenecks and CI gates
Число без причини нікому не допомагає. Зв'язуй латентність із насиченням ресурсів (CPU, GC, пул з'єднань, блокування в БД), а результат перетворюй на порогові значення, які автоматично валять збірку.
A number without a cause helps nobody. Tie latency to resource saturation (CPU, GC, connection pool, database locks), and turn the result into thresholds that fail the build automatically.
55
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
40/40
питань квізу покритоquiz questions covered
📚 8 РОЗДІЛІВ📚 8 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 7
1 Навіщо потрібне performance-тестування і коли його проводити? Why do we need performance testing and when should it be done?
Типи навантаження Load types Junior ▶

Що це. Performance-тестування перевіряє не «чи працює функція», а «як швидко, стабільно і скільки користувачів вона витримує». Результат — числа: час відповіді в перцентилях, запити за секунду, частка помилок, завантаження CPU, пам'яті й бази під конкретним профілем навантаження.

What it is. Performance testing checks not "does the feature work" but "how fast, how stable and for how many users". The output is numbers: response time percentiles, requests per second, error rate, CPU, memory and database utilisation under a specific load profile.

Навіщо. Функціонально правильна система може бути непридатною: кошик, що відповідає за 8 секунд у піковий вечір, втрачає покупців так само, як кошик з багом. Тест відповідає на бізнесові питання: чи переживемо розпродаж, скільки серверів купувати, чи не став новий реліз повільнішим, де перше вузьке місце.

Why. A functionally correct system can still be unusable: a cart that answers in 8 seconds on a peak evening loses buyers just like a buggy one. The test answers business questions: will we survive the sale, how many servers do we need, did the new release get slower, where is the first bottleneck.

Коли. Перед великими подіями (запуск, маркетингова кампанія, сезонний пік), після зміни архітектури чи інфраструктури (нова БД, міграція в хмару, оновлення фреймворку), регулярно в CI як короткий smoke-тест продуктивності і після інциденту, щоб відтворити його в контрольованому середовищі.

When. Before big events (a launch, a marketing campaign, a seasonal peak), after architecture or infrastructure changes (a new database, a cloud migration, a framework upgrade), regularly in CI as a short performance smoke test, and after an incident to reproduce it in a controlled environment.

Підводні камені. Найчастіша помилка — запускати тест «щоб був», без цілі й критеріїв. Тоді є гарний графік, але незрозуміло, добре це чи погано. Спершу формулюють питання й пороги (наприклад, p95 менше 400 мс при 300 RPS і помилок менше 1%), а вже потім пишуть скрипт.

Pitfalls. The most common mistake is running a test "just to have one", with no goal and no criteria. You get a nice chart but nobody can say whether it is good or bad. First write down the question and the thresholds (for example p95 under 400 ms at 300 RPS with under 1% errors), and only then write the script.

Уточнення. Інтерв'юер може спитати, хто за це відповідає. Чесна відповідь: спільно команда, а QA приносить методологію, скрипти й інтерпретацію результатів.

Follow-up. The interviewer may ask who owns this. The honest answer: the whole team, while QA brings the methodology, the scripts and the interpretation of results.

Мінімальний план тесту перед першим рядком коду
A minimal test plan before the first line of code
Питання:   чи витримає checkout розпродаж 11.11?
Профіль:   300 RPS, 70% перегляд / 25% кошик / 5% оплата
Тривалість: 5 хв розгону + 30 хв плато
Критерії:  p95 < 400 мс, p99 < 1 с, помилок < 1%
Середовище: perf-стенд 1:1 з продом, дані 2 млн товарів
Спостерігаємо: APM, CPU/пам'ять, пул з'єднань, повільні запити
Question:  will checkout survive the 11.11 sale?
Profile:   300 RPS, 70% browse / 25% cart / 5% payment
Duration:  5 min ramp-up + 30 min plateau
Criteria:  p95 < 400 ms, p99 < 1 s, errors < 1%
Env:       perf stand 1:1 with prod, 2M products
Observe:   APM, CPU/memory, connection pool, slow queries
🔧 Розбір: План читається як контракт: питання визначає тип тесту, профіль і тривалість — сценарій, а критерії — умову «пройшов/не пройшов». Рядок «Спостерігаємо» нагадує, що без серверних метрик результат не можна пояснити.
🔧 Breakdown: The plan reads like a contract: the question picks the test type, the profile and duration shape the scenario, and the criteria define pass or fail. The "Observe" line is a reminder that without server-side metrics the result cannot be explained.
💡 Простими словами: Функціональний тест питає «чи правильно?», performance-тест питає «чи правильно, коли одночасно прийшли всі?».
💡 In plain words: A functional test asks "is it correct?"; a performance test asks "is it still correct when everyone shows up at once?".
🗣 Як читати вголос Для мене performance-тестування — це відповідь на конкретне бізнес-питання числами. Перш ніж писати скрипт, я фіксую, що саме хочемо дізнатися: переживемо пік, скільки потрібно інстансів чи не сповільнився реліз. Потім домовляюся про критерії — перцентилі, частку помилок, пропускну здатність — і про середовище. Запускаю перед великими подіями, після змін інфраструктури і короткою версією в CI на кожну збірку. Без цілі й порогів тест дає красивий графік і жодного рішення.
🗣 How to say it out loud To me performance testing means answering a concrete business question with numbers. Before writing any script I pin down what we want to learn: will we survive the peak, how many instances we need, or whether the release got slower. Then I agree on criteria — percentiles, error rate, throughput — and on the environment. I run it before big events, after infrastructure changes, and a short version in CI on every build. Without a goal and thresholds a test gives you a pretty chart and no decision.
2 Чим load-тест відрізняється від stress-тесту? How does a load test differ from a stress test?
Типи навантаження Load types Junior ▶

Що це. Load-тест подає очікуване навантаження — звичайний або піковий трафік з прогнозу — і перевіряє, що система вкладається в цілі за латентністю й помилками. Stress-тест свідомо йде за межу очікуваного, щоб знайти точку, де система ламається, і подивитися, як вона ламається.

What it is. A load test applies the expected load — normal or forecast peak traffic — and checks that the system meets its latency and error targets. A stress test deliberately goes beyond the expected to find the point where the system breaks and to see how it breaks.

Навіщо обидва. Load відповідає «чи готові ми до реального трафіку», stress — «що буде, коли прогноз помилився». Для stress важлива не лише цифра межі, а поведінка: система повертає зрозумілі 503 і швидко відновлюється, чи зависає, втрачає дані, падає каскадом і не піднімається після зниження навантаження.

Why both. Load answers "are we ready for real traffic"; stress answers "what happens when the forecast was wrong". For stress it is not only the limit that matters but the behaviour: does the system return clear 503s and recover quickly, or does it hang, lose data, fail in a cascade and stay down after the load drops.

Приклад. Прогноз піку — 500 RPS. Load-тест: розгін до 500 і 30 хвилин плато, критерій p95 < 300 мс. Stress-тест: сходинки 500 → 750 → 1000 → 1250 RPS по 5 хвилин, поки p95 не перевищить 2 с або помилки не підуть вище 5%, а потім спад до 300 RPS, щоб перевірити відновлення.

Example. The forecast peak is 500 RPS. Load test: ramp to 500 and a 30-minute plateau, criterion p95 < 300 ms. Stress test: steps of 500 → 750 → 1000 → 1250 RPS for 5 minutes each until p95 exceeds 2 s or errors pass 5%, then a drop back to 300 RPS to verify recovery.

Підводні камені. Stress-тест на спільному стенді кладе роботу іншим командам — його планують і попереджають. Друга пастка: вважати результатом лише «впало на 1100 RPS». Цінність у тому, що впало першим — пул з'єднань, CPU, чергу чи сторонній сервіс.

Pitfalls. A stress test on a shared stand takes down other teams' work, so it is scheduled and announced. The second trap is reporting only "it broke at 1100 RPS". The value is in what broke first — the connection pool, CPU, a queue or a third-party service.

Уточнення. Часто питають про breakpoint-тест — це stress з поступовим безперервним зростанням до відмови, щоб точно знайти межу.

Follow-up. You are often asked about a breakpoint test: a stress test with gradual, continuous growth until failure, to pinpoint the limit.

Профілі навантаження: load і stress
Load profiles: load and stress
RPS    Load-тест (очікуваний пік)
500 |      ______________________
    |     /                      \
    |    /                        \
  0 |___/__________________________\____ час
       розгін    плато 30 хв          спад

RPS    Stress-тест (сходинки за межу)
1250|                        ____  <- p95 > 2 с: зупинка
1000|                  _____|    |
 750|            _____|          |
 500|      _____|                |___ <- перевірка відновлення
  0 |_____|___________________________ час
RPS    Load test (expected peak)
500 |      ______________________
    |     /                      \
    |    /                        \
  0 |___/__________________________\____ time
       ramp     plateau 30 min      down

RPS    Stress test (steps past the limit)
1250|                        ____  <- p95 > 2 s: stop
1000|                  _____|    |
 750|            _____|          |
 500|      _____|                |___ <- recovery check
  0 |_____|___________________________ time
🔧 Розбір: Горизонтальна вісь — час, вертикальна — інтенсивність запитів. У load-профілі головне — довге рівне плато на прогнозованому рівні; у stress-профілі кожна сходинка тримається кілька хвилин, щоб метрики встигли стабілізуватися, а хвіст після зупинки показує відновлення.
🔧 Breakdown: The horizontal axis is time, the vertical one is request rate. In the load profile what matters is a long flat plateau at the forecast level; in the stress profile each step is held for several minutes so metrics can settle, and the tail after the stop shows recovery.
💡 Простими словами: Load — «чи витримаємо звичайний вечір п'ятниці», stress — «що станеться, коли прийде вдвічі більше людей, ніж ми думали».
💡 In plain words: Load is "can we handle a normal Friday night"; stress is "what happens when twice as many people show up as we expected".
🗣 Як читати вголос Load-тест у мене завжди прив'язаний до прогнозу: беру очікуваний пік, роблю розгін і довге плато, і перевіряю, що перцентилі та помилки в межах SLO. Stress-тест — свідомий вихід за прогноз сходинками, поки система не почне деградувати. Там мене цікавить не тільки число, на якому все впало, а що саме здалося першим і чи піднялася система після зниження навантаження. Хороший результат stress-тесту — це контрольована деградація з 503, а не зависання.
🗣 How to say it out loud My load test is always tied to a forecast: I take the expected peak, ramp up, hold a long plateau and check that percentiles and errors stay inside the SLO. A stress test is a deliberate step-by-step push past the forecast until the system starts to degrade. What interests me there is not just the number where it fell over, but which resource gave up first and whether the system came back once the load dropped. A good stress result is controlled degradation with 503s, not a hang.
3 Що таке spike-тест і що в ньому перевіряють? What is a spike test and what does it verify?
Типи навантаження Load types Junior ▶

Що це. Spike-тест моделює раптовий стрибок навантаження: за секунди трафік зростає в кілька разів, тримається недовго і так само різко падає. Мета — побачити, як система переживає удар і як швидко повертається до норми.

What it is. A spike test models a sudden jump in load: within seconds traffic multiplies, holds briefly and drops just as sharply. The goal is to see how the system takes the hit and how fast it returns to normal.

Навіщо. Реальні піки рідко бувають плавними: push-сповіщення на мільйон користувачів, старт продажу квитків о 10:00, згадка в новинах. Автоскейлінг, пули з'єднань і кеші мають час реакції; якщо нові інстанси піднімаються за 3 хвилини, а стрибок триває 2, користувачі бачать помилки саме в цьому вікні.

Why. Real peaks are rarely smooth: a push notification to a million users, a ticket sale opening at 10:00, a mention in the news. Autoscaling, connection pools and caches all have reaction times; if new instances take 3 minutes to start and the spike lasts 2, users see errors exactly in that window.

Що дивитися. Помилки й таймаути в перші секунди стрибка, ріст черг, поведінку автоскейлера (коли спрацював, скільки інстансів додав), холодні кеші, а головне — час відновлення: за скільки хвилин після спаду p95 повернувся до базового рівня і чи не залишилися «завислі» з'єднання.

What to watch. Errors and timeouts in the first seconds of the spike, queue growth, autoscaler behaviour (when it fired, how many instances it added), cold caches, and above all recovery time: how many minutes after the drop p95 returned to baseline and whether any stuck connections remained.

Приклад. Сервіс квитків: база 100 RPS, о 10:00 стрибок до 2000 RPS за 10 секунд, 3 хвилини тримання, потім спад. Критерій: помилки лише 429/503 з Retry-After, жодних 500, жодного продажу одного місця двічі, повернення p95 до норми менш ніж за 2 хвилини.

Example. A ticketing service: baseline 100 RPS, at 10:00 a jump to 2000 RPS within 10 seconds, held for 3 minutes, then a drop. Criteria: only 429/503 with Retry-After as errors, no 500s, no seat sold twice, p95 back to normal within 2 minutes.

Підводні камені. Генератор навантаження має сам витримати стрибок; інакше «spike» насправді плавний. Також важливо стартувати з прогрітої системи, щоб відокремити ефект стрибка від холодного старту.

Pitfalls. The load generator itself must handle the jump; otherwise the "spike" is actually smooth. It is also important to start from a warmed-up system so the spike effect is separated from a cold start.

k6: spike-профіль через ramping-arrival-rate
k6: a spike profile with ramping-arrival-rate
export const options = {
  scenarios: {
    spike: {
      executor: 'ramping-arrival-rate',
      startRate: 100, timeUnit: '1s',
      preAllocatedVUs: 200, maxVUs: 3000,
      stages: [
        { duration: '2m', target: 100 },   // база
        { duration: '10s', target: 2000 }, // стрибок
        { duration: '3m', target: 2000 },  // тримання
        { duration: '10s', target: 100 },  // спад
        { duration: '5m', target: 100 },   // відновлення
      ],
    },
  },
};
export const options = {
  scenarios: {
    spike: {
      executor: 'ramping-arrival-rate',
      startRate: 100, timeUnit: '1s',
      preAllocatedVUs: 200, maxVUs: 3000,
      stages: [
        { duration: '2m', target: 100 },   // baseline
        { duration: '10s', target: 2000 }, // spike
        { duration: '3m', target: 2000 },  // hold
        { duration: '10s', target: 100 },  // drop
        { duration: '5m', target: 100 },   // recovery
      ],
    },
  },
};
🔧 Розбір: Executor ramping-arrival-rate задає інтенсивність у ітераціях за timeUnit, а не кількість користувачів, тому стрибок буде справжнім навіть якщо система сповільниться. maxVUs — запас віртуальних користувачів для такого темпу; якщо його не вистачить, k6 покаже метрику dropped_iterations.
🔧 Breakdown: The ramping-arrival-rate executor sets intensity in iterations per timeUnit rather than a number of users, so the spike stays real even when the system slows down. maxVUs is the pool of virtual users for that rate; if it runs out, k6 reports the dropped_iterations metric.
💡 Простими словами: Spike — це як відкриття дверей магазину в Чорну п'ятницю: цікаво не лише, чи витримають двері, а й чи зможуть продавці працювати через п'ять хвилин.
💡 In plain words: A spike is the store doors opening on Black Friday: the question is not only whether the doors hold, but whether the staff can still work five minutes later.
🗣 Як читати вголос Spike-тест я роблю, коли в продукті є події з різким стартом — розсилки, продаж квитків, запуск акції. Профіль простий: база, стрибок за секунди в кілька разів, коротке тримання і спад. Дивлюся на три речі: які помилки бачать користувачі в перші секунди, як швидко реагує автоскейлер і за скільки система повертається до базової латентності. Навантаження задаю через arrival rate, щоб генератор не пом'якшив стрибок, коли сервер почне гальмувати.
🗣 How to say it out loud I run a spike test when the product has events with a hard start — mass notifications, ticket sales, a promo launch. The profile is simple: baseline, a jump of several times within seconds, a short hold and a drop. I watch three things: which errors users see in the first seconds, how fast the autoscaler reacts, and how long it takes to get back to baseline latency. I drive it by arrival rate so the generator does not soften the spike once the server starts to slow down.
4 Що таке soak- або endurance-тест і які проблеми він знаходить? What is a soak or endurance test and what problems does it find?
Типи навантаження Load types Junior ▶

Що це. Soak (endurance) — тривалий тест на помірному, реалістичному навантаженні: 4, 8, 24 години. Інтенсивність зазвичай 60–80% від очікуваного піку, бо мета — не знайти межу, а побачити, що відбувається з системою з часом.

What it is. A soak (endurance) test is a long run at a moderate, realistic load: 4, 8, 24 hours. The intensity is usually 60–80% of the expected peak, because the aim is not to find the limit but to see what happens to the system over time.

Що знаходить. Проблеми, яких не видно за 15 хвилин: витоки пам'яті (heap повільно росте й не повертається після GC), витік з'єднань чи файлових дескрипторів, ріст черг, переповнення диска логами, деградацію через фрагментацію, зростання таблиць без індексів, проблеми з ротацією токенів, нічні cron-задачі, що конфліктують із трафіком.

What it finds. Problems invisible in 15 minutes: memory leaks (the heap slowly grows and does not come back after GC), leaked connections or file descriptors, growing queues, disks filling up with logs, degradation from fragmentation, tables growing without indexes, token rotation issues, nightly cron jobs clashing with traffic.

Приклад. 8-годинний тест API на 200 RPS: перші 2 години p95 стабільний 180 мс, далі щогодини додається 20 мс, а пам'ять після кожного full GC стає вищою. Heap dump показав кеш у статичній мапі без обмеження розміру — класичний витік, який короткий load-тест не ловить.

Example. An 8-hour API test at 200 RPS: for the first 2 hours p95 is a stable 180 ms, then it adds 20 ms every hour, and memory after each full GC keeps ending higher. A heap dump showed a cache in a static map with no size limit — a classic leak that a short load test never catches.

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

What to monitor. Trends, not points: memory after GC, active connections and threads, queue sizes, GC pause time, disk space. Compare the first and the last hour: a stable system has the same percentiles at the start and at the end.

Підводні камені. Тестові дані закінчуються (унікальні email, купони) і тест починає падати з бізнес-помилок. Токени протухають через годину. Генератор пише гігабайти сирих результатів. Усе це треба передбачити до старту.

Pitfalls. Test data runs out (unique emails, coupons) and the test starts failing on business errors. Tokens expire after an hour. The generator writes gigabytes of raw results. All of this has to be planned before the start.

Як виглядає витік пам'яті в soak-тесті
What a memory leak looks like in a soak test
heap після full GC (MB)
1800|                                   .-*
1400|                         .-*--*--*'
1000|              .-*--*--*'
 600|  *--*--*--*'
    |___________________________________ години
     0    1    2    3    4    5    6    7    8

p95 (ms):  180  180  185  200  220  240  265  290
Висновок: ріст і пам'яті, і p95 з часом = витік, а не шум
heap after full GC (MB)
1800|                                   .-*
1400|                         .-*--*--*'
1000|              .-*--*--*'
 600|  *--*--*--*'
    |___________________________________ hours
     0    1    2    3    4    5    6    7    8

p95 (ms):  180  180  185  200  220  240  265  290
Conclusion: memory and p95 both grow over time = a leak, not noise
🔧 Розбір: Ключова лінія — пам'ять після повного збирання сміття: якщо навіть після GC вона щогодини вища, об'єкти десь утримуються. Рядок p95 під графіком показує наслідок для користувача: довші GC-паузи і повільніші відповіді.
🔧 Breakdown: The key line is memory after a full garbage collection: if it is higher every hour even after GC, objects are being held somewhere. The p95 row below the chart shows the user-facing effect: longer GC pauses and slower responses.
💡 Простими словами: Soak-тест — це марафон замість спринту: не цікаво, як швидко біжиш, цікаво, чи не натер ногу на двадцятому кілометрі.
💡 In plain words: A soak test is a marathon instead of a sprint: speed is not the point, the point is whether a blister shows up at kilometre twenty.
🗣 Як читати вголос Soak-тест — це довгий прогін на реалістичному рівні, години замість хвилин. Я запускаю його, щоб ловити те, що накопичується: витоки пам'яті, з'єднань, ріст черг і логів. Дивлюся на тренди — пам'ять після GC, кількість з'єднань, p95 на першій і останній годині. Якщо все росте лінійно, це не шум, а витік. І заздалегідь готую дані на всю тривалість та оновлення токенів, інакше тест впаде з причин, які до продуктивності стосунку не мають.
🗣 How to say it out loud A soak test is a long run at a realistic level, hours instead of minutes. I use it to catch things that accumulate: leaked memory and connections, growing queues and logs. I look at trends — memory after GC, connection counts, p95 in the first versus the last hour. If everything climbs linearly, that is a leak, not noise. And I prepare enough data for the whole run and token refresh up front, otherwise the test fails for reasons that have nothing to do with performance.
5 Чим відрізняються volume-, scalability-, capacity- і breakpoint-тести, і що таке горизонтальне та вертикальне масштабування? How do volume, scalability, capacity and breakpoint tests differ, and what are horizontal and vertical scaling?
Типи навантаження Load types Middle ▶

Volume. Змінюється не кількість запитів, а обсяг даних: 50 мільйонів замовлень у таблиці замість 10 тисяч, файл на 2 ГБ, відповідь зі 100 тисячами рядків. Знаходить повні скани таблиць, пагінацію без індексу, сортування в пам'яті.

Volume. What changes is not the number of requests but the amount of data: 50 million orders in a table instead of 10 thousand, a 2 GB file, a response with 100 thousand rows. It finds full table scans, pagination without an index, in-memory sorting.

Scalability. Перевіряє, як росте пропускна здатність при додаванні ресурсів: 2 → 4 → 8 інстансів. Ідеал — лінійний ріст; реальність — насичення на спільному ресурсі (одна БД, один лок, один кеш). Capacity відповідає, скільки користувачів або RPS система тримає в межах SLO на заданій конфігурації — число для планування закупівель. Breakpoint повільно й безперервно підвищує навантаження до відмови, щоб знайти точну межу й перший ресурс, що здався.

Scalability. Checks how throughput grows as resources are added: 2 → 4 → 8 instances. The ideal is linear growth; reality is saturation on a shared resource (one database, one lock, one cache). Capacity answers how many users or RPS the system holds within the SLO on a given configuration — a number for purchase planning. Breakpoint raises load slowly and continuously until failure to find the exact limit and the first resource that gives up.

Масштабування. Вертикальне — більший сервер (більше CPU, RAM): просто, але має стелю і не додає відмовостійкості. Горизонтальне — більше інстансів за балансувальником: майже безмежне і стійке, але вимагає stateless-сервісів, спільних сесій і уважності до БД, яка часто залишається єдиною.

Scaling. Vertical means a bigger server (more CPU, RAM): simple, but it has a ceiling and adds no fault tolerance. Horizontal means more instances behind a load balancer: nearly unlimited and resilient, but it requires stateless services, shared sessions and care about the database, which often stays single.

Приклад. Scalability-тест: 1 інстанс — 400 RPS, 2 — 780, 4 — 1400, 8 — 1500. Ефективність падає після 4, бо БД досягла 95% CPU. Висновок: додавати застосункові сервери безглуздо, потрібні репліки для читання або кеш.

Example. A scalability test: 1 instance — 400 RPS, 2 — 780, 4 — 1400, 8 — 1500. Efficiency collapses after 4 because the database hit 95% CPU. Conclusion: adding app servers is pointless; you need read replicas or a cache.

Підводні камені. Називати все «load-тестом». На співбесіді цінують, коли кандидат обирає тип під питання бізнесу.

Pitfalls. Calling everything a "load test". Interviewers value a candidate who picks the test type for the business question.

Результат scalability-тесту: де закінчується лінійність
A scalability result: where linearity ends
інстанси  RPS@SLO  ідеал   ефективність  CPU БД
   1        400     400      100%          25%
   2        780     800       98%          48%
   4       1400    1600       88%          85%
   8       1500    3200       47%          97%  <- вузьке місце: БД

Ефективність = RPS / (RPS на 1 інстансі × кількість)
instances  RPS@SLO  ideal   efficiency  DB CPU
   1        400     400      100%          25%
   2        780     800       98%          48%
   4       1400    1600       88%          85%
   8       1500    3200       47%          97%  <- bottleneck: DB

Efficiency = RPS / (single-instance RPS × count)
🔧 Розбір: Таблиця порівнює фактичну пропускну здатність з ідеально лінійною. Коли ефективність падає, а метрика спільного ресурсу (тут CPU бази) наближається до 100%, додавати інстанси вже марно — це і є висновок scalability-тесту.
🔧 Breakdown: The table compares actual throughput with perfectly linear throughput. When efficiency drops and a shared resource's metric (here DB CPU) approaches 100%, adding instances no longer helps — that is the finding of a scalability test.
💡 Простими словами: Вертикально — купити більшу машину, горизонтально — купити більше машин. А тести відповідають, яка з покупок узагалі допоможе.
💡 In plain words: Vertical is buying a bigger truck, horizontal is buying more trucks. The tests tell you which purchase will actually help.
🗣 Як читати вголос Я розрізняю ці тести за питанням. Volume — чи витримаємо великі обсяги даних, а не запитів. Scalability — чи росте пропускна здатність, коли додаємо інстанси. Capacity — скільки тримаємо в межах SLO на поточному залізі. Breakpoint — де точна межа і що ламається першим. Щодо масштабування: вертикальне простіше, але впирається в стелю, горизонтальне гнучкіше, але вимагає stateless-сервісів. І scalability-тест часто показує, що справжня межа — спільна база, а не кількість серверів застосунку.
🗣 How to say it out loud I tell these tests apart by the question they answer. Volume: can we handle big amounts of data rather than requests. Scalability: does throughput grow when we add instances. Capacity: how much we hold within the SLO on the current hardware. Breakpoint: where exactly the limit is and what breaks first. On scaling: vertical is simpler but hits a ceiling, horizontal is more flexible but needs stateless services. And a scalability test often shows the real limit is a shared database, not the number of app servers.
6 Що таке warm-up і чому його виключають з вимірювань? What is warm-up and why is it excluded from measurements?
Типи навантаження Load types Middle ▶

Що це. Warm-up — початкова фаза тесту, коли система працює, але ще не в стабільному стані: JIT-компілятор JVM або V8 ще не оптимізував гарячий код, кеші (застосунку, БД, ОС) порожні, пули з'єднань і потоків лише створюються, автоскейлер ще не відреагував, DNS і TLS-сесії не закешовані.

What it is. Warm-up is the initial phase of a test when the system is running but not yet in a steady state: the JVM or V8 JIT compiler has not optimised the hot code, caches (application, database, OS) are empty, connection and thread pools are still being created, the autoscaler has not reacted, DNS and TLS sessions are not cached.

Навіщо виключати. Перші хвилини дають аномально високу латентність, яка не описує типову роботу продакшену, де сервіс працює днями. Якщо змішати їх з плато, p99 виявиться завищеним, а порівняння двох прогонів — нестабільним, бо частка «холодних» запитів залежить від тривалості тесту.

Why exclude it. The first minutes produce abnormally high latency that does not describe typical production, where a service runs for days. Mixed into the plateau, it inflates p99 and makes comparisons between runs unstable, because the share of "cold" requests depends on test length.

Як робити. Додати окремий етап розгону на 2–5 хвилин і рахувати критерії лише для плато. У k6 зручно розділити фази на різні сценарії з тегами та ставити пороги лише на тег плато. У JMeter — окрема Thread Group або відкидання перших хвилин при аналізі JTL.

How. Add a separate 2–5 minute ramp stage and evaluate criteria only on the plateau. In k6 it is handy to split phases into different scenarios with tags and put thresholds only on the plateau tag. In JMeter use a separate Thread Group or drop the first minutes when analysing the JTL file.

Коли warm-up не виключають. Коли саме холодний старт — предмет тесту: serverless-функції, перший запуск після деплою, поведінка автоскейлера на новому поді. Тоді навпаки, вимірюють окремо перші запити.

When warm-up is not excluded. When the cold start is exactly what you test: serverless functions, the first requests after a deploy, autoscaler behaviour on a fresh pod. Then you measure the first requests separately instead.

Підводні камені. Прогрівати не тими запитами: якщо warm-up читає лише один товар, кеш прогріється нереалістично. Прогрів має використовувати той самий мікс, що й основна фаза.

Pitfalls. Warming up with the wrong requests: if warm-up only reads one product, the cache warms unrealistically. Warm-up must use the same mix as the main phase.

k6: окремі сценарії прогріву і вимірювання
k6: separate warm-up and measurement scenarios
export const options = {
  scenarios: {
    warmup: {
      executor: 'constant-arrival-rate', rate: 50, timeUnit: '1s',
      duration: '3m', preAllocatedVUs: 50, tags: { phase: 'warmup' },
    },
    steady: {
      executor: 'constant-arrival-rate', rate: 300, timeUnit: '1s',
      duration: '20m', startTime: '3m', preAllocatedVUs: 300,
      tags: { phase: 'steady' },
    },
  },
  thresholds: {
    // пороги лише для плато
    'http_req_duration{phase:steady}': ['p(95)<400', 'p(99)<900'],
    'http_req_failed{phase:steady}': ['rate<0.01'],
  },
};
export const options = {
  scenarios: {
    warmup: {
      executor: 'constant-arrival-rate', rate: 50, timeUnit: '1s',
      duration: '3m', preAllocatedVUs: 50, tags: { phase: 'warmup' },
    },
    steady: {
      executor: 'constant-arrival-rate', rate: 300, timeUnit: '1s',
      duration: '20m', startTime: '3m', preAllocatedVUs: 300,
      tags: { phase: 'steady' },
    },
  },
  thresholds: {
    // thresholds only for the plateau
    'http_req_duration{phase:steady}': ['p(95)<400', 'p(99)<900'],
    'http_req_failed{phase:steady}': ['rate<0.01'],
  },
};
🔧 Розбір: startTime: '3m' запускає другий сценарій після прогріву. Тег phase з'являється на кожній метриці сценарію, а синтаксис метрика{тег:значення} у thresholds відфільтровує лише потрібну фазу.
🔧 Breakdown: startTime: '3m' starts the second scenario after the warm-up. The phase tag lands on every metric of the scenario, and the metric{tag:value} syntax in thresholds filters only the phase you care about.
💡 Простими словами: Як спортсмен перед забігом: розминку ніхто не засікає секундоміром.
💡 In plain words: Like an athlete before a race: nobody times the warm-up lap.
🗣 Як читати вголос Перші хвилини будь-якого тесту — нечесні: JIT ще не скомпілював гарячий код, кеші порожні, пули з'єднань тільки відкриваються. Тому я роблю окрему фазу прогріву на кілька хвилин тим самим міксом запитів і рахую пороги лише для плато. У k6 розводжу це на два сценарії з тегом фази. Виняток — коли саме холодний старт і є предметом тесту, наприклад serverless або перші запити після деплою; тоді міряю їх окремо і свідомо.
🗣 How to say it out loud The first minutes of any test are unfair: the JIT has not compiled the hot code, caches are empty, pools are only opening connections. So I add a separate warm-up phase of a few minutes with the same request mix and evaluate thresholds only on the plateau. In k6 I split it into two scenarios with a phase tag. The exception is when the cold start itself is under test, like serverless or the first requests after a deploy; then I measure those separately and on purpose.
7 Чим synthetic-моніторинг відрізняється від RUM і коли потрібен кожен? How does synthetic monitoring differ from RUM, and when do you need each?
Типи навантаження Load types Middle ▶

Synthetic. Скрипт-робот з відомих локацій регулярно виконує сценарій (відкрити головну, пошук, логін) у контрольованих умовах: однаковий браузер, мережа, пристрій. Дає стабільну, порівнянну між днями картину і працює навіть без живого трафіку — вночі, на стейджингу, до релізу.

Synthetic. A robot script from known locations regularly runs a scenario (open home, search, log in) under controlled conditions: same browser, network, device. It gives a stable picture comparable across days and works even without live traffic — at night, on staging, before a release.

RUM (Real User Monitoring). Скрипт на сторінці (наприклад, бібліотека web-vitals) збирає метрики з браузерів реальних користувачів: їхні пристрої, мережі, країни, розширення. Показує справжній досвід, включно з довгим хвостом слабких телефонів, якого жоден лабораторний тест не відтворить.

RUM (Real User Monitoring). A script on the page (for example the web-vitals library) collects metrics from real users' browsers: their devices, networks, countries, extensions. It shows the true experience, including the long tail of weak phones that no lab test can reproduce.

Коли що. Synthetic — для регресій і алертів: зміна на 300 мс відразу видна, бо шум мінімальний. RUM — для пріоритезації: якою є реальна p75 LCP на мобільних в Індії і чи варто туди інвестувати. Разом: synthetic ловить, що саме зламалося, RUM підтверджує, що це відчули люди.

When to use which. Synthetic for regressions and alerts: a 300 ms change is visible at once because noise is minimal. RUM for prioritisation: what the real p75 LCP on mobile in India is and whether it is worth investing there. Together: synthetic catches what broke, RUM confirms that people felt it.

Приклад. Synthetic-перевірка показує LCP 1,8 с стабільно, але RUM показує p75 = 3,9 с. Розбивка за пристроями пояснює: 60% трафіку — бюджетні Android на 4G, а synthetic працює з десктопа в дата-центрі. Висновок: додати synthetic-профіль із тротлінгом CPU і мережі.

Example. A synthetic check shows LCP at a steady 1.8 s, but RUM shows p75 = 3.9 s. A breakdown by device explains it: 60% of traffic is budget Android on 4G, while synthetic runs from a desktop in a data centre. Conclusion: add a synthetic profile with CPU and network throttling.

Підводні камені. Порівнювати числа synthetic і RUM напряму — умови різні. RUM без сегментації (пристрій, країна, сторінка) дає середню температуру по лікарні.

Pitfalls. Comparing synthetic and RUM numbers directly — the conditions differ. RUM without segmentation (device, country, page) gives you a hospital-wide average temperature.

RUM: збір Core Web Vitals з реальних браузерів
RUM: collecting Core Web Vitals from real browsers
import { onLCP, onINP, onCLS } from 'web-vitals';

function send(metric) {
  const body = JSON.stringify({
    name: metric.name, value: metric.value, rating: metric.rating,
    page: location.pathname, conn: navigator.connection ? navigator.connection.effectiveType : 'n/a',
  });
  // sendBeacon переживає закриття вкладки
  navigator.sendBeacon('/rum', body);
}

onLCP(send);
onINP(send);
onCLS(send);
import { onLCP, onINP, onCLS } from 'web-vitals';

function send(metric) {
  const body = JSON.stringify({
    name: metric.name, value: metric.value, rating: metric.rating,
    page: location.pathname, conn: navigator.connection ? navigator.connection.effectiveType : 'n/a',
  });
  // sendBeacon survives tab close
  navigator.sendBeacon('/rum', body);
}

onLCP(send);
onINP(send);
onCLS(send);
🔧 Розбір: Функції onLCP, onINP, onCLS з бібліотеки web-vitals викликають колбек, коли значення метрики готове; rating одразу каже good / needs-improvement / poor. Поле conn потрібне для сегментації за типом мережі, а sendBeacon надійно відправляє дані навіть під час закриття сторінки.
🔧 Breakdown: The web-vitals functions onLCP, onINP and onCLS call the callback when a metric value is ready; rating already says good / needs-improvement / poor. The conn field enables segmentation by network type, and sendBeacon reliably ships data even while the page is closing.
💡 Простими словами: Synthetic — це тест-драйв на закритому треку, RUM — відгуки водіїв, які їздять щодня по ямах.
💡 In plain words: Synthetic is a test drive on a closed track; RUM is feedback from drivers who hit real potholes every day.
🗣 Як читати вголос Synthetic-моніторинг — це робот, який в однакових умовах проходить сценарій за розкладом. Він стабільний, тому я використовую його для алертів і пошуку регресій, навіть коли живого трафіку немає. RUM збирає метрики з браузерів справжніх користувачів і показує реальну картину, включно зі слабкими телефонами й поганою мережею. Я не порівнюю їхні числа напряму: synthetic каже мені, що змінилося, а RUM — наскільки це відчули люди і в якому сегменті.
🗣 How to say it out loud Synthetic monitoring is a robot running a scenario on a schedule under identical conditions. It is stable, so I use it for alerts and regression hunting, even when there is no live traffic. RUM collects metrics from real users' browsers and shows the real picture, including weak phones and bad networks. I never compare their numbers directly: synthetic tells me what changed, and RUM tells me how much people felt it and in which segment.
▶ 2 7
8 Що таке latency і throughput і як вони пов'язані? What are latency and throughput and how are they related?
Метрики Metrics Junior ▶

Latency. Час, за який система відповідає на один запит: від відправлення до отримання відповіді. Часто вживають як синонім response time, хоча строго latency — це час очікування (мережа, черга), а response time — повний час разом з обробкою. На співбесіді варто назвати різницю і сказати, що саме міряє ваш інструмент: k6 http_req_duration — це відправлення, очікування і отримання, без DNS і встановлення з'єднання.

Latency. The time the system takes to answer one request: from sending it to receiving the response. It is often used as a synonym for response time, although strictly latency is waiting time (network, queue) and response time is the full time including processing. In an interview it is worth naming the difference and saying what your tool measures: k6 http_req_duration is sending, waiting and receiving, without DNS and connection setup.

Throughput. Скільки операцій система виконує за одиницю часу: запити за секунду (RPS), транзакції за хвилину, повідомлення за секунду. Це характеристика системи в цілому, а не одного запиту.

Throughput. How many operations the system completes per unit of time: requests per second (RPS), transactions per minute, messages per second. It describes the system as a whole, not a single request.

Зв'язок. Поки система не насичена, збільшення навантаження збільшує throughput, а latency майже не змінюється. Коли ресурс насичується, запити стають у чергу: throughput перестає рости (упирається в стелю), а latency різко зростає. Ця точка перегину — «коліно» кривої — і є практична межа системи.

The relationship. While the system is not saturated, more load means more throughput and latency barely moves. Once a resource saturates, requests queue up: throughput stops growing (it hits a ceiling) and latency shoots up. That inflection point — the "knee" of the curve — is the practical limit of the system.

Приклад. При 100, 200, 300 RPS p95 = 120, 125, 140 мс. При 400 RPS система обробляє лише 360 RPS, а p95 = 1,8 с. Коліно між 300 і 360 RPS: тут закінчується запас.

Example. At 100, 200, 300 RPS p95 = 120, 125, 140 ms. At 400 RPS the system only processes 360 RPS and p95 = 1.8 s. The knee is between 300 and 360 RPS: that is where the headroom ends.

Підводні камені. Вимірювати throughput з боку генератора, а не сервера: якщо запити відвалюються з таймаутом, генератор «відправив» 400, а корисних відповідей було менше. Throughput рахують за успішними відповідями.

Pitfalls. Measuring throughput on the generator side instead of the server: if requests time out, the generator "sent" 400 but there were fewer useful responses. Throughput is counted from successful responses.

Крива латентності: де «коліно»
The latency curve: where the knee is
p95 (ms)
1800|                                  *
    |                                 /
 900|                               /
    |                            _/
 140|  *-------*-------*------*'     <- коліно
    |________________________________ пропоноване навантаження (RPS)
     100     200     300    360   400

пропоноване  оброблено  p95
   300           300          140 ms
   400           360         1800 ms   <- черга росте
p95 (ms)
1800|                                  *
    |                                 /
 900|                               /
    |                            _/
 140|  *-------*-------*------*'     <- knee
    |________________________________ offered load (RPS)
     100     200     300    360   400

offered  processed  p95
   300           300          140 ms
   400           360         1800 ms   <- queue grows
🔧 Розбір: Горизонтальна вісь — навантаження, яке подає генератор, вертикальна — p95. Нижня таблиця показує головну ознаку насичення: подали 400 RPS, а оброблено лише 360, решта стоїть у черзі й додає секунди до латентності.
🔧 Breakdown: The horizontal axis is the load the generator offers, the vertical is p95. The table underneath shows the main sign of saturation: 400 RPS offered but only 360 processed, the rest waits in a queue and adds seconds of latency.
💡 Простими словами: Latency — як довго чекаєш свою каву, throughput — скільки кав бариста робить за годину. Коли черга довшає, кав більше не стає, а чекати доводиться довше.
💡 In plain words: Latency is how long you wait for your coffee; throughput is how many coffees the barista makes per hour. When the queue grows, coffees per hour stay flat and your wait gets longer.
🗣 Як читати вголос Latency — це час відповіді на один запит, throughput — скільки запитів система обробляє за секунду. Поки є запас ресурсів, я додаю навантаження, і throughput росте, а латентність майже стоїть. Коли щось насичується, з'являється черга: пропускна здатність упирається в стелю, а латентність стрімко йде вгору. Цю точку на кривій я і шукаю — вона показує реальну межу системи. І throughput рахую тільки за успішними відповідями, а не за тим, що відправив генератор.
🗣 How to say it out loud Latency is the time to answer one request; throughput is how many requests the system handles per second. While there is spare capacity, I add load and throughput grows while latency hardly moves. Once something saturates, a queue forms: throughput hits a ceiling and latency climbs steeply. That point on the curve is what I look for, because it shows the real limit of the system. And I count throughput only from successful responses, not from what the generator sent.
9 Що таке p95 і чому середня латентність бреше? What is p95 and why does average latency lie?
Метрики Metrics Junior ▶

Що це. Перцентиль p95 — значення, швидше за яке виконалися 95% запитів. p95 = 400 мс означає: 95 зі 100 запитів відповіли за 400 мс або швидше, а 5 — повільніше. p50 — медіана, типовий запит. p99 — хвіст: один запит зі ста.

What it is. The p95 percentile is the value that 95% of requests were faster than. p95 = 400 ms means 95 out of 100 requests answered in 400 ms or less and 5 were slower. p50 is the median, the typical request. p99 is the tail: one request in a hundred.

Чому середнє бреше. Розподіл латентності майже ніколи не симетричний: більшість запитів швидкі, а невелика частка дуже повільна (GC-пауза, промах кешу, блокування в БД). Середнє змішує ці групи в число, якого ніхто не пережив. Воно може бути «нормальним», поки 2% користувачів чекають 10 секунд.

Why the average lies. A latency distribution is almost never symmetrical: most requests are fast and a small share is very slow (a GC pause, a cache miss, a database lock). The average blends these groups into a number nobody actually experienced. It can look "fine" while 2% of users wait 10 seconds.

Чому хвости важливі. Одна сторінка робить 20–50 запитів до бекенду. Якщо кожен має 1% шанс бути повільним, то ймовірність, що хоча б один із 50 запитів сторінки потрапить у хвіст, дорівнює 1 − 0,99⁵⁰ ≈ 40%. Тобто p99 бекенду визначає досвід майже половини переглядів сторінки. Найактивніші користувачі роблять найбільше запитів і найчастіше бачать хвіст.

Why tails matter. One page makes 20–50 backend requests. If each has a 1% chance of being slow, the probability that at least one of 50 requests lands in the tail is 1 − 0.99⁵⁰ ≈ 40%. So backend p99 shapes the experience of almost half of page views. The most active users make the most requests and hit the tail most often.

Приклад. 1000 запитів: 980 по 100 мс, 20 по 5000 мс. Середнє = (980×100 + 20×5000)/1000 = 198 мс — виглядає прийнятно. p50 = 100 мс, p95 = 100 мс, але p99 = 5000 мс — і саме він показує проблему.

Example. 1000 requests: 980 at 100 ms, 20 at 5000 ms. Average = (980×100 + 20×5000)/1000 = 198 ms — looks acceptable. p50 = 100 ms, p95 = 100 ms, but p99 = 5000 ms, and that is the number that reveals the problem.

Підводні камені. Перцентилі не можна усереднювати між інстансами чи інтервалами: середнє p95 двох серверів не є p95 системи. Їх рахують із сирих даних або гістограм.

Pitfalls. Percentiles cannot be averaged across instances or intervals: the mean of two servers' p95 is not the system's p95. They are computed from raw data or histograms.

Той самий набір даних — різні висновки
Same data set — different conclusions
запити: 980 × 100 ms, 20 × 5000 ms

середнє = (980×100 + 20×5000) / 1000 = 198 ms   виглядає ок
p50     = 100 ms
p95     = 100 ms
p99     = 5000 ms                               <- реальна біль

Сторінка з 50 запитів, 1% повільних кожен:
P(хоч один повільний) = 1 - 0.99^50 = 0.395  ->  ~40% переглядів
requests: 980 × 100 ms, 20 × 5000 ms

average = (980×100 + 20×5000) / 1000 = 198 ms   looks ok
p50     = 100 ms
p95     = 100 ms
p99     = 5000 ms                               <- the real pain

A page with 50 calls, each 1% slow:
P(at least one slow) = 1 - 0.99^50 = 0.395  ->  ~40% of views
🔧 Розбір: Перший блок рахує середнє вручну і показує, як 2% повільних запитів майже не зрушують його, хоча p99 = 5 с. Другий блок — формула ймовірності «хоча б одного успіху»: 1 мінус ймовірність, що всі 50 викликів швидкі.
🔧 Breakdown: The first block computes the average by hand and shows how 2% slow requests barely move it, while p99 = 5 s. The second block is the "at least one" probability: 1 minus the probability that all 50 calls are fast.
💡 Простими словами: Якщо Білл Гейтс заходить у бар, середній статок відвідувачів стає мільярдним, але пиво від цього ніхто не отримав швидше.
💡 In plain words: When Bill Gates walks into a bar, the average patron becomes a billionaire, yet nobody's beer arrived any faster.
🗣 Як читати вголос p95 — це межа, в яку вклалися 95% запитів. Середнє я майже не дивлюся, бо розподіл латентності має довгий хвіст: кілька відсотків дуже повільних запитів майже не рухають середнє, але саме їх відчувають люди. До того ж одна сторінка робить десятки запитів, тож якщо кожен має один відсоток шансу бути повільним, то близько сорока відсотків сторінок зачепить хвіст. Тому в критеріях я ставлю p95 і p99 і ніколи не усереднюю перцентилі між серверами.
🗣 How to say it out loud p95 is the boundary that 95% of requests came in under. I barely look at the average, because latency has a long tail: a few percent of very slow requests hardly move the mean, yet they are exactly what people feel. On top of that a page makes dozens of calls, so if each has a one percent chance of being slow, around forty percent of page views hit the tail. That is why my criteria use p95 and p99, and I never average percentiles across servers.
10 Що вимірювати в performance-тесті API і що означає високий error rate під навантаженням? What should you measure in an API performance test, and what does a high error rate under load mean?
Метрики Metrics Junior ▶

Мінімальний набір. Три метрики клієнта: латентність у перцентилях (p50, p95, p99) окремо для кожного ендпоінта, пропускна здатність (успішні RPS) і частка помилок. Плюс метрики сервера, без яких числа не пояснити: CPU, пам'ять, GC, активні з'єднання з БД, черги, латентність залежностей.

Minimal set. Three client metrics: latency percentiles (p50, p95, p99) per endpoint, throughput (successful RPS) and error rate. Plus server metrics, without which the numbers cannot be explained: CPU, memory, GC, active database connections, queues, dependency latency.

Що таке помилка. Не лише 5xx. Помилка — це таймаут, розірване з'єднання, 4xx, якого не мало бути (наприклад, 401 через протухлий токен), і відповідь 200 з неправильним тілом. Тому в k6 додають check на вміст, а не лише на статус.

What counts as an error. Not just 5xx. An error is a timeout, a dropped connection, a 4xx that should not happen (for example a 401 from an expired token), and a 200 with a wrong body. That is why k6 scripts add a check on content, not only on status.

Що означає ріст помилок. Якщо помилки з'являються лише зі зростанням навантаження, система перевантажена або деградує залежність. За типом можна звузити причину: 503 і 429 — спрацював захист (rate limit, load shedding), 504 — проксі не дочекався upstream, 500 з «connection pool exhausted» — пул з'єднань, розриви з'єднань — вичерпані сокети чи ліміти балансувальника.

What rising errors mean. If errors appear only as load grows, the system is overloaded or a dependency is degrading. The type narrows the cause: 503 and 429 mean a protection kicked in (rate limit, load shedding), 504 means a proxy gave up waiting for upstream, a 500 with "connection pool exhausted" points at the pool, connection resets mean exhausted sockets or load balancer limits.

Приклад. До 250 RPS помилок 0%, на 300 RPS — 4%, усі 504 на /checkout. APM показує, що платіжний сервіс-заглушка відповідає за 10 с, бо має ліміт 20 потоків. Висновок: вузьке місце в тестовому оточенні, а не в продукті — і це теж важливий результат.

Example. Up to 250 RPS errors are 0%; at 300 RPS they are 4%, all 504 on /checkout. APM shows the payment stub answers in 10 s because it is capped at 20 threads. Conclusion: the bottleneck is in the test environment, not in the product — and that is an important finding too.

Підводні камені. Рахувати латентність усіх запитів разом, включно з миттєвими помилками: швидкі 503 «покращують» p95. Латентність дивляться окремо для успішних відповідей.

Pitfalls. Computing latency over all requests including instant failures: fast 503s "improve" p95. Latency is examined separately for successful responses.

k6: перевірка вмісту і частки помилок
k6: checking content and error rate
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  thresholds: {
    http_req_failed: ['rate<0.01'],                          // помилок < 1%
    'http_req_duration{expected_response:true}': ['p(95)<400'], // лише успішні
    checks: ['rate>0.99'],
  },
};

export default function () {
  const res = http.get('https://shop.example.com/api/products/42');
  check(res, {
    'status 200': (r) => r.status === 200,
    'has price': (r) => r.json('price') > 0,
  });
}
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  thresholds: {
    http_req_failed: ['rate<0.01'],                          // errors < 1%
    'http_req_duration{expected_response:true}': ['p(95)<400'], // successful only
    checks: ['rate>0.99'],
  },
};

export default function () {
  const res = http.get('https://shop.example.com/api/products/42');
  check(res, {
    'status 200': (r) => r.status === 200,
    'has price': (r) => r.json('price') > 0,
  });
}
🔧 Розбір: http_req_failed — вбудована метрика частки невдалих запитів (за замовчуванням успіхом вважаються статуси 200–399). Тег expected_response:true k6 ставить автоматично, тож поріг латентності рахується лише для успішних відповідей. check не зупиняє тест, а накопичує метрику checks, на яку теж можна поставити поріг.
🔧 Breakdown: http_req_failed is the built-in rate of failed requests (by default statuses 200–399 count as success). k6 sets the expected_response:true tag automatically, so the latency threshold only covers successful responses. A check does not stop the test; it feeds the checks metric, which can also carry a threshold.
💡 Простими словами: Швидка відмова — це все одно відмова. Якщо сервер миттєво відповідає «я зайнятий», ваш p95 гарний, а покупець пішов.
💡 In plain words: A fast failure is still a failure. If the server instantly says "I'm busy", your p95 looks great and the buyer is gone.
🗣 Як читати вголос В API-тесті я завжди дивлюся на три речі: перцентилі латентності по кожному ендпоінту, успішний RPS і частку помилок. Помилкою вважаю не лише 5xx, а й таймаути, неочікувані 4xx і 200 з неправильним тілом, тому ставлю перевірки на вміст. Якщо помилки ростуть разом із навантаженням, дивлюся на тип: 429 і 503 — спрацював захист, 504 — повільний upstream, помилки пулу — база. І латентність рахую лише для успішних відповідей, бо швидкі відмови штучно покращують картину.
🗣 How to say it out loud In an API test I always look at three things: latency percentiles per endpoint, successful RPS and the error rate. I count as errors not only 5xx but also timeouts, unexpected 4xx and a 200 with a wrong body, so I add content checks. If errors grow with load I look at the type: 429 and 503 mean a protection fired, 504 means a slow upstream, pool errors point at the database. And I compute latency only for successful responses, because fast failures make the picture look artificially good.
11 Що таке TTFB і з чого складається час відповіді HTTP-запиту? What is TTFB and what makes up the response time of an HTTP request?
Метрики Metrics Junior ▶

Що це. TTFB (Time To First Byte) — час від початку запиту до отримання першого байта відповіді. Він включає DNS-запит, встановлення TCP-з'єднання, TLS-рукостискання, відправлення запиту і час, поки сервер думає. Після першого байта йде завантаження тіла (content download).

What it is. TTFB (Time To First Byte) is the time from the start of a request to receiving the first byte of the response. It includes the DNS lookup, the TCP connection, the TLS handshake, sending the request and the time the server spends thinking. After the first byte comes the body download (content download).

Навіщо розкладати. Один повільний запит — це симптом, а фази показують причину. Великий DNS — проблема резолвера. Великий connect або TLS — далеко до сервера, немає keep-alive або повторне використання з'єднань вимкнене. Великий час між відправленням і першим байтом — сервер повільно генерує відповідь. Довге завантаження тіла — завеликий або нестиснений payload.

Why break it down. One slow request is a symptom; the phases show the cause. Large DNS means a resolver problem. Large connect or TLS means the server is far away, keep-alive is missing or connection reuse is off. A large gap between sending and the first byte means the server is slow to build the response. A long body download means a payload that is too big or uncompressed.

Приклад. Головна сторінка: TTFB 1,4 с, з них DNS 20 мс, connect 30 мс, TLS 60 мс, а решта — очікування сервера. Отже, дивимося на бекенд: у заголовку Server-Timing видно db;dur=1100 — повільний запит до БД. Якби TTFB був високим лише з іншого континенту, рішенням був би CDN або регіональний сервер.

Example. The home page: TTFB 1.4 s, of which DNS 20 ms, connect 30 ms, TLS 60 ms, and the rest is server wait. So we look at the backend: the Server-Timing header shows db;dur=1100 — a slow database query. If TTFB were high only from another continent, the fix would be a CDN or a regional server.

Для веба. Google радить TTFB до 0,8 с як орієнтир; це не Core Web Vital, але він напряму впливає на LCP, бо браузер не може нічого намалювати, поки не отримав HTML.

For the web. Google suggests TTFB under 0.8 s as a rough guide; it is not a Core Web Vital, but it directly affects LCP, because the browser cannot paint anything until it has the HTML.

Підводні камені. Міряти TTFB з тієї ж мережі, де стоїть сервер, і радіти 20 мс. Для реального досвіду потрібні вимірювання з регіонів користувачів або RUM.

Pitfalls. Measuring TTFB from the same network as the server and celebrating 20 ms. For the real experience you need measurements from users' regions or RUM.

curl: розклад запиту на фази
curl: breaking a request into phases
curl -s -o /dev/null https://shop.example.com/ -w '
dns      %{time_namelookup}
connect  %{time_connect}
tls      %{time_appconnect}
ttfb     %{time_starttransfer}
total    %{time_total}
'
# dns 0.021  connect 0.052  tls 0.113  ttfb 1.402  total 1.460
# сервер думав ~ 1.402 - 0.113 = 1.29 s
curl -s -o /dev/null https://shop.example.com/ -w '
dns      %{time_namelookup}
connect  %{time_connect}
tls      %{time_appconnect}
ttfb     %{time_starttransfer}
total    %{time_total}
'
# dns 0.021  connect 0.052  tls 0.113  ttfb 1.402  total 1.460
# server think time ~ 1.402 - 0.113 = 1.29 s
🔧 Розбір: Змінні -w у curl — кумулятивні часи від старту в секундах: time_appconnect закінчується після TLS, time_starttransfer — це TTFB. Різниця між ними приблизно дорівнює часу обробки на сервері, а total мінус TTFB — завантаженню тіла.
🔧 Breakdown: The curl -w variables are cumulative times from the start, in seconds: time_appconnect ends after TLS, time_starttransfer is TTFB. The difference between them roughly equals server processing time, and total minus TTFB is the body download.
💡 Простими словами: TTFB — це час, поки офіціант приніс першу тарілку. Якщо кухня повільна, швидкий офіціант не допоможе.
💡 In plain words: TTFB is how long until the waiter brings the first plate. If the kitchen is slow, a fast waiter won't help.
🗣 Як читати вголос TTFB — це час до першого байта відповіді. Я завжди розкладаю його на фази: DNS, з'єднання, TLS і очікування сервера, і роблю це прямо через curl з форматом виводу. Якщо більшу частину займає очікування, проблема на бекенді, і далі дивлюся Server-Timing або APM. Якщо довгі з'єднання і TLS, то справа у відстані чи відсутності keep-alive. Для веба TTFB — не Core Web Vital, але він задає нижню межу для LCP.
🗣 How to say it out loud TTFB is the time to the first byte of the response. I always split it into phases: DNS, connect, TLS and server wait, and I do that right in curl with a write-out format. If most of it is server wait, the problem is in the backend and I move on to Server-Timing or APM. If connect and TLS are long, it is distance or missing keep-alive. For the web TTFB is not a Core Web Vital, but it sets the floor for LCP.
12 Як пов'язані конкурентність, RPS і час відповіді за законом Літтла? How are concurrency, RPS and response time related by Little's law?
Метрики Metrics Middle ▶

Що це. Закон Літтла: L = λ × W. L — середня кількість запитів, що одночасно перебувають у системі (конкурентність), λ — інтенсивність надходження (RPS), W — середній час перебування запиту в системі. Закон працює для будь-якої стабільної системи, незалежно від розподілу запитів.

What it is. Little's law: L = λ × W. L is the average number of requests in the system at once (concurrency), λ is the arrival rate (RPS), W is the average time a request spends in the system. The law holds for any stable system, regardless of the request distribution.

Навіщо QA. Щоб перекладати мову бізнесу («5000 користувачів онлайн») у параметри тесту (RPS і кількість віртуальних користувачів) і перевіряти результати на правдоподібність. Якщо тест показує 200 RPS при 0,25 с відповіді, то одночасно в обробці в середньому 50 запитів — і пул з 20 з'єднань до БД мусить бути вузьким місцем.

Why QA cares. To translate business language ("5000 users online") into test parameters (RPS and the number of virtual users) and to sanity-check results. If a test shows 200 RPS at 0.25 s per response, on average 50 requests are in flight at once — so a pool of 20 database connections must be a bottleneck.

Для віртуальних користувачів. У закритій моделі кожен VU проходить цикл «запит + пауза». Тоді N = λ × (R + Z), де R — час відповіді, Z — think time. Звідси потрібна кількість VU для цільового RPS.

For virtual users. In a closed model each VU loops over "request + pause". Then N = λ × (R + Z), where R is response time and Z is think time. From that you get the number of VUs needed for a target RPS.

Приклад. Ціль — 100 запитів/с, відповідь 0,5 с, пауза користувача 4,5 с. N = 100 × (0,5 + 4,5) = 500 VU. Якщо під навантаженням відповідь зросте до 2 с, ті самі 500 VU дадуть лише 500 / 6,5 ≈ 77 RPS — генератор сам себе пригальмує. Це і є головна слабкість закритої моделі.

Example. Target 100 requests/s, response 0.5 s, user pause 4.5 s. N = 100 × (0.5 + 4.5) = 500 VUs. If under load the response grows to 2 s, the same 500 VUs produce only 500 / 6.5 ≈ 77 RPS — the generator throttles itself. That is the core weakness of the closed model.

Підводні камені. Плутати «користувачів онлайн» з конкурентними запитами. 5000 людей на сайті, кожен робить запит раз на 10 с при відповіді 0,2 с, створюють лише 500 RPS і близько 100 запитів одночасно в обробці.

Pitfalls. Confusing "users online" with concurrent requests. 5000 people on the site, each making a request every 10 s with a 0.2 s response, create only 500 RPS and about 100 requests in flight.

Розрахунки на дошці
Whiteboard calculations
Закон Літтла:  L = λ × W

1) Скільки запитів одночасно в обробці?
   λ = 200 RPS, W = 0.25 s   ->  L = 200 × 0.25 = 50

2) Скільки VU для 100 RPS (закрита модель)?
   N = λ × (R + Z) = 100 × (0.5 + 4.5) = 500 VU

3) Ті самі 500 VU, коли R виріс до 2 s:
   λ = N / (R + Z) = 500 / (2 + 4.5) ≈ 77 RPS   <- генератор сповільнився

4) 5000 онлайн, запит раз на 10 s:
   λ = 5000 / 10 = 500 RPS;  L = 500 × 0.2 = 100 в обробці
Little's law:  L = λ × W

1) How many requests in flight?
   λ = 200 RPS, W = 0.25 s   ->  L = 200 × 0.25 = 50

2) How many VUs for 100 RPS (closed model)?
   N = λ × (R + Z) = 100 × (0.5 + 4.5) = 500 VU

3) Same 500 VUs when R grows to 2 s:
   λ = N / (R + Z) = 500 / (2 + 4.5) ≈ 77 RPS   <- generator slowed down

4) 5000 online, one request every 10 s:
   λ = 5000 / 10 = 500 RPS;  L = 500 × 0.2 = 100 in flight
🔧 Розбір: Усі чотири розрахунки — одна формула, переставлена під різні невідомі. Третій рядок пояснює, чому для перевірки цільового RPS краще відкрита модель: у закритій кількість запитів залежить від швидкості системи, яку ми якраз і міряємо.
🔧 Breakdown: All four calculations are one formula rearranged for different unknowns. The third line explains why an open model is better for verifying a target RPS: in a closed one the request rate depends on the very system speed we are trying to measure.
💡 Простими словами: У кав'ярні, куди заходить 2 людини за хвилину і кожна сидить 30 хвилин, у середньому сидить 60 людей. Потрібно 60 стільців, а не 2.
💡 In plain words: A café where 2 people arrive per minute and each stays 30 minutes has 60 people seated on average. You need 60 chairs, not 2.
🗣 Як читати вголос Закон Літтла я використовую постійно: кількість запитів у системі дорівнює інтенсивності, помноженій на час у системі. Він допомагає перекласти «п'ять тисяч користувачів онлайн» у реальні RPS і кількість віртуальних користувачів з урахуванням think time. І він же — швидка перевірка результатів: якщо двісті RPS по чверть секунди, то одночасно п'ятдесят запитів, і пул на двадцять з'єднань точно стане вузьким місцем. А ще він показує, чому закрита модель сама себе пригальмовує, коли сервер сповільнюється.
🗣 How to say it out loud I use Little's law all the time: the number of requests in the system equals the arrival rate times the time spent in it. It helps me turn "five thousand users online" into real RPS and a number of virtual users, including think time. It is also a quick sanity check on results: two hundred RPS at a quarter of a second means fifty in flight, so a pool of twenty connections will definitely be a bottleneck. And it shows why a closed model throttles itself when the server slows down.
13 Що таке Apdex і коли він кращий за перцентилі? What is Apdex and when is it better than percentiles?
Метрики Metrics Middle ▶

Що це. Apdex (Application Performance Index) — одне число від 0 до 1, що описує задоволеність користувачів швидкістю. Обирають поріг T. Запити не довші за T — «задоволені», від T до 4T — «терплять», довші за 4T або з помилкою — «роздратовані». Формула: Apdex = (задоволені + терплячі / 2) / усього.

What it is. Apdex (Application Performance Index) is a single number from 0 to 1 describing user satisfaction with speed. You choose a threshold T. Requests no longer than T are "satisfied", from T to 4T "tolerating", longer than 4T or failed "frustrated". Formula: Apdex = (satisfied + tolerating / 2) / total.

Навіщо. Перцентилі зручні інженерам, а менеджменту потрібна одна зрозуміла шкала для дашборда і тренду: 0,94 — добре, 0,70 — погано. Apdex також враховує помилки, а p95 сам по собі — ні.

Why. Percentiles suit engineers, but management wants one clear scale for a dashboard and a trend: 0.94 is good, 0.70 is bad. Apdex also accounts for errors, while p95 on its own does not.

Приклад. T = 500 мс, 1000 запитів: 850 до 500 мс, 100 від 500 мс до 2 с, 50 довших або з помилкою. Apdex = (850 + 100/2) / 1000 = 0,90. Після релізу 700 / 220 / 80 дає (700 + 110) / 1000 = 0,81 — одна цифра чітко фіксує погіршення.

Example. T = 500 ms, 1000 requests: 850 under 500 ms, 100 between 500 ms and 2 s, 50 slower or failed. Apdex = (850 + 100/2) / 1000 = 0.90. After a release, 700 / 220 / 80 gives (700 + 110) / 1000 = 0.81 — one number clearly captures the regression.

Обмеження. Apdex втрачає деталі: запит на 2,1 с і на 60 с однаково «роздратовані», тож хвіст важкості не видно. Результат дуже залежить від вибору T, а різні T для різних операцій (пошук і формування звіту) роблять загальне число складно порівнюваним. Тому в тестах Apdex доповнює перцентилі, а не замінює.

Limitations. Apdex loses detail: a request at 2.1 s and one at 60 s are equally "frustrated", so the severity of the tail is invisible. The result depends heavily on the choice of T, and different T values for different operations (search versus report generation) make an overall number hard to compare. So in tests Apdex complements percentiles rather than replacing them.

Підводні камені. Обирати T «щоб було гарно». T має відображати реальне очікування користувача для конкретної операції і бути узгодженим з продуктом.

Pitfalls. Picking T "so it looks nice". T must reflect the real user expectation for the specific operation and be agreed with the product team.

Розрахунок Apdex до і після релізу
Apdex before and after a release
T = 500 ms  (терплять до 4T = 2000 ms)

                задоволені  терплять  роздратовані  Apdex
v1.4             850         100        50            (850 + 50)  / 1000 = 0.90
v1.5             700         220        80            (700 + 110) / 1000 = 0.81

Шкала: 0.94+ відмінно | 0.85-0.93 добре | 0.70-0.84 прийнятно | <0.70 погано
T = 500 ms  (tolerating up to 4T = 2000 ms)

                satisfied   tolerating  frustrated   Apdex
v1.4             850         100        50            (850 + 50)  / 1000 = 0.90
v1.5             700         220        80            (700 + 110) / 1000 = 0.81

Scale: 0.94+ excellent | 0.85-0.93 good | 0.70-0.84 fair | <0.70 poor
🔧 Розбір: Кожен рядок — підрахунок запитів у трьох кошиках за порогом T; терплячі йдуть з вагою 0,5, роздратовані — з нулем. Шкала внизу — поширена інтерпретація значень Apdex; конкретні межі команда може узгодити під свій продукт.
🔧 Breakdown: Each row counts requests in three buckets by threshold T; tolerating ones weigh 0.5, frustrated ones zero. The scale at the bottom is a common interpretation of Apdex values; a team can agree its own bands for its product.
💡 Простими словами: Apdex — це оцінка в щоденнику: зручно показати батькам, але вчитель усе одно дивиться, які саме задачі провалені.
💡 In plain words: Apdex is a school grade: handy to show your parents, but the teacher still checks which problems you got wrong.
🗣 Як читати вголос Apdex — це індекс від нуля до одиниці: обираєш поріг T, запити до T рахуються як задоволені, до чотирьох T — як терплячі з половинною вагою, решта і помилки — як роздратовані. Я використовую його для дашбордів і розмови з бізнесом, бо одна цифра добре показує тренд між релізами. Але в критеріях тесту він у мене завжди поруч із p95 і p99, бо Apdex не розрізняє запит на дві секунди і на хвилину, і сильно залежить від того, як обрали T.
🗣 How to say it out loud Apdex is an index from zero to one: you pick a threshold T, requests under T count as satisfied, up to four T as tolerating with half weight, and everything else plus errors as frustrated. I use it for dashboards and conversations with the business, because a single number shows the trend between releases well. But in test criteria I always keep it next to p95 and p99, because Apdex cannot tell a two-second request from a one-minute one, and it depends heavily on how T was chosen.
14 Що таке насичення (saturation) і як методи USE і RED допомагають знайти вузьке місце? What is saturation, and how do the USE and RED methods help find a bottleneck?
Метрики Metrics Middle ▶

Що це. Bottleneck — ресурс, який першим обмежує пропускну здатність або латентність усієї системи. Saturation — стан, коли ресурс завантажений майже до межі і роботу доводиться ставити в чергу: CPU з run queue більшою за кількість ядер, пул з'єднань, де потоки чекають на з'єднання, диск з довгою чергою вводу-виводу.

What it is. A bottleneck is the resource that first limits the throughput or latency of the whole system. Saturation is the state where a resource is loaded close to its limit and work has to queue: a CPU with a run queue longer than the core count, a connection pool where threads wait for a connection, a disk with a long I/O queue.

USE (Brendan Gregg). Для кожного ресурсу — Utilization (зайнятість), Saturation (черга до нього), Errors (помилки). Проходиш по списку: CPU, пам'ять, диск, мережа, пули потоків і з'єднань. Ресурс з високою утилізацією і ненульовою чергою — кандидат у вузьке місце.

USE (Brendan Gregg). For every resource: Utilization (how busy), Saturation (the queue in front of it), Errors. You walk the list: CPU, memory, disk, network, thread and connection pools. A resource with high utilisation and a non-empty queue is a bottleneck candidate.

RED (Tom Wilkie). Для кожного сервісу — Rate (запити/с), Errors, Duration (розподіл латентності). Це погляд ззовні: який сервіс у ланцюжку першим почав сповільнюватися. Google SRE описує схожі «чотири золоті сигнали»: latency, traffic, errors, saturation.

RED (Tom Wilkie). For every service: Rate (requests/s), Errors, Duration (latency distribution). It is the outside view: which service in the chain slowed down first. Google SRE describes the similar "four golden signals": latency, traffic, errors, saturation.

Як поєднувати. RED показує, де болить (сервіс замовлень, p99 виріс з 200 мс до 3 с), USE показує, чому (у цього сервісу пул з'єднань з БД 100% зайнятий і 40 потоків у черзі, при цьому CPU лише 35%).

Combining them. RED shows where it hurts (the order service, p99 up from 200 ms to 3 s); USE shows why (that service's database pool is 100% busy with 40 threads waiting, while CPU is only 35%).

Підводні камені. Утилізація 60% не означає запас 40%: при 80–90% черги ростуть нелінійно. І середня утилізація за хвилину ховає короткі піки по 100%.

Pitfalls. 60% utilisation does not mean 40% headroom: at 80–90% queues grow non-linearly. And a one-minute average hides short spikes at 100%.

Prometheus: RED для сервісу і USE для пулу з'єднань
Prometheus: RED for a service and USE for a connection pool
# RED
sum(rate(http_server_requests_seconds_count{service="orders"}[1m]))                    # Rate
sum(rate(http_server_requests_seconds_count{service="orders",status=~"5.."}[1m]))      # Errors
histogram_quantile(0.99, sum by (le) (
  rate(http_server_requests_seconds_bucket{service="orders"}[5m])))                    # Duration p99

# USE для пулу HikariCP
hikaricp_connections_active / hikaricp_connections_max                                 # Utilization
hikaricp_connections_pending                                                           # Saturation
rate(hikaricp_connections_timeout_total[1m])                                           # Errors
# RED
sum(rate(http_server_requests_seconds_count{service="orders"}[1m]))                    # Rate
sum(rate(http_server_requests_seconds_count{service="orders",status=~"5.."}[1m]))      # Errors
histogram_quantile(0.99, sum by (le) (
  rate(http_server_requests_seconds_bucket{service="orders"}[5m])))                    # Duration p99

# USE for the HikariCP pool
hikaricp_connections_active / hikaricp_connections_max                                 # Utilization
hikaricp_connections_pending                                                           # Saturation
rate(hikaricp_connections_timeout_total[1m])                                           # Errors
🔧 Розбір: Метрики http_server_requests_seconds і hikaricp_* — стандартні імена, які Spring Boot експортує через Micrometer. histogram_quantile рахує перцентиль з бакетів гістограми, тому p99 тут коректний для всіх інстансів разом, на відміну від усереднення готових перцентилів.
🔧 Breakdown: The http_server_requests_seconds and hikaricp_* metrics are the standard names Spring Boot exports via Micrometer. histogram_quantile computes a percentile from histogram buckets, so this p99 is correct across all instances together, unlike averaging ready-made percentiles.
💡 Простими словами: RED — це «який відділ магазину тормозить», USE — «чому: закінчились каси, продавці чи пакети».
💡 In plain words: RED is "which department of the shop is slow"; USE is "why: out of tills, out of staff or out of bags".
🗣 Як читати вголос Вузьке місце — це ресурс, що першим обмежує всю систему, а насичення — коли перед ресурсом утворюється черга. Я шукаю його двома методами. RED дає погляд на сервіси: інтенсивність, помилки й тривалість, і так видно, який сервіс першим почав гальмувати. Потім USE по ресурсах цього сервісу: утилізація, черга, помилки для CPU, пам'яті, пулів. Типова картина — CPU спокійний, а пул з'єднань забитий і потоки в нього стоять у черзі. Це і є відповідь.
🗣 How to say it out loud A bottleneck is the resource that limits the whole system first, and saturation is when a queue forms in front of that resource. I hunt for it with two methods. RED gives the service view — rate, errors and duration — so I can see which service started slowing down first. Then USE across that service's resources: utilisation, saturation and errors for CPU, memory and pools. A typical picture is a calm CPU while the connection pool is full and threads are queueing for it. That is the answer.
▶ 3 7
15 Як побудувати модель навантаження з продакшн-даних? How do you build a workload model from production data?
Модель навантаження Workload model Middle ▶

Що це. Модель навантаження — опис того, що і з якою інтенсивністю робитимуть віртуальні користувачі: які сценарії, в якій пропорції, з яким темпом і в який пік. Без неї тест перевіряє вигадану систему.

What it is. A workload model describes what virtual users will do and at what rate: which scenarios, in what proportion, at what pace and at what peak. Without it the test exercises an imaginary system.

Джерела. Логи балансувальника або API gateway (запити за хвилину по ендпоінтах), APM (топ транзакцій за кількістю і часом), вебаналітика (шляхи користувачів, конверсія з перегляду в кошик), бізнес-прогноз (очікуваний ріст, маркетингові кампанії).

Sources. Load balancer or API gateway logs (requests per minute per endpoint), APM (top transactions by count and time), web analytics (user journeys, conversion from view to cart), the business forecast (expected growth, marketing campaigns).

Кроки. 1) Знайти пікову годину за останні місяці і в ній пікові 5–15 хвилин. 2) Порахувати RPS по ендпоінтах і згрупувати в бізнес-сценарії. 3) Визначити пропорції: скільки відсотків переглядають, шукають, додають у кошик, платять. 4) Помножити на коефіцієнт росту і запас (наприклад, ×1,5 на прогноз і ×1,2 на безпеку). 5) Врахувати розподіл даних: не всі дивляться один товар, 20% популярних товарів дають 80% переглядів.

Steps. 1) Find the peak hour over recent months and the peak 5–15 minutes within it. 2) Count RPS per endpoint and group them into business scenarios. 3) Work out proportions: what percentage browse, search, add to cart, pay. 4) Multiply by the growth factor and a safety margin (for example ×1.5 for the forecast and ×1.2 for safety). 5) Account for data distribution: not everyone views the same product, 20% of popular products get 80% of views.

Приклад. Пік — 18 000 запитів за хвилину = 300 RPS: каталог 60%, пошук 20%, кошик 15%, оплата 5%. Прогноз на розпродаж ×3, запас ×1,2: ціль 1080 RPS з тим самим міксом, але оплата може вирости непропорційно — з маркетингом узгоджуємо окремий сценарій з 10% оплат.

Example. The peak is 18,000 requests per minute = 300 RPS: catalogue 60%, search 20%, cart 15%, payment 5%. The sale forecast is ×3 plus ×1.2 margin: target 1080 RPS with the same mix, but payments may grow disproportionately, so we agree a separate scenario with 10% payments with marketing.

Підводні камені. Брати середнє за добу замість піку (ділення на 86 400 секунд занижує навантаження в рази). Ігнорувати фонові процеси — імпорти, нічні звіти, вебхуки партнерів, які навантажують ту ж БД.

Pitfalls. Using the daily average instead of the peak (dividing by 86,400 seconds understates load many times over). Ignoring background work — imports, nightly reports, partner webhooks that hit the same database.

Від логів до міксу сценаріїв
From logs to a scenario mix
# пікова хвилина з логів nginx: запити по ендпоінтах
grep '12/Nov/2025:20:1' access.log | awk '{print $7}' | sed 's/[0-9]\+/:id/g' \
  | sort | uniq -c | sort -rn | head
#  10800 /api/products/:id     -> 180 RPS  (60%)
#   3600 /api/search           ->  60 RPS  (20%)
#   2700 /api/cart             ->  45 RPS  (15%)
#    900 /api/checkout         ->  15 RPS   (5%)
# ціль = 300 RPS × 3 (прогноз) × 1.2 (запас) = 1080 RPS
# peak minute from nginx logs: requests per endpoint
grep '12/Nov/2025:20:1' access.log | awk '{print $7}' | sed 's/[0-9]\+/:id/g' \
  | sort | uniq -c | sort -rn | head
#  10800 /api/products/:id     -> 180 RPS  (60%)
#   3600 /api/search           ->  60 RPS  (20%)
#   2700 /api/cart             ->  45 RPS  (15%)
#    900 /api/checkout         ->  15 RPS   (5%)
# target = 300 RPS × 3 (forecast) × 1.2 (margin) = 1080 RPS
🔧 Розбір: Конвеєр бере запити пікових десяти хвилин, виділяє шлях (сьоме поле формату combined), замінює числові id на :id, щоб згрупувати однакові ендпоінти, і рахує частоти. Результат ділиться на тривалість вікна, і виходять RPS і відсотки для міксу сценаріїв.
🔧 Breakdown: The pipeline takes requests from the peak ten minutes, extracts the path (field seven of the combined log format), replaces numeric ids with :id to group identical endpoints and counts frequencies. Dividing by the window length gives RPS and percentages for the scenario mix.
💡 Простими словами: Модель навантаження — це рецепт: без пропорцій інгредієнтів можна мати всі продукти й отримати не той суп.
💡 In plain words: A workload model is a recipe: without the ingredient ratios you can have every ingredient and still cook the wrong soup.
🗣 Як читати вголос Модель навантаження я будую з реальних даних, а не з голови. Беру логи балансувальника або APM, знаходжу пікову годину і пікові хвилини всередині неї, рахую RPS по ендпоінтах і групую в бізнес-сценарії з відсотками. Потім множу на прогноз росту і додаю запас. Окремо дивлюся на розподіл даних і фонові задачі, бо вони навантажують ту саму базу. Найчастіша помилка, яку бачу, — брати середнє за добу, а не пік.
🗣 How to say it out loud I build the workload model from real data, not from my head. I take load balancer logs or APM, find the peak hour and the peak minutes inside it, count RPS per endpoint and group them into business scenarios with percentages. Then I multiply by the growth forecast and add a margin. I also check data distribution and background jobs separately, because they load the same database. The most common mistake I see is using the daily average instead of the peak.
16 Чим відкрита модель навантаження відрізняється від закритої і коли задавати arrival rate замість VU? How does an open workload model differ from a closed one, and when do you set an arrival rate instead of VUs?
Модель навантаження Workload model Senior ▶

Закрита модель. Фіксована кількість віртуальних користувачів; кожен надсилає наступний запит лише після відповіді на попередній (плюс пауза). Нові запити народжуються з завершення старих, тому коли система сповільнюється, інтенсивність падає сама. Так поводяться системи з обмеженим пулом клієнтів: call-центр із 50 операторами, батч-воркери, внутрішня CRM.

Closed model. A fixed number of virtual users; each sends the next request only after the previous one returns (plus a pause). New requests are born from finished ones, so when the system slows down the rate drops by itself. This is how systems with a bounded client pool behave: a call centre with 50 agents, batch workers, an internal CRM.

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

Open model. Requests arrive at a set rate regardless of how fast the system answers: 300 new requests per second even if earlier ones are still hanging. That is how a public website behaves: a million independent people do not wait for the server to free up.

Чому це важливо. Закрита модель на публічному сервісі маскує деградацію: сервер сповільнився, генератор зменшив тиск, черга не росте, p95 виглядає «терпимо». У реальності люди продовжують приходити, черга росте, і латентність іде в десятки секунд. Відкрита модель чесно показує цей обвал і межу системи.

Why it matters. A closed model on a public service masks degradation: the server slows down, the generator eases off, the queue does not grow, p95 looks "tolerable". In reality people keep arriving, the queue grows, and latency climbs into tens of seconds. An open model honestly shows that collapse and the system limit.

У k6. Закриті executors — constant-vus, ramping-vus, per-vu-iterations. Відкриті — constant-arrival-rate і ramping-arrival-rate, де задаються rate і timeUnit, а VU — лише пул виконавців. Якщо VU не вистачає, k6 рахує dropped_iterations, і на цю метрику варто ставити поріг.

In k6. Closed executors are constant-vus, ramping-vus, per-vu-iterations. Open ones are constant-arrival-rate and ramping-arrival-rate, where you set rate and timeUnit, and VUs are just a pool of workers. If VUs run out, k6 counts dropped_iterations, and that metric deserves a threshold.

Рішення. Для перевірки SLO публічного API — відкрита модель і RPS із продакшну. Для систем з фіксованою кількістю клієнтів — закрита. На співбесіді сильний сигнал — пояснити, що вибір моделі впливає на правдивість результату, а не лише на зручність налаштування.

Decision. To verify the SLO of a public API: an open model with production RPS. For systems with a fixed number of clients: a closed one. In an interview, a strong signal is explaining that the model choice affects how truthful the result is, not only how convenient it is to configure.

k6: та сама ціль у закритій і відкритій моделях
k6: the same target in a closed and an open model
export const options = {
  scenarios: {
    // закрита: 500 VU, темп залежить від швидкості сервера
    closed: { executor: 'constant-vus', vus: 500, duration: '15m' },

    // відкрита: рівно 100 ітерацій/с, хоч би як відповідав сервер
    open: {
      executor: 'constant-arrival-rate',
      rate: 100, timeUnit: '1s', duration: '15m',
      preAllocatedVUs: 200, maxVUs: 2000,
      startTime: '16m',
    },
  },
  thresholds: {
    dropped_iterations: ['count<1'], // генератору вистачило VU
  },
};
export const options = {
  scenarios: {
    // closed: 500 VUs, pace depends on server speed
    closed: { executor: 'constant-vus', vus: 500, duration: '15m' },

    // open: exactly 100 iterations/s whatever the server does
    open: {
      executor: 'constant-arrival-rate',
      rate: 100, timeUnit: '1s', duration: '15m',
      preAllocatedVUs: 200, maxVUs: 2000,
      startTime: '16m',
    },
  },
  thresholds: {
    dropped_iterations: ['count<1'], // the generator had enough VUs
  },
};
🔧 Розбір: Обидва сценарії в одному файлі для порівняння, другий стартує після першого через startTime. У відкритому maxVUs — стеля пулу: за законом Літтла при 100 ітераціях/с і сповільненні ітерації до 10 с потрібно близько 1000 VU, тому стеля 2000 дає запас.
🔧 Breakdown: Both scenarios sit in one file for comparison; the second starts after the first via startTime. In the open one maxVUs is the pool ceiling: by Little's law, at 100 iterations/s with iterations slowing to 10 s you need about 1000 VUs, so a ceiling of 2000 leaves headroom.
💡 Простими словами: Закрита модель — 50 гостей на банкеті: поки не з'їдять страву, нову не замовлять. Відкрита — вулиця біля кіоску: люди підходять, хоч би яка довга була черга.
💡 In plain words: A closed model is 50 guests at a banquet: nobody orders the next course before finishing this one. An open model is a street kiosk: people keep walking up however long the line gets.
🗣 Як читати вголос Різниця в тому, що породжує новий запит. У закритій моделі — завершення попереднього, тому коли сервер гальмує, генератор сам зменшує тиск і маскує проблему. У відкритій моделі запити приходять із заданим темпом незалежно від відповіді, як на реальному публічному сайті, і тоді видно справжню чергу й обвал. Для публічних API я задаю arrival rate з продакшн-даних і стежу за dropped iterations, а закриту модель беру там, де клієнтів справді фіксована кількість, наприклад внутрішні воркери.
🗣 How to say it out loud The difference is what triggers a new request. In a closed model it is the completion of the previous one, so when the server slows down the generator eases off by itself and hides the problem. In an open model requests arrive at a set pace regardless of responses, like on a real public site, and then you see the true queue and the collapse. For public APIs I set an arrival rate from production data and watch dropped iterations; I use a closed model where the number of clients really is fixed, such as internal workers.
17 Навіщо потрібен think time і чим він відрізняється від pacing? Why do we need think time and how does it differ from pacing?
Модель навантаження Workload model Junior ▶

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

Think time. A pause between a virtual user's actions that mimics a human: reading the page, thinking, typing. Without it one VU fires requests non-stop and generates load like dozens of real people, while sessions, caches and connections behave unrealistically.

Pacing. Контроль тривалості всієї ітерації сценарію, а не паузи між кроками. Наприклад, «кожен VU починає нову ітерацію раз на 30 секунд»: якщо запити виконались за 4 с, VU чекає ще 26; якщо за 12 с — лише 18. Так у закритій моделі тримають стабільну інтенсивність.

Pacing. Control over the duration of a whole scenario iteration rather than the pause between steps. For example, "each VU starts a new iteration every 30 seconds": if the requests took 4 s, the VU waits another 26; if they took 12 s, only 18. That is how a closed model keeps a steady rate.

Як задавати. З аналітики: медіанний час на сторінці товару 20 с — такою і має бути пауза після перегляду. Краще брати випадкове значення з діапазону, щоб VU не синхронізувались і не били хвилями.

How to set it. From analytics: if the median time on a product page is 20 s, that is the pause after a product view. It is better to take a random value from a range, so VUs do not synchronise and hit in waves.

Приклад. Без think time 50 VU при відповіді 100 мс дають ~500 RPS. З паузою 5 с ті самі 50 VU дають ~10 RPS (закон Літтла: 50 / 5,1). Тобто параметр змінює навантаження в 50 разів, і його треба свідомо рахувати, а не ставити «щоб було».

Example. Without think time, 50 VUs at 100 ms per response produce ~500 RPS. With a 5 s pause the same 50 VUs produce ~10 RPS (Little's law: 50 / 5.1). The parameter changes the load fifty-fold, so it has to be calculated deliberately, not set "just because".

Підводні камені. Включати паузи у виміряний час транзакції. І використовувати think time як основний регулятор RPS там, де потрібна відкрита модель: тоді інтенсивність усе одно залежатиме від швидкості сервера.

Pitfalls. Including pauses in the measured transaction time. And using think time as the main RPS regulator where an open model is needed: the rate will still depend on server speed.

k6: випадковий think time і pacing ітерації
k6: random think time and iteration pacing
import http from 'k6/http';
import { sleep } from 'k6';

const PACING = 30; // секунд на ітерацію

export default function () {
  const start = Date.now();
  http.get('https://shop.example.com/api/products/42');
  sleep(15 + Math.random() * 10);   // think time 15-25 s
  http.post('https://shop.example.com/api/cart', JSON.stringify({ id: 42 }),
    { headers: { 'Content-Type': 'application/json' } });

  const spent = (Date.now() - start) / 1000;
  if (spent < PACING) sleep(PACING - spent); // pacing
}
import http from 'k6/http';
import { sleep } from 'k6';

const PACING = 30; // seconds per iteration

export default function () {
  const start = Date.now();
  http.get('https://shop.example.com/api/products/42');
  sleep(15 + Math.random() * 10);   // think time 15-25 s
  http.post('https://shop.example.com/api/cart', JSON.stringify({ id: 42 }),
    { headers: { 'Content-Type': 'application/json' } });

  const spent = (Date.now() - start) / 1000;
  if (spent < PACING) sleep(PACING - spent); // pacing
}
🔧 Розбір: sleep у k6 приймає секунди і не потрапляє в http_req_duration, тож паузи не псують латентність. Pacing реалізовано вручну: міряємо, скільки зайняла ітерація, і доспати до 30 секунд. Для чистої відкритої моделі замість цього краще executor з arrival rate.
🔧 Breakdown: k6 sleep takes seconds and is not counted in http_req_duration, so pauses do not distort latency. Pacing is implemented by hand: measure how long the iteration took and sleep up to 30 seconds. For a pure open model, an arrival-rate executor is the better choice.
💡 Простими словами: Think time — людина читає сторінку. Pacing — «приходжу до кав'ярні рівно раз на пів години», хоч би скільки чекав каву.
💡 In plain words: Think time is a person reading the page. Pacing is "I show up at the café exactly every half hour", however long the coffee took.
🗣 Як читати вголос Think time — це паузи між діями віртуального користувача, які імітують людину, що читає й думає. Без них кожен VU створює навантаження як десятки людей, і модель стає нереалістичною. Pacing — інше: він задає тривалість цілої ітерації, щоб темп лишався стабільним, навіть коли відповіді то швидші, то повільніші. Значення беру з аналітики і роблю їх випадковими в діапазоні, щоб користувачі не синхронізувалися. І пам'ятаю, що паузи в латентність не рахуються.
🗣 How to say it out loud Think time is the pauses between a virtual user's actions that mimic a person reading and thinking. Without them each VU generates load like dozens of people and the model stops being realistic. Pacing is different: it fixes the length of a whole iteration so the pace stays steady even when responses get faster or slower. I take the values from analytics and randomise them within a range so users do not synchronise. And I keep in mind that pauses are not counted in latency.
18 Як готувати тестові дані і робити кореляцію токенів та динамічних id у скрипті навантаження? How do you prepare test data and correlate tokens and dynamic ids in a load script?
Модель навантаження Workload model Middle ▶

Кореляція. Сервер повертає значення, які потрібні в наступних запитах: токен доступу, CSRF-токен, id створеного замовлення, id сесії кошика. Кореляція — витягнути значення з відповіді і підставити далі. Записаний у браузері скрипт з «зашитим» токеном чи order id проходить один раз, а під навантаженням дає 401 або 404, що виглядає як проблема продуктивності.

Correlation. The server returns values that later requests need: an access token, a CSRF token, the id of a created order, a cart session id. Correlation means extracting the value from the response and passing it on. A browser-recorded script with a hard-coded token or order id passes once, and under load produces 401 or 404, which looks like a performance problem.

Тестові дані. Кожен VU має працювати від імені свого користувача: однаковий логін для 500 VU перетворює тест на перевірку одного рядка в БД з блокуваннями, а кеш дає нереалістично гарні числа. Потрібен пул облікових записів, товарів і купонів, достатній на весь тест, і розподіл, схожий на продакшн.

Test data. Each VU should act as its own user: one login for 500 VUs turns the test into a check of a single database row with locking, while the cache produces unrealistically good numbers. You need a pool of accounts, products and coupons large enough for the whole test, with a distribution similar to production.

Як у k6. Дані завантажують у SharedArray (одна копія в пам'яті на всіх VU), користувача обирають за номером VU, логін робиться у setup() або на першій ітерації, значення з відповіді беруть через res.json('шлях'). У JMeter те саме роблять JSON Extractor, Regular Expression Extractor і CSV Data Set Config.

In k6. Data is loaded into a SharedArray (one in-memory copy for all VUs), the user is picked by VU number, login happens in setup() or on the first iteration, and values are pulled from responses with res.json('path'). In JMeter the same is done with JSON Extractor, Regular Expression Extractor and CSV Data Set Config.

Приклад. Тест створення замовлень давав 30% 404. Причина: скрипт записали з браузера, і /orders/81723/pay містив id першого замовлення. Після кореляції (id з відповіді на створення) помилки зникли, а p95 оплати виявився 1,2 с замість «швидких» 80 мс для 404.

Example. An order-creation test returned 30% 404s. Cause: the script was recorded in a browser and /orders/81723/pay held the id of the first order. After correlation (the id from the create response) the errors disappeared, and payment p95 turned out to be 1.2 s instead of the "fast" 80 ms of a 404.

Підводні камені. Протухання токенів у довгих тестах (потрібне оновлення), вичерпання одноразових даних (купонів, email) і прибирання після тесту, щоб наступний прогін стартував із тієї ж бази.

Pitfalls. Tokens expiring in long tests (refresh is needed), running out of one-time data (coupons, emails), and cleaning up afterwards so the next run starts from the same database state.

k6: пул користувачів і кореляція токена та id замовлення
k6: a user pool and correlating a token and order id
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json')); // [{ "email": ..., "password": ... }]
});
const BASE = 'https://shop.example.com/api';

export default function () {
  const u = users[(__VU - 1) % users.length]; // свій користувач на VU
  const login = http.post(BASE + '/login', JSON.stringify(u),
    { headers: { 'Content-Type': 'application/json' } });
  const auth = { headers: { Authorization: 'Bearer ' + login.json('accessToken'),
                            'Content-Type': 'application/json' } };

  const order = http.post(BASE + '/orders', JSON.stringify({ productId: 42, qty: 1 }), auth);
  const orderId = order.json('id'); // кореляція динамічного id

  const pay = http.post(BASE + '/orders/' + orderId + '/pay', null,
    Object.assign({ tags: { name: 'POST /orders/:id/pay' } }, auth));
  check(pay, { 'paid': (r) => r.status === 200 });
}
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', function () {
  return JSON.parse(open('./users.json')); // [{ "email": ..., "password": ... }]
});
const BASE = 'https://shop.example.com/api';

export default function () {
  const u = users[(__VU - 1) % users.length]; // one user per VU
  const login = http.post(BASE + '/login', JSON.stringify(u),
    { headers: { 'Content-Type': 'application/json' } });
  const auth = { headers: { Authorization: 'Bearer ' + login.json('accessToken'),
                            'Content-Type': 'application/json' } };

  const order = http.post(BASE + '/orders', JSON.stringify({ productId: 42, qty: 1 }), auth);
  const orderId = order.json('id'); // correlating a dynamic id

  const pay = http.post(BASE + '/orders/' + orderId + '/pay', null,
    Object.assign({ tags: { name: 'POST /orders/:id/pay' } }, auth));
  check(pay, { 'paid': (r) => r.status === 200 });
}
🔧 Розбір: SharedArray читає файл один раз в init-контексті і ділить дані між VU без копіювання. __VU — номер віртуального користувача, починається з 1. URL збирається конкатенацією рядків, а тег name об'єднує всі /orders/{id}/pay в одну метрику, інакше кожен id дав би окремий рядок у звіті.
🔧 Breakdown: SharedArray reads the file once in the init context and shares the data across VUs without copying. __VU is the virtual user number, starting at 1. The URL is built by string concatenation, and the name tag merges every /orders/{id}/pay into one metric; otherwise each id would get its own line in the report.
💡 Простими словами: Записаний скрипт без кореляції — як ключ від чужого номера в готелі: одного разу відчинив, а далі стоїш під дверима.
💡 In plain words: A recorded script without correlation is like someone else's hotel key card: it opened the door once, now you're stuck in the corridor.
🗣 Як читати вголос Кореляція — це коли я беру з відповіді динамічне значення, наприклад токен або id створеного замовлення, і підставляю в наступні запити. Без неї записаний скрипт під навантаженням дає 401 і 404, і вони ще й покращують латентність, бо відмова швидка. Дані готую окремо: пул користувачів, щоб кожен VU працював від свого імені, достатньо одноразових даних на весь прогін і прибирання після. У k6 для цього SharedArray і res.json, у JMeter — CSV Data Set і екстрактори.
🗣 How to say it out loud Correlation is when I take a dynamic value from a response, say a token or the id of a created order, and feed it into later requests. Without it a recorded script produces 401s and 404s under load, and they even improve latency because failures are fast. I prepare data separately: a pool of users so each VU acts as itself, enough one-time data for the whole run, and cleanup afterwards. In k6 that means SharedArray and res.json; in JMeter, CSV Data Set and extractors.
19 Навіщо окреме performance-середовище і чому тест на зменшеному стенді вводить в оману? Why use a dedicated performance environment, and why does testing on a smaller stand mislead?
Модель навантаження Workload model Middle ▶

Навіщо окреме. На спільному стейджингу в той самий час хтось ганяє E2E, хтось мігрує БД, а хтось деплоїть. Результати стають шумом: сьогодні p95 350 мс, завтра 900, і невідомо, чи винен код. Навантаження теж заважає іншим. Окремий стенд дає ізоляцію і відтворюваність, а тестувати на продакшені можна лише свідомо і з запобіжниками.

Why a separate one. On a shared staging environment someone runs E2E, someone migrates the database and someone deploys at the same time. Results become noise: p95 is 350 ms today, 900 tomorrow, and nobody knows whether the code is to blame. The load also disturbs others. A dedicated stand gives isolation and reproducibility, while testing in production is only done deliberately and with safeguards.

Паритет. Стенд має збігатися з продом у тому, що впливає на продуктивність: типи інстансів і ліміти CPU/пам'яті контейнерів, версії ПЗ і конфіги (розміри пулів, таймаути, GC), топологія (балансувальник, кеш, репліки БД), обсяг і розподіл даних, мережеві затримки до залежностей.

Parity. The stand must match production in everything that affects performance: instance types and container CPU/memory limits, software versions and configs (pool sizes, timeouts, GC), topology (load balancer, cache, database replicas), data volume and distribution, network latency to dependencies.

Чому зменшений стенд бреше. Системи масштабуються нелінійно. Стенд у 4 рази менший не дає «чверть продуктивності»: в продакшені вузьким місцем може бути спільна БД чи лок, який на малому стенді не насичується. Навпаки, маленька БД з 10 тис. рядків уміщається в пам'ять і дає швидкі запити, які на 50 млн рядків стануть повними сканами. Кеш із малим обсягом даних має hit rate 99%, у проді — 70%.

Why a smaller stand lies. Systems scale non-linearly. A stand four times smaller does not give "a quarter of the performance": in production the bottleneck may be a shared database or a lock that never saturates on a small stand. Conversely, a small database with 10 thousand rows fits in memory and gives fast queries that turn into full scans at 50 million rows. A cache with little data has a 99% hit rate; in production it is 70%.

Приклад. На стенді 2 інстанси по 2 vCPU витримали 400 RPS, команда екстраполювала ×5 для прода з 10 інстансами. У проді при 1200 RPS упала БД: пул 10 інстансів × 30 з'єднань = 300 при max_connections = 200. На малому стенді цього не могло статися.

Example. On the stand, 2 instances with 2 vCPU each handled 400 RPS, and the team extrapolated ×5 for production with 10 instances. In production at 1200 RPS the database fell over: a pool of 10 instances × 30 connections = 300 against max_connections = 200. On the small stand this could never happen.

Що робити без повного паритету. Масштабувати висновки обережно, тестувати окремі компоненти до насичення, мати повний обсяг даних навіть на меншому залізі і документувати відмінності у звіті.

Without full parity. Scale conclusions carefully, test individual components to saturation, keep the full data volume even on smaller hardware, and document the differences in the report.

Чекліст паритету стенду з продом
An environment parity checklist
параметр               прод            perf-стенд      вплив
інстанси застосунку          10 × 4 vCPU     2 × 4 vCPU      масштаб, пули
пул з'єднань БД / інст.     30              30              OK
max_connections        200             200             ліміт 10×30 не видно на 2×30
рядків orders           52 M            52 M            OK (плани запитів)
Redis                  кластер 3 вузли  1 вузол        мережа, failover
платіжний шлюз        реальний 300 ms   мок 300 ms      OK (затримка в моку)
parameter              prod            perf stand      impact
app instances          10 × 4 vCPU     2 × 4 vCPU      scale, pools
DB pool / inst.     30              30              OK
max_connections        200             200             10×30 limit invisible at 2×30
orders rows           52 M            52 M            OK (query plans)
Redis                  3-node cluster  1 node        network, failover
payment gateway        real 300 ms   mock 300 ms      OK (latency in mock)
🔧 Розбір: Кожен рядок фіксує параметр, що впливає на продуктивність, і наслідок розбіжності. Такий чекліст додається до звіту: читач одразу бачить, які висновки можна переносити на прод, а які ні.
🔧 Breakdown: Each row records a parameter that affects performance and the consequence of a mismatch. The checklist goes into the report, so the reader immediately sees which conclusions carry over to production and which do not.
💡 Простими словами: Тест на зменшеному стенді — як краш-тест іграшкової машинки: щось дізнаєшся, але не про справжню.
💡 In plain words: Testing on a scaled-down stand is like crash-testing a toy car: you learn something, just not about the real one.
🗣 Як читати вголос Окреме середовище потрібне, щоб результати були відтворюваними: на спільному стенді чужі деплої й тести роблять з моїх чисел шум, а моє навантаження заважає іншим. Але важлива не лише ізоляція, а й паритет із продом: ресурси, конфіги пулів, топологія, обсяг даних. Зменшений стенд вводить в оману, бо системи масштабуються нелінійно: маленька база вміщається в пам'ять, спільні ліміти не досягаються. Якщо паритету немає, я прямо пишу в звіті, які висновки не можна переносити.
🗣 How to say it out loud A dedicated environment makes results reproducible: on a shared stand other people's deploys and tests turn my numbers into noise, and my load gets in their way. Isolation is not enough though; it needs parity with production: resources, pool configs, topology, data volume. A scaled-down stand misleads because systems scale non-linearly: a small database fits in memory and shared limits are never reached. When parity is missing, I state plainly in the report which conclusions do not carry over.
20 Що таке coordinated omission і як він спотворює перцентилі? What is coordinated omission and how does it distort percentiles?
Модель навантаження Workload model Senior ▶

Що це. Термін Гіла Тене: генератор навантаження «домовляється» з системою про те, щоб не бачити її найгірших моментів. Коли сервер зависає, закритий генератор чекає відповіді і не відправляє запити, які мав би відправити за розкладом. Ці пропущені запити в реальності отримали б величезну латентність, але вони взагалі не потрапляють у вибірку.

What it is. Gil Tene's term: the load generator "coordinates" with the system so as not to see its worst moments. When the server stalls, a closed generator waits for the response and does not send the requests it was scheduled to send. Those missing requests would have seen huge latency in reality, but they never enter the sample.

Приклад. Один клієнт шле запит кожні 10 мс, відповідь — 1 мс. 100 секунд усе добре: 10 000 вимірів по 1 мс. Потім система зависає на 100 секунд. Генератор фіксує один вимір у 100 с. Разом 10 001 вимір, p99 = 1 мс. Але за ці 100 секунд реальні користувачі надіслали б ще 10 000 запитів з латентністю від 100 с до 0. Чесні дані: p50 ≈ 1 мс, p75 ≈ 50 с, p99 ≈ 98 с.

Example. One client sends a request every 10 ms, the response takes 1 ms. For 100 seconds all is well: 10,000 samples at 1 ms. Then the system freezes for 100 seconds. The generator records one sample of 100 s. In total 10,001 samples, p99 = 1 ms. But during those 100 seconds real users would have sent another 10,000 requests with latency from 100 s down to 0. Honest data: p50 ≈ 1 ms, p75 ≈ 50 s, p99 ≈ 98 s.

Як виправляти. Відкрита модель: запити відправляються за розкладом незалежно від відповідей (у k6 — arrival-rate executors; перевіряй dropped_iterations, бо це і є пропущені запити). Латентність рахувати від запланованого часу відправлення, а не фактичного. Інструменти на кшталт wrk2 і HdrHistogram мають корекцію. У JMeter Precise Throughput Timer задає розклад, але нестача потоків усе одно спотворює картину.

How to fix it. An open model: requests go out on schedule regardless of responses (in k6, arrival-rate executors; watch dropped_iterations, because those are exactly the omitted requests). Measure latency from the intended send time, not the actual one. Tools like wrk2 and HdrHistogram offer correction. In JMeter the Precise Throughput Timer sets a schedule, but a shortage of threads still distorts the picture.

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

Where else it appears. In monitoring: an agent that times only completed requests does not see stuck ones. A "mean latency per minute" metric during a total freeze may show zero requests instead of a disaster.

Для співбесіди. Сильна відповідь — пояснити механізм на числах і сказати, що хороший p99 у закритому тесті під час зависань — підозрілий, а не заспокійливий.

For the interview. A strong answer explains the mechanism with numbers and says that a good p99 from a closed test during stalls is suspicious rather than reassuring.

Розрахунок: що бачить генератор і що було насправді
The math: what the generator sees versus reality
розклад: 1 запит / 10 ms, норма 1 ms, зависання 100 s

Закритий генератор (з coordinated omission):
  10 000 × 1 ms  +  1 × 100 s   ->  10 001 вимірів
  p50 = 1 ms   p99 = 1 ms   p99.99 = 100 s        виглядає чудово

Чесний розклад (відкрита модель):
  10 000 × 1 ms  +  10 000 × (100 s ... 0 s)  ->  20 000 вимірів
  p50 ≈ 1 ms   p75 ≈ 50 s   p99 ≈ 98 s          реальність
schedule: 1 req / 10 ms, normal 1 ms, freeze 100 s

Closed generator (with coordinated omission):
  10 000 × 1 ms  +  1 × 100 s   ->  10 001 samples
  p50 = 1 ms   p99 = 1 ms   p99.99 = 100 s        looks great

Honest schedule (open model):
  10 000 × 1 ms  +  10 000 × (100 s ... 0 s)  ->  20 000 samples
  p50 ≈ 1 ms   p75 ≈ 50 s   p99 ≈ 98 s          reality
🔧 Розбір: Обидва блоки описують ту саму подію. У першому зависання дає один вимір, тому навіть p99 не зачеплений. У другому кожен запланований за 100 секунд запит отримує латентність «скільки лишалося до кінця зависання», і половина вибірки стає повільною.
🔧 Breakdown: Both blocks describe the same event. In the first, the freeze yields one sample, so even p99 is untouched. In the second, every request scheduled during the 100 seconds gets a latency of "how long until the freeze ended", and half the sample becomes slow.
💡 Простими словами: Це як опитувати задоволеність клієнтів лише тих, хто дочекався в черзі, і не питати тих, хто пішов, не дочекавшись.
💡 In plain words: It's like surveying only the customers who made it through the queue and never asking the ones who gave up and left.
🗣 Як читати вголос Coordinated omission — це коли генератор під час зависання системи чекає відповіді і просто не відправляє заплановані запити, а саме вони мали б найгіршу латентність. У результаті хвіст зникає з вибірки: система стояла сто секунд, а p99 показує одну мілісекунду. Я пояснюю це на числах і лікую відкритою моделлю з arrival rate, де запити йдуть за розкладом, плюс слідкую за dropped iterations. Гарний p99 у закритому тесті при відомих зависаннях для мене — привід перевірити методику.
🗣 How to say it out loud Coordinated omission is when the generator, during a system stall, waits for the response and simply does not send the scheduled requests, and those are exactly the ones that would have the worst latency. The tail disappears from the sample: the system froze for a hundred seconds and p99 shows one millisecond. I explain it with numbers and fix it with an open, arrival-rate model where requests go out on schedule, plus I watch dropped iterations. A nice p99 from a closed test with known stalls tells me to check the methodology.
21 Навіщо потрібен baseline і як порівнювати прогони між релізами? Why do you need a baseline and how do you compare runs between releases?
Модель навантаження Workload model Middle ▶

Що це. Baseline — зафіксований результат еталонного прогону: той самий сценарій, профіль, середовище й дані, на відомій версії системи. Без нього число «p95 = 420 мс» нічого не означає: це погіршення, покращення чи норма?

What it is. A baseline is the recorded result of a reference run: the same scenario, profile, environment and data, on a known version of the system. Without it, "p95 = 420 ms" means nothing: is it worse, better or normal?

Як будувати. Кілька прогонів (3–5) на стабільній версії, щоб оцінити природний розкид. Якщо p95 між прогонами коливається 400–440 мс, то зміна на 20 мс — шум, а на 150 — сигнал. Разом із числами фіксують версії коду, конфіги, розмір даних, характеристики стенду.

How to build it. Several runs (3–5) on a stable version to estimate the natural spread. If p95 varies between 400 and 440 ms across runs, a 20 ms change is noise and a 150 ms change is a signal. Alongside the numbers you record code versions, configs, data size and stand specs.

Як порівнювати. Однакові умови — головне правило. Порівнювати по ендпоінтах і перцентилях, а не загальним середнім. Задати допустиму деградацію: наприклад, реліз не проходить, якщо p95 будь-якого ключового ендпоінта гірший за baseline більш ніж на 10% або помилок стало більше. Дивитися й на ресурси: той самий p95 при CPU 80% замість 50% — теж регресія, просто прихована.

How to compare. Identical conditions are the golden rule. Compare per endpoint and per percentile, not overall averages. Define the allowed degradation: for example, a release fails if the p95 of any key endpoint is more than 10% worse than baseline or if errors went up. Look at resources too: the same p95 at 80% CPU instead of 50% is also a regression, just a hidden one.

Приклад. Baseline v2.3: /search p95 = 310 мс. Реліз v2.4: 480 мс (+55%). Інші ендпоінти без змін, отже проблема локальна. Порівняння планів запиту показало, що новий фільтр вимкнув використання індексу.

Example. Baseline v2.3: /search p95 = 310 ms. Release v2.4: 480 ms (+55%). Other endpoints are unchanged, so the problem is local. Comparing query plans showed the new filter stopped using the index.

Підводні камені. Оновлювати baseline мовчки після кожного релізу — тоді повільна деградація по 5% за реліз ніколи не спрацює. Baseline оновлюють свідомо і зберігають історію трендів.

Pitfalls. Silently updating the baseline after every release — then a slow 5%-per-release degradation never triggers anything. A baseline is updated deliberately, and the trend history is kept.

Порівняння з baseline по ендпоінтах
Comparing against the baseline per endpoint
ендпоінт          baseline p95   v2.4 p95   зміна   розкид   вердикт
GET /products/:id    120 ms         124 ms     +3%     ±6%      OK (шум)
GET /search          310 ms         480 ms     +55%    ±8%      FAIL
POST /cart           180 ms         176 ms     -2%     ±5%      OK
POST /checkout       650 ms         690 ms     +6%     ±7%      OK
CPU застосунку         52%            54%        +2 pp            OK
правило: FAIL, якщо зміна > max(10%, 2 × розкид)
endpoint          baseline p95   v2.4 p95   delta   spread   verdict
GET /products/:id    120 ms         124 ms     +3%     ±6%      OK (noise)
GET /search          310 ms         480 ms     +55%    ±8%      FAIL
POST /cart           180 ms         176 ms     -2%     ±5%      OK
POST /checkout       650 ms         690 ms     +6%     ±7%      OK
app CPU         52%            54%        +2 pp            OK
rule: FAIL if delta > max(10%, 2 × spread)
🔧 Розбір: Колонка «розкид» — природне коливання між прогонами baseline. Правило внизу відсікає шум: зміну вважаємо регресією, лише якщо вона більша і за 10%, і за подвійний розкид. Ресурси порівнюються поруч із латентністю, щоб не пропустити приховану регресію.
🔧 Breakdown: The "spread" column is the natural run-to-run variation of the baseline. The rule at the bottom filters noise: a change counts as a regression only if it exceeds both 10% and twice the spread. Resources are compared next to latency so a hidden regression is not missed.
💡 Простими словами: Baseline — це зріст, записаний на одвірку. Без позначки не скажеш, чи дитина виросла.
💡 In plain words: A baseline is the height mark on the door frame. Without it you can't tell whether the kid has grown.
🗣 Як читати вголос Без baseline будь-яке число з тесту беззмістовне, бо не з чим порівняти. Я роблю кілька прогонів на стабільній версії в однакових умовах, щоб побачити природний розкид, і фіксую разом із числами версії та конфіги. Далі кожен реліз порівнюю по ендпоінтах і перцентилях з правилом, яке відсікає шум, і дивлюся ще й на ресурси. Baseline оновлюю тільки свідомо, інакше повільна деградація по кілька відсотків за реліз ніколи не буде помічена.
🗣 How to say it out loud Without a baseline any number from a test is meaningless, because there is nothing to compare it to. I do several runs on a stable version under identical conditions to see the natural spread, and I record versions and configs next to the numbers. Then I compare every release per endpoint and percentile using a rule that filters out noise, and I also look at resources. I update the baseline only deliberately, otherwise a slow degradation of a few percent per release never gets noticed.
▶ 4 8
22 Що таке VU в k6 і як виглядає найпростіший скрипт навантаження? What is a VU in k6 and what does the simplest load script look like?
Інструменти Tools Junior ▶

Що це. VU (virtual user) — віртуальний користувач: незалежний виконавець, який у циклі запускає функцію default скрипта. Кожен VU має власний стан (cookies, змінні), і всі вони працюють паралельно. Один прохід функції — ітерація.

What it is. A VU (virtual user) is an independent executor that runs the script's default function in a loop. Each VU has its own state (cookies, variables), and they all run in parallel. One pass through the function is an iteration.

Як це працює. k6 написаний на Go, а скрипти — на JavaScript, який виконується вбудованим рушієм, а не Node.js і не браузером. Тому в скрипті немає require з npm чи DOM, зате є модулі k6: k6/http, k6/data, k6/metrics. Легкий рушій дозволяє запускати тисячі VU з однієї машини.

How it works. k6 is written in Go, and scripts are JavaScript executed by an embedded engine — not Node.js and not a browser. So a script has no npm require and no DOM, but it does have k6 modules: k6/http, k6/data, k6/metrics. The lightweight engine lets one machine run thousands of VUs.

Приклад. Скрипт нижче: 20 VU протягом 1 хвилини відкривають товар і роблять паузу 1 с. За законом Літтла при відповіді ~0,2 с це приблизно 20 / 1,2 ≈ 17 запитів за секунду. Запуск: k6 run smoke.js. Наприкінці k6 друкує зведення: http_reqs, http_req_duration з avg, med, p(90), p(95), max, http_req_failed, iterations, vus.

Example. The script below: 20 VUs for 1 minute open a product and pause for 1 s. By Little's law, with ~0.2 s responses that is about 20 / 1.2 ≈ 17 requests per second. Run it with k6 run smoke.js. At the end k6 prints a summary: http_reqs, http_req_duration with avg, med, p(90), p(95), max, plus http_req_failed, iterations, vus.

Підводні камені. Думати, що VU = реальний користувач один до одного. Без пауз один VU генерує навантаження десятків людей. Також VU — не потік браузера: HTTP-модуль не рендерить сторінку і не завантажує JS, CSS і картинки, якщо їх не запросити явно.

Pitfalls. Assuming one VU equals one real user. Without pauses one VU generates the load of dozens of people. Also a VU is not a browser tab: the HTTP module does not render the page or fetch JS, CSS and images unless you request them explicitly.

Уточнення. Для навантаження через справжній браузер у k6 є модуль browser, але він дорогий за ресурсами; зазвичай поєднують багато протокольних VU і кілька браузерних.

Follow-up. For load through a real browser k6 has a browser module, but it is resource-hungry; teams usually combine many protocol-level VUs with a few browser ones.

smoke.js — мінімальний скрипт k6
smoke.js — a minimal k6 script
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 20,
  duration: '1m',
};

export default function () {
  const res = http.get('https://shop.example.com/api/products/42');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(1);
}
🔧 Розбір: options експортується з модуля і задає навантаження; vus і duration — скорочення для простого закритого сценарію. default — тіло ітерації, яке кожен VU повторює, поки не мине хвилина. check повертає true/false і записує результат у метрику checks, не зупиняючи тест.
🔧 Breakdown: options is exported from the module and defines the load; vus and duration are shorthand for a simple closed scenario. default is the iteration body each VU repeats until the minute is up. check returns true/false and records the result in the checks metric without stopping the test.
💡 Простими словами: VU — це невтомний робот-відвідувач: він робить рівно те, що написано в сценарії, скільки завгодно разів поспіль.
💡 In plain words: A VU is a tireless robot visitor: it does exactly what the scenario says, as many times in a row as you like.
🗣 Як читати вголос VU в k6 — це віртуальний користувач, незалежний виконавець, який по колу виконує функцію default мого скрипта зі своїм станом. Скрипти пишуться на JavaScript, але виконуються вбудованим рушієм на Go, тому одна машина тягне тисячі VU, а npm-пакетів і DOM там немає. Найпростіший скрипт — опції з кількістю VU і тривалістю, запит, перевірка статусу і пауза. І я пам'ятаю, що VU без пауз створює навантаження як десятки людей, тож ставлю think time.
🗣 How to say it out loud A VU in k6 is a virtual user, an independent executor that keeps running my script's default function with its own state. Scripts are written in JavaScript but run on an embedded Go-based engine, so one machine can drive thousands of VUs, and there are no npm packages or DOM. The simplest script is options with a VU count and duration, a request, a status check and a pause. And I remember that a VU without pauses generates the load of dozens of people, so I add think time.
23 З яких етапів складається життєвий цикл скрипта k6 і навіщо setup, teardown і handleSummary? What are the stages of a k6 script lifecycle, and what are setup, teardown and handleSummary for?
Інструменти Tools Middle ▶

Чотири етапи. 1) init — код поза функціями: імпорти, options, читання файлів через open(). Виконується для кожного VU окремо, тому HTTP-запити тут заборонені. 2) setup() — один раз перед тестом, результат передається всім VU як аргумент. 3) default (або функції сценаріїв) — навантаження, багато разів. 4) teardown(data) — один раз після тесту.

Four stages. 1) init — code outside functions: imports, options, reading files with open(). It runs once per VU, so HTTP requests are not allowed there. 2) setup() — once before the test; its return value is passed to every VU as an argument. 3) default (or scenario functions) — the load, many times over. 4) teardown(data) — once after the test.

Навіщо setup. Підготувати спільне: отримати сервісний токен, створити тестові сутності, перевірити, що стенд живий, і зупинити тест одразу, якщо ні. Запити з setup мають тег групи ::setup, тож їх легко відфільтрувати, щоб службові запити не впливали на пороги.

Why setup. To prepare shared things: get a service token, create test entities, check that the stand is alive and stop the test immediately if not. Requests from setup carry the ::setup group tag, so they are easy to filter out and keep service requests away from thresholds.

teardown і handleSummary. teardown прибирає: видаляє створені дані, повертає стенд у вихідний стан. handleSummary(data) отримує фінальні агрегати і повертає об'єкт «файл → вміст», що дозволяє зберегти JSON для порівняння з baseline, JUnit XML для CI або власний текстовий звіт.

teardown and handleSummary. teardown cleans up: deletes created data and returns the stand to its initial state. handleSummary(data) receives the final aggregates and returns a "file → content" object, which lets you save JSON for baseline comparison, JUnit XML for CI or your own text report.

Приклад. Перед тестом кошика setup створює 200 товарів і повертає їхні id; VU беруть id зі списку; teardown видаляє товари; handleSummary пише summary.json, який наступний крок CI порівнює з baseline.

Example. Before a cart test, setup creates 200 products and returns their ids; VUs pick ids from the list; teardown deletes the products; handleSummary writes summary.json, which the next CI step compares with the baseline.

Підводні камені. Робити важку роботу в init (наприклад, парсити великий JSON без SharedArray) — пам'ять множиться на кількість VU. Повертати з setup великі структури — вони серіалізуються для кожного VU.

Pitfalls. Doing heavy work in init (for example parsing a big JSON without SharedArray) — memory is multiplied by the number of VUs. Returning large structures from setup — they are serialised for every VU.

Життєвий цикл k6 в одному файлі
The k6 lifecycle in one file
import http from 'k6/http';
import exec from 'k6/execution';

const BASE = 'https://shop.example.com/api';   // init: для кожного VU
export const options = { vus: 50, duration: '10m' };

export function setup() {                      // один раз до тесту
  const health = http.get(BASE + '/health');
  if (health.status !== 200) exec.test.abort('stand is down');
  const ids = [];
  for (let i = 0; i < 200; i++) {
    ids.push(http.post(BASE + '/admin/products', JSON.stringify({ name: 'perf-' + i })).json('id'));
  }
  return { ids: ids };
}

export default function (data) {               // навантаження
  const id = data.ids[Math.floor(Math.random() * data.ids.length)];
  http.get(BASE + '/products/' + id, { tags: { name: 'GET /products/:id' } });
}

export function teardown(data) {               // один раз після
  data.ids.forEach(function (id) { http.del(BASE + '/admin/products/' + id); });
}

export function handleSummary(data) {
  return { 'summary.json': JSON.stringify(data, null, 2) };
}
import http from 'k6/http';
import exec from 'k6/execution';

const BASE = 'https://shop.example.com/api';   // init: per VU
export const options = { vus: 50, duration: '10m' };

export function setup() {                      // once before
  const health = http.get(BASE + '/health');
  if (health.status !== 200) exec.test.abort('stand is down');
  const ids = [];
  for (let i = 0; i < 200; i++) {
    ids.push(http.post(BASE + '/admin/products', JSON.stringify({ name: 'perf-' + i })).json('id'));
  }
  return { ids: ids };
}

export default function (data) {               // the load
  const id = data.ids[Math.floor(Math.random() * data.ids.length)];
  http.get(BASE + '/products/' + id, { tags: { name: 'GET /products/:id' } });
}

export function teardown(data) {               // once after
  data.ids.forEach(function (id) { http.del(BASE + '/admin/products/' + id); });
}

export function handleSummary(data) {
  return { 'summary.json': JSON.stringify(data, null, 2) };
}
🔧 Розбір: exec.test.abort() з модуля k6/execution зупиняє тест, якщо стенд не живий. Об'єкт, повернутий з setup, приходить аргументом data і в default, і в teardown. handleSummary замінює стандартний вивід, тому якщо потрібен і консольний текст, його додають окремим ключем stdout.
🔧 Breakdown: exec.test.abort() from the k6/execution module stops the test if the stand is not alive. The object returned from setup arrives as the data argument in both default and teardown. handleSummary replaces the default output, so if you also want console text you add it under a separate stdout key.
💡 Простими словами: Setup — розставити стільці перед концертом, default — сам концерт, teardown — прибрати зал, handleSummary — написати рецензію.
💡 In plain words: Setup is setting out chairs before the concert, default is the concert, teardown is clearing the hall, handleSummary is writing the review.
🗣 Як читати вголос У k6 чотири етапи. Init — код на верхньому рівні, він виконується для кожного VU, тому там лише імпорти, опції й читання файлів. Setup — один раз перед тестом: я там перевіряю, що стенд живий, отримую токени і створюю дані, а результат k6 передає всім VU. Default — саме навантаження. Teardown прибирає за тестом. А handleSummary дозволяє зберегти результат у JSON або JUnit, щоб CI порівняв його з baseline. Важкі дані в init тримаю в SharedArray, інакше пам'ять множиться на кількість VU.
🗣 How to say it out loud k6 has four stages. Init is the top-level code, which runs for every VU, so it only holds imports, options and file reads. Setup runs once before the test: I check the stand is alive, get tokens and create data there, and k6 passes the result to every VU. Default is the load itself. Teardown cleans up after the test. And handleSummary lets me save the result as JSON or JUnit so CI can compare it with the baseline. Heavy data in init goes into a SharedArray, otherwise memory multiplies by the VU count.
24 Як у k6 групувати запити тегами і збирати власні бізнес-метрики? How do you group requests with tags and collect custom business metrics in k6?
Інструменти Tools Middle ▶

Навіщо теги. Кожна метрика k6 має теги: method, status, url, name, scenario, group. Без явного name кожен URL з динамічним id стає окремою серією, і звіт перетворюється на тисячі рядків. Тег name об'єднує такі запити в один логічний ендпоінт.

Why tags. Every k6 metric carries tags: method, status, url, name, scenario, group. Without an explicit name, every URL with a dynamic id becomes its own series and the report turns into thousands of lines. The name tag merges such requests into one logical endpoint.

Групи. group('Checkout', fn) об'єднує кілька кроків в одну бізнес-транзакцію і дає метрику group_duration — скільки триває весь крок користувача, а не окремий запит.

Groups. group('Checkout', fn) wraps several steps into one business transaction and yields the group_duration metric — how long the whole user step takes, not a single request.

Власні метрики. Модуль k6/metrics дає чотири типи: Trend (розподіл значень, перцентилі), Rate (частка true), Counter (сума), Gauge (останнє значення). Типові приклади: час від створення замовлення до статусу «оплачено», частка відповідей з кешу, кількість відмов через відсутність товару.

Custom metrics. The k6/metrics module offers four types: Trend (a distribution of values, percentiles), Rate (share of true), Counter (a sum), Gauge (the latest value). Typical examples: time from order creation to "paid" status, share of cache hits, number of rejections due to out-of-stock.

Приклад. Ендпоінт оплати повертає 202, а обробка йде асинхронно. http_req_duration показує 90 мс, але користувач чекає підтвердження 6 с. Власний Trend order_confirmed_ms, що міряє час до статусу PAID через полінг, показує справжню бізнес-латентність, і на нього ставиться поріг.

Example. The payment endpoint returns 202 and processing continues asynchronously. http_req_duration shows 90 ms, but the user waits 6 s for confirmation. A custom Trend order_confirmed_ms, measuring the time to PAID status via polling, shows the real business latency, and the threshold goes on that.

Підводні камені. Високу кардинальність тегів (id користувача, повний URL) — вона роздуває пам'ять генератора і бази метрик. Теги мають мати обмежену кількість значень.

Pitfalls. High tag cardinality (user id, full URL) bloats generator memory and the metrics database. Tags must have a bounded set of values.

k6: група, тег name і власний Trend для асинхронної оплати
k6: a group, a name tag and a custom Trend for async payment
import http from 'k6/http';
import { group, sleep } from 'k6';
import { Trend, Rate } from 'k6/metrics';

const confirmed = new Trend('order_confirmed_ms', true);
const cacheHit = new Rate('cache_hit');
const BASE = 'https://shop.example.com/api';

export const options = {
  thresholds: {
    order_confirmed_ms: ['p(95)<8000'],
    'group_duration{group:::Checkout}': ['p(95)<3000'],
  },
};

export default function () {
  group('Checkout', function () {
    const p = http.get(BASE + '/products/42', { tags: { name: 'GET /products/:id' } });
    cacheHit.add(p.headers['X-Cache'] === 'HIT');
    const order = http.post(BASE + '/orders', JSON.stringify({ productId: 42 }));
    const id = order.json('id');
    const start = Date.now();
    for (let i = 0; i < 20; i++) {
      const s = http.get(BASE + '/orders/' + id, { tags: { name: 'GET /orders/:id' } });
      if (s.json('status') === 'PAID') break;
      sleep(0.5);
    }
    confirmed.add(Date.now() - start);
  });
}
🔧 Розбір: Другий аргумент true у new Trend позначає метрику як час, тож у звіті вона форматується в мілісекундах. Групи мають префікс ::, тому фільтр порогу виглядає як group:::Checkout. Цикл полінгу обмежений 20 спробами, щоб зависле замовлення не тримало VU вічно.
🔧 Breakdown: The second argument true in new Trend marks the metric as time, so the report formats it in milliseconds. Groups get a :: prefix, which is why the threshold filter reads group:::Checkout. The polling loop is capped at 20 attempts so a stuck order cannot hold a VU forever.
💡 Простими словами: Теги — це ярлики на коробках при переїзді: без них усе лежить однією купою і нічого не знайдеш.
💡 In plain words: Tags are the labels on moving boxes: without them everything is one big pile and you can't find a thing.
🗣 Як читати вголос Теги я використовую, щоб звіт був читабельним: тег name зводить усі URL з різними id до одного логічного ендпоінта, інакше буде тисяча рядків. Групи об'єднують кілька запитів у бізнес-крок, і group_duration показує, скільки чекає користувач на весь крок. А власні метрики з k6/metrics потрібні, коли HTTP-латентність не відображає реальність, наприклад асинхронна оплата: запит відповідає миттєво, а підтвердження приходить через секунди. Тоді я міряю саме це і ставлю поріг на свою метрику.
🗣 How to say it out loud I use tags to keep the report readable: the name tag collapses all URLs with different ids into one logical endpoint, otherwise you get a thousand lines. Groups bundle several requests into a business step, and group_duration shows how long the user waits for the whole step. Custom metrics from k6/metrics matter when HTTP latency does not reflect reality, such as asynchronous payment: the request returns instantly but the confirmation comes seconds later. Then I measure exactly that and put the threshold on my own metric.
25 Як побудований тест-план JMeter і чому навантаження запускають у non-GUI режимі? How is a JMeter test plan structured and why is load run in non-GUI mode?
Інструменти Tools Middle ▶

Структура. Test Plan містить Thread Group (кількість потоків = віртуальних користувачів, ramp-up, тривалість), у ньому семплери (HTTP Request), конфіг-елементи (HTTP Request Defaults, HTTP Header Manager, CSV Data Set Config, HTTP Cookie Manager), таймери (think time, Constant Throughput Timer), постпроцесори-екстрактори (JSON Extractor для кореляції), асерти (Response Assertion, Duration Assertion) і лісенери.

Structure. A Test Plan holds a Thread Group (number of threads = virtual users, ramp-up, duration), and inside it samplers (HTTP Request), config elements (HTTP Request Defaults, HTTP Header Manager, CSV Data Set Config, HTTP Cookie Manager), timers (think time, Constant Throughput Timer), extractor post-processors (JSON Extractor for correlation), assertions (Response Assertion, Duration Assertion) and listeners.

Чому non-GUI. GUI створений для запису й налагодження. Під навантаженням він сам споживає CPU і пам'ять генератора, а лісенери на кшталт View Results Tree тримають у пам'яті кожну відповідь, тому генератор стає вузьким місцем і спотворює результати. Навантаження запускають з командного рядка: jmeter -n -t plan.jmx -l results.jtl -e -o report/, де -e -o будує HTML-дашборд з перцентилями й графіками.

Why non-GUI. The GUI is for recording and debugging. Under load it consumes the generator's CPU and memory itself, and listeners like View Results Tree keep every response in memory, so the generator becomes the bottleneck and skews results. Load runs from the command line: jmeter -n -t plan.jmx -l results.jtl -e -o report/, where -e -o builds an HTML dashboard with percentiles and charts.

Параметризація. Щоб один план працював у різних прогонах, значення не вшивають: ${__P(users,10)} читає властивість, яку передають ключем -Jusers=200. Так CI змінює навантаження без редагування XML.

Parameterisation. So one plan works across runs, values are not hard-coded: ${__P(users,10)} reads a property passed with -Jusers=200. That way CI changes the load without editing XML.

Приклад. План для кошика: Thread Group 200 потоків, ramp-up 120 с, тривалість 900 с; логін → JSON Extractor бере $.accessToken у змінну token → Header Manager додає Bearer ${token} → додавання в кошик → Uniform Random Timer 2–5 с.

Example. A cart plan: Thread Group of 200 threads, 120 s ramp-up, 900 s duration; login → JSON Extractor puts $.accessToken into a token variable → Header Manager adds Bearer ${token} → add to cart → Uniform Random Timer 2–5 s.

Підводні камені. Thread Group — закрита модель; для заданого RPS потрібні таймери пропускної здатності або плагін Concurrency/Arrivals Thread Group. І зберігати в JTL лише потрібні поля.

Pitfalls. A Thread Group is a closed model; for a set RPS you need throughput timers or the Concurrency/Arrivals Thread Group plugin. And save only the needed fields in the JTL.

JMeter: запуск з CLI з параметрами
JMeter: a CLI run with parameters
jmeter -n -t cart.jmx \
  -Jusers=200 -Jrampup=120 -Jduration=900 -Jhost=perf.shop.example.com \
  -l results/cart.jtl \
  -e -o results/html-report
# в Thread Group:  Threads = ${__P(users,10)}
#                   Ramp-up = ${__P(rampup,30)}   Duration = ${__P(duration,60)}
jmeter -n -t cart.jmx \
  -Jusers=200 -Jrampup=120 -Jduration=900 -Jhost=perf.shop.example.com \
  -l results/cart.jtl \
  -e -o results/html-report
# in the Thread Group:  Threads = ${__P(users,10)}
#                   Ramp-up = ${__P(rampup,30)}   Duration = ${__P(duration,60)}
Фрагмент .jmx: JSON Extractor для токена
A .jmx fragment: a JSON Extractor for the token
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract token">
  <stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
  <stringProp name="JSONPostProcessor.jsonPathExprs">$.accessToken</stringProp>
  <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
  <stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
</JSONPostProcessor>
🔧 Розбір: -n — non-GUI, -t — план, -l — файл сирих результатів, -J задає властивості, які план читає функцією __P з дефолтом. У XML-фрагменті JSON Extractor кладе перше збігання $.accessToken у змінну token, а NOT_FOUND робить збій кореляції помітним у наступних запитах.
🔧 Breakdown: -n is non-GUI, -t the plan, -l the raw results file, -J sets properties that the plan reads with the __P function and a default. In the XML fragment the JSON Extractor puts the first match of $.accessToken into the token variable, and NOT_FOUND makes a correlation failure visible in later requests.
💡 Простими словами: GUI JMeter — це майстерня, де збирають машину. На трасу її виганяють без майстерні на борту.
💡 In plain words: The JMeter GUI is the workshop where you build the car. You don't take the workshop with you onto the race track.
🗣 Як читати вголос План JMeter — це дерево: Thread Group з кількістю потоків і ramp-up, у ньому HTTP-семплери, менеджери заголовків і cookies, CSV з даними, екстрактори для кореляції, таймери для пауз і асерти. GUI я використовую лише для створення й налагодження, а навантаження запускаю в non-GUI режимі, бо інтерфейс і важкі лісенери з'їдають ресурси генератора і спотворюють результати. Параметри передаю через властивості з командного рядка, і CI будує HTML-звіт після прогону.
🗣 How to say it out loud A JMeter plan is a tree: a Thread Group with thread count and ramp-up, and inside it HTTP samplers, header and cookie managers, a CSV data set, extractors for correlation, timers for pauses and assertions. I use the GUI only to build and debug, and run the load in non-GUI mode, because the interface and heavy listeners eat the generator's resources and distort results. I pass parameters as properties on the command line, and CI builds the HTML report after the run.
26 Як виглядає симуляція Gatling і як у ній задають профіль навантаження та критерії? What does a Gatling simulation look like, and how do you set the load profile and criteria in it?
Інструменти Tools Middle ▶

Що це. Gatling — інструмент навантаження на JVM з DSL на Java, Kotlin або Scala. Тест — клас-симуляція з трьох частин: протокол (базовий URL, заголовки), сценарій (ланцюжок запитів, пауз і перевірок) і setUp, де сценарію призначається профіль інжекції та асерти.

What it is. Gatling is a JVM load tool with a DSL in Java, Kotlin or Scala. A test is a simulation class in three parts: the protocol (base URL, headers), the scenario (a chain of requests, pauses and checks) and setUp, which assigns the scenario an injection profile and assertions.

Сильні сторони. Асинхронна неблокувальна архітектура дає високе навантаження з однієї машини; відкрита модель навантаження підтримується напряму (injectOpen), закрита — через injectClosed; HTML-звіт з перцентилями генерується автоматично; код симуляції живе в репозиторії поруч із сервісом і рев'юється як звичайний код.

Strengths. An asynchronous non-blocking architecture delivers high load from one machine; the open workload model is supported directly (injectOpen), the closed one via injectClosed; an HTML report with percentiles is generated automatically; the simulation code lives in the repository next to the service and is reviewed like any other code.

Профіль. rampUsersPerSec(1).to(50).during(...) — розгін інтенсивності прибуття нових користувачів, constantUsersPerSec(50).during(...) — плато. Це відкрита модель: задається, скільки нових користувачів приходить щосекунди, а не скільки їх одночасно.

Profile. rampUsersPerSec(1).to(50).during(...) ramps up the arrival rate of new users, constantUsersPerSec(50).during(...) is the plateau. It is an open model: you set how many new users arrive each second, not how many exist at once.

Критерії. assertions валять прогін (ненульовий код виходу для Maven/Gradle-плагіна), якщо, наприклад, p95 глобально вище 500 мс або невдалих запитів понад 1%. Так Gatling вбудовується в CI як гейт.

Criteria. assertions fail the run (a non-zero exit code for the Maven/Gradle plugin) if, for example, global p95 is above 500 ms or failed requests exceed 1%. That is how Gatling plugs into CI as a gate.

Підводні камені. Плутати «users per sec» з RPS: один користувач сценарію може зробити 5 запитів, тож 50 користувачів/с — це 250 запитів/с. І забувати про кореляцію: значення з відповіді зберігають через saveAs і використовують у Gatling EL як #{productId}.

Pitfalls. Confusing "users per sec" with RPS: one scenario user can make 5 requests, so 50 users/s is 250 requests/s. And forgetting correlation: a value from a response is stored with saveAs and used in Gatling EL as #{productId}.

Gatling Java DSL: відкритий профіль і асерти
Gatling Java DSL: an open profile and assertions
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import java.time.Duration;

public class CheckoutSimulation extends Simulation {
  HttpProtocolBuilder protocol = http.baseUrl("https://shop.example.com")
      .acceptHeader("application/json");

  ScenarioBuilder buyer = scenario("Browse and add to cart")
      .exec(http("search").get("/api/search?q=phone")
          .check(status().is(200), jsonPath("$.items[0].id").saveAs("productId")))
      .pause(2, 5)
      .exec(http("product").get("/api/products/#{productId}"))
      .pause(3)
      .exec(http("add to cart").post("/api/cart")
          .body(StringBody("{\"productId\": \"#{productId}\"}")).asJson());

  {
    setUp(buyer.injectOpen(
          rampUsersPerSec(1).to(50).during(Duration.ofMinutes(5)),
          constantUsersPerSec(50).during(Duration.ofMinutes(15))))
      .protocols(protocol)
      .assertions(
          global().responseTime().percentile(95.0).lt(500),
          global().failedRequests().percent().lt(1.0));
  }
}
🔧 Розбір: pause(2, 5) — випадкова пауза від 2 до 5 секунд. saveAs кладе id з відповіді в сесію користувача, а #{productId} підставляє його далі. Блок в фігурних дужках — ініціалізатор екземпляра: у Java DSL саме там викликається setUp. Один користувач робить 3 запити, тож плато 50 користувачів/с дає близько 150 запитів/с.
🔧 Breakdown: pause(2, 5) is a random pause between 2 and 5 seconds. saveAs stores the id from the response in the user's session, and #{productId} injects it later. The block in braces is an instance initialiser: in the Java DSL that is where setUp is called. One user makes 3 requests, so a 50 users/s plateau yields about 150 requests/s.
💡 Простими словами: Gatling — це тест навантаження, який читається як звичайний код і живе поруч із сервісом, а не як XML у чиїйсь теці.
💡 In plain words: Gatling is a load test that reads like ordinary code and lives next to the service, not as XML in someone's folder.
🗣 Як читати вголос Симуляція Gatling складається з протоколу, сценарію і setUp. У сценарії ланцюжок запитів з перевірками і паузами, а значення з відповідей зберігаються в сесію через saveAs. Профіль задаю відкритою моделлю: розгін кількості нових користувачів за секунду і плато. Асерти на p95 і частку помилок роблять прогін червоним, тож його легко вбудувати в CI. Головне, про що попереджаю: users per second — не RPS, бо один користувач робить кілька запитів.
🗣 How to say it out loud A Gatling simulation has a protocol, a scenario and setUp. The scenario is a chain of requests with checks and pauses, and values from responses go into the session via saveAs. I define the profile as an open model: a ramp in new users per second and then a plateau. Assertions on p95 and error share turn the run red, so it is easy to wire into CI. The main thing I warn about: users per second is not RPS, because one user makes several requests.
27 Як написати тест навантаження на Locust і коли він доречний? How do you write a load test in Locust and when is it a good fit?
Інструменти Tools Middle ▶

Що це. Locust — інструмент навантаження на Python. Користувач описується класом, наслідуваним від HttpUser: методи з декоратором @task — дії, вага в дужках задає пропорцію, wait_time — пауза між задачами, on_start — підготовка (логін) для кожного користувача.

What it is. Locust is a Python load tool. A user is described as a class inheriting from HttpUser: methods decorated with @task are actions, the weight in parentheses sets the proportion, wait_time is the pause between tasks, and on_start is per-user preparation (login).

Коли доречний. Команда пише на Python і хоче перевикористати клієнтські бібліотеки, генератори даних чи складну логіку сценарію; потрібен нестандартний протокол (свій клієнт замість HTTP); потрібен зручний веб-інтерфейс для дослідницьких прогонів. Для дуже високих RPS з однієї машини Locust поступається k6 і Gatling, тому його масштабують режимом master/worker.

When it fits. The team writes Python and wants to reuse client libraries, data generators or complex scenario logic; a non-standard protocol is needed (your own client instead of HTTP); a handy web UI is wanted for exploratory runs. For very high RPS from one machine Locust falls behind k6 and Gatling, so it scales out in master/worker mode.

Pacing. Крім between(1, 3) є constant_pacing(10) — одна задача не частіше ніж раз на 10 с незалежно від відповіді, і constant_throughput(0.1) — те саме, виражене в задачах за секунду.

Pacing. Besides between(1, 3) there is constant_pacing(10) — one task at most every 10 s regardless of the response — and constant_throughput(0.1), the same expressed as tasks per second.

Приклад запуску. Без UI для CI: locust -f locustfile.py --headless -u 500 -r 50 -t 15m --host https://shop.example.com --csv results. Тут -u — кількість користувачів, -r — скільки додавати за секунду. Файли CSV зі статистикою (перцентилі по кожному name) аналізують або порівнюють з baseline.

Example run. Headless for CI: locust -f locustfile.py --headless -u 500 -r 50 -t 15m --host https://shop.example.com --csv results. Here -u is the user count and -r how many to add per second. The CSV statistics files (percentiles per name) are analysed or compared with a baseline.

Підводні камені. Не ставити name для URL з id — статистика розсипається на тисячі рядків. І забувати, що Python-процес використовує одне ядро, тож на машині з 8 ядрами запускають кілька worker-процесів.

Pitfalls. Not setting name for URLs with ids — statistics shatter into thousands of lines. And forgetting that a Python process uses one core, so on an 8-core machine you run several worker processes.

locustfile.py
locustfile.py
import random
from locust import HttpUser, task, between

class Shopper(HttpUser):
    wait_time = between(2, 5)   # think time

    def on_start(self):
        r = self.client.post("/api/login", json={"email": "perf@example.com", "password": "secret"})
        self.headers = {"Authorization": "Bearer " + r.json()["accessToken"]}

    @task(6)
    def view_product(self):
        pid = random.randint(1, 50000)
        self.client.get(f"/api/products/{pid}", headers=self.headers, name="/api/products/[id]")

    @task(3)
    def search(self):
        self.client.get("/api/search?q=phone", headers=self.headers)

    @task(1)
    def add_to_cart(self):
        with self.client.post("/api/cart", json={"productId": 42}, headers=self.headers,
                              catch_response=True) as resp:
            if resp.status_code != 201:
                resp.failure("cart returned " + str(resp.status_code))
🔧 Розбір: Ваги 6/3/1 дають мікс 60/30/10%. name групує запити з різними id в один рядок статистики. catch_response=True дозволяє самому вирішити, чи вважати відповідь помилкою: тут усе, крім 201, позначається через resp.failure.
🔧 Breakdown: Weights 6/3/1 give a 60/30/10% mix. name groups requests with different ids into one statistics line. catch_response=True lets you decide whether a response counts as a failure: here anything but 201 is flagged with resp.failure.
💡 Простими словами: Locust — для команди, яка думає на Python: сценарій навантаження пишеться як звичайний клас.
💡 In plain words: Locust is for a team that thinks in Python: a load scenario is written as an ordinary class.
🗣 Як читати вголос У Locust користувач — це Python-клас від HttpUser. Задачі позначаю декоратором task з вагою, паузи задаю через wait_time, а логін роблю в on_start. Для CI запускаю headless з кількістю користувачів, темпом додавання і тривалістю, і зберігаю CSV зі статистикою. Обираю його, коли команда пише на Python або сценарій потребує складної логіки чи нестандартного клієнта. Для великих навантажень запускаю master і кілька воркерів, бо один процес Python займає лише одне ядро.
🗣 How to say it out loud In Locust a user is a Python class based on HttpUser. I mark tasks with the task decorator and a weight, set pauses with wait_time, and log in inside on_start. For CI I run it headless with a user count, spawn rate and duration, and save CSV statistics. I pick it when the team writes Python or the scenario needs complex logic or a custom client. For heavy load I run a master and several workers, because one Python process uses only one core.
28 JMeter чи k6 (а також Gatling і Locust) — як обрати інструмент навантаження? JMeter or k6 (and Gatling or Locust) — how do you choose a load testing tool?
Інструменти Tools Middle ▶

Суть. Усі чотири здатні дати коректний результат; вибір визначають команда, протоколи, CI і вимоги до масштабу, а не «який найкращий».

The gist. All four can produce a correct result; the choice is driven by the team, the protocols, CI and scale requirements, not by "which one is best".

JMeter. Зрілий, з GUI і записом через проксі, величезна кількість плагінів і протоколів (HTTP, JDBC, JMS, FTP, LDAP). Добрий вибір, коли тести пишуть люди без досвіду програмування або потрібні рідкісні протоколи. Мінуси: XML-плани погано рев'юються в git, потік на кожного користувача з'їдає пам'ять.

JMeter. Mature, with a GUI and proxy recording, a huge number of plugins and protocols (HTTP, JDBC, JMS, FTP, LDAP). A good choice when tests are written by people without programming experience or rare protocols are needed. Downsides: XML plans review poorly in git, and one thread per user eats memory.

k6. Скрипти на JavaScript, CLI-first, пороги як код і ненульовий код виходу — природно вбудовується в CI. Легкий за ресурсами, має відкриту модель через arrival-rate executors і розширення. Мінус: не Node.js, тож npm-бібліотеки напряму не підключиш, і для розподіленого запуску потрібні k6-operator або хмара.

k6. JavaScript scripts, CLI-first, thresholds as code and a non-zero exit code — it fits naturally into CI. Light on resources, supports the open model via arrival-rate executors, and has extensions. Downside: it is not Node.js, so npm libraries cannot be plugged in directly, and distributed runs need k6-operator or the cloud.

Gatling і Locust. Gatling — для JVM-команд: Java/Kotlin DSL, асинхронний рушій, чудові HTML-звіти. Locust — для Python-команд і складної логіки сценаріїв, з вебінтерфейсом і master/worker.

Gatling and Locust. Gatling is for JVM teams: a Java/Kotlin DSL, an asynchronous engine, excellent HTML reports. Locust is for Python teams and complex scenario logic, with a web UI and master/worker.

Як вирішувати. Мова, яку команда підтримуватиме; протоколи; інтеграція з CI і метриками (Prometheus, Grafana); потрібний масштаб; вартість ліцензій і хмари. На співбесіді погана відповідь — «k6 кращий за JMeter», добра — критерії вибору і приклад, коли ви обрали б інший інструмент.

How to decide. The language the team will maintain; protocols; integration with CI and metrics (Prometheus, Grafana); the scale needed; licence and cloud costs. In an interview a weak answer is "k6 is better than JMeter"; a strong one gives selection criteria and an example of when you would pick another tool.

Матриця вибору
A selection matrix
критерій             JMeter        k6            Gatling        Locust
мова сценаріїв        GUI / XML     JavaScript    Java/Kotlin    Python
рев'ю в git           слабко         добре         добре          добре
відкрита модель       плагін/таймер  arrival-rate  injectOpen     pacing/throughput
пороги в CI           плагіни/скрипт  thresholds    assertions     скрипт
рідкісні протоколи    JDBC, JMS...  розширення    деякі          свій клієнт
розподілений запуск   -R сервери    k6-operator   хмара/вручну   master/worker

Приклад рішення: Java-команда, CI на GitHub Actions -> Gatling або k6.
QA без коду, треба JMS -> JMeter.
criterion             JMeter        k6            Gatling        Locust
script language        GUI / XML     JavaScript    Java/Kotlin    Python
git review           weak         good         good          good
open model       plugin/timer  arrival-rate  injectOpen     pacing/throughput
CI thresholds           plugins/script  thresholds    assertions     script
rare protocols    JDBC, JMS...  extensions    some          own client
distributed run   -R servers    k6-operator   cloud/manual   master/worker

Example decision: Java team, CI on GitHub Actions -> Gatling or k6.
QA without coding, JMS needed -> JMeter.
🔧 Розбір: Матриця — не рейтинг, а список компромісів. Корисно назвати критерії першими, а інструмент — висновком: тоді відповідь звучить як інженерне рішення, а не вподобання.
🔧 Breakdown: The matrix is not a ranking but a list of trade-offs. It helps to name the criteria first and the tool as the conclusion: then the answer sounds like an engineering decision rather than a preference.
💡 Простими словами: Питання «JMeter чи k6» схоже на «молоток чи шурупокрут»: залежить від того, що забиваєте і хто триматиме інструмент.
💡 In plain words: "JMeter or k6" is like "hammer or drill": it depends on what you're fixing and who will hold the tool.
🗣 Як читати вголос Я не вважаю, що є найкращий інструмент: усі чотири дадуть коректні числа при правильній методиці. JMeter обираю, коли потрібні GUI, запис або рідкісні протоколи, і тести пишуть люди без коду. k6 — коли хочу скрипти як код, пороги в CI і легкий генератор. Gatling добре лягає в JVM-команду, Locust — у Python-команду зі складною логікою. Вирішую за мовою команди, протоколами, інтеграцією з CI і метриками, масштабом і вартістю.
🗣 How to say it out loud I don't think there is a single best tool: all four give correct numbers with the right methodology. I choose JMeter when a GUI, recording or rare protocols are needed and tests are written by people who don't code. k6 when I want scripts as code, thresholds in CI and a light generator. Gatling fits a JVM team, Locust a Python team with complex logic. I decide by the team's language, protocols, CI and metrics integration, scale and cost.
29 Як організувати розподілену генерацію навантаження і як зрозуміти, що вузьке місце — сам генератор? How do you organise distributed load generation, and how do you tell the generator itself is the bottleneck?
Інструменти Tools Senior ▶

Навіщо розподіляти. Одна машина має межу за CPU, пам'яттю, мережею і кількістю портів (ефемерних портів близько 28 тис. за замовчуванням у Linux на одну пару адрес). Для десятків тисяч RPS, тестів з різних регіонів або обходу обмежень однієї IP-адреси потрібно кілька генераторів.

Why distribute. One machine has limits in CPU, memory, network and ports (about 28 thousand ephemeral ports by default on Linux per address pair). Tens of thousands of RPS, tests from different regions, or getting around single-IP limits need several generators.

Як у різних інструментах. JMeter — master і remote-сервери (jmeter-server, ключ -R host1,host2); кожен сервер виконує повний план, тож 200 потоків × 5 серверів = 1000. k6 — --execution-segment для ручного поділу, k6-operator у Kubernetes (ресурс TestRun з parallelism) або Grafana Cloud k6. Locust — --master і --worker. Gatling — кілька інстансів з об'єднанням логів або хмарна версія.

In different tools. JMeter — a master and remote servers (jmeter-server, the -R host1,host2 flag); each server runs the full plan, so 200 threads × 5 servers = 1000. k6 — --execution-segment for manual splitting, k6-operator in Kubernetes (a TestRun resource with parallelism) or Grafana Cloud k6. Locust — --master and --worker. Gatling — several instances with merged logs or the cloud edition.

Як зрозуміти, що винен генератор. CPU генератора понад 70–80%; у k6 попередження про нестачу VU і ріст dropped_iterations; фактичний RPS нижчий за цільовий, хоча сервер спокійний (CPU 30%, черг немає); латентність за даними генератора росте, а серверна латентність в APM стабільна; помилки на кшталт «cannot assign requested address» чи «too many open files».

How to tell the generator is at fault. Generator CPU above 70–80%; in k6, warnings about insufficient VUs and growing dropped_iterations; actual RPS below target while the server is calm (CPU 30%, no queues); latency grows on the generator side while server-side latency in APM is stable; errors such as "cannot assign requested address" or "too many open files".

Приклад. Тест показував p95 = 900 мс, а APM сервісу — 120 мс. Генератор з 2 vCPU був завантажений на 98% і не встигав читати відповіді. Після переходу на 4 генератори по 8 vCPU p95 на клієнті став 140 мс.

Example. A test showed p95 = 900 ms while the service's APM showed 120 ms. The 2 vCPU generator was at 98% and could not read responses fast enough. After moving to 4 generators with 8 vCPU each, client-side p95 became 140 ms.

Правило. Моніторити генератори так само, як систему, і перевіряти узгодженість клієнтських і серверних чисел.

Rule. Monitor the generators as closely as the system, and check that client-side and server-side numbers agree.

k6-operator: розподілений прогін у Kubernetes
k6-operator: a distributed run in Kubernetes
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: checkout-peak
spec:
  parallelism: 8                 # 8 подів ділять навантаження
  script:
    configMap:
      name: checkout-script
      file: checkout.js
  arguments: --tag testid=peak-2025-11 --out experimental-prometheus-rw
  runner:
    resources:
      limits:
        cpu: "4"
        memory: 8Gi
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
  name: checkout-peak
spec:
  parallelism: 8                 # 8 pods share the load
  script:
    configMap:
      name: checkout-script
      file: checkout.js
  arguments: --tag testid=peak-2025-11 --out experimental-prometheus-rw
  runner:
    resources:
      limits:
        cpu: "4"
        memory: 8Gi
Швидка діагностика генератора
Quick generator diagnostics
ознака                               генератор      система
CPU                                  98%            31%
фактичний RPS / ціль                  620 / 1000     -
p95 клієнт vs APM                     900 ms         120 ms
dropped_iterations                   14 200         -
висновок: вузьке місце — генератор
signal                               generator      system
CPU                                  98%            31%
actual RPS / target                  620 / 1000     -
p95 client vs APM                     900 ms         120 ms
dropped_iterations                   14 200         -
verdict: the generator is the bottleneck
🔧 Розбір: parallelism каже k6-operator запустити 8 подів, і кожен автоматично отримує свій сегмент виконання, тож сумарний rate зі скрипта ділиться між ними. Тег testid об'єднує метрики всіх подів в одному дашборді. Таблиця під YAML — чекліст: розбіжність клієнтської і серверної латентності при спокійній системі майже завжди вказує на генератор.
🔧 Breakdown: parallelism tells k6-operator to start 8 pods, and each automatically gets its own execution segment, so the script's total rate is split between them. The testid tag joins all pods' metrics in one dashboard. The table under the YAML is a checklist: a gap between client and server latency while the system is calm almost always points at the generator.
💡 Простими словами: Якщо кидаєш м'ячі в кошик і не встигаєш їх піднімати, то вимірюєш свою втому, а не кошик.
💡 In plain words: If you can't pick the balls up fast enough, you're measuring your own fatigue, not the hoop.
🗣 Як читати вголос Розподілену генерацію я вмикаю, коли одна машина впирається в CPU, мережу чи порти, або потрібне навантаження з кількох регіонів. У JMeter це master з remote-серверами, у k6 — k6-operator з parallelism або хмара, у Locust — master і воркери. Але спершу перевіряю, чи генератор не став вузьким місцем: високий CPU генератора, dropped iterations, RPS нижчий за ціль і розбіжність між клієнтською латентністю і серверною в APM. Генератори моніторю так само уважно, як систему.
🗣 How to say it out loud I turn to distributed generation when one machine runs out of CPU, network or ports, or when load is needed from several regions. In JMeter that means a master with remote servers, in k6 the k6-operator with parallelism or the cloud, in Locust a master and workers. But first I check whether the generator has become the bottleneck: high generator CPU, dropped iterations, RPS below target, and a gap between client latency and server latency in APM. I monitor generators as closely as the system itself.
▶ 5 7
30 Що таке SLI, SLO і SLA і як вони пов'язані між собою? What are SLI, SLO and SLA and how do they relate to each other?
SLO і CI SLO and CI Junior ▶

SLI (Service Level Indicator). Вимірюваний показник якості сервісу, зазвичай частка «добрих» подій: відсоток запитів, що відповіли успішно, відсоток запитів, швидших за 300 мс. Формулюється так, щоб його можна було порахувати з метрик без суперечок.

SLI (Service Level Indicator). A measurable indicator of service quality, usually the share of "good" events: the percentage of requests answered successfully, the percentage faster than 300 ms. It is phrased so it can be computed from metrics without argument.

SLO (Service Level Objective). Цільове значення SLI за період: «99,5% запитів до /checkout за 28 днів успішні і швидші за 800 мс». Це внутрішня домовленість команди, на яку орієнтуються рішення про релізи й пріоритети.

SLO (Service Level Objective). The target value of an SLI over a period: "99.5% of requests to /checkout over 28 days succeed and are faster than 800 ms". It is an internal team agreement that drives release and priority decisions.

SLA (Service Level Agreement). Юридичний договір з клієнтом із фінансовими наслідками: «доступність 99,9% на місяць, інакше повертаємо 10% оплати». SLA зазвичай м'якший за SLO: внутрішня ціль суворіша, щоб команда встигла відреагувати ще до того, як порушить комерційну обіцянку.

SLA (Service Level Agreement). A legal contract with a customer with financial consequences: "99.9% availability per month, or we refund 10% of the fee". An SLA is usually looser than the SLO: the internal target is stricter so the team can react before the commercial promise is broken.

Як це в тестах. Критерії performance-тесту варто виводити з SLO, а не вигадувати: якщо SLO каже p95 < 800 мс для оплати, поріг у k6 буде p(95)<800 на тегу оплати, можливо з запасом. Тоді зелений тест означає «цей реліз не з'їсть наш SLO».

In tests. Performance test criteria should be derived from the SLO rather than invented: if the SLO says p95 < 800 ms for payment, the k6 threshold is p(95)<800 on the payment tag, possibly with a margin. Then a green test means "this release won't eat our SLO".

Підводні камені. Ставити SLO 100% (недосяжно і блокує будь-які зміни) і міряти SLI не там, де його відчуває користувач: CPU сервера — не SLI, а успішність і швидкість запитів — так.

Pitfalls. Setting a 100% SLO (unreachable and it blocks every change) and measuring the SLI where the user does not feel it: server CPU is not an SLI; request success and speed are.

Від SLI до SLA і до порогу тесту
From SLI to SLA and on to a test threshold
SLI:  частка запитів /checkout, що успішні І швидші за 800 ms
SLO:  SLI >= 99.5% за 28 днів                 внутрішня ціль
SLA:  доступність >= 99.0% на місяць, інакше компенсація  договір

Поріг k6, виведений із SLO:
  'http_req_duration{name:POST /checkout}': ['p(99)<800'],
  'http_req_failed{name:POST /checkout}':   ['rate<0.005'],
SLI:  share of /checkout requests that succeed AND are faster than 800 ms
SLO:  SLI >= 99.5% over 28 days                 internal target
SLA:  availability >= 99.0% per month, else a credit  contract

k6 threshold derived from the SLO:
  'http_req_duration{name:POST /checkout}': ['p(99)<800'],
  'http_req_failed{name:POST /checkout}':   ['rate<0.005'],
🔧 Розбір: SLI визначає, що міряємо, SLO — яку ціль ставимо, SLA — що обіцяємо клієнту. Поріг у тесті повторює SLO: 99,5% «добрих» запитів означає, що не більше 0,5% можуть бути помилками і p99.5 має вкладатися в 800 мс; p99 тут — практичне наближення з невеликим запасом.
🔧 Breakdown: The SLI defines what we measure, the SLO which target we set, the SLA what we promise the customer. The test threshold mirrors the SLO: 99.5% "good" requests means at most 0.5% may fail and p99.5 must stay under 800 ms; p99 here is a practical approximation with a small margin.
💡 Простими словами: SLI — спідометр, SLO — «їду не швидше 90», SLA — штраф, який заплатиш, якщо поліція зловить на 110.
💡 In plain words: The SLI is the speedometer, the SLO is "I drive no faster than 90", the SLA is the fine you pay if the police catch you at 110.
🗣 Як читати вголос SLI — це те, що міряємо, зазвичай частка добрих запитів, наприклад успішних і швидших за певний поріг. SLO — ціль для цього показника за період, внутрішня домовленість команди. SLA — договір із клієнтом з грошовими наслідками, і він навмисно м'якший за SLO, щоб у нас був запас на реакцію. Для мене як для тестувальника головне, що пороги в performance-тестах я виводжу саме з SLO, тоді зелений прогін має бізнесовий сенс.
🗣 How to say it out loud An SLI is what we measure, usually the share of good requests, such as those that succeed and are faster than some threshold. The SLO is the target for that indicator over a period, an internal team agreement. The SLA is a contract with the customer with money attached, and it is deliberately looser than the SLO so we have room to react. For me as a tester the key point is that I derive performance test thresholds from the SLO, so a green run has business meaning.
31 Що таке error budget і як він впливає на рішення про релізи і тестування? What is an error budget and how does it influence release and testing decisions?
SLO і CI SLO and CI Middle ▶

Що це. Error budget — дозволена кількість «поганих» подій, яка випливає з SLO: 100% мінус ціль. SLO 99,9% успішних запитів за 30 днів дає бюджет 0,1%. Для 10 млн запитів на місяць це 10 000 невдалих або повільних запитів; для доступності в часі — 43,2 хвилини простою за 30 днів.

What it is. An error budget is the allowed number of "bad" events implied by the SLO: 100% minus the target. An SLO of 99.9% successful requests over 30 days gives a 0.1% budget. With 10 million requests a month that is 10,000 failed or slow requests; for time-based availability it is 43.2 minutes of downtime per 30 days.

Як використовують. Бюджет перетворює надійність із суперечки на правило. Поки бюджету достатньо, команда релізить сміливо й експериментує. Коли його спалено швидше, ніж планувалося, діє політика: заморозка фіч, пріоритет стабільності, додаткові performance-тести перед кожним релізом.

How it is used. The budget turns reliability from an argument into a rule. While there is enough budget, the team releases boldly and experiments. When it burns faster than planned, a policy applies: a feature freeze, priority on stability, extra performance tests before every release.

Burn rate. Швидкість витрачання бюджету: burn rate 1 означає, що бюджету вистачить рівно на період; 14,4 означає, що за годину згоріло 2% місячного бюджету — привід для термінового алерту.

Burn rate. The rate at which the budget is spent: a burn rate of 1 means the budget lasts exactly the period; 14.4 means 2% of the monthly budget burned in one hour — a reason for an urgent alert.

Для QA. Performance-тест прогнозує, скільки бюджету з'їсть реліз. Якщо новий реліз під піковим навантаженням дає 0,3% запитів повільніших за поріг SLO, а бюджет 0,1%, то реліз за тиждень спалить місячний бюджет — це аргумент не випускати його, виражений мовою, яку розуміє бізнес.

For QA. A performance test predicts how much budget a release will eat. If under peak load the new release has 0.3% of requests slower than the SLO threshold while the budget is 0.1%, the release will burn the monthly budget in about a week — an argument against shipping it, expressed in language the business understands.

Підводні камені. Рахувати бюджет лише за помилками і забувати про повільні запити, якщо SLI включає латентність. І не мати узгодженої політики: бюджет без наслідків — просто ще один графік.

Pitfalls. Counting only errors and forgetting slow requests when the SLI includes latency. And not having an agreed policy: a budget without consequences is just another chart.

Розрахунки бюджету і burn rate
Budget and burn rate calculations
SLO = 99.9% за 30 днів  ->  бюджет = 0.1%

у часі:      30 × 24 × 60 × 0.001 = 43.2 хв простою
у запитах:   10 000 000 × 0.001  = 10 000 поганих запитів

Реліз під піком дає 0.3% поганих:
  burn rate = 0.3% / 0.1% = 3   ->  бюджет згорить за 30 / 3 = 10 днів

Алерт швидкого горіння: 2% бюджету за 1 год
  burn rate = 0.02 × (30 × 24) / 1 = 14.4
SLO = 99.9% over 30 days  ->  budget = 0.1%

in time:      30 × 24 × 60 × 0.001 = 43.2 min of downtime
in requests:   10 000 000 × 0.001  = 10 000 bad requests

Release under peak yields 0.3% bad:
  burn rate = 0.3% / 0.1% = 3   ->  budget gone in 30 / 3 = 10 days

Fast-burn alert: 2% of budget in 1 h
  burn rate = 0.02 × (30 × 24) / 1 = 14.4
🔧 Розбір: Бюджет рахується з тієї ж формули SLO, лише в різних одиницях. Burn rate — відношення фактичної частки поганих подій до допустимої; ділення періоду на burn rate дає час до вичерпання бюджету. Значення 14,4 для швидкого алерту — поширена практика з книги Google SRE Workbook.
🔧 Breakdown: The budget comes from the same SLO formula in different units. Burn rate is the ratio of the actual bad-event share to the allowed one; dividing the period by the burn rate gives time to exhaustion. The 14.4 fast-burn value is a common practice from the Google SRE Workbook.
💡 Простими словами: Error budget — кишенькові гроші на ризики: поки вони є, можна експериментувати; закінчились — сидиш удома й лагодиш.
💡 In plain words: An error budget is pocket money for risk: while you have some, you can experiment; once it's gone, you stay home and fix things.
🗣 Як читати вголос Error budget — це сто відсотків мінус SLO, тобто скільки поганих подій ми собі дозволяємо. Для SLO 99,9 за тридцять днів це трохи більше сорока трьох хвилин простою або тисячна частка запитів. Бюджет робить надійність правилом: поки він є, релізимо сміливо, коли згорає швидко — заморожуємо фічі. Performance-тест для мене — спосіб заздалегідь оцінити, скільки бюджету з'їсть реліз, і сказати бізнесу цифрою, чому реліз варто притримати.
🗣 How to say it out loud An error budget is one hundred percent minus the SLO, meaning how many bad events we allow ourselves. For a 99.9 SLO over thirty days that is just over forty-three minutes of downtime, or one request in a thousand. The budget turns reliability into a rule: while it lasts we release boldly, when it burns fast we freeze features. For me a performance test is a way to estimate in advance how much budget a release will eat and to tell the business in numbers why the release should wait.
32 Як сформулювати критерії pass/fail для performance-тесту і налаштувати їх як thresholds у k6? How do you define pass/fail criteria for a performance test and set them as thresholds in k6?
SLO і CI SLO and CI Middle ▶

Що це. Критерії — умови, за яких прогін вважається успішним: перцентилі латентності по ключових ендпоінтах, частка помилок, мінімальна пропускна здатність, відсоток пройдених перевірок, відсутність пропущених ітерацій. Вони записуються до запуску тесту й узгоджуються з SLO, а не підганяються під результат.

What it is. Criteria are the conditions under which a run counts as passed: latency percentiles for key endpoints, error rate, minimum throughput, share of passed checks, no dropped iterations. They are written before the test runs and agreed with the SLO, not fitted to the result.

Хороші критерії. Конкретні (ендпоінт, перцентиль, число), вимірювані інструментом автоматично, розділені по критичності. Наприклад: /checkout p95 < 600 мс і p99 < 1500 мс, помилок < 0,5%, пошук p95 < 400 мс, dropped_iterations = 0 (генератор впорався).

Good criteria. Specific (endpoint, percentile, number), measured automatically by the tool, split by criticality. For example: /checkout p95 < 600 ms and p99 < 1500 ms, errors < 0.5%, search p95 < 400 ms, dropped_iterations = 0 (the generator kept up).

У k6. Блок thresholds: ключ — метрика з необов'язковим фільтром тегу, значення — масив умов. Якщо хоч одна не виконана, k6 завершується з кодом виходу 99, і CI позначає крок червоним. abortOnFail: true зупиняє тест одразу, а delayAbortEval дає метрикам накопичитися, щоб не зупинити тест через перші кілька запитів.

In k6. The thresholds block: the key is a metric with an optional tag filter, the value is an array of conditions. If any fails, k6 exits with code 99 and CI marks the step red. abortOnFail: true stops the test immediately, and delayAbortEval lets metrics accumulate so the test is not aborted on the first few requests.

Приклад. Stress-тест триває 40 хвилин, але якщо p99 оплати перевищив 3 с і тримається там, далі тест лише навантажує зламаний стенд. abortOnFail з затримкою оцінки 1 хвилина зупиняє прогін на 8-й хвилині, економлячи час і не заважаючи іншим командам.

Example. A stress test runs for 40 minutes, but if payment p99 exceeded 3 s and stays there, the rest of the run only hammers a broken stand. abortOnFail with a 1-minute evaluation delay stops the run at minute 8, saving time and not getting in other teams' way.

Підводні камені. Пороги лише на загальну http_req_duration: швидкий ендпоінт здоров'я «розбавляє» повільну оплату. І пороги без запасу на шум, через які тест флакує.

Pitfalls. Thresholds only on the overall http_req_duration: a fast health endpoint "dilutes" a slow payment. And thresholds with no margin for noise, which make the test flaky.

k6 thresholds: по ендпоінтах, з раннім перериванням
k6 thresholds: per endpoint, with early abort
export const options = {
  thresholds: {
    'http_req_duration{name:POST /checkout}': [
      'p(95)<600',
      { threshold: 'p(99)<1500', abortOnFail: true, delayAbortEval: '1m' },
    ],
    'http_req_duration{name:GET /search}': ['p(95)<400'],
    'http_req_failed': ['rate<0.005'],
    'checks': ['rate>0.99'],
    'dropped_iterations': ['count<1'],
    'http_reqs': ['rate>250'],        // мінімальна пропускна здатність
  },
};
// $ k6 run checkout.js ; echo $?
// 99   <- пороги не пройдено, CI червоний
export const options = {
  thresholds: {
    'http_req_duration{name:POST /checkout}': [
      'p(95)<600',
      { threshold: 'p(99)<1500', abortOnFail: true, delayAbortEval: '1m' },
    ],
    'http_req_duration{name:GET /search}': ['p(95)<400'],
    'http_req_failed': ['rate<0.005'],
    'checks': ['rate>0.99'],
    'dropped_iterations': ['count<1'],
    'http_reqs': ['rate>250'],        // minimum throughput
  },
};
// $ k6 run checkout.js ; echo $?
// 99   <- thresholds failed, CI goes red
🔧 Розбір: Рядковий запис 'p(95)<600' — звичайна умова; об'єктний запис дозволяє додати abortOnFail і delayAbortEval. Фільтр {name:...} обмежує умову одним ендпоінтом. http_reqs з rate перевіряє, що тест справді видав потрібну інтенсивність, а не «пройшов» на половинному навантаженні.
🔧 Breakdown: The string form 'p(95)<600' is a plain condition; the object form adds abortOnFail and delayAbortEval. The {name:...} filter limits a condition to one endpoint. http_reqs with rate checks the test really produced the intended intensity instead of "passing" at half load.
💡 Простими словами: Критерії пишуть до іспиту, а не після того, як побачили оцінку.
💡 In plain words: You set the pass mark before the exam, not after you've seen the score.
🗣 Як читати вголос Критерії я фіксую до запуску і виводжу з SLO: перцентилі по кожному критичному ендпоінту, частку помилок, пройдені перевірки, мінімальний RPS і нуль пропущених ітерацій. У k6 це блок thresholds, і якщо хоч один не пройдено, k6 повертає код 99 і CI червоніє. Для довгих stress-тестів додаю abortOnFail із затримкою оцінки, щоб не мучити зламаний стенд. І ніколи не ставлю пороги лише на загальну латентність, бо швидкі ендпоінти маскують повільні.
🗣 How to say it out loud I fix criteria before the run and derive them from the SLO: percentiles for each critical endpoint, error rate, passed checks, a minimum RPS and zero dropped iterations. In k6 that is the thresholds block, and if any one fails k6 returns exit code 99 and CI goes red. For long stress tests I add abortOnFail with an evaluation delay so we don't keep hammering a broken stand. And I never put thresholds only on overall latency, because fast endpoints mask slow ones.
33 Як запускати performance-тести в CI, наприклад у GitHub Actions? How do you run performance tests in CI, for example in GitHub Actions?
SLO і CI SLO and CI Middle ▶

Рівні. 1) На кожен pull request — короткий smoke-тест продуктивності: 1–3 хвилини, невелике навантаження, пороги з запасом; ловить грубі регресії (N+1, забутий індекс). 2) Нічний або перед релізом — повний load-тест на perf-стенді з порівнянням з baseline. 3) Періодично (раз на тиждень чи місяць) — stress і soak.

Tiers. 1) On every pull request — a short performance smoke test: 1–3 minutes, light load, thresholds with margin; it catches gross regressions (N+1, a forgotten index). 2) Nightly or before release — a full load test on the perf stand compared with the baseline. 3) Periodically (weekly or monthly) — stress and soak.

Що важливо. Стабільне цільове середовище (не спільний стенд, де хтось деплоїть паралельно), генератор із достатніми ресурсами (GitHub-hosted раннер має обмежені CPU, тому для серйозного навантаження беруть self-hosted або хмару), збереження результатів як артефактів, пороги як код і ненульовий код виходу при провалі.

What matters. A stable target environment (not a shared stand where someone deploys in parallel), a generator with enough resources (a GitHub-hosted runner has limited CPU, so serious load uses self-hosted runners or the cloud), results saved as artefacts, thresholds as code and a non-zero exit code on failure.

Приклад. Workflow нижче запускається вночі і вручну: деплой на perf-стенд, прогін k6 у Docker, збереження summary.json як артефакту. Якщо пороги не пройдено, k6 повертає 99, крок падає, і команда отримує сповіщення.

Example. The workflow below runs nightly and on demand: deploy to the perf stand, run k6 in Docker, save summary.json as an artefact. If thresholds fail, k6 returns 99, the step fails and the team gets notified.

Результати. Крім артефактів, метрики варто відправляти в спільне сховище (Prometheus remote write, InfluxDB) і тегувати номером коміту — тоді тренд видно на дашборді, а не лише «червоно/зелено».

Results. Besides artefacts, metrics should go to shared storage (Prometheus remote write, InfluxDB) tagged with the commit — then the trend is visible on a dashboard, not only "red or green".

Підводні камені. Робити повний load-тест блокувальним для кожного PR (довго і шумно — розробники почнуть ігнорувати) і ганяти тести з того ж раннера, що збирає код, паралельно з іншими задачами.

Pitfalls. Making a full load test blocking for every PR (slow and noisy — developers will start ignoring it) and running tests from the same runner that builds code, in parallel with other jobs.

.github/workflows/perf.yml
.github/workflows/perf.yml
name: perf-nightly
on:
  schedule:
    - cron: '0 2 * * *'        # щоночі о 02:00 UTC
  workflow_dispatch:

jobs:
  load:
    runs-on: [self-hosted, perf-generator]
    concurrency: perf-stand     # один прогін на стенд одночасно
    steps:
      - uses: actions/checkout@v4
      - name: Deploy build to perf stand
        run: ./scripts/deploy-perf.sh ${{ github.sha }}
      - name: Run k6
        run: |
          docker run --rm -v "$PWD/perf:/perf" -w /perf grafana/k6 run \
            --tag commit=${{ github.sha }} checkout.js
      - name: Keep results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-summary
          path: perf/summary.json
name: perf-nightly
on:
  schedule:
    - cron: '0 2 * * *'        # nightly at 02:00 UTC
  workflow_dispatch:

jobs:
  load:
    runs-on: [self-hosted, perf-generator]
    concurrency: perf-stand     # one run per stand at a time
    steps:
      - uses: actions/checkout@v4
      - name: Deploy build to perf stand
        run: ./scripts/deploy-perf.sh ${{ github.sha }}
      - name: Run k6
        run: |
          docker run --rm -v "$PWD/perf:/perf" -w /perf grafana/k6 run \
            --tag commit=${{ github.sha }} checkout.js
      - name: Keep results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-summary
          path: perf/summary.json
🔧 Розбір: concurrency гарантує, що два прогони не навантажують стенд одночасно й не псують результати одне одному. Скрипт у теці perf монтується в офіційний образ grafana/k6, а тег commit потрапляє на всі метрики. if: always() зберігає артефакт навіть тоді, коли крок k6 впав через пороги.
🔧 Breakdown: concurrency ensures two runs never load the stand at the same time and spoil each other's results. The script in the perf folder is mounted into the official grafana/k6 image, and the commit tag lands on every metric. if: always() keeps the artefact even when the k6 step failed on thresholds.
💡 Простими словами: CI-тест продуктивності — це датчик диму, а не пожежна інспекція: швидко і часто, а детальна перевірка — за розкладом.
💡 In plain words: A CI performance test is a smoke detector, not a fire inspection: quick and frequent, while the detailed check runs on a schedule.
🗣 Як читати вголос У CI я розкладаю продуктивність на рівні. На кожен PR — короткий smoke на кілька хвилин з м'якими порогами, щоб ловити грубі регресії. Вночі — повний load на окремому стенді з порівнянням з baseline, а stress і soak — рідше. У GitHub Actions запускаю k6 в Docker на self-hosted раннері, обмежую concurrency, щоб два прогони не накладалися, зберігаю summary як артефакт, а метрики з тегом коміту відправляю в Prometheus. Червоний крок — це код 99 від k6.
🗣 How to say it out loud In CI I split performance into tiers. On every PR, a short smoke of a few minutes with soft thresholds to catch gross regressions. Nightly, a full load test on a dedicated stand compared with the baseline, and stress and soak less often. In GitHub Actions I run k6 in Docker on a self-hosted runner, limit concurrency so two runs never overlap, keep the summary as an artefact, and push metrics tagged with the commit to Prometheus. A red step is k6's exit code 99.
34 Який головний антипатерн performance-тестів у CI і як його уникнути? What is the top anti-pattern of performance tests in CI and how do you avoid it?
SLO і CI SLO and CI Senior ▶

Антипатерн. Запускати навантаження на нестабільному спільному середовищі без baseline і порівнювати з жорстким абсолютним порогом. Сьогодні на стенді паралельно йде регресія іншої команди — тест червоний; завтра стенд вільний — зелений. Код не змінився, а результат стрибає. Через тиждень розробники перестають довіряти тесту, додають «retry» або вимикають його.

The anti-pattern. Running load on an unstable shared environment without a baseline and comparing against a hard absolute threshold. Today another team's regression runs on the stand in parallel — the test is red; tomorrow the stand is idle — green. The code has not changed, yet the result jumps. Within a week developers stop trusting the test, add "retry" or switch it off.

Чому це погано. Шум перевищує сигнал. Регресія на 15% тоне в розкиді ±40%, тож тест одночасно дає хибні тривоги й пропускає справжні проблеми. Це гірше за відсутність тесту: створює ілюзію контролю.

Why it is bad. Noise exceeds signal. A 15% regression drowns in ±40% spread, so the test both raises false alarms and misses real problems. That is worse than no test at all: it creates an illusion of control.

Як уникнути. Ізольоване середовище або принаймні ексклюзивне вікно (concurrency у CI, бронювання стенду). Стабільні дані, що скидаються перед прогоном. Фіксовані ресурси генератора. Baseline і порівняння відносно нього з урахуванням виміряного розкиду. Кілька повторів для рішень на межі. Відділення «сигналу для людини» (тренд, дашборд) від «блокуючого гейту» (лише грубі регресії).

How to avoid it. An isolated environment or at least an exclusive window (concurrency in CI, booking the stand). Stable data reset before each run. Fixed generator resources. A baseline and comparison against it that accounts for the measured spread. Several repeats for borderline decisions. Separating the "signal for humans" (trend, dashboard) from the "blocking gate" (gross regressions only).

Приклад з практики. Нічний тест падав 3 рази на тиждень без змін у коді. Аналіз показав, що в ці ночі на тому ж кластері запускався бекап БД. Перенос прогону, ізоляція вузлів і перехід з абсолютного порогу на правило «гірше за медіану п'яти останніх прогонів більш ніж на 15%» прибрали хибні спрацювання, а наступна реальна регресія була спіймана з першого разу.

A real example. A nightly test failed three times a week with no code changes. Analysis showed a database backup ran on the same cluster on those nights. Moving the run, isolating nodes and switching from an absolute threshold to the rule "worse than the median of the last five runs by more than 15%" removed the false alarms, and the next real regression was caught on the first try.

Інші антипатерни. Генератор на тому ж хості, що й система; тест без серверних метрик; ігнорування червоного результату «бо він завжди такий».

Other anti-patterns. The generator on the same host as the system; a test without server metrics; ignoring a red result "because it's always like that".

Відносний гейт замість абсолютного порогу
A relative gate instead of an absolute threshold
// compare.js — запускається після k6, читає summary.json і історію
const fs = require('fs');
const current = JSON.parse(fs.readFileSync('summary.json'));
const history = JSON.parse(fs.readFileSync('history.json')); // p95 останніх прогонів

const p95 = current.metrics['http_req_duration{name:GET /search}'].values['p(95)'];
const last5 = history.search_p95.slice(-5).sort(function (a, b) { return a - b; });
const median = last5[2];
const limit = median * 1.15;

console.log('search p95 ' + p95.toFixed(0) + ' ms, median ' + median + ' ms, limit ' + limit.toFixed(0) + ' ms');
if (p95 > limit) {
  console.error('REGRESSION: more than 15% worse than the median of the last 5 runs');
  process.exit(1);
}
// compare.js — runs after k6, reads summary.json and history
const fs = require('fs');
const current = JSON.parse(fs.readFileSync('summary.json'));
const history = JSON.parse(fs.readFileSync('history.json')); // p95 of recent runs

const p95 = current.metrics['http_req_duration{name:GET /search}'].values['p(95)'];
const last5 = history.search_p95.slice(-5).sort(function (a, b) { return a - b; });
const median = last5[2];
const limit = median * 1.15;

console.log('search p95 ' + p95.toFixed(0) + ' ms, median ' + median + ' ms, limit ' + limit.toFixed(0) + ' ms');
if (p95 > limit) {
  console.error('REGRESSION: more than 15% worse than the median of the last 5 runs');
  process.exit(1);
}
🔧 Розбір: Скрипт працює в Node.js на кроці CI після k6, тому тут доступні require і process. Метрика з фільтром тегу потрапляє в summary, бо для неї заданий threshold. Медіана п'яти прогонів стійка до одного аномального значення, а 15% — допуск, більший за виміряний розкид.
🔧 Breakdown: The script runs in Node.js in a CI step after k6, which is why require and process are available here. The tag-filtered metric appears in the summary because a threshold is defined for it. The median of five runs is robust to a single outlier, and 15% is a tolerance larger than the measured spread.
💡 Простими словами: Ваги на кораблі в шторм показують не вашу вагу, а хвилі.
💡 In plain words: A bathroom scale on a ship in a storm measures the waves, not you.
🗣 Як читати вголос Головний антипатерн — ганяти навантаження на спільному нестабільному стенді без baseline і з жорстким абсолютним порогом. Шум тоді більший за сигнал: тест то червоний, то зелений без змін у коді, і команда перестає йому вірити, а реальна регресія тоне в розкиді. Я лікую це ізоляцією стенду або ексклюзивним вікном, стабільними даними, відносним порівнянням з медіаною останніх прогонів і розділенням: блокуючий гейт лише для грубих регресій, а тонкі тренди — на дашборді.
🗣 How to say it out loud The main anti-pattern is running load on a shared, unstable stand without a baseline and with a hard absolute threshold. Noise then outweighs signal: the test flips red and green with no code changes, the team stops believing it, and a real regression drowns in the spread. I fix it with an isolated stand or an exclusive window, stable data, relative comparison against the median of recent runs, and a split: a blocking gate only for gross regressions, subtle trends on a dashboard.
35 Як вбудувати performance-тестування в Agile і зсунути його вліво? How do you build performance testing into Agile and shift it left?
SLO і CI SLO and CI Middle ▶

Проблема. Класичний підхід — один великий load-тест перед релізом. Проблеми знаходять за тиждень до запуску, коли архітектура вже зафіксована, а виправлення дорогі й ризиковані.

The problem. The classic approach is one big load test before release. Problems are found a week before launch, when the architecture is already fixed and fixes are expensive and risky.

Що означає shift-left. Питання продуктивності з'являються на ранніх етапах: у вимогах (нефункціональні критерії в user story: «пошук повертає 20 результатів за p95 < 300 мс при 200 RPS»), на рев'ю дизайну (скільки запитів до БД, чи потрібна пагінація, кеш), у коді (мікробенчмарки критичних функцій, перевірка кількості SQL-запитів у інтеграційних тестах), у CI (короткі smoke-тести продуктивності на кожен PR).

What shift-left means. Performance questions appear early: in requirements (non-functional criteria in a user story: "search returns 20 results at p95 < 300 ms under 200 RPS"), in design review (how many database queries, is pagination or a cache needed), in code (micro-benchmarks of critical functions, checking SQL query counts in integration tests), in CI (short performance smoke tests on every PR).

Ролі. Performance — не окрема команда в кінці, а спільна відповідальність. QA приносить шаблони скриптів, пороги й інтерпретацію; розробники запускають короткі тести локально; SRE дає метрики продакшну і SLO; продукт узгоджує цілі.

Roles. Performance is not a separate team at the end but a shared responsibility. QA brings script templates, thresholds and interpretation; developers run short tests locally; SRE supplies production metrics and SLOs; product agrees the targets.

Приклад. У Definition of Done додали пункт «для нових ендпоінтів є k6-сценарій і поріг». Під час рев'ю PR з новим фільтром каталогу smoke-тест показав 48 SQL-запитів на один виклик замість 3 — N+1 знайшли за годину після коміту, а не за тиждень до Чорної п'ятниці.

Example. The Definition of Done gained the item "new endpoints have a k6 scenario and a threshold". During review of a PR with a new catalogue filter, the smoke test showed 48 SQL queries per call instead of 3 — the N+1 was found an hour after the commit rather than a week before Black Friday.

Shift-right теж. Моніторинг продакшну, RUM, канаркові релізи з порівнянням латентності нової версії зі старою замикають цикл: реальні дані повертаються в модель навантаження.

Shift-right too. Production monitoring, RUM, and canary releases comparing the new version's latency with the old one close the loop: real data flows back into the workload model.

Нефункціональні критерії в user story і DoD
Non-functional criteria in a user story and DoD
Story: Фільтр каталогу за брендом
  AC-1: повертає товари обраного бренду
  AC-2 (perf): GET /api/products?brand=X
        p95 < 300 ms при 200 RPS, помилок < 0.1%
        <= 5 SQL запитів на виклик

Definition of Done:
  [x] k6-сценарій у perf/catalog.js з порогами AC-2
  [x] smoke-тест у PR зелений
  [x] дашборд: панель для нового ендпоінта
Story: Catalogue filter by brand
  AC-1: returns products of the chosen brand
  AC-2 (perf): GET /api/products?brand=X
        p95 < 300 ms at 200 RPS, errors < 0.1%
        <= 5 SQL queries per call

Definition of Done:
  [x] k6 scenario in perf/catalog.js with AC-2 thresholds
  [x] PR smoke test green
  [x] dashboard: a panel for the new endpoint
🔧 Розбір: Критерій продуктивності записано поруч із функціональним, у тих самих одиницях, які потім перевіряє k6. Пункти DoD роблять перевірку обов'язковою частиною готовності задачі, а не окремою активністю «колись потім».
🔧 Breakdown: The performance criterion sits next to the functional one, in the same units k6 will later check. The DoD items make the check a mandatory part of a task being done rather than a separate "some day" activity.
💡 Простими словами: Дешевше перевірити, чи пройде шафа у двері, на кресленні, ніж коли вантажники вже на сходах.
💡 In plain words: It's cheaper to check whether the wardrobe fits through the door on the blueprint than when the movers are on the stairs.
🗣 Як читати вголос Shift-left для продуктивності — це коли питання про швидкість з'являються з першого дня. Я прошу додавати в user story нефункціональні критерії в числах, на рев'ю дизайну питаю про кількість запитів і кеш, а в CI на кожен PR ганяю короткий smoke-тест продуктивності. У Definition of Done — сценарій і поріг для нових ендпоінтів. Це не замінює великий тест перед піком, але більшість проблем ловиться за години після коміту. А моніторинг продакшну замикає цикл і оновлює модель навантаження.
🗣 How to say it out loud Shift-left for performance means speed questions come up from day one. I ask for non-functional criteria in numbers in user stories, I ask about query counts and caching in design reviews, and in CI I run a short performance smoke test on every PR. The Definition of Done includes a scenario and a threshold for new endpoints. It doesn't replace the big test before a peak, but most problems are caught within hours of the commit. Production monitoring then closes the loop and updates the workload model.
36 Як написати звіт про результати performance-тесту, щоб він привів до рішення? How do you write a performance test report that leads to a decision?
SLO і CI SLO and CI Middle ▶

Для кого. Звіт читають різні люди: менеджер хоче вердикт і ризики, розробник — де вузьке місце, SRE — ресурси й конфіг. Тому структура: спершу висновок, потім деталі.

Who reads it. Different people read the report: a manager wants a verdict and risks, a developer where the bottleneck is, SRE the resources and config. So the structure is: conclusion first, details after.

Структура. 1) Вердикт одним реченням: «Реліз 3.2 витримує прогнозований пік 1100 RPS з p95 420 мс; SLO виконано; ризик — пул БД на 85%». 2) Мета і критерії. 3) Умови: версія, середовище, відмінності від проду, профіль, тривалість, дані. 4) Результати по ендпоінтах (перцентилі, RPS, помилки) порівняно з baseline. 5) Ресурси: CPU, пам'ять, GC, пули, БД. 6) Знайдені проблеми з доказами (графік, трейс, запит). 7) Рекомендації з пріоритетом і власником.

Structure. 1) A one-sentence verdict: "Release 3.2 handles the forecast peak of 1100 RPS with p95 420 ms; SLO met; risk: DB pool at 85%". 2) Goal and criteria. 3) Conditions: version, environment, differences from production, profile, duration, data. 4) Per-endpoint results (percentiles, RPS, errors) compared with the baseline. 5) Resources: CPU, memory, GC, pools, database. 6) Problems found with evidence (a chart, a trace, a query). 7) Prioritised recommendations with owners.

Як показувати дані. Графіки в часі (латентність, RPS і помилки на одній шкалі часу з ресурсами), таблиця перцентилів, а не лише середні, і явне порівняння «було / стало». Один графік, де видно, що p99 злетів у ту ж хвилину, коли пул з'єднань досяг максимуму, переконує краще за сторінку тексту.

Showing data. Time-series charts (latency, RPS and errors on the same timeline as resources), a percentile table rather than only averages, and an explicit "before / after" comparison. One chart showing p99 jumping in the very minute the connection pool hit its maximum convinces better than a page of text.

Приклад формулювання. Погано: «Система працює повільно під навантаженням». Добре: «При 900 RPS p99 /checkout зростає з 700 мс до 4,2 с, бо пул HikariCP (30 з'єднань) вичерпується і запити чекають у середньому 3,1 с. Рекомендація: збільшити пул до 50 після перевірки max_connections БД, повторний тест — четвер».

Example wording. Bad: "The system is slow under load". Good: "At 900 RPS, /checkout p99 rises from 700 ms to 4.2 s because the HikariCP pool (30 connections) is exhausted and requests wait 3.1 s on average. Recommendation: raise the pool to 50 after checking the database max_connections; retest on Thursday".

Підводні камені. Сирі дашборди без висновків, приховані відмінності середовища, відсутність рекомендацій і повторного прогону для перевірки виправлень.

Pitfalls. Raw dashboards without conclusions, hidden environment differences, no recommendations and no rerun to verify fixes.

Шапка звіту
The report header
ВЕРДИКТ: УМОВНО ГОТОВО  (release 3.2, perf-stand, 2025-11-04)
Пік 1100 RPS утримано 30 хв; SLO p95 < 500 ms виконано.
Ризик: пул БД 85% на піку, запасу для +20% трафіку немає.

ендпоінт         p50    p95    p99     помилки  vs baseline
GET /products     45     120    310     0.00%    +2%
GET /search       90     380    820     0.02%    +4%
POST /checkout    210    470    1350    0.08%    +9%

Рекомендації:
  1. [високий] пул БД 30 -> 50, перевірити max_connections (@backend, до 06.11)
  2. [середній] індекс на orders(user_id, created_at) (@dba)
  3. повторний прогін після п.1 (@qa, 07.11)
VERDICT: READY WITH RISK  (release 3.2, perf-stand, 2025-11-04)
Peak 1100 RPS held for 30 min; SLO p95 < 500 ms met.
Risk: DB pool at 85% at peak, no headroom for +20% traffic.

endpoint         p50    p95    p99     errors  vs baseline
GET /products     45     120    310     0.00%    +2%
GET /search       90     380    820     0.02%    +4%
POST /checkout    210    470    1350    0.08%    +9%

Recommendations:
  1. [high] DB pool 30 -> 50, verify max_connections (@backend, by 06.11)
  2. [medium] index on orders(user_id, created_at) (@dba)
  3. rerun after item 1 (@qa, 07.11)
🔧 Розбір: Перші три рядки — все, що потрібно менеджеру для рішення. Таблиця дає інженерам перцентилі й порівняння з baseline, а рекомендації мають пріоритет, власника й дату, тож звіт закінчується діями, а не описом.
🔧 Breakdown: The first three lines are all a manager needs to decide. The table gives engineers percentiles and a baseline comparison, and the recommendations carry a priority, an owner and a date, so the report ends with actions rather than description.
💡 Простими словами: Звіт — це не фотоальбом графіків, а лист лікаря: діагноз, аналізи, призначення.
💡 In plain words: A report is not a photo album of charts but a doctor's letter: diagnosis, test results, prescription.
🗣 Як читати вголос Звіт я починаю з вердикту одним-двома реченнями: чи витримали ціль, чи виконано SLO і який головний ризик. Далі — умови тесту з чесним переліком відмінностей від проду, таблиця перцентилів по ендпоінтах з порівнянням з baseline, ресурси і знайдені проблеми з доказами. Закінчую рекомендаціями з пріоритетом, власником і датою повторного прогону. Найкраще працює графік, де латентність і метрика ресурсу на одній шкалі часу: причину видно без пояснень.
🗣 How to say it out loud I open the report with a verdict in one or two sentences: did we hit the target, is the SLO met and what is the main risk. Then the test conditions with an honest list of differences from production, a per-endpoint percentile table compared with the baseline, resources, and the problems found with evidence. I finish with recommendations that have a priority, an owner and a rerun date. What works best is a chart with latency and a resource metric on the same timeline: the cause is visible without explanation.
▶ 6 10
37 Як шукати вузьке місце під час тесту за допомогою APM і метрик? How do you find a bottleneck during a test using APM and metrics?
Вузькі місця Bottlenecks Middle ▶

Принцип. Тест навантаження лише створює умови; причину показують серверні дані. APM (Datadog, New Relic, Dynatrace, Elastic APM, Grafana з Tempo) дає розподілені трейси: з чого складається час запиту — власний код, SQL, виклики інших сервісів, черги. Метрики інфраструктури показують, який ресурс насичений.

Principle. A load test only creates the conditions; server-side data shows the cause. APM (Datadog, New Relic, Dynatrace, Elastic APM, Grafana with Tempo) provides distributed traces: what a request's time is made of — own code, SQL, calls to other services, queues. Infrastructure metrics show which resource is saturated.

Алгоритм. 1) Знайти момент деградації на графіку клієнта (коли p95 почав рости, при якому RPS). 2) У APM відкрити найповільніші трейси цього проміжку і подивитися, який спан займає найбільше часу. 3) Для цього компонента перевірити ресурси методом USE: CPU, пам'ять і GC, пули потоків і з'єднань, диск, мережа. 4) Сформулювати гіпотезу, змінити одну річ, повторити тест.

Algorithm. 1) Find the moment of degradation on the client chart (when p95 started to rise, at what RPS). 2) In APM open the slowest traces from that window and see which span takes the most time. 3) For that component check resources with the USE method: CPU, memory and GC, thread and connection pools, disk, network. 4) Form a hypothesis, change one thing, rerun the test.

Приклад. Після 600 RPS p95 /orders росте. Трейс: 40 мс коду, 30 мс SQL і 1,8 с спану HikariPool.getConnection. Отже, запити не виконуються повільно, а чекають з'єднання. Метрика hikaricp_connections_pending росте до 60. Вузьке місце — розмір пулу або довгі транзакції, що його тримають.

Example. Beyond 600 RPS, /orders p95 rises. The trace: 40 ms of code, 30 ms of SQL and a 1.8 s HikariPool.getConnection span. So queries are not slow to run; they wait for a connection. The hikaricp_connections_pending metric climbs to 60. The bottleneck is the pool size or long transactions holding it.

Кореляція часу. Годинники генератора і серверів мають бути синхронізовані, а всі графіки — на одній шкалі часу. Корисно тегувати запити генератора заголовком (наприклад, X-Test-Run), щоб відфільтрувати трейси саме тестового трафіку.

Time correlation. Generator and server clocks must be synchronised and all charts on the same timeline. It helps to tag generator requests with a header (for example X-Test-Run) so traces of test traffic can be filtered.

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

Pitfalls. Changing several parameters at once (you can't tell what helped), looking only at resource averages, and forgetting dependencies: a slow third-party service looks like "our slow code" until you open the trace.

Розклад трейсу повільного запиту
Breakdown of a slow request trace
трейс GET /api/orders  всього 1 912 ms
├─ http.server.request                    1 912 ms
│  ├─ OrderController.list                   42 ms
│  ├─ HikariPool.getConnection            1 804 ms   <- чекає з'єднання
│  ├─ SELECT orders WHERE user_id = ?        31 ms
│  └─ serialize JSON                         12 ms

метрики в той самий момент:
  hikaricp_connections_active   30 / 30
  hikaricp_connections_pending  58
  app CPU                       34%        <- CPU не винен
trace GET /api/orders  total 1 912 ms
├─ http.server.request                    1 912 ms
│  ├─ OrderController.list                   42 ms
│  ├─ HikariPool.getConnection            1 804 ms   <- waiting for a connection
│  ├─ SELECT orders WHERE user_id = ?        31 ms
│  └─ serialize JSON                         12 ms

metrics at the same moment:
  hikaricp_connections_active   30 / 30
  hikaricp_connections_pending  58
  app CPU                       34%        <- CPU is not the cause
🔧 Розбір: Дерево спанів читається як бюджет часу: найширший дочірній спан — кандидат у причину. Тут SQL швидкий, а 94% часу — очікування з'єднання, що підтверджують метрики пулу: усі 30 зайняті, 58 потоків у черзі, CPU спокійний.
🔧 Breakdown: A span tree reads like a time budget: the widest child span is the prime suspect. Here SQL is fast and 94% of the time is waiting for a connection, which the pool metrics confirm: all 30 busy, 58 threads queued, CPU calm.
💡 Простими словами: Load-тест — це стетоскоп, який каже «серце б'ється нерівно». Щоб зрозуміти чому, потрібна кардіограма — APM.
💡 In plain words: The load test is the stethoscope saying "the heartbeat is irregular". To learn why, you need the ECG — that's APM.
🗣 Як читати вголос Навантажувальний тест показує, що і коли стало повільно, а причину я шукаю в APM і метриках. Спочатку знаходжу на графіку, при якому навантаженні почалася деградація, потім відкриваю найповільніші трейси з того проміжку і дивлюся, який спан з'їдає час. Далі перевіряю ресурси цього компонента: CPU, GC, пули, диск. Типовий випадок — SQL швидкий, а запит секунду чекає з'єднання з пулу. Після цього міняю одну річ і повторюю тест, щоб довести гіпотезу.
🗣 How to say it out loud A load test shows what got slow and when; I look for the cause in APM and metrics. First I find on the chart the load level where degradation began, then open the slowest traces from that window and see which span eats the time. Next I check that component's resources: CPU, GC, pools, disk. A typical case is fast SQL while the request waits a second for a pool connection. Then I change one thing and rerun the test to prove the hypothesis.
38 Як CPU, пам'ять і паузи GC впливають на латентність під навантаженням? How do CPU, memory and GC pauses affect latency under load?
Вузькі місця Bottlenecks Middle ▶

CPU. Коли CPU наближається до 100% (або до ліміту контейнера), потоки чекають у черзі на процесор, і латентність росте для всіх запитів. У Kubernetes окрема пастка — CPU throttling: контейнер з лімітом 1 CPU отримує паузи в межах кожного 100-мілісекундного періоду CFS, навіть якщо середнє навантаження лише 60%. Метрика container_cpu_cfs_throttled_periods_total показує це.

CPU. As CPU approaches 100% (or the container limit), threads queue for the processor and latency rises for every request. In Kubernetes there is a separate trap — CPU throttling: a container limited to 1 CPU gets paused within each 100-millisecond CFS period even if average load is only 60%. The container_cpu_cfs_throttled_periods_total metric shows it.

Пам'ять. Нестача пам'яті веде до частого збирання сміття, свопу (катастрофа для латентності) або OOM-kill контейнера. Витік пам'яті видно як ріст використання після кожного повного GC.

Memory. A memory shortage leads to frequent garbage collection, swapping (a disaster for latency) or the container being OOM-killed. A memory leak shows up as usage growing after every full GC.

GC-паузи. У JVM, .NET, Go, Node.js збирач сміття періодично зупиняє або сповільнює потоки застосунку. Середню латентність це майже не змінює, але хвости — дуже: пауза 300 мс раз на 10 секунд потрапляє прямо в p99. Під навантаженням об'єктів створюється більше, GC працює частіше, і хвости ростуть нелінійно.

GC pauses. In the JVM, .NET, Go and Node.js the garbage collector periodically stops or slows application threads. It barely changes average latency, but it hits the tails hard: a 300 ms pause every 10 seconds lands squarely in p99. Under load more objects are allocated, GC runs more often, and tails grow non-linearly.

Приклад. Сервіс на Java: p50 = 25 мс, p99 = 480 мс. GC-лог показує паузи G1 по 350–450 мс кожні кілька секунд, heap 2 ГБ заповнюється за 6 секунд. Збільшення heap до 4 ГБ і усунення зайвих алокацій при серіалізації дали p99 = 90 мс. Альтернатива для низьких пауз — Generational ZGC.

Example. A Java service: p50 = 25 ms, p99 = 480 ms. The GC log shows G1 pauses of 350–450 ms every few seconds, with a 2 GB heap filling up in 6 seconds. Raising the heap to 4 GB and removing extra allocations in serialisation gave p99 = 90 ms. An alternative for low pauses is Generational ZGC.

Що дивитися. Час і частоту пауз GC (jvm_gc_pause_seconds), рівень пам'яті після GC, швидкість алокацій, CPU throttling контейнера, кількість потоків.

What to watch. GC pause time and frequency (jvm_gc_pause_seconds), memory level after GC, allocation rate, container CPU throttling, thread count.

JVM: увімкнути GC-лог і знайти довгі паузи
JVM: enable the GC log and find long pauses
java -Xms4g -Xmx4g -XX:+UseG1GC \
     -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags \
     -jar orders.jar

# паузи довші за 200 ms під час тесту
grep -E 'Pause (Young|Full)' /var/log/app/gc.log | awk '{ if ($NF+0 > 200) print }'
# [2025-11-04T20:14:07.311+0000][1834.221s][info][gc] GC(812) Pause Young (Normal) (G1 Evacuation Pause) 1843M->1210M(2048M) 412.337ms
java -Xms4g -Xmx4g -XX:+UseG1GC \
     -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags \
     -jar orders.jar

# pauses longer than 200 ms during the test
grep -E 'Pause (Young|Full)' /var/log/app/gc.log | awk '{ if ($NF+0 > 200) print }'
# [2025-11-04T20:14:07.311+0000][1834.221s][info][gc] GC(812) Pause Young (Normal) (G1 Evacuation Pause) 1843M->1210M(2048M) 412.337ms
🔧 Розбір: -Xlog:gc* — уніфіковане логування JVM (JDK 9+), яке пише кожну паузу з часом. Однакові -Xms і -Xmx прибирають зміну розміру heap під час тесту. Рядок логу показує заповнення heap до і після збирання та тривалість паузи — останнє поле, яке й фільтрує awk.
🔧 Breakdown: -Xlog:gc* is the JVM's unified logging (JDK 9+), writing every pause with a timestamp. Equal -Xms and -Xmx prevent heap resizing mid-test. The log line shows heap occupancy before and after collection and the pause duration, the last field, which awk filters on.
💡 Простими словами: GC — прибиральник, який час від часу просить усіх вийти з кімнати. Що більше смітиш, то частіше чекаєш у коридорі.
💡 In plain words: GC is the cleaner who now and then asks everyone to leave the room. The more mess you make, the more often you wait in the corridor.
🗣 Як читати вголос CPU під навантаженням дає черги на процесор, а в контейнерах ще й throttling, коли під обмежено лімітом, навіть при помірному середньому завантаженні. Пам'ять і GC б'ють по хвостах: пауза збирача сміття в кількасот мілісекунд майже не рухає середнє, але саме вона сидить у p99. Тому в тестах я вмикаю GC-логи, дивлюся тривалість і частоту пауз, рівень пам'яті після збирання і швидкість алокацій. Часто рішення — менше алокацій або інший збирач, а не більше серверів.
🗣 How to say it out loud Under load CPU creates queues for the processor, and in containers there is also throttling when a pod hits its limit even at moderate average usage. Memory and GC hit the tails: a garbage collection pause of a few hundred milliseconds barely moves the mean but sits right in p99. So in tests I turn on GC logs and look at pause duration and frequency, memory after collection and allocation rate. Often the fix is fewer allocations or a different collector, not more servers.
39 Як читати flame graph і що він показує під час performance-аналізу? How do you read a flame graph and what does it show during performance analysis?
Вузькі місця Bottlenecks Senior ▶

Що це. Flame graph (Brendan Gregg) — візуалізація результатів семплювального профайлера. Профайлер багато разів за секунду знімає стек викликів, а граф агрегує ці семпли: кожен прямокутник — функція, по вертикалі — глибина стека (виклик зверху стоїть на тому, хто його викликав), по горизонталі — частка семплів, у яких функція була на стеку.

What it is. A flame graph (Brendan Gregg) visualises the output of a sampling profiler. The profiler captures the call stack many times a second, and the graph aggregates those samples: each box is a function, the vertical axis is stack depth (a call sits on top of its caller), the horizontal axis is the share of samples in which the function was on the stack.

Як читати. Ширина = час (CPU або очікування, залежно від режиму профайлера). Вісь X не є часовою шкалою: функції впорядковані за алфавітом, щоб однакові стеки злилися. Шукають широкі «плато» вгорі — функції, які самі (а не їхні нащадки) займають багато часу. Широка основа з вузькими вершинами означає, що час розподілений між багатьма дрібними викликами.

How to read it. Width = time (CPU or waiting, depending on the profiler mode). The X axis is not a timeline: functions are ordered alphabetically so identical stacks merge. You look for wide "plateaus" at the top — functions that themselves (not their children) take a lot of time. A wide base with narrow peaks means time is spread over many small calls.

Приклад. Під навантаженням CPU сервісу 90%. У flame graph 35% ширини займає Pattern.compile всередині валідатора, що компілює регулярний вираз на кожен запит. Винесення патерна в статичну константу знизило CPU до 55% і p95 на 40%. Без профайлера цю функцію ніхто б не запідозрив.

Example. Under load the service CPU is at 90%. In the flame graph, 35% of the width is Pattern.compile inside a validator that compiles a regular expression on every request. Moving the pattern to a static constant cut CPU to 55% and p95 by 40%. Without a profiler nobody would have suspected that function.

Режими. CPU flame graph показує, де процесор зайнятий. Off-CPU або wall-clock показує, де потоки чекають: блокування, I/O, мережа — корисно, коли CPU низький, а латентність висока. Allocation flame graph показує, хто створює найбільше об'єктів і навантажує GC.

Modes. A CPU flame graph shows where the processor is busy. Off-CPU or wall-clock shows where threads wait: locks, I/O, network — useful when CPU is low but latency is high. An allocation flame graph shows who creates the most objects and loads the GC.

Інструменти. async-profiler для JVM, py-spy для Python, perf і eBPF для нативного коду, вбудовані профайлери APM (continuous profiling). Від QA зазвичай чекають уміння зняти профіль під навантаженням і прочитати на високому рівні, а не оптимізувати код самостійно.

Tools. async-profiler for the JVM, py-spy for Python, perf and eBPF for native code, the built-in profilers of APM tools (continuous profiling). QA is usually expected to capture a profile under load and read it at a high level, not to optimise the code themselves.

Зняти профіль під навантаженням і як виглядає «плато»
Capturing a profile under load and what a plateau looks like
# async-profiler: 30 с CPU-профілю процесу під час плато тесту
asprof -d 30 -e cpu -f /tmp/orders-cpu.html <pid>

схематично (ширина = частка семплів):
            [Pattern.compile ████████████]          <- плато вгорі: сама функція гаряча
        [Validator.check ██████████████████]
    [OrderService.create ████████████████████████] [JSON ████]
[HttpHandler.handle ███████████████████████████████████████████]
 0%                                                        100%
# async-profiler: 30 s CPU profile of the process during the test plateau
asprof -d 30 -e cpu -f /tmp/orders-cpu.html <pid>

schematically (width = share of samples):
            [Pattern.compile ████████████]          <- top plateau: the function itself is hot
        [Validator.check ██████████████████]
    [OrderService.create ████████████████████████] [JSON ████]
[HttpHandler.handle ███████████████████████████████████████████]
 0%                                                        100%
🔧 Розбір: -d 30 — тривалість у секундах, -e cpu — подія (можна wall або alloc), -f з розширенням .html дає інтерактивний flame graph. Профіль знімають саме під час плато навантаження, бо на холостому ходу гарячі місця інші.
🔧 Breakdown: -d 30 is the duration in seconds, -e cpu the event (wall or alloc also work), and -f with an .html extension produces an interactive flame graph. The profile is taken during the load plateau, because at idle the hot spots are different.
💡 Простими словами: Flame graph — це вид на місто з висоти: найширші будинки на даху — ті, де світло горить найдовше.
💡 In plain words: A flame graph is a city seen from above: the widest buildings at the top are where the lights stay on longest.
🗣 Як читати вголос Flame graph — це агреговані стеки викликів із семплювального профайлера. По вертикалі глибина стека, по горизонталі частка часу, і важливо, що це не часова шкала — функції відсортовані, щоб однакові стеки злилися. Я шукаю широкі плато вгорі: функції, що самі по собі гарячі. CPU-граф показує, де зайнятий процесор, а wall-clock або off-CPU — де потоки чекають, це потрібно, коли CPU низький, а латентність висока. Профіль знімаю саме на плато навантаження.
🗣 How to say it out loud A flame graph is aggregated call stacks from a sampling profiler. Vertically it is stack depth, horizontally the share of time, and crucially it is not a timeline — functions are sorted so identical stacks merge. I look for wide plateaus at the top: functions that are hot on their own. A CPU graph shows where the processor is busy, while wall-clock or off-CPU shows where threads wait, which matters when CPU is low but latency is high. I capture the profile right on the load plateau.
40 Як знайти вузьке місце в базі даних: повільні запити, блокування, відсутні індекси? How do you find a database bottleneck: slow queries, locks, missing indexes?
Вузькі місця Bottlenecks Middle ▶

Симптоми. Латентність росте разом із навантаженням, а CPU застосунку низький; трейси показують, що більшість часу — SQL-спани; CPU або I/O бази високий; з'являються таймаути пулу з'єднань.

Symptoms. Latency grows with load while app CPU is low; traces show most of the time in SQL spans; database CPU or I/O is high; connection pool timeouts appear.

Повільні запити. У PostgreSQL розширення pg_stat_statements агрегує статистику за нормалізованими запитами: скільки разів викликано, сумарний і середній час. Сортування за total_exec_time показує, що навантажує базу найбільше в сумі — часто це не найповільніший запит, а швидкий, що викликається мільйон разів. Slow query log (log_min_duration_statement) фіксує окремі повільні виклики.

Slow queries. In PostgreSQL the pg_stat_statements extension aggregates statistics per normalised query: call count, total and mean time. Sorting by total_exec_time shows what loads the database most overall — often not the slowest query but a fast one called a million times. The slow query log (log_min_duration_statement) records individual slow calls.

Плани і індекси. EXPLAIN (ANALYZE, BUFFERS) показує реальний план: Seq Scan на великій таблиці з фільтром, що відкидає майже всі рядки, — ознака відсутнього індексу. Складений індекс під фільтр і сортування перетворює скан мільйонів рядків на кілька сторінок індексу.

Plans and indexes. EXPLAIN (ANALYZE, BUFFERS) shows the real plan: a Seq Scan on a large table with a filter that discards almost every row is a sign of a missing index. A composite index matching the filter and sort turns a scan of millions of rows into a few index pages.

Блокування. Запити чекають один одного: pg_stat_activity з wait_event_type = 'Lock' і функція pg_blocking_pids() показують, хто кого блокує. Типові причини — довгі транзакції, оновлення одного «гарячого» рядка (лічильник, залишок товару), міграції з блокуванням таблиці.

Locks. Queries wait for each other: pg_stat_activity with wait_event_type = 'Lock' and the pg_blocking_pids() function show who blocks whom. Typical causes are long transactions, updates to one "hot" row (a counter, stock level) and migrations that lock a table.

Підводні камені. Тестувати на базі з 1000 рядків: індекс не потрібен, план інший. Для висновків потрібен продакшн-обсяг і розподіл даних.

Pitfalls. Testing on a database with 1000 rows: no index is needed and the plan differs. Conclusions need production volume and data distribution.

PostgreSQL: топ запитів, план і блокування
PostgreSQL: top queries, a plan and locks
-- що найбільше навантажує БД сумарно
SELECT calls, round(mean_exec_time::numeric, 1) AS mean_ms,
       round(total_exec_time::numeric) AS total_ms, left(query, 80) AS query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;

-- реальний план підозрілого запиту
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 4812 ORDER BY created_at DESC LIMIT 20;
--  Seq Scan on orders (rows=20) (actual time=0.03..1840.2) Rows Removed by Filter: 51999980
CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);

-- хто кого блокує
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, left(query, 60)
FROM pg_stat_activity WHERE wait_event_type = 'Lock';
-- what loads the DB most in total
SELECT calls, round(mean_exec_time::numeric, 1) AS mean_ms,
       round(total_exec_time::numeric) AS total_ms, left(query, 80) AS query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;

-- the real plan of a suspect query
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 4812 ORDER BY created_at DESC LIMIT 20;
--  Seq Scan on orders (rows=20) (actual time=0.03..1840.2) Rows Removed by Filter: 51999980
CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);

-- who blocks whom
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, left(query, 60)
FROM pg_stat_activity WHERE wait_event_type = 'Lock';
🔧 Розбір: mean_exec_time і total_exec_time — назви колонок з PostgreSQL 13. «Rows Removed by Filter» на 52 млн рядків — сигнал, що фільтр не має індексу. CONCURRENTLY будує індекс без блокування записів у таблицю, що важливо на живій системі.
🔧 Breakdown: mean_exec_time and total_exec_time are the column names since PostgreSQL 13. "Rows Removed by Filter" at 52 million rows signals the filter has no index. CONCURRENTLY builds the index without blocking writes to the table, which matters on a live system.
💡 Простими словами: Шукати товар у складі без каталогу — обійти всі полиці. Індекс — це каталог, а блокування — черга до однієї полиці, яку всі хочуть одночасно.
💡 In plain words: Finding an item in a warehouse without a catalogue means walking every shelf. An index is the catalogue; a lock is a queue at the one shelf everyone wants at once.
🗣 Як читати вголос Коли трейси показують, що час іде в SQL, я йду в базу. Спершу pg_stat_statements, відсортований за сумарним часом, бо найбільше навантажує часто не найповільніший запит, а той, що викликається мільйони разів. Потім EXPLAIN ANALYZE для підозрілих: послідовне сканування великої таблиці з фільтром — майже завжди відсутній індекс. Окремо перевіряю блокування через pg_stat_activity і pg_blocking_pids. І все це має сенс лише на продакшн-обсязі даних, бо на маленькій базі плани інші.
🗣 How to say it out loud When traces show time going into SQL, I go to the database. First pg_stat_statements sorted by total time, because the biggest load is often not the slowest query but one called millions of times. Then EXPLAIN ANALYZE for the suspects: a sequential scan of a big table with a filter almost always means a missing index. Separately I check locks through pg_stat_activity and pg_blocking_pids. And all of this only makes sense at production data volume, because a small database has different plans.
41 Що таке проблема N+1 запитів і як її виявити в тестах? What is the N+1 query problem and how do you detect it in tests?
Вузькі місця Bottlenecks Junior ▶

Що це. Код робить один запит, щоб отримати список з N записів, а потім для кожного запису — ще один запит за пов'язаними даними. Разом N+1 запит замість одного-двох. Типово виникає в ORM з лінивим завантаженням: orders.forEach(o => o.getCustomer().getName()) тихо робить окремий SELECT на кожного клієнта.

What it is. Code makes one query to fetch a list of N records and then one more query per record for related data. N+1 queries in total instead of one or two. It typically appears with lazy loading in an ORM: orders.forEach(o => o.getCustomer().getName()) silently fires a separate SELECT per customer.

Чому це проблема продуктивності. Кожен запит — мережевий раунд до БД (0,5–2 мс навіть якщо сам запит миттєвий) плюс з'єднання з пулу. Список з 20 замовлень — 21 запит, терпимо. Список з 500 — 501 запит і пів секунди лише на мережу. Під навантаженням ці запити множаться на RPS і забивають пул з'єднань.

Why it hurts performance. Every query is a network round trip to the database (0.5–2 ms even when the query itself is instant) plus a pool connection. A list of 20 orders is 21 queries, tolerable. A list of 500 is 501 queries and half a second on the network alone. Under load these queries multiply by RPS and clog the connection pool.

Як виявити. У трейсі APM — «драбинка» з десятків однакових коротких SQL-спанів у межах одного HTTP-запиту. У логах ORM — повторювані запити з різними id. У pg_stat_statements — простий запит з величезною кількістю викликів. В інтеграційних тестах можна рахувати кількість SQL на ендпоінт і падати, якщо перевищено поріг.

How to detect it. In an APM trace, a "staircase" of dozens of identical short SQL spans inside one HTTP request. In ORM logs, repeated queries with different ids. In pg_stat_statements, a simple query with a huge call count. In integration tests you can count SQL statements per endpoint and fail if a limit is exceeded.

Як виправляють. JOIN або завантаження пов'язаних даних одним запитом (JOIN FETCH, @EntityGraph у JPA, include в інших ORM), пакетне завантаження (WHERE id IN (...)), DataLoader у GraphQL.

How it is fixed. A JOIN or loading related data in one query (JOIN FETCH, @EntityGraph in JPA, include in other ORMs), batch loading (WHERE id IN (...)), DataLoader in GraphQL.

Підводні камені. N+1 не видно на тестових даних із 3 записами: відповідь швидка. Тому тест має використовувати реалістичні розміри списків.

Pitfalls. N+1 is invisible on test data with 3 records: the response is fast. So the test must use realistic list sizes.

Як N+1 виглядає в SQL-лозі і як його виправляють
What N+1 looks like in the SQL log and how it is fixed
-- було: 1 + N запитів на GET /api/orders?limit=500
SELECT id, customer_id, total FROM orders ORDER BY created_at DESC LIMIT 500;
SELECT name FROM customers WHERE id = 101;
SELECT name FROM customers WHERE id = 102;
-- ... ще 498 однакових запитів

-- стало: один запит
SELECT o.id, o.total, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC LIMIT 500;
-- before: 1 + N queries for GET /api/orders?limit=500
SELECT id, customer_id, total FROM orders ORDER BY created_at DESC LIMIT 500;
SELECT name FROM customers WHERE id = 101;
SELECT name FROM customers WHERE id = 102;
-- ... 498 more identical queries

-- after: one query
SELECT o.id, o.total, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC LIMIT 500;
🔧 Розбір: Нормалізований запит SELECT name FROM customers WHERE id = ? з 500 викликами на один HTTP-запит — підпис N+1. Після JOIN база сама з'єднує таблиці за один прохід, і кількість мережевих раундів падає з 501 до одного.
🔧 Breakdown: The normalised query SELECT name FROM customers WHERE id = ? with 500 calls per HTTP request is the N+1 signature. After the JOIN the database joins the tables in one pass, and network round trips drop from 501 to one.
💡 Простими словами: N+1 — як ходити в магазин окремо по кожен продукт зі списку замість того, щоб купити все за один раз.
💡 In plain words: N+1 is like going to the shop separately for every item on your list instead of buying everything in one trip.
🗣 Як читати вголос N+1 — це коли код одним запитом бере список, а потім для кожного елемента робить ще один запит за пов'язаними даними. На трьох тестових записах цього не видно, а на п'ятистах це п'ятсот мережевих раундів і забитий пул з'єднань під навантаженням. Я помічаю його як драбинку однакових SQL-спанів у трейсі або як простий запит з гігантською кількістю викликів у pg_stat_statements. Виправляють JOIN-ом або пакетним завантаженням, а в інтеграційних тестах можна обмежити кількість SQL на ендпоінт.
🗣 How to say it out loud N+1 is when code fetches a list in one query and then fires one more query per item for related data. On three test records you can't see it, but on five hundred it means five hundred network round trips and a clogged connection pool under load. I spot it as a staircase of identical SQL spans in a trace, or as a simple query with a gigantic call count in pg_stat_statements. The fix is a JOIN or batch loading, and integration tests can cap the SQL count per endpoint.
42 Як проявляється вичерпання пулу з'єднань і як правильно обрати його розмір? How does connection pool exhaustion show up and how do you size a pool correctly?
Вузькі місця Bottlenecks Middle ▶

Що це. Пул з'єднань тримає обмежену кількість відкритих з'єднань до БД (або до іншого сервісу через HTTP-клієнт). Потік бере з'єднання, виконує запит і повертає. Коли всі з'єднання зайняті, наступний потік чекає; якщо чекає довше за таймаут — отримує помилку.

What it is. A connection pool holds a limited number of open connections to the database (or to another service via an HTTP client). A thread borrows a connection, runs the query and returns it. When all connections are busy the next thread waits; if it waits longer than the timeout, it gets an error.

Симптоми. Зі зростанням конкурентності латентність стрибає, хоча SQL у трейсах швидкий, а CPU бази помірний; у трейсі довгий спан отримання з'єднання; метрика очікувальних потоків росте; з'являються помилки на кшталт HikariCP «Connection is not available, request timed out after 30000ms». Під навантаженням це виглядає як раптова «стіна»: до певного RPS усе добре, потім таймаути лавиною.

Symptoms. As concurrency grows, latency jumps although SQL in traces is fast and database CPU is moderate; the trace has a long connection-acquire span; the waiting-threads metric rises; errors appear such as HikariCP's "Connection is not available, request timed out after 30000ms". Under load it looks like a sudden wall: fine up to some RPS, then an avalanche of timeouts.

Причини. Замалий пул для потрібної конкурентності; довгі транзакції, що тримають з'єднання (виклик зовнішнього API всередині транзакції); витік з'єднань (не повернули в пул у гілці з помилкою); N+1.

Causes. A pool too small for the needed concurrency; long transactions holding connections (an external API call inside a transaction); a connection leak (not returned to the pool on an error path); N+1.

Розмір. Більший пул — не завжди краще: база має обмежену кількість ядер і max_connections, і сотні паралельних запитів лише конкурують за ресурси. Оцінка за законом Літтла: потрібні з'єднання ≈ RPS запитів до БД × середній час утримання з'єднання. 400 запитів/с × 0,02 с = 8 з'єднань у середньому, з запасом на піки — 15–20. І сумарно: інстанси × розмір пулу ≤ ліміт бази з запасом.

Sizing. Bigger is not always better: the database has a limited number of cores and max_connections, and hundreds of parallel queries just compete for resources. Estimate with Little's law: connections needed ≈ database RPS × average connection hold time. 400 queries/s × 0.02 s = 8 connections on average; with headroom for spikes, 15–20. And in total: instances × pool size ≤ the database limit with margin.

Підводні камені. «Лікувати» збільшенням пулу проблему довгих транзакцій — черга переїде в базу і стане гіршою.

Pitfalls. "Fixing" long transactions by enlarging the pool — the queue moves into the database and gets worse.

HikariCP: конфіг і розрахунок
HikariCP: config and the math
# application.yml (Spring Boot)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20        # за замовчуванням 10
      connection-timeout: 2000     # ms; за замовчуванням 30000 — швидка відмова краще за зависання
      leak-detection-threshold: 10000

# розрахунок: 400 запитів/с × 0.02 с = 8 з'єднань; пік ×2 = 16 -> 20
# перевірка ліміту: 10 інстансів × 20 = 200 <= max_connections 300
# application.yml (Spring Boot)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20        # default 10
      connection-timeout: 2000     # ms; default 30000 — fail fast beats hanging
      leak-detection-threshold: 10000

# math: 400 queries/s × 0.02 s = 8 connections; peak ×2 = 16 -> 20
# limit check: 10 instances × 20 = 200 <= max_connections 300
🔧 Розбір: maximum-pool-size обмежує пул на інстанс, connection-timeout — скільки потік чекає на з'єднання, перш ніж отримати помилку. leak-detection-threshold пише в лог стек потоку, який тримає з'єднання довше за 10 с, — так знаходять витоки і довгі транзакції.
🔧 Breakdown: maximum-pool-size caps the pool per instance, connection-timeout is how long a thread waits for a connection before erroring. leak-detection-threshold logs the stack of any thread holding a connection longer than 10 s — that is how leaks and long transactions are found.
💡 Простими словами: Пул з'єднань — каси в супермаркеті. Якщо покупець на касі ще й дзвонить у банк, черга росте, скільки б кас не відкрили.
💡 In plain words: A connection pool is the checkout lanes. If a shopper at the till also phones the bank, the queue grows however many lanes you open.
🗣 Як читати вголос Вичерпання пулу виглядає як раптова стіна: до певного навантаження все гаразд, потім латентність стрибає і сиплються таймаути, хоча SQL у трейсах швидкий, а база не перевантажена. У трейсі видно довге очікування з'єднання, метрика очікувальних потоків росте. Причини — замалий пул, довгі транзакції, витоки або N+1. Розмір рахую за законом Літтла і перевіряю, що інстанси, помножені на пул, не перевищують ліміт бази. І не лікую довгі транзакції збільшенням пулу.
🗣 How to say it out loud Pool exhaustion looks like a sudden wall: fine up to some load, then latency jumps and timeouts pour in, although SQL in traces is fast and the database is not overloaded. The trace shows a long wait for a connection, and the waiting-threads metric climbs. Causes are a pool too small, long transactions, leaks or N+1. I size it with Little's law and check that instances times pool size stays under the database limit. And I never treat long transactions by enlarging the pool.
43 Як кешування і CDN впливають на продуктивність і на результати тесту навантаження? How do caching and a CDN affect performance and load test results?
Вузькі місця Bottlenecks Middle ▶

Як кеш допомагає. Кеш зберігає результат дорогої роботи (запит до БД, рендеринг, виклик стороннього API) і віддає його повторно за мікросекунди. Рівні: браузер (Cache-Control), CDN на краю мережі, reverse proxy, кеш застосунку (Redis, in-memory), кеш БД. Кожен влучний запит не доходить до дорожчих рівнів.

How a cache helps. A cache stores the result of expensive work (a database query, rendering, a third-party API call) and serves it again in microseconds. Levels: the browser (Cache-Control), a CDN at the network edge, a reverse proxy, the application cache (Redis, in-memory), the database cache. Every hit never reaches the more expensive levels.

Як CDN допомагає. CDN тримає копії статики (а іноді й API-відповідей) на серверах поруч із користувачем: менше відстань і TLS-рукостискання, менше навантаження на origin. Для сайту з аудиторією в різних країнах це часто найбільший виграш у TTFB і LCP.

How a CDN helps. A CDN keeps copies of static assets (and sometimes API responses) on servers close to the user: shorter distance and TLS handshakes, less load on the origin. For a site with an audience across countries it is often the biggest win in TTFB and LCP.

Вплив на тест. Якщо скрипт 1000 разів запитує той самий товар, hit rate кешу 100%, і тест міряє Redis, а не систему. Реальний трафік має довгий хвіст рідкісних товарів, і hit rate може бути 70%. Навпаки, тест з унікальним id на кожен запит показує гірший за реальний результат. Потрібен розподіл даних, схожий на продакшн. Навантаження через CDN теж часто некоректне: тест міряє CDN провайдера (і може порушити його умови), тому origin тестують напряму, а CDN — окремо.

Effect on the test. If the script requests the same product 1000 times, the cache hit rate is 100% and the test measures Redis, not the system. Real traffic has a long tail of rare products and the hit rate may be 70%. Conversely, a test with a unique id per request shows worse-than-real results. You need a data distribution similar to production. Load through the CDN is often wrong too: the test measures the provider's CDN (and may breach its terms), so the origin is tested directly and the CDN separately.

Холодний кеш. Окремий сценарій: після деплою чи рестарту Redis усі запити йдуть у БД одночасно — cache stampede. Перевіряють, що база це витримує або що є захист (прогрів, блокування на перерахунок).

Cold cache. A separate scenario: after a deploy or a Redis restart, every request hits the database at once — a cache stampede. You verify the database survives it or that there is protection (warming, a lock on recomputation).

Метрики. Hit rate кожного рівня, латентність влучань і промахів окремо, заголовки X-Cache, Age, CF-Cache-Status.

Metrics. Hit rate for each level, hit and miss latency separately, and the X-Cache, Age and CF-Cache-Status headers.

k6: реалістичний розподіл id замість одного товару
k6: a realistic id distribution instead of one product
import http from 'k6/http';
import { Rate } from 'k6/metrics';

const hit = new Rate('cache_hit');

// 80% запитів у 2 000 популярних товарів, 20% — у довгий хвіст з 200 000
function productId() {
  return Math.random() < 0.8
    ? 1 + Math.floor(Math.random() * 2000)
    : 2001 + Math.floor(Math.random() * 198000);
}

export default function () {
  const res = http.get('https://origin.shop.example.com/api/products/' + productId(),
    { tags: { name: 'GET /products/:id' } });
  hit.add(res.headers['X-Cache'] === 'HIT');
}
import http from 'k6/http';
import { Rate } from 'k6/metrics';

const hit = new Rate('cache_hit');

// 80% of requests go to 2,000 popular products, 20% to a long tail of 200,000
function productId() {
  return Math.random() < 0.8
    ? 1 + Math.floor(Math.random() * 2000)
    : 2001 + Math.floor(Math.random() * 198000);
}

export default function () {
  const res = http.get('https://origin.shop.example.com/api/products/' + productId(),
    { tags: { name: 'GET /products/:id' } });
  hit.add(res.headers['X-Cache'] === 'HIT');
}
🔧 Розбір: Функція productId відтворює правило Парето: більшість запитів в невелику групу популярних товарів плюс довгий хвіст. Запити йдуть на origin, а не через CDN, а власна метрика cache_hit дозволяє порівняти hit rate у тесті з продакшн-значенням — якщо вони сильно різняться, модель даних неправильна.
🔧 Breakdown: The productId function reproduces the Pareto rule: most requests hit a small set of popular products, plus a long tail. Requests go to the origin rather than through the CDN, and the custom cache_hit metric lets you compare the test hit rate with production — if they differ a lot, the data model is wrong.
💡 Простими словами: Тест, що завжди просить той самий товар, — це іспит, де студент заздалегідь знає єдине питання.
💡 In plain words: A test that always asks for the same product is an exam where the student knows the only question in advance.
🗣 Як читати вголос Кеш прибирає повторну дорогу роботу, а CDN наближає контент до користувача, і це часто найбільший виграш у швидкості. Але для тесту кеш — пастка: якщо скрипт просить той самий товар, я міряю Redis, а не систему. Тому я моделюю розподіл даних як у продакшні, з популярними товарами і довгим хвостом, і порівнюю hit rate тесту з реальним. Origin навантажую напряму, повз CDN, а холодний кеш і cache stampede перевіряю окремим сценарієм.
🗣 How to say it out loud A cache removes repeated expensive work and a CDN brings content closer to the user, often the biggest speed win of all. But for a test the cache is a trap: if the script asks for the same product, I'm measuring Redis, not the system. So I model the data distribution like production, with popular products and a long tail, and compare the test hit rate with the real one. I load the origin directly, bypassing the CDN, and test a cold cache and cache stampede as a separate scenario.
44 Як стиснення gzip і brotli впливає на продуктивність і які в нього компроміси? How does gzip and brotli compression affect performance, and what are the trade-offs?
Вузькі місця Bottlenecks Junior ▶

Що це. Сервер стискає текстові відповіді (HTML, JS, CSS, JSON, SVG) перед відправленням, браузер розпаковує. Клієнт повідомляє, що вміє, заголовком Accept-Encoding: gzip, br, сервер відповідає Content-Encoding: br. Brotli зазвичай стискає текст на 15–25% краще за gzip.

What it is. The server compresses text responses (HTML, JS, CSS, JSON, SVG) before sending and the browser decompresses them. The client announces support with Accept-Encoding: gzip, br, and the server replies with Content-Encoding: br. Brotli usually compresses text 15–25% better than gzip.

Виграш. JSON на 800 КБ стискається до ~90 КБ. На мобільній мережі це сотні мілісекунд завантаження; менше трафіку і дешевший CDN. Для першого завантаження сторінки це прямо покращує LCP.

The win. An 800 KB JSON compresses to ~90 KB. On a mobile network that is hundreds of milliseconds of download; less traffic and a cheaper CDN bill. For the first page load it directly improves LCP.

Компроміс. Стиснення коштує CPU сервера. Максимальні рівні brotli (10–11) повільні, тому для динамічних відповідей беруть середні рівні (4–6 для brotli, 5–6 для gzip), а статику стискають заздалегідь при збірці на максимальному рівні. Стискати JPEG, PNG, WOFF2, відео безглуздо — вони вже стиснені. Дуже малі відповіді (менше ~1 КБ) можуть навіть вирости.

The trade-off. Compression costs server CPU. The top brotli levels (10–11) are slow, so dynamic responses use medium levels (4–6 for brotli, 5–6 for gzip), while static assets are pre-compressed at build time at the maximum level. Compressing JPEG, PNG, WOFF2 or video is pointless — they are already compressed. Very small responses (under ~1 KB) can even grow.

Що перевіряє QA. Що стиснення увімкнене для текстових типів і вимкнене для бінарних; що розмір передачі порівняний з очікуваним; що під навантаженням CPU не став вузьким місцем через занадто високий рівень стиснення. У DevTools в колонці Size видно передані і розпаковані байти.

What QA checks. That compression is on for text types and off for binary ones; that the transfer size is close to expected; that under load CPU did not become the bottleneck because of an overly high compression level. In DevTools the Size column shows transferred and decoded bytes.

Підводні камені. Навантажувальний інструмент без Accept-Encoding отримує нестиснені відповіді, і тест не відображає реальне навантаження ні на мережу, ні на CPU. Тому варто перевірити, який заголовок реально надсилає інструмент, і за потреби задати Accept-Encoding: gzip, br у скрипті явно, як це робить браузер.

Pitfalls. A load tool without Accept-Encoding receives uncompressed responses, so the test reflects neither real network nor CPU load. So check which header the tool actually sends and, if needed, set Accept-Encoding: gzip, br explicitly in the script, the way a browser does.

Порівняти розмір передачі з різним стисненням
Compare transfer size with different compression
URL=https://shop.example.com/api/catalog?limit=500
for enc in identity gzip br; do
  printf '%-9s' "$enc"
  curl -s -o /dev/null -H "Accept-Encoding: $enc" \
       -w '%{size_download} bytes  %{time_total}s\n' "$URL"
done
# identity 812344 bytes  0.94s
# gzip     104221 bytes  0.31s
# br        86410 bytes  0.29s
🔧 Розбір: %{size_download} у curl — кількість байтів, що реально пройшли мережею, тобто стиснений розмір. Цикл запитує ту саму відповідь без стиснення, з gzip і з brotli; якщо рядки gzip і br показують той самий розмір, що й identity, стиснення на сервері не увімкнене.
🔧 Breakdown: curl's %{size_download} is the number of bytes that actually crossed the network, i.e. the compressed size. The loop requests the same response uncompressed, with gzip and with brotli; if the gzip and br lines show the same size as identity, compression is not enabled on the server.
💡 Простими словами: Стиснення — вакуумний пакет для одягу у валізі: влазить більше, але щоб запакувати, треба трохи попрацювати.
💡 In plain words: Compression is a vacuum bag for clothes in a suitcase: more fits, but packing takes a bit of effort.
🗣 Як читати вголос Стиснення зменшує текстові відповіді в кілька разів, а brotli зазвичай стискає ще на п'ятнадцять-двадцять відсотків краще за gzip. Це швидше завантаження, особливо на мобільній мережі, і менше трафіку. Ціна — CPU сервера, тому динаміку стискають на середніх рівнях, а статику — заздалегідь на максимальному. Бінарні формати не стискають. У тестах я перевіряю, що інструмент надсилає правильний Accept-Encoding, інакше навантаження на мережу і процесор буде нереалістичним.
🗣 How to say it out loud Compression shrinks text responses several times over, and brotli usually beats gzip by another fifteen to twenty percent. That means faster downloads, especially on mobile networks, and less traffic. The price is server CPU, so dynamic content uses medium levels and static assets are pre-compressed at the maximum. Binary formats are left alone. In tests I make sure the tool sends the right Accept-Encoding, otherwise the network and CPU load is unrealistic.
45 Що таке backpressure і rate limiting і як вони проявляються в тесті навантаження? What are backpressure and rate limiting, and how do they show up in a load test?
Вузькі місця Bottlenecks Middle ▶

Backpressure. Механізм, яким перевантажена система каже «повільніше» тим, хто її навантажує, замість того щоб приймати все і падати. Приклади: обмежена черга, яка відхиляє нові задачі при заповненні; сервер, що повертає 503 з Retry-After; брокер, що сповільнює продюсерів; load shedding — свідоме відкидання частини запитів, щоб решта обслуговувалась у межах SLO.

Backpressure. The mechanism by which an overloaded system tells its callers "slow down" instead of accepting everything and crashing. Examples: a bounded queue that rejects new jobs when full; a server returning 503 with Retry-After; a broker slowing down producers; load shedding — deliberately dropping part of the requests so the rest are served within the SLO.

Rate limiting. Обмеження кількості запитів від клієнта, ключа API чи IP за період (token bucket, sliding window). Перевищення дає 429 Too Many Requests, часто з заголовками про ліміт і Retry-After.

Rate limiting. Capping the number of requests from a client, API key or IP per period (token bucket, sliding window). Exceeding it returns 429 Too Many Requests, often with limit headers and Retry-After.

Як виглядає в тесті. Після певного RPS з'являються 429 або 503, а латентність успішних запитів залишається стабільною — це здорова поведінка: система захищається. Погано, якщо захисту немає: черги ростуть без межі, латентність іде в десятки секунд, пам'ять закінчується, і система падає повністю.

How it looks in a test. Past some RPS, 429s or 503s appear while latency of successful requests stays stable — that is healthy behaviour: the system is protecting itself. It is bad when there is no protection: queues grow without bound, latency climbs to tens of seconds, memory runs out and the system falls over completely.

Типова пастка. Увесь тест іде з одного IP або одного ключа API, і rate limiter на gateway починає відповідати 429 на 100 RPS, хоча бекенд витримав би 2000. Тест міряє ліміт, а не систему. Рішення — пул ключів і користувачів, whitelist генераторів на perf-стенді або окремий тест самого ліміту.

A typical trap. The whole test comes from one IP or one API key, and the gateway rate limiter starts answering 429 at 100 RPS although the backend would handle 2000. The test measures the limit, not the system. The fix is a pool of keys and users, allowlisting generators on the perf stand, or a separate test of the limit itself.

Що перевіряти. Ліміт спрацьовує на заявленому значенні; відповідь має правильний код і заголовки; після зниження навантаження доступ відновлюється; клієнти (мобільний застосунок, SDK) поважають Retry-After, а не ретраять миттєво.

What to check. The limit triggers at the stated value; the response has the right code and headers; access recovers once load drops; clients (the mobile app, an SDK) honour Retry-After instead of retrying instantly.

k6: перевірка, що ліміт спрацьовує коректно
k6: verifying the rate limit behaves correctly
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    over_limit: { executor: 'constant-arrival-rate', rate: 150, timeUnit: '1s',
                  duration: '2m', preAllocatedVUs: 50 },
  },
};

// ліміт для одного ключа: 100 запитів/с
export default function () {
  const res = http.get('https://api.example.com/v1/quotes',
    { headers: { 'X-Api-Key': __ENV.API_KEY } });
  check(res, {
    'ok or limited': (r) => r.status === 200 || r.status === 429,
    '429 has Retry-After': (r) => r.status !== 429 || r.headers['Retry-After'] !== undefined,
    'no 5xx': (r) => r.status < 500,
  });
}
import http from 'k6/http';
import { check } from 'k6';

export const options = {
  scenarios: {
    over_limit: { executor: 'constant-arrival-rate', rate: 150, timeUnit: '1s',
                  duration: '2m', preAllocatedVUs: 50 },
  },
};

// limit for one key: 100 requests/s
export default function () {
  const res = http.get('https://api.example.com/v1/quotes',
    { headers: { 'X-Api-Key': __ENV.API_KEY } });
  check(res, {
    'ok or limited': (r) => r.status === 200 || r.status === 429,
    '429 has Retry-After': (r) => r.status !== 429 || r.headers['Retry-After'] !== undefined,
    'no 5xx': (r) => r.status < 500,
  });
}
🔧 Розбір: Тест свідомо шле 150 запитів/с при ліміті 100, тож приблизно третина відповідей має бути 429. Перевірки фіксують контракт захисту: лише 200 або 429, у 429 є Retry-After, жодних 5xx. __ENV.API_KEY читає ключ зі змінної оточення, переданої через -e API_KEY=....
🔧 Breakdown: The test deliberately sends 150 requests/s against a limit of 100, so roughly a third of responses should be 429. The checks capture the protection contract: only 200 or 429, a 429 carries Retry-After, no 5xx. __ENV.API_KEY reads the key from an environment variable passed with -e API_KEY=....
💡 Простими словами: Backpressure — охоронець на вході в клуб: «зачекайте, всередині повно». Без нього всередині тиснява і ніхто не танцює.
💡 In plain words: Backpressure is the bouncer at the club door: "wait, it's full inside". Without one there's a crush and nobody dances.
🗣 Як читати вголос Backpressure — це спосіб перевантаженої системи сказати клієнтам сповільнитися: обмежені черги, 503 з Retry-After, свідоме відкидання частини запитів. Rate limiting — обмеження на клієнта чи ключ, яке дає 429. У тесті для мене 429 і 503 при стабільній латентності успішних запитів — здорова ознака, а от безмежні черги й падіння — погана. Головна пастка — тест з одного ключа чи IP, який упирається в ліміт gateway і міряє його замість системи. Сам ліміт я тестую окремим сценарієм.
🗣 How to say it out loud Backpressure is how an overloaded system tells callers to slow down: bounded queues, 503 with Retry-After, deliberately shedding some requests. Rate limiting is a per-client or per-key cap that returns 429. In a test, 429s and 503s with stable latency for successful requests are a healthy sign to me, while unbounded queues and a crash are a bad one. The main trap is a test from one key or IP that hits the gateway limit and measures that instead of the system. I test the limit itself as a separate scenario.
46 Чому ретраї під навантаженням небезпечні і як ідемпотентність робить їх безпечними? Why are retries dangerous under load, and how does idempotency make them safe?
Вузькі місця Bottlenecks Senior ▶

Проблема 1: retry storm. Коли сервіс сповільнюється, клієнти отримують таймаути і повторюють запити. Кожен ретрай — додаткове навантаження саме на той сервіс, що вже не справляється. Три рівні сервісів, кожен з 3 ретраями, при збої нижнього рівня дають до 27 запитів замість одного. Система, яка могла б відновитися, добивається власними клієнтами.

Problem 1: retry storms. When a service slows down, clients hit timeouts and repeat their requests. Every retry is extra load on the very service that is already struggling. Three service tiers, each with 3 retries, turn one request into up to 27 when the bottom tier fails. A system that could have recovered is finished off by its own clients.

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

Problem 2: duplicates. A timeout does not mean the operation did not happen: the request may have arrived and created a payment while the response got lost. A naive retry creates a second payment. Under load there are more timeouts and so more duplicates — a functional bug that only surfaces in a performance test.

Як роблять безпечно. Ретраїти лише ідемпотентні операції: GET, PUT, DELETE за визначенням HTTP, а POST — з ключем ідемпотентності (заголовок Idempotency-Key): сервер запам'ятовує ключ і на повтор повертає той самий результат без повторного виконання. Експоненційна затримка з jitter, обмеження кількості спроб, retry budget (не більше 10% ретраїв від загального трафіку), повага до Retry-After, circuit breaker, який перестає бити в сервіс, що лежить.

Making it safe. Retry only idempotent operations: GET, PUT, DELETE by HTTP definition, and POST only with an idempotency key (an Idempotency-Key header): the server remembers the key and returns the same result on a repeat without executing again. Exponential backoff with jitter, a cap on attempts, a retry budget (no more than 10% retries relative to total traffic), honouring Retry-After, and a circuit breaker that stops hammering a service that is down.

Як тестувати. Під навантаженням штучно сповільнити залежність (fault injection, мок з затримкою більшою за таймаут) і перевірити: кількість запитів до неї не множиться лавиною; після відновлення система сама повертається до норми; у БД немає дублікатів замовлень і платежів.

How to test it. Under load, artificially slow a dependency (fault injection, a mock with a delay longer than the timeout) and verify: the number of requests to it does not snowball; the system returns to normal on its own after recovery; the database holds no duplicate orders or payments.

Приклад. Stress-тест оплати при 2 с затримки платіжного шлюзу показав 0,7% подвійних списань. Причина — ретрай POST без ключа ідемпотентності після таймауту 1,5 с.

Example. A payment stress test with a 2 s payment gateway delay showed 0.7% double charges. The cause: a POST retry without an idempotency key after a 1.5 s timeout.

Як множаться ретраї і перевірка на дублікати
How retries multiply, and a duplicate check
ретраї на кожному рівні (1 спроба + 2 повтори = 3):
  web -> api -> payments -> gateway (лежить)
  3 × 3 × 3 = 27 запитів до шлюзу на один клік користувача

безпечний POST:
  POST /api/payments
  Idempotency-Key: 7f3c9a2e-order-81723
  -> 201 Created  (перший раз)
  -> 201 Created  (повтор: той самий payment_id, без нового списання)

перевірка після тесту:
  SELECT order_id, count(*) FROM payments GROUP BY order_id HAVING count(*) > 1;  -- очікуємо 0 рядків
retries at each tier (1 attempt + 2 retries = 3):
  web -> api -> payments -> gateway (down)
  3 × 3 × 3 = 27 gateway requests per user click

a safe POST:
  POST /api/payments
  Idempotency-Key: 7f3c9a2e-order-81723
  -> 201 Created  (first time)
  -> 201 Created  (repeat: same payment_id, no new charge)

post-test check:
  SELECT order_id, count(*) FROM payments GROUP BY order_id HAVING count(*) > 1;  -- expect 0 rows
🔧 Розбір: Перший блок показує мультиплікативний ефект: ретраї на кожному рівні перемножуються. Другий — контракт ключа ідемпотентності: повтор із тим самим ключем повертає збережений результат. SQL після тесту — найпростіший спосіб довести, що під навантаженням не з'явилося подвійних списань.
🔧 Breakdown: The first block shows the multiplicative effect: retries at each tier multiply. The second shows the idempotency key contract: a repeat with the same key returns the stored result. The post-test SQL is the simplest way to prove no double charges appeared under load.
💡 Простими словами: Ретрай без ідемпотентності — натиснути «оплатити» вдруге, бо сторінка завантажується повільно. Тепер у вас два замовлення.
💡 In plain words: A retry without idempotency is pressing "pay" again because the page is slow. Now you have two orders.
🗣 Як читати вголос Ретраї під навантаженням мають дві небезпеки. Перша — лавина: сервіс гальмує, клієнти повторюють запити, і на кількох рівнях ретраї перемножуються, добиваючи систему, яка могла б відновитися. Друга — дублікати: таймаут не означає, що операція не виконалась, і повторний POST створює другий платіж. Безпечні ретраї — лише для ідемпотентних операцій або з ключем ідемпотентності, з експоненційною затримкою і jitter, лімітом спроб і circuit breaker. У тестах я сповільнюю залежність і потім перевіряю базу на дублікати.
🗣 How to say it out loud Retries under load carry two dangers. The first is an avalanche: a service slows down, clients repeat requests, and across several tiers retries multiply and finish off a system that could have recovered. The second is duplicates: a timeout does not mean the operation failed, and a repeated POST creates a second payment. Safe retries are only for idempotent operations or with an idempotency key, with exponential backoff and jitter, an attempt cap and a circuit breaker. In tests I slow a dependency down and then check the database for duplicates.
▶ 7 5
47 Що таке Core Web Vitals (LCP, INP, CLS) і які в них порогові значення? What are Core Web Vitals (LCP, INP, CLS) and what are their thresholds?
Фронтенд Front-end Junior ▶

Що це. Core Web Vitals — три метрики Google, що описують досвід користувача на сторінці: швидкість появи головного контенту, чуйність до дій і візуальну стабільність. Вони впливають на пошукове ранжування і є спільною мовою між QA, фронтендом і продуктом.

What it is. Core Web Vitals are three Google metrics describing the user's experience of a page: how fast the main content appears, how responsive it is to actions, and how visually stable it is. They affect search ranking and give QA, front-end and product a shared language.

LCP (Largest Contentful Paint). Коли відмалювався найбільший видимий елемент у першому екрані — зазвичай головне зображення або заголовок. Добре — до 2,5 с, погано — понад 4 с. Типові причини поганого LCP: повільний TTFB, важке зображення без пріоритету, CSS і JS, що блокують рендеринг, клієнтський рендеринг, який малює контент лише після завантаження бандла.

LCP (Largest Contentful Paint). When the largest visible element in the first viewport has painted — usually the hero image or the headline. Good is up to 2.5 s, poor is above 4 s. Typical causes of bad LCP: slow TTFB, a heavy image with no priority, render-blocking CSS and JS, client-side rendering that paints content only after the bundle loads.

INP (Interaction to Next Paint). Скільки минає від дії користувача (клік, тап, натискання клавіші) до наступного кадру, що показує реакцію. Враховує всі взаємодії за час перебування на сторінці. Добре — до 200 мс, погано — понад 500 мс. Причини: довгі задачі в головному потоці, важкі обробники подій, великі перерендери.

INP (Interaction to Next Paint). How long passes from a user action (click, tap, key press) to the next frame showing a response. It considers all interactions during the visit. Good is up to 200 ms, poor is above 500 ms. Causes: long tasks on the main thread, heavy event handlers, large re-renders.

CLS (Cumulative Layout Shift). Наскільки неочікувано зсувається вміст: банер, що з'явився зверху, картинка без розмірів, підміна шрифту. Добре — до 0,1, погано — понад 0,25. Одиниці умовні: частка екрана, що зсунулася, помножена на відстань зсуву.

CLS (Cumulative Layout Shift). How much content shifts unexpectedly: a banner appearing on top, an image without dimensions, a font swap. Good is up to 0.1, poor is above 0.25. The units are abstract: the share of the viewport that moved multiplied by the distance it moved.

Як оцінюють. Сторінка «проходить», якщо 75-й перцентиль реальних візитів (окремо мобільні й десктоп) у зеленій зоні для всіх трьох метрик. Тобто цілі ставлять на p75 польових даних, а не на один запуск Lighthouse.

How it is assessed. A page "passes" if the 75th percentile of real visits (mobile and desktop separately) is in the green zone for all three metrics. So targets are set on field-data p75, not on a single Lighthouse run.

Пороги Core Web Vitals (оцінка на p75 польових даних)
Core Web Vitals thresholds (assessed at field-data p75)
метрика   що міряє                добре      потребує уваги    погано
LCP       поява головного контенту            <= 2.5 s   2.5 - 4.0 s   > 4.0 s
INP       реакція на дії користувача        <= 200 ms  200 - 500 ms  > 500 ms
CLS       візуальна стабільність            <= 0.1     0.1 - 0.25    > 0.25

Сторінка проходить, якщо p75 усіх трьох у зеленій зоні.
metric   what it measures                good      needs work    poor
LCP       main content appears            <= 2.5 s   2.5 - 4.0 s   > 4.0 s
INP       response to user input        <= 200 ms  200 - 500 ms  > 500 ms
CLS       visual stability            <= 0.1     0.1 - 0.25    > 0.25

A page passes when p75 of all three is green.
🔧 Розбір: Таблиця — межі з документації web.dev. Кожна метрика має три зони, а оцінюється 75-й перцентиль: якщо 75% візитів мають LCP до 2,5 с, метрика «добра», навіть якщо на частині слабких пристроїв вона гірша.
🔧 Breakdown: The table holds the boundaries from the web.dev documentation. Each metric has three zones and the 75th percentile is assessed: if 75% of visits have LCP under 2.5 s, the metric is "good" even if it is worse on some weak devices.
💡 Простими словами: LCP — чи швидко з'явилася їжа на столі, INP — чи швидко офіціант реагує, коли кличеш, CLS — чи не смикають тарілку, коли тягнешся виделкою.
💡 In plain words: LCP is how fast the food arrives, INP is how fast the waiter responds when you call, CLS is whether someone pulls the plate away as you reach for it.
🗣 Як читати вголос Core Web Vitals — це три метрики досвіду на сторінці. LCP — коли з'явився найбільший елемент першого екрана, добре до двох з половиною секунд. INP — затримка від дії користувача до наступного кадру з реакцією, добре до двохсот мілісекунд. CLS — неочікувані зсуви макета, добре до нуля цілих однієї десятої. Важливо, що оцінюють сімдесят п'ятий перцентиль реальних візитів окремо для мобільних і десктопа, тож ціль ставлю на польові дані, а не на один лабораторний прогін.
🗣 How to say it out loud Core Web Vitals are three metrics of the page experience. LCP is when the largest first-screen element appeared, good is under two and a half seconds. INP is the delay from a user action to the next frame showing a response, good is under two hundred milliseconds. CLS is unexpected layout shifts, good is under zero point one. Importantly, what gets assessed is the seventy-fifth percentile of real visits, separately for mobile and desktop, so I set targets on field data rather than one lab run.
48 Чому INP замінив FID у Core Web Vitals і що змінилося для тестування? Why did INP replace FID in Core Web Vitals and what changed for testing?
Фронтенд Front-end Middle ▶

Факт. 12 березня 2024 року INP (Interaction to Next Paint) офіційно замінив FID (First Input Delay) як Core Web Vital чуйності. FID після цього виведено з ужитку в інструментах Google.

The fact. On 12 March 2024, INP (Interaction to Next Paint) officially replaced FID (First Input Delay) as the responsiveness Core Web Vital. FID was then deprecated across Google's tools.

Чим був поганий FID. Він міряв лише затримку до початку обробки першої взаємодії. Не враховував, скільки триває сам обробник і скільки браузер малює результат, і ігнорував усі наступні кліки. Тому 90%+ сайтів мали «добрий» FID, навіть якщо фільтр каталогу зависав на секунду після кожного кліку.

What was wrong with FID. It measured only the delay before handling of the first interaction began. It ignored how long the handler ran and how long the browser took to paint the result, and it ignored every later click. So 90%+ of sites had a "good" FID even when the catalogue filter froze for a second after each click.

Що міряє INP. Повну затримку кожної взаємодії: input delay (головний потік зайнятий іншою задачею) + processing time (обробники подій) + presentation delay (рендер і відмальовка наступного кадру). Звітне значення близьке до найгіршої взаємодії за візит (для сторінок з великою кількістю взаємодій відкидається по одній найгіршій на кожні 50).

What INP measures. The full latency of every interaction: input delay (the main thread busy with another task) + processing time (event handlers) + presentation delay (rendering and painting the next frame). The reported value is close to the worst interaction of the visit (on pages with many interactions, one worst interaction is discarded per 50).

Що змінилося для QA. Недостатньо перевірити завантаження сторінки: потрібно проходити реальні сценарії взаємодії — відкриття меню, фільтри, додавання в кошик, введення в пошук — на слабкому CPU. У DevTools панель Performance показує INP для кожної взаємодії з розкладом на три фази, а довгі задачі (понад 50 мс) — червоним. У лабораторному Lighthouse без взаємодій INP не виміряти; найближчий проксі — Total Blocking Time.

What changed for QA. Checking page load is no longer enough: you need to walk real interaction scenarios — opening a menu, filters, adding to cart, typing in search — on a slow CPU. The DevTools Performance panel shows INP per interaction broken into the three phases, with long tasks (over 50 ms) in red. Lab Lighthouse without interactions cannot measure INP; the closest proxy is Total Blocking Time.

Приклад. Кнопка «Додати в кошик»: FID був 12 мс, бо головний потік вільний, але INP — 640 мс: обробник синхронно перераховує весь кошик і перерендерює сторінку. Розбиття задачі і оновлення лише лічильника дало 140 мс.

Example. An "Add to cart" button: FID was 12 ms because the main thread was idle, but INP was 640 ms: the handler synchronously recalculated the whole cart and re-rendered the page. Splitting the task and updating only the counter brought it to 140 ms.

Розклад INP на фази і що з ним робити
INP broken into phases and what to do about it
клік «Додати в кошик» (CPU 4x slowdown)
|-- input delay --|------ processing ------|-- presentation --|
      90 ms               430 ms                  120 ms          INP = 640 ms
      ^                    ^                       ^
      інша довга задача     обробник рахує весь кошик   перерендер сторінки

FID бачив лише перший відрізок першої взаємодії:  90 ms -> добре
INP бачить усю взаємодію:                           640 ms -> погано
click on "Add to cart" (CPU 4x slowdown)
|-- input delay --|------ processing ------|-- presentation --|
      90 ms               430 ms                  120 ms          INP = 640 ms
      ^                    ^                       ^
      another long task     handler recomputes the cart   full re-render

FID saw only the first segment of the first interaction:  90 ms -> good
INP sees the whole interaction:                           640 ms -> poor
🔧 Розбір: Три відрізки — фази, з яких складається INP. FID дорівнював би лише першому відрізку і лише для першої взаємодії, тому виглядав би «добрим». Фаза з найбільшою часткою підказує рішення: тут це обробник, тож оптимізувати треба код обробника, а не завантаження.
🔧 Breakdown: The three segments are the phases INP is made of. FID would equal only the first segment, and only for the first interaction, so it would look "good". The largest phase points at the fix: here it is the handler, so the handler code needs optimising, not the page load.
💡 Простими словами: FID міряв, як швидко офіціант почув замовлення. INP міряє, коли їжа реально на столі — і так для кожного замовлення, а не лише першого.
💡 In plain words: FID measured how fast the waiter heard your order. INP measures when the food actually lands on the table — for every order, not just the first.
🗣 Як читати вголос У березні двадцять четвертого року INP замінив FID як метрику чуйності в Core Web Vitals. FID міряв лише затримку перед початком обробки першої взаємодії, тому майже всі сайти мали добрий FID, навіть коли інтерфейс гальмував після кожного кліку. INP враховує всі взаємодії і повну затримку до наступного кадру: очікування, обробник і відмальовку. Для мене як тестувальника це означає перевіряти реальні сценарії кліків і введення на тротлінгованому CPU, а не лише завантаження сторінки.
🗣 How to say it out loud In March twenty twenty-four INP replaced FID as the responsiveness metric in Core Web Vitals. FID measured only the delay before handling of the first interaction started, so nearly every site had a good FID even when the UI lagged after every click. INP covers all interactions and the full latency to the next frame: waiting, the handler and painting. For me as a tester that means checking real click and typing scenarios on a throttled CPU, not just page load.
49 Чим лабораторні дані Lighthouse відрізняються від польових даних CrUX і яким вірити? How does Lighthouse lab data differ from CrUX field data, and which should you trust?
Фронтенд Front-end Middle ▶

Lighthouse (лабораторія). Один завантажувальний прогін у контрольованих умовах: емуляція середнього мобільного пристрою, тротлінг CPU і мережі, без кешу і без взаємодій. Дає відтворюваний результат і діагностику («зображення не оптимізоване», «JS блокує рендеринг»), тому підходить для налагодження і CI. Метрики: LCP, CLS, Total Blocking Time (як проксі для INP), FCP, Speed Index; оцінка Performance — зважена сума.

Lighthouse (lab). A single page-load run under controlled conditions: an emulated mid-range mobile device, CPU and network throttling, no cache and no interactions. It gives a reproducible result and diagnostics ("image not optimised", "JS blocks rendering"), so it suits debugging and CI. Metrics: LCP, CLS, Total Blocking Time (as an INP proxy), FCP, Speed Index; the Performance score is a weighted sum.

CrUX (поле). Chrome User Experience Report — реальні дані від користувачів Chrome, які погодилися на збір статистики, агреговані за 28 днів на рівні сторінки й домену. Показує p75 LCP, INP, CLS для мобільних і десктопа. Саме ці дані Google використовує для оцінки Core Web Vitals. Доступ — PageSpeed Insights, CrUX API, BigQuery, Search Console.

CrUX (field). The Chrome User Experience Report — real data from Chrome users who opted into statistics, aggregated over 28 days at page and origin level. It shows p75 LCP, INP and CLS for mobile and desktop. This is the data Google uses to assess Core Web Vitals. Access: PageSpeed Insights, the CrUX API, BigQuery, Search Console.

Чому вони розходяться. Лабораторія не знає реальних пристроїв, мереж, кешу повторних візитів, взаємодій, A/B-тестів, cookie-банерів у ЄС і сторінок за логіном. Lighthouse може показувати LCP 4 с, а CrUX 1,9 с (у більшості користувачів гарячий кеш і швидкі телефони), або навпаки.

Why they disagree. The lab does not know real devices, networks, repeat-visit caching, interactions, A/B tests, EU cookie banners or pages behind login. Lighthouse may show LCP 4 s while CrUX shows 1.9 s (most users have a warm cache and fast phones), or the other way round.

Кому вірити. Для рішень про стан продукту — полю (CrUX або власний RUM): це те, що відчувають люди. Для пошуку причин і контролю регресій — лабораторії: вона стабільна і пояснює. Сильна відповідь: «лабораторія — для налагодження і гейтів, поле — для цілей і пріоритетів».

Which to trust. For decisions about the product's state — the field (CrUX or your own RUM): that is what people feel. For finding causes and controlling regressions — the lab: it is stable and explanatory. A strong answer: "lab for debugging and gates, field for targets and priorities".

Підводні камені. Чекати, що виправлення відразу видно в CrUX — дані ковзають за 28 днів. Порівнювати оцінку Lighthouse між машинами різної потужності. Запускати Lighthouse один раз: беруть медіану з 3–5 прогонів.

Pitfalls. Expecting a fix to show up in CrUX immediately — the data rolls over 28 days. Comparing Lighthouse scores between machines of different power. Running Lighthouse once: take the median of 3–5 runs.

Лабораторія і поле для однієї сторінки
Lab and field for one page
# лабораторія: Lighthouse CLI, мобільний профіль за замовчуванням
npx lighthouse https://shop.example.com/product/42 --only-categories=performance \
  --output=json --output-path=./lh.json --quiet
#  LCP 3.8 s   TBT 420 ms   CLS 0.02   score 61

# поле: CrUX API, p75 за 28 днів
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://shop.example.com/product/42", "formFactor": "PHONE"}'
#  LCP p75 2.1 s   INP p75 280 ms   CLS p75 0.04
# lab: Lighthouse CLI, mobile profile by default
npx lighthouse https://shop.example.com/product/42 --only-categories=performance \
  --output=json --output-path=./lh.json --quiet
#  LCP 3.8 s   TBT 420 ms   CLS 0.02   score 61

# field: CrUX API, p75 over 28 days
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://shop.example.com/product/42", "formFactor": "PHONE"}'
#  LCP p75 2.1 s   INP p75 280 ms   CLS p75 0.04
🔧 Розбір: Перша команда — один лабораторний прогін із діагностикою. Друга запитує CrUX API за конкретним URL і типом пристрою і повертає розподіл і p75 реальних візитів. Тут поле показує проблему з INP, якої лабораторія не бачить узагалі, бо не клікає, а лабораторний LCP песимістичніший за реальний.
🔧 Breakdown: The first command is a single lab run with diagnostics. The second queries the CrUX API for a specific URL and form factor and returns the distribution and p75 of real visits. Here the field reveals an INP problem the lab cannot see at all because it never clicks, while the lab LCP is more pessimistic than reality.
💡 Простими словами: Lighthouse — це тест-драйв у салоні, CrUX — відгуки власників через місяць їзди по місту.
💡 In plain words: Lighthouse is a test drive at the dealership; CrUX is what owners say after a month of city driving.
🗣 Як читати вголос Lighthouse — це лабораторія: один прогін на емульованому пристрої з тротлінгом, без кешу і без взаємодій. Він стабільний і дає діагностику, тому я використовую його для налагодження і гейтів у CI. CrUX — польові дані реальних користувачів Chrome за двадцять вісім днів, p75, і саме за ними Google оцінює Core Web Vitals. Вони часто розходяться, і це нормально. Рішення про стан продукту приймаю за полем, а причини шукаю в лабораторії. І пам'ятаю, що INP лабораторія без кліків не бачить.
🗣 How to say it out loud Lighthouse is the lab: one run on an emulated device with throttling, no cache and no interactions. It is stable and gives diagnostics, so I use it for debugging and CI gates. CrUX is field data from real Chrome users over twenty-eight days at p75, and it is what Google uses to assess Core Web Vitals. They often disagree, and that is normal. I make decisions about the product's state from the field and look for causes in the lab. And I remember the lab cannot see INP without clicks.
50 Що показує network waterfall і як емулювати повільну мережу та CPU в DevTools? What does a network waterfall show and how do you emulate a slow network and CPU in DevTools?
Фронтенд Front-end Junior ▶

Waterfall. Діаграма у вкладці Network: кожен рядок — ресурс, смуга — коли він почав і закінчив завантажуватися, кольори — фази (очікування в черзі, DNS, з'єднання, TLS, очікування сервера, завантаження). Показує порядок і паралельність: що блокує рендеринг, що вантажиться занадто пізно, які запити йдуть ланцюжком.

Waterfall. A chart in the Network tab: each row is a resource, the bar shows when it started and finished loading, and colours show phases (queueing, DNS, connection, TLS, waiting for the server, download). It shows order and parallelism: what blocks rendering, what loads too late, which requests run in a chain.

Що шукати. Довгі «сходинки» — ланцюжки залежностей (HTML → CSS → шрифт → зображення), які можна скоротити через preload або fetchpriority=high. Героїчне зображення, що стартує на 2-й секунді. Великі JS-бандли перед контентом. Десятки дрібних запитів до API після завантаження. Ресурси без стиснення чи кешу. Сторонні скрипти, що займають мережу.

What to look for. Long "staircases" — dependency chains (HTML → CSS → font → image) that can be shortened with preload or fetchpriority=high. A hero image that starts at second 2. Big JS bundles before the content. Dozens of small API requests after load. Resources without compression or caching. Third-party scripts hogging the network.

Тротлінг. Розробник працює на швидкому ноутбуці з оптикою, користувач — на бюджетному телефоні з 4G. DevTools дозволяє емулювати обидва обмеження: у Network — пресети на кшталт Slow 4G або власний профіль із затримкою і пропускною здатністю; у Performance — CPU slowdown у 4 або 6 разів. Мережа впливає на LCP, а CPU — на INP і TBT.

Throttling. The developer works on a fast laptop with fibre, the user on a budget phone with 4G. DevTools emulates both limits: in Network, presets like Slow 4G or a custom profile with latency and bandwidth; in Performance, a CPU slowdown of 4x or 6x. The network affects LCP, the CPU affects INP and TBT.

В автотестах. Ті самі умови можна задати через Chrome DevTools Protocol з Playwright і стабільно перевіряти час ключового сценарію на «повільному телефоні».

In automated tests. The same conditions can be set via the Chrome DevTools Protocol from Playwright to check the timing of a key scenario on a "slow phone" consistently.

Підводні камені. Тротлінг DevTools — емуляція: він не відтворює втрату пакетів і нестабільність справжньої мобільної мережі. І забувати вимкнути кеш (галочка Disable cache), через що повторне завантаження виглядає швидким.

Pitfalls. DevTools throttling is an emulation: it does not reproduce packet loss and the instability of a real mobile network. And forgetting to disable the cache (the Disable cache checkbox), which makes a reload look fast.

Playwright + CDP: повільна мережа і CPU в автотесті
Playwright + CDP: slow network and CPU in an automated test
import { test, expect } from '@playwright/test';

test('product page on a slow phone', async ({ page }) => {
  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Network.enable');
  await cdp.send('Network.emulateNetworkConditions', {
    offline: false,
    latency: 150,                                 // ms RTT
    downloadThroughput: (1.6 * 1024 * 1024) / 8,  // ~1.6 Mbit/s
    uploadThroughput: (750 * 1024) / 8,
  });
  await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });

  const start = Date.now();
  await page.goto('https://shop.example.com/product/42');
  await expect(page.getByRole('button', { name: 'Add to cart' })).toBeVisible();
  expect(Date.now() - start).toBeLessThan(6000);
});
🔧 Розбір: newCDPSession відкриває прямий канал до Chromium (працює лише в Chromium). emulateNetworkConditions приймає пропускну здатність у байтах за секунду, тому мегабіти ділять на 8. setCPUThrottlingRate з rate: 4 робить процесор у 4 рази повільнішим. Поріг 6 с — грубий гейт на регресію, а не заміна польових метрик.
🔧 Breakdown: newCDPSession opens a direct channel to Chromium (it works in Chromium only). emulateNetworkConditions takes throughput in bytes per second, so megabits are divided by 8. setCPUThrottlingRate with rate: 4 makes the CPU four times slower. The 6 s limit is a coarse regression gate, not a replacement for field metrics.
💡 Простими словами: Waterfall — розклад поїздів: видно, хто чекає на кого, і який потяг затримав усю станцію.
💡 In plain words: A waterfall is a train timetable: you see who waits for whom and which train held up the whole station.
🗣 Як читати вголос Waterfall у вкладці Network показує кожен ресурс як смугу в часі з фазами: черга, DNS, з'єднання, очікування сервера, завантаження. Я шукаю ланцюжки залежностей, пізно стартовані ключові ресурси, важкі бандли перед контентом і сторонні скрипти. А щоб бачити сторінку очима реального користувача, вмикаю тротлінг мережі й CPU в DevTools, бо мережа б'є по LCP, а процесор — по INP. В автотестах те саме задаю через CDP з Playwright, пам'ятаючи, що це емуляція, а не справжня мобільна мережа.
🗣 How to say it out loud The waterfall in the Network tab shows each resource as a bar on a timeline with phases: queueing, DNS, connection, server wait, download. I look for dependency chains, key resources that start late, heavy bundles before content and third-party scripts. And to see the page through a real user's eyes I turn on network and CPU throttling in DevTools, because the network hurts LCP and the CPU hurts INP. In automated tests I set the same through CDP from Playwright, keeping in mind it is an emulation, not a real mobile network.
51 Що таке performance budget і як зробити його гейтом у CI? What is a performance budget and how do you make it a CI gate?
Фронтенд Front-end Middle ▶

Що це. Performance budget — узгоджені ліміти, які команда не має права перевищувати: метрики (LCP ≤ 2,5 с у лабораторному профілі, TBT ≤ 200 мс, CLS ≤ 0,1), розміри (JS на сторінку ≤ 300 КБ стисненого, зображення першого екрана ≤ 200 КБ), кількості (не більше 50 запитів, не більше 5 сторонніх доменів).

What it is. A performance budget is a set of agreed limits the team may not exceed: metrics (LCP ≤ 2.5 s in the lab profile, TBT ≤ 200 ms, CLS ≤ 0.1), sizes (JS per page ≤ 300 KB compressed, first-screen images ≤ 200 KB), counts (no more than 50 requests, no more than 5 third-party domains).

Навіщо. Продуктивність деградує поступово: кожна нова бібліотека, піксель аналітики чи віджет додає по 20–50 КБ, і ніхто окремо не винен. Бюджет перетворює «сайт став повільнішим» на конкретний червоний PR, у якому видно, хто і на скільки перевищив.

Why. Performance degrades gradually: every new library, analytics pixel or widget adds 20–50 KB and nobody is individually to blame. A budget turns "the site got slower" into a concrete red PR showing who exceeded what and by how much.

Як у CI. Lighthouse CI (lhci autorun) робить кілька прогонів, бере медіану і перевіряє асерти з lighthouserc: рівень error валить збірку, warn лише попереджає. Для розмірів бандла є окремі інструменти (наприклад, size-limit), що перевіряють збірку без браузера.

In CI. Lighthouse CI (lhci autorun) does several runs, takes the median and checks the assertions from lighthouserc: level error fails the build, warn only warns. For bundle sizes there are separate tools (for example size-limit) that check the build without a browser.

Приклад. PR додав бібліотеку для форматування дат: +180 КБ JS. Асерт resource-summary:script:size упав, і в описі збірки видно, яке правило перевищене. Розробник замінив бібліотеку на вбудований Intl.DateTimeFormat, і бюджет повернувся в норму ще до мерджу.

Example. A PR added a date-formatting library: +180 KB of JS. The resource-summary:script:size assertion failed and the build output shows which rule was exceeded. The developer replaced the library with the built-in Intl.DateTimeFormat, and the budget was back to normal before the merge.

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

Pitfalls. Budgets that are already broken today (the check is always red, so it gets ignored): start from the current state and tighten gradually. And lab thresholds with no link to field data: the budget should lead to a better CrUX p75, not just a green score.

lighthouserc.json — бюджет як асерти
lighthouserc.json — the budget as assertions
{
  "ci": {
    "collect": {
      "url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/product/42"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 300000 }],
        "categories:performance": ["warn", { "minScore": 0.9 }]
      }
    },
    "upload": { "target": "temporary-public-storage" }
  }
}
🔧 Розбір: numberOfRuns: 3 зменшує шум: асерти перевіряються за медіанним прогоном. maxNumericValue задається в мілісекундах для часових метрик і в байтах для розмірів. categories:performance на рівні warn — свідомий вибір: загальна оцінка інформує, а валять збірку лише конкретні метрики.
🔧 Breakdown: numberOfRuns: 3 reduces noise: assertions are checked against the median run. maxNumericValue is in milliseconds for timing metrics and in bytes for sizes. Keeping categories:performance at warn is deliberate: the overall score informs, while only specific metrics fail the build.
💡 Простими словами: Performance budget — як сімейний бюджет: кожна покупка дрібна, але без ліміту в кінці місяця раптом нічого не лишилося.
💡 In plain words: A performance budget is like a household budget: every purchase is small, but without a limit there's suddenly nothing left at month's end.
🗣 Як читати вголос Performance budget — це узгоджені ліміти на метрики, розміри й кількість ресурсів, які сторінка не має перевищувати. Він потрібен, бо продуктивність деградує потроху: кожна бібліотека додає кілька десятків кілобайтів, і ніхто окремо не винен. Я вбудовую бюджет у CI через Lighthouse CI: кілька прогонів, медіана, асерти рівня error валять PR. Починаю з поточного стану, щоб перевірка не була вічно червоною, і поступово стискаю, звіряючись із польовими даними.
🗣 How to say it out loud A performance budget is a set of agreed limits on metrics, sizes and resource counts that a page must not exceed. It is needed because performance degrades bit by bit: each library adds a few dozen kilobytes and nobody is individually to blame. I build the budget into CI with Lighthouse CI: several runs, the median, and error-level assertions that fail the PR. I start from the current state so the check isn't permanently red, and tighten gradually, checking against field data.
▶ 8 4
52 Як підготувати систему до Black Friday з точки зору performance-тестування? How do you prepare a system for Black Friday from a performance testing standpoint?
Сценарії Scenarios Senior ▶

Ціль і модель (за 8–10 тижнів). Беремо торішній пік (хвилинний, не добовий), прогноз маркетингу і множимо з запасом: наприклад, торік 1200 RPS, прогноз ×2, запас ×1,3 — ціль 3100 RPS. Мікс змінюється: частка кошика й оплати в розпродаж зростає в 2–3 рази, пошук б'є по акційних товарах, а старт акції о 00:00 — це spike, а не плавний розгін.

Target and model (8–10 weeks out). Take last year's peak (per minute, not per day), marketing's forecast, and multiply with a margin: for example, 1200 RPS last year, forecast ×2, margin ×1.3 — target 3100 RPS. The mix changes: the share of cart and payment grows 2–3 times during a sale, search hammers promoted products, and a sale starting at 00:00 is a spike, not a smooth ramp.

Серія тестів. 1) Load на цільовому рівні з новим міксом. 2) Spike: з 30% до 100% за хвилину, як після розсилки. 3) Stress до відмови, щоб знати межу і що ламається першим. 4) Soak 8–12 годин, бо розпродаж триває добу. 5) Тести відмов під навантаженням: падіння вузла БД, повільний платіжний провайдер, холодний кеш після рестарту. Кожен знайдений вузький місце — виправлення і повторний прогін.

Test series. 1) Load at the target level with the new mix. 2) Spike: from 30% to 100% within a minute, like after a mass email. 3) Stress to failure, to know the limit and what breaks first. 4) Soak for 8–12 hours, because the sale lasts all day. 5) Failure tests under load: a database node down, a slow payment provider, a cold cache after restart. Every bottleneck found means a fix and a rerun.

Залежності. Платіжні шлюзи, доставка, SMS мають свої ліміти: узгоджуємо з ними квоти, тестуємо з моками, що імітують їхні затримки й 429, і перевіряємо, що наш сервіс деградує акуратно (черга оплат, повідомлення користувачу), а не падає.

Dependencies. Payment gateways, delivery and SMS have their own limits: agree quotas with them, test with mocks that mimic their latency and 429s, and verify our service degrades gracefully (a payment queue, a message to the user) instead of crashing.

Операційна готовність. Автоскейлінг перевірено на spike і попередньо прогріто (pre-scaling за годину до старту), дашборди й алерти на SLO готові, feature flags для вимкнення важких функцій (рекомендації, персоналізація), заморожування релізів за тиждень, runbook і черговий склад.

Operational readiness. Autoscaling verified on a spike and pre-warmed (pre-scaling an hour before the start), SLO dashboards and alerts ready, feature flags to switch off heavy features (recommendations, personalisation), a release freeze a week before, a runbook and an on-call roster.

Критерій готовності. Ціль 3100 RPS проходить з p95 у межах SLO при запасі до межі щонайменше 30%, відмови залежностей не каскадять, а команда знає, що вимикати першим, якщо прогноз помилиться.

Readiness criterion. The 3100 RPS target passes with p95 within the SLO and at least 30% headroom to the limit, dependency failures do not cascade, and the team knows what to switch off first if the forecast turns out wrong.

План тестів до Black Friday
The Black Friday test plan
тиждень  тест                      профіль                         питання
-10     модель навантаження      логи торішнього піку + прогноз    яка ціль?
-8      load                      3100 RPS, мікс розпродажу, 30 хв      тримаємо SLO?
-7      spike                     900 -> 3100 RPS за 60 s             автоскейлінг встигає?
-6      stress / breakpoint       до відмови                       де межа, що ламається?
-5      soak                      2200 RPS, 10 год                  витоки, ріст черг?
-4      відмови під навантаженням     вузол БД, повільна оплата, холодний кеш  чи немає каскаду?
-2      фінальний повтор усіх       на релізі-кандидаті              регресій немає?
-1      заморожування релізів, pre-scaling, runbook
week  test                      profile                         question
-10     workload model      last year's peak logs + forecast    what target?
-8      load                      3100 RPS, sale mix, 30 min      do we hold the SLO?
-7      spike                     900 -> 3100 RPS in 60 s             does autoscaling keep up?
-6      stress / breakpoint       to failure                       where is the limit, what breaks?
-5      soak                      2200 RPS, 10 h                  leaks, queue growth?
-4      failures under load     DB node, slow payment, cold cache  no cascade?
-2      final rerun of all       on the release candidate              no regressions?
-1      release freeze, pre-scaling, runbook
🔧 Розбір: План читається справа наліво: кожне питання визначає тип тесту і профіль. Тести йдуть від простого до руйнівного, а фінальний повтор на релізі-кандидаті гарантує, що виправлення за попередні тижні не принесли нових регресій.
🔧 Breakdown: Read the plan right to left: each question determines the test type and profile. Tests progress from simple to destructive, and the final rerun on the release candidate ensures the fixes of the previous weeks brought no new regressions.
💡 Простими словами: Black Friday — це не один великий тест за тиждень до дати, а сезон тренувань із генеральною репетицією наприкінці.
💡 In plain words: Black Friday is not one big test a week before the date but a training season with a dress rehearsal at the end.
🗣 Як читати вголос Підготовку до Black Friday я починаю за два місяці з моделі: торішній хвилинний пік, прогноз маркетингу, запас, і новий мікс, бо в розпродаж частка кошика й оплати різко росте. Далі серія тестів: load на цілі, spike на старт акції, stress до відмови, soak на всю тривалість і відмови залежностей під навантаженням. Паралельно узгоджую ліміти з платіжними провайдерами, перевіряю автоскейлінг і готуємо feature flags, щоб вимкнути важкі функції. Критерій готовності — ціль проходить із запасом і без каскадів.
🗣 How to say it out loud I start Black Friday prep two months out with the model: last year's per-minute peak, marketing's forecast, a margin, and a new mix, because during a sale the share of cart and payment jumps. Then a series of tests: load at the target, a spike for the sale start, stress to failure, soak for the full duration and dependency failures under load. In parallel I agree limits with payment providers, verify autoscaling, and we prepare feature flags to switch off heavy features. The readiness criterion is the target passing with headroom and no cascades.
53 Після релізу p95 виріс на 40%. Як розслідувати регресію латентності? After a release p95 went up by 40%. How do you investigate the latency regression?
Сценарії Scenarios Senior ▶

1. Підтвердити і локалізувати. Чи це справжня регресія, а не зміна трафіку: порівнюю той самий час доби і день тижня, RPS і мікс запитів. Далі звужую: які ендпоінти (зазвичай один-два, а не всі), який перцентиль (зсунувся весь розподіл чи лише хвіст), які інстанси, регіони, клієнти. Якщо деградація почалася рівно з хвилини деплою — підозрюємо реліз; якщо поступово — дані, трафік або залежність.

1. Confirm and localise. Is it a real regression rather than a traffic change: compare the same time of day and day of week, the RPS and request mix. Then narrow it down: which endpoints (usually one or two, not all), which percentile (did the whole distribution shift or only the tail), which instances, regions, clients. If degradation started exactly at the deploy minute, suspect the release; if gradually — data, traffic or a dependency.

2. Порівняти версії. Diff релізу (код, залежності, конфіги, міграції, feature flags). У APM порівнюю трейси повільного ендпоінта до і після: який спан подовжився — новий SQL-запит, додатковий виклик сервісу, серіалізація. Якщо є канарковий деплой — порівнюю нову і стару версії під однаковим трафіком.

2. Compare versions. The release diff (code, dependencies, configs, migrations, feature flags). In APM compare traces of the slow endpoint before and after: which span got longer — a new SQL query, an extra service call, serialisation. With a canary deploy, compare new and old versions under identical traffic.

3. Відтворити. Прогін на perf-стенді: baseline (попередня версія) і нова з однаковим профілем. Якщо регресія відтворюється — бісекція по комітах або по feature flags. Якщо ні — відмінність у середовищі або даних продакшну (наприклад, міграція додала колонку, і таблиця без індексу стала великою).

3. Reproduce. A run on the perf stand: baseline (previous version) and the new one with the same profile. If the regression reproduces, bisect by commits or feature flags. If not, the difference is in the environment or production data (for example a migration added a column and an unindexed table grew large).

4. Рішення. Якщо шкода велика і причина неочевидна — спершу відкат або вимкнення прапора, потім розслідування. Після виправлення — повторний тест проти baseline і постмортем з питанням: чому наш CI-гейт цього не спіймав? Часто відповідь — у тесті немає цього ендпоінта або даних реального обсягу.

4. Decide. If the damage is big and the cause unclear, roll back or switch off the flag first, investigate second. After the fix, retest against the baseline and hold a postmortem asking: why did our CI gate not catch this? Often the answer is that the test does not cover this endpoint or lacks realistic data volume.

Приклад. Реліз додав у відповідь /orders поле з кількістю товарів через лінивий ORM-зв'язок — N+1 на 20 запитів. На стенді зі 100 замовленнями на користувача не видно, у проді у частини клієнтів 3000 замовлень. Виправлення — агрегація в одному запиті, плюс сценарій із «важким» користувачем у perf-тесті.

Example. A release added an item-count field to the /orders response via a lazy ORM relation — N+1 with 20 queries. Invisible on a stand with 100 orders per user; in production some customers have 3000 orders. The fix was one aggregated query, plus a "heavy user" scenario in the perf test.

Хід розслідування з конкретними числами
The investigation path with concrete numbers
симптом:  p95 усього API 210 -> 295 ms (+40%) з 14:02, деплой v5.8 о 14:00

крок 1  RPS і мікс як тиждень тому                    -> не трафік
крок 2  по ендпоінтах: GET /orders p95 180 -> 1150 ms, решта ±3%
крок 3  трейс до/після: +20 SQL спанів "SELECT count(*) FROM order_items WHERE order_id = ?"
крок 4  perf-стенд: v5.7 vs v5.8, користувач із 3000 замовлень  -> відтворено
рішення:  feature flag orders.itemCount = off  -> p95 повернувся 212 ms
виправлення:  один агрегований запит + perf-сценарій «важкий користувач»
symptom:  p95 all API 210 -> 295 ms (+40%) since 14:02, deploy v5.8 at 14:00

step 1  RPS and mix same as a week ago                    -> not traffic
step 2  per endpoint: GET /orders p95 180 -> 1150 ms, the rest ±3%
step 3  trace before/after: +20 SQL spans "SELECT count(*) FROM order_items WHERE order_id = ?"
step 4  perf-stand: v5.7 vs v5.8, user with 3000 orders  -> reproduced
decision:  feature flag orders.itemCount = off  -> p95 back to 212 ms
fix:  one aggregated query + a "heavy user" perf scenario
🔧 Розбір: Кожен крок відкидає одну гіпотезу: трафік, «повільне все», причину в коді, різницю середовищ. Зверніть увагу, що загальний p95 виріс лише на 40%, а конкретний ендпоінт — у 6 разів: агреговані метрики розмивають локальну регресію, тому розбивка по ендпоінтах — перший інструмент.
🔧 Breakdown: Each step eliminates one hypothesis: traffic, "everything is slow", the cause in the code, an environment difference. Note that overall p95 rose only 40% while one endpoint got six times slower: aggregate metrics blur a local regression, which is why the per-endpoint breakdown is the first tool.
💡 Простими словами: Спершу зупини кровотечу (відкат чи прапор), потім з'ясовуй, хто порізався і чим.
💡 In plain words: Stop the bleeding first (roll back or flip the flag), then work out who got cut and with what.
🗣 Як читати вголос Спершу я перевіряю, що це справжня регресія, а не інший трафік: той самий час, RPS і мікс. Потім звужую до конкретних ендпоінтів і перцентилів, бо загальна цифра зазвичай розмиває одну проблемну точку. Далі порівнюю трейси до і після релізу і дивлюся diff: код, конфіги, міграції, прапори. Відтворюю на стенді з baseline і, якщо треба, роблю бісекцію. Якщо шкода велика, спершу відкат або вимкнення прапора. А в постмортемі обов'язково питаю, чому наш гейт у CI цього не побачив.
🗣 How to say it out loud First I check it is a real regression and not different traffic: same time of day, RPS and mix. Then I narrow it down to specific endpoints and percentiles, because the overall number usually blurs a single problem spot. Next I compare traces before and after the release and read the diff: code, configs, migrations, flags. I reproduce on the stand against the baseline and bisect if needed. If the damage is big, rollback or flag-off comes first. And in the postmortem I always ask why our CI gate did not see it.
54 Як провести capacity planning: скільки інстансів потрібно на наступний рік? How do you do capacity planning: how many instances will we need next year?
Сценарії Scenarios Senior ▶

Вхідні дані. Прогноз бізнесу (ріст користувачів, нові ринки, кампанії), поточний піковий трафік і його сезонність, SLO і результати capacity-тесту: скільки RPS тримає один інстанс у межах SLO.

Inputs. The business forecast (user growth, new markets, campaigns), current peak traffic and its seasonality, the SLO, and capacity test results: how many RPS one instance holds within the SLO.

Capacity-тест одного юніта. Навантажуємо один інстанс (або мінімальну групу) реалістичним міксом, піднімаючи RPS, поки p95 не наблизиться до SLO або ресурс не насититься. Робоча ємність — не межа, а рівень з запасом: наприклад, межа 520 RPS, але при 400 RPS CPU 70% і p95 з запасом, тож плануємо 400.

Capacity test of one unit. Load one instance (or the smallest group) with a realistic mix, raising RPS until p95 approaches the SLO or a resource saturates. The working capacity is not the limit but a level with headroom: for example, the limit is 520 RPS, but at 400 RPS CPU is 70% and p95 has margin, so we plan with 400.

Розрахунок. Пік через рік = поточний пік × ріст × сезонний коефіцієнт. Інстанси = пік / ємність одного, округлити вгору, плюс резерв на відмову зони доступності (N+1 або +50% при двох зонах), плюс час на розгортання. Перевірити, що росте не лише застосунок: БД, кеш, черги, ліміти сторонніх API і ліцензій мають свою ємність.

Calculation. Peak in a year = current peak × growth × seasonal factor. Instances = peak / capacity per instance, rounded up, plus reserve for losing an availability zone (N+1, or +50% with two zones), plus deployment time. Check that not only the app grows: the database, cache, queues, third-party API limits and licences each have their own capacity.

Перевірка лінійності. Розрахунок припускає, що 20 інстансів дадуть 20× ємність. Це треба довести scalability-тестом на 2, 4, 8 інстансах: якщо ефективність падає, вузьке місце в спільному ресурсі, і рішення — не просто кількість серверів.

Linearity check. The calculation assumes 20 instances give 20× capacity. That must be proven with a scalability test at 2, 4 and 8 instances: if efficiency drops, the bottleneck is a shared resource, and the answer is not just a server count.

Підводні камені. Планувати за середнім трафіком; ігнорувати ріст даних (запити повільнішають, коли таблиці ростуть); забувати про розмір пулів з'єднань, що множиться на кількість інстансів і впирається в ліміт БД.

Pitfalls. Planning on average traffic; ignoring data growth (queries slow down as tables grow); forgetting connection pool size, which multiplies by the instance count and runs into the database limit.

Розрахунок ємності на наступний рік
Next year's capacity calculation
ємність 1 інстансу (capacity-тест):   межа 520 RPS, план 400 RPS (CPU 70%, p95 310 ms)
поточний пік:                          2 400 RPS
ріст за рік × сезон (листопад):          × 1.6 × 1.4
пік через рік:                         2 400 × 1.6 × 1.4 = 5 376 RPS

інстансів:  5 376 / 400 = 13.4  -> 14
3 зони, витримати втрату однієї:  14 × 3/2 = 21  -> 21 інстанс

перевірка БД:  21 × пул 20 = 420 з'єднань  >  max_connections 300   <- проблема
рішення:  PgBouncer або пул 12 + read-репліки для каталогу
capacity of 1 instance (capacity test):   limit 520 RPS, plan 400 RPS (CPU 70%, p95 310 ms)
current peak:                          2 400 RPS
growth over a year × season (November):          × 1.6 × 1.4
peak in a year:                         2 400 × 1.6 × 1.4 = 5 376 RPS

instances:  5 376 / 400 = 13.4  -> 14
3 zones, survive losing one:  14 × 3/2 = 21  -> 21 instances

DB check:  21 × pool 20 = 420 connections  >  max_connections 300   <- problem
fix:  PgBouncer or pool 12 + read replicas for the catalogue
🔧 Розбір: Розрахунок іде від виміряної, а не теоретичної ємності інстансу і враховує відмову однієї з трьох зон: дві зони, що лишилися, мають тримати весь пік. Останні рядки показують, навіщо перевіряти спільні ресурси: кількість з'єднань масштабується разом з інстансами і першою впирається в ліміт.
🔧 Breakdown: The calculation starts from measured rather than theoretical instance capacity and accounts for losing one of three zones: the two remaining zones must carry the whole peak. The last lines show why shared resources must be checked: connection count scales with instances and hits its limit first.
💡 Простими словами: Capacity planning — рахувати стільці на весілля: гостей за списком, плюс родичі, яких забули, плюс запас на випадок, якщо один стіл зламається.
💡 In plain words: Capacity planning is counting chairs for a wedding: guests on the list, plus forgotten relatives, plus spares in case a table collapses.
🗣 Як читати вголос Capacity planning я будую на виміряній ємності, а не на відчуттях. Спершу capacity-тест одного інстансу реалістичним міксом, і беру не межу, а рівень із запасом. Потім пік через рік: поточний пік, помножений на ріст і сезонність, ділю на ємність, округлюю і додаю резерв на втрату зони. Обов'язково перевіряю спільні ресурси, бо кількість з'єднань до бази множиться на інстанси і першою впирається в ліміт. І підтверджую лінійність scalability-тестом, інакше вся арифметика неправдива.
🗣 How to say it out loud I build capacity planning on measured capacity, not gut feeling. First a capacity test of one instance with a realistic mix, and I take a level with headroom rather than the limit. Then next year's peak: the current peak times growth and seasonality, divided by capacity, rounded up, plus reserve for losing a zone. I always check shared resources, because database connections multiply with instances and hit their limit first. And I confirm linearity with a scalability test, otherwise the whole arithmetic is wrong.
55 Спрацював алерт про порушення SLO латентності. Як ви розслідуєте інцидент і що зміните в тестах після нього? An SLO latency breach alert fired. How do you investigate the incident and what do you change in testing afterwards?
Сценарії Scenarios Senior ▶

1. Оцінити масштаб. Який SLO, яка швидкість спалювання бюджету (burn rate 14 — термінова ситуація, 2 — є години), які користувачі й регіони зачеплені. Від цього залежить, чи будимо людей і чи робимо відкат одразу.

1. Assess the scale. Which SLO, what burn rate (14 is an emergency, 2 means you have hours), which users and regions are affected. That decides whether to wake people up and whether to roll back immediately.

2. Знайти, що змінилося. Хронологія: деплої, зміни конфігів і прапорів, міграції, трафік (кампанія, бот, клієнт з новою інтеграцією), інциденти в хмарі й у залежностей. Більшість порушень SLO корелює з якоюсь зміною в останні години.

2. Find what changed. A timeline: deploys, config and flag changes, migrations, traffic (a campaign, a bot, a client with a new integration), incidents in the cloud and in dependencies. Most SLO breaches correlate with some change in the last few hours.

3. Локалізувати. RED по сервісах ланцюжка — який першим почав гальмувати; далі USE по його ресурсах і трейси найповільніших запитів. Типові знахідки: вичерпаний пул з'єднань через повільний запит, GC-паузи після зміни розміру контейнера, затримки стороннього API і ретраї, що множать навантаження, лок у БД від нічної задачі.

3. Localise. RED across the services in the chain — which slowed down first; then USE across its resources and traces of the slowest requests. Typical findings: a connection pool exhausted by a slow query, GC pauses after a container size change, third-party API latency with retries multiplying load, a database lock from a nightly job.

4. Стабілізувати, потім виправляти. Відкат, вимкнення прапора, масштабування, rate limit на проблемного клієнта. Після стабілізації — корінна причина і постмортем без пошуку винних.

4. Stabilise, then fix. Roll back, switch off a flag, scale out, rate-limit the offending client. Once stable — the root cause and a blameless postmortem.

5. Що змінити в тестуванні. Відтворити інцидент у perf-тесті (це і є найкращий регресійний тест), додати відсутній сценарій або дані (важкий користувач, відмова залежності, нічна задача паралельно з трафіком), перевірити, що нові пороги впіймали б проблему на старій версії, і додати алерт, що спрацював би раніше. Постмортем без нового тесту — половина роботи.

5. What to change in testing. Reproduce the incident in a perf test (that is the best regression test there is), add the missing scenario or data (a heavy user, a dependency failure, a nightly job running alongside traffic), verify the new thresholds would have caught the problem on the old version, and add an alert that would have fired earlier. A postmortem without a new test is half the job.

Хронологія інциденту і тест, що з'явився після нього
The incident timeline and the test that came out of it
02:00  нічний імпорт каталогу стартує (UPDATE products ... пакети по 50 000)
02:04  алерт: SLO пошуку p95 < 400 ms, burn rate 16
02:06  RED: search-service p95 2.8 s; трейси: очікування блокування на products
02:09  pg_stat_activity: 140 запитів чекають, blocked_by = сесія імпорту
02:11  імпорт зупинено, p95 повернувся за 1 хв
корінь:  довга транзакція імпорту тримає блокування рядків
фікс:    пакети по 500 з окремими комітами

новий perf-сценарій:  search 300 RPS + імпорт паралельно
поріг:            'http_req_duration{name:GET /search}': ['p(95)<400']
на старій версії — червоний, на виправленій — зелений
02:00  nightly catalogue import starts (UPDATE products ... batches of 50,000)
02:04  alert: SLO search p95 < 400 ms, burn rate 16
02:06  RED: search-service p95 2.8 s; traces: lock wait on products
02:09  pg_stat_activity: 140 queries waiting, blocked_by = the import session
02:11  import stopped, p95 back within 1 min
root cause:  the import's long transaction holds row locks
fix:    batches of 500 with separate commits

new perf scenario:  search 300 RPS + import in parallel
threshold:            'http_req_duration{name:GET /search}': ['p(95)<400']
red on the old version, green on the fixed one
🔧 Розбір: Хронологія з точним часом пов'язує симптом (алерт), локалізацію (RED і трейси), причину (блокування від імпорту) і рішення. Останні рядки — головний внесок QA: сценарій, що відтворює інцидент, і перевірка, що поріг червоний на старій версії, тобто тест справді ловить цю проблему.
🔧 Breakdown: A timeline with exact times links the symptom (the alert), localisation (RED and traces), the cause (locks from the import) and the fix. The last lines are QA's main contribution: a scenario that reproduces the incident and proof that the threshold is red on the old version, i.e. the test really catches this problem.
💡 Простими словами: Інцидент — це безкоштовний урок, за який уже заплачено. Не записати його в тест — викинути квитанцію.
💡 In plain words: An incident is a lesson you've already paid for. Not turning it into a test is throwing away the receipt.
🗣 Як читати вголос Спершу оцінюю масштаб: який SLO і з якою швидкістю горить бюджет, бо від цього залежить, чи відкочуємо негайно. Далі хронологія змін за останні години — деплої, прапори, міграції, трафік, нічні задачі. Потім локалізація: RED по сервісах, USE по ресурсах винного сервісу, трейси повільних запитів. Стабілізую відкатом або прапором, і лише потім шукаю корінь. А після постмортему обов'язково відтворюю інцидент у perf-тесті і перевіряю, що поріг червоний на старій версії — інакше урок не засвоєно.
🗣 How to say it out loud First I assess the scale: which SLO and how fast the budget is burning, because that decides whether we roll back immediately. Next the timeline of changes in recent hours — deploys, flags, migrations, traffic, nightly jobs. Then localisation: RED across services, USE across the guilty service's resources, traces of slow requests. I stabilise with a rollback or a flag, and only then dig for the root cause. After the postmortem I always reproduce the incident in a perf test and check the threshold is red on the old version — otherwise the lesson hasn't been learned.