Що це. 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.
Питання: чи витримає 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
Що це. 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.
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
Що це. 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.
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
],
},
},
};
ramping-arrival-rate задає інтенсивність у ітераціях за timeUnit, а не кількість користувачів, тому стрибок буде справжнім навіть якщо система сповільниться. maxVUs — запас віртуальних користувачів для такого темпу; якщо його не вистачить, k6 покаже метрику dropped_iterations.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.Що це. 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.
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
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.
інстанси 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)
Що це. 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.
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 відфільтровує лише потрібну фазу.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.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.
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 надійно відправляє дані навіть під час закриття сторінки.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.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.
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 — значення, швидше за яке виконалися 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.
запити: 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
Мінімальний набір. Три метрики клієнта: латентність у перцентилях (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.
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, на яку теж можна поставити поріг.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.Що це. 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 -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 — завантаженню тіла.-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.Що це. Закон Літтла: 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.
Закон Літтла: 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
Що це. 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.
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
Що це. 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%.
# 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 тут коректний для всіх інстансів разом, на відміну від усереднення готових перцентилів.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.Що це. Модель навантаження — опис того, що і з якою інтенсивністю робитимуть віртуальні користувачі: які сценарії, в якій пропорції, з яким темпом і в який пік. Без неї тест перевіряє вигадану систему.
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.
# пікова хвилина з логів 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
:id, щоб згрупувати однакові ендпоінти, і рахує частоти. Результат ділиться на тривалість вікна, і виходять RPS і відсотки для міксу сценаріїв.:id to group identical endpoints and counts frequencies. Dividing by the window length gives RPS and percentages for the scenario mix.Закрита модель. Фіксована кількість віртуальних користувачів; кожен надсилає наступний запит лише після відповіді на попередній (плюс пауза). Нові запити народжуються з завершення старих, тому коли система сповільнюється, інтенсивність падає сама. Так поводяться системи з обмеженим пулом клієнтів: 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.
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 дає запас.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.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.
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.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.Кореляція. Сервер повертає значення, які потрібні в наступних запитах: токен доступу, 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.
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 дав би окремий рядок у звіті.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.Навіщо окреме. На спільному стейджингу в той самий час хтось ганяє 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.
параметр прод 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)
Що це. Термін Гіла Тене: генератор навантаження «домовляється» з системою про те, щоб не бачити її найгірших моментів. Коли сервер зависає, закритий генератор чекає відповіді і не відправляє запити, які мав би відправити за розкладом. Ці пропущені запити в реальності отримали б величезну латентність, але вони взагалі не потрапляють у вибірку.
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.
розклад: 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
Що це. 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 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)
Що це. 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.
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, не зупиняючи тест.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.Чотири етапи. 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.
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.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.Навіщо теги. Кожна метрика 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.
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 вічно.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.Структура. 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 -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)}
<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 робить збій кореляції помітним у наступних запитах.-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.Що це. 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}.
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 запитів/с.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.Що це. 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.
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))
name групує запити з різними id в один рядок статистики. catch_response=True дозволяє самому вирішити, чи вважати відповідь помилкою: тут усе, крім 201, позначається через resp.failure.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.Суть. Усі чотири здатні дати коректний результат; вибір визначають команда, протоколи, 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.
критерій 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.
Навіщо розподіляти. Одна машина має межу за 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.
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
ознака генератор система
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 — чекліст: розбіжність клієнтської і серверної латентності при спокійній системі майже завжди вказує на генератор.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.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: частка запитів /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'],
Що це. 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.
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, а не підганяються під результат.
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.
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 перевіряє, що тест справді видав потрібну інтенсивність, а не «пройшов» на половинному навантаженні.'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.Рівні. 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.
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 впав через пороги.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.Антипатерн. Запускати навантаження на нестабільному спільному середовищі без 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".
// 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);
}
require і process. Метрика з фільтром тегу потрапляє в summary, бо для неї заданий threshold. Медіана п'яти прогонів стійка до одного аномального значення, а 15% — допуск, більший за виміряний розкид.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.Проблема. Класичний підхід — один великий 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.
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
Для кого. Звіт читають різні люди: менеджер хоче вердикт і ризики, розробник — де вузьке місце, 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.
ВЕРДИКТ: УМОВНО ГОТОВО (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)
Принцип. Тест навантаження лише створює умови; причину показують серверні дані. 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.
трейс 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
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.
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.-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.Що це. 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.
# 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. Профіль знімають саме під час плато навантаження, бо на холостому ходу гарячі місця інші.-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.Симптоми. Латентність росте разом із навантаженням, а 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.
-- що найбільше навантажує БД сумарно
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 будує індекс без блокування записів у таблицю, що важливо на живій системі.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.Що це. Код робить один запит, щоб отримати список з 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.
-- було: 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 до одного.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.Що це. Пул з'єднань тримає обмежену кількість відкритих з'єднань до БД (або до іншого сервісу через 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.
# 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 с, — так знаходять витоки і довгі транзакції.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.Як кеш допомагає. Кеш зберігає результат дорогої роботи (запит до БД, рендеринг, виклик стороннього 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.
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 у тесті з продакшн-значенням — якщо вони сильно різняться, модель даних неправильна.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.Що це. Сервер стискає текстові відповіді (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.
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, стиснення на сервері не увімкнене.%{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.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.
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,
});
}
Retry-After, жодних 5xx. __ENV.API_KEY читає ключ зі змінної оточення, переданої через -e API_KEY=....Retry-After, no 5xx. __ENV.API_KEY reads the key from an environment variable passed with -e API_KEY=....Проблема 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.
ретраї на кожному рівні (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
Що це. 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.
метрика що міряє добре потребує уваги погано
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.
Факт. 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.
клік «Додати в кошик» (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
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.
# лабораторія: 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
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.
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 с — грубий гейт на регресію, а не заміна польових метрик.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.Що це. 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.
{
"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 — свідомий вибір: загальна оцінка інформує, а валять збірку лише конкретні метрики.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.Ціль і модель (за 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.
тиждень тест профіль питання
-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
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.
симптом: 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
Вхідні дані. Прогноз бізнесу (ріст користувачів, нові ринки, кампанії), поточний піковий трафік і його сезонність, 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.
ємність 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
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.
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