Производительность систем Нагрузочное тестирование: профили нагрузки, k6, чтение результатов
0%

Нагрузочное тестирование: профили нагрузки, k6, чтение результатов

Нагрузочное тестирование: профили нагрузки, k6, чтение результатов

Всё, чем мы занимались в предыдущих статьях трека, — от профилирования CPU до сетевых задержек — было исследованием системы изнутри. Нагрузочное тестирование смотрит снаружи и задаёт единственный вопрос, который в итоге волнует бизнес: что произойдёт, когда придут все сразу?

Проблема в том, что вопрос сформулирован неправильно. «Выдержим ли мы Чёрную пятницу» — это не инженерный вопрос, на него нельзя ответить числом. Инженерные вопросы звучат иначе: при какой интенсивности прибытия запросов p99 перестаёт укладываться в 300 мс; что именно ломается первым — пул соединений к базе, CPU приложения или файловые дескрипторы балансировщика; деградирует система плавно или сваливается в коллапс, из которого сама не выходит; сколько времени занимает восстановление после того, как пик прошёл.

Главный принцип трека здесь применяется к самому инструменту измерения. Нагрузочный тест — это эксперимент, а у эксперимента есть валидность. Огромная доля отчётов, которые показывают в переговорках, недействительна не потому, что цифры посчитаны неправильно, а потому что измеряли не то: генератор упёрся в свой потолок, задержка считалась не от того момента, кэш был прогрет одним ключом, а среднее взято по прогону, в который вошёл разогрев. Эта статья примерно наполовину про то, как проводить тест, и наполовину про то, как понять, можно ли верить его результату.

Все числа в статье — иллюстрация метода, а не справочные значения. Ваши 100 мс — это чужие 4 мс и чужие 3 с. Даже классическая «таблица задержек, которую должен знать каждый программист» Джеффа Дина датируется серединой 2000-х: с тех пор NVMe вытеснил вращающиеся диски, а межрегиональные RTT почти не изменились, поэтому пропорции внутри таблицы поехали неравномерно. Пользуйтесь ей как способом прикинуть порядок величины перед измерением, а не как источником истины после него.

Тест начинается с вопроса, а не с инструмента

«Провести нагрузочное тестирование» — задача без критерия завершения. Прежде чем открывать редактор, сформулируйте вопрос и выберите под него профиль — форму, по которой интенсивность нагрузки меняется во времени.

Шесть профилей нагрузки: smoke, ступени, breakpoint, spike, soak, реплей суточной кривой

Вопрос Профиль Длительность Что смотрим в первую очередь
Скрипт и стенд вообще живы? smoke, 1–5 VU 1–2 мин ошибки, а не задержки
Где колено и какова ёмкость? ступени до колена 1–2 ч точка, где RPS перестал расти
Как именно ломается? breakpoint, рампа до срыва 30–90 мин характер отказа, время восстановления
Переживём ли резкий наплыв? spike 15–30 мин реакция автоскейлинга, лавина ретраев
Что накапливается со временем? soak, ровное плато 4–24 ч тренд RSS, p99, размера пулов
Выдержим ли реальный день? реплей суточной кривой 24 ч всё сразу, включая фоновые задачи

Ступенчатый профиль — рабочая лошадка, и его ценность в плато: система должна успеть прийти в стационарное состояние, иначе вы измеряете переходный процесс. Плато короче 3–5 минут почти всегда врёт (автоскейлинг не успел, JIT не прогрелся, кэши не наполнились, пул соединений не раскрылся), а плавная рампа без плато даёт красивый график и почти бесполезные числа: на каждой точке кривой система находится в разном переходном состоянии.

Главное решение: открытая модель против закрытой

Это самая важная развилка нагрузочного тестирования, и её проскакивают почти все.

Закрытая и открытая модель нагрузки: кто задаёт темп запросов

Закрытая модель (в k6 — исполнители constant-vus, ramping-vus; в терминах инструментов «N виртуальных пользователей») устроена так: каждый VU в цикле шлёт запрос, ждёт ответа, думает think time, шлёт следующий. Темп запросов определяется скоростью ответа сервиса. Замедлился сервис — упала нагрузка.

Открытая модель (constant-arrival-rate, ramping-arrival-rate) задаёт темп прибытия извне: 500 итераций в секунду начинаются каждую секунду независимо от того, ответил сервер на предыдущие или нет. VU здесь — просто пул исполнителей, из которого планировщик берёт свободного.

Разница не академическая. В закрытой модели, когда сервис тормозит, генератор автоматически снижает нагрузку — и вы не измеряете самое интересное: время, которое живой пользователь провёл бы в очереди. Это coordinated omission, подробно разобранный в статьях «Измерение» и «Бенчмаркинг честно». Реальные пользователи не координируются с вашим сервером: они кликают по своему расписанию, и во время зависания очередь растёт, а не рассасывается. Практическое правило: интенсивность прибытия — это внешний параметр вашей системы, значит, в тесте она должна быть внешним параметром генератора. Открытая модель по умолчанию. Закрытая модель уместна там, где число клиентов действительно ограничено и они действительно блокируются: пул из 40 воркеров, ходящих в вашу внутреннюю gRPC-ручку; 200 соединений с базой; батч-джоб с фиксированной конкурентностью. Тогда вы моделируете реальный замкнутый контур, и constant-vus — правильный выбор. Но пользовательский HTTP-трафик так не устроен почти никогда.

Закон Литтла как рабочий инструмент

Связь между двумя моделями даёт закон Литтла: для любой стационарной системы среднее число заявок внутри равно интенсивности прибытия, умноженной на среднее время пребывания.

$$L = \lambda \cdot W$$

Здесь $L$ — конкурентность (сколько запросов «в полёте»), $\lambda$ — интенсивность (запросов в секунду), $W$ — время ответа. Закон не требует никаких предположений о распределениях — он верен для любой стационарной системы, и именно поэтому им можно пользоваться как калькулятором.

Сколько VU выделить под открытую модель. Чтобы удержать $\lambda$ итераций в секунду при среднем времени итерации $\bar{W}$ (включая think time), нужно как минимум $\lambda \cdot \bar{W}$ одновременных VU. Для 100 итераций/с при итерации в 1.3 с — 130 VU. Но это среднее по здоровой системе; в момент деградации $\bar{W}$ вырастает в 20 раз, и требуется 2600. Отсюда правило: maxVUs берите с многократным запасом относительно расчёта, иначе тест развалится ровно тогда, когда станет интересно.

Сколько конкурентности выдержит сервис. Если один инстанс держит 200 RPS при 50 мс ответа, внутри него в среднем $200 \cdot 0.05 = 10$ запросов. Значит, пул из 8 воркеров — уже узкое место, а из 200 — бессмысленная трата памяти и источник контеншна (см. «Производительность конкурентного кода» и раздел про пул соединений в «Производительность БД»).

Сколько инстансов нужно на пик. При пике $\lambda_{peak}$ и целевой утилизации $\rho$ (обычно 0.6–0.7, потому что при $\rho \to 1$ время ожидания в очереди уходит в бесконечность) число инстансов $n \ge \lambda_{peak} / (\rho \cdot \lambda_{one})$, где $\lambda_{one}$ — измеренная ёмкость одного инстанса. Именно ради $\lambda_{one}$ и проводится ступенчатый тест.

Обратите внимание: закон Литтла — ещё и детектор вранья в отчёте. Если в сводке написано «200 VU, 1000 RPS, среднее время ответа 500 мс», то по закону Литтла нужно $1000 \cdot 0.5 = 500$ VU. Отчёт внутренне противоречив, где-то ошибка — скорее всего, в отчёт попал разогрев или часть запросов не дошла.

k6: модель исполнения

k6 — генератор нагрузки на Go со скриптами на JavaScript. Скрипт исполняется не в Node.js, а во встроенном рантайме; сетевая работа целиком на стороне Go, поэтому один процесс тянет тысячи VU. Оценка потребления — единицы мегабайт RAM на VU, так что 10 000 VU это уже десятки гигабайт: планируйте генератор как отдельную мощность.

Жизненный цикл скрипта: init-контекст выполняется в каждом VU при старте — здесь нельзя слать запросы, здесь грузят данные и объявляют метрики; setup() отрабатывает один раз на прогон (прогрев, фикстуры, логин), и возвращённое им значение приходит аргументом в остальные функции; default или именованная exec-функция — это одна итерация; teardown() убирает за собой. Данные грузите только через SharedArray: обычный JSON.parse(open(...)) в init отработает в каждом VU, и 500 VU по 40 МБ дадут гарантированный OOM генератора.

Исполнители (executors) — то, чем задаётся профиль:

Исполнитель Модель Когда применять
constant-vus закрытая замкнутый контур с известной конкурентностью
ramping-vus закрытая плавный рост конкурентности, не интенсивности
constant-arrival-rate открытая плато на заданном RPS — основной рабочий режим
ramping-arrival-rate открытая ступени и рампы по интенсивности
shared-iterations ограничение по объёму «прогнать 10 000 операций и посчитать время»
per-vu-iterations ограничение по объёму воспроизводимые сценарии с фиксированным числом проходов

Полный список параметров — в документации по исполнителям.

Первый честный скрипт

import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';

const users = new SharedArray('users', () => JSON.parse(open('./users.json')));

export const options = {
  scenarios: {
    steps: {
      executor: 'ramping-arrival-rate',   // ОТКРЫТАЯ модель
      startRate: 20, timeUnit: '1s',
      preAllocatedVUs: 200,               // выделяются заранее, до старта
      maxVUs: 3000,                       // потолок; см. расчёт по закону Литтла
      stages: [
        { target: 50,  duration: '2m' },  // рампа
        { target: 50,  duration: '5m' },  // ПЛАТО — только эти данные пойдут в отчёт
        { target: 100, duration: '2m' }, { target: 100, duration: '5m' },
        { target: 150, duration: '2m' }, { target: 150, duration: '5m' },
        { target: 0,   duration: '1m' },  // спад: как быстро рассасывается очередь
      ],
    },
  },
  thresholds: {
    // Пороги — единственное, что делает тест ПРОВАЛЬНЫМ. check() прогон не валит.
    'http_req_failed': ['rate<0.01'],
    'http_req_duration{group:::checkout}': ['p(95)<800', 'p(99)<2000'],
    // Генератор не удержал темп => результат недействителен, рвём прогон сразу.
    'dropped_iterations': [{ threshold: 'count<1', abortOnFail: true }],
  },
  summaryTrendStats: ['min', 'med', 'avg', 'p(90)', 'p(95)', 'p(99)', 'max', 'count'],
};

export default function (data) {
  const user = users[Math.floor(Math.random() * users.length)];
  // ВАЖНО: тег name склеивает параметризованные URL в одну метрику. Без него
  // /catalog/item/1, /catalog/item/2 ... породят миллион уникальных метрик.
  const params = { headers: { Authorization: `Bearer ${data.token}` } };

  group('catalog', () => {
    const res = http.get(`${__ENV.BASE_URL}/catalog/item/${user.favoriteItem}`,
      { ...params, tags: { name: 'GET /catalog/item' } });
    check(res, { 'каталог 200': (r) => r.status === 200 });
  });

  sleep(Math.random() * 2 + 1); // think time 1–3 с: живой человек читает страницу

  group('checkout', () => {
    const res = http.post(`${__ENV.BASE_URL}/checkout`,
      JSON.stringify({ userId: user.id, itemId: user.favoriteItem }),
      { ...params, tags: { name: 'POST /checkout' } });
    check(res, {
      'checkout 201': (r) => r.status === 201,
      'есть order_id': (r) => r.status === 201 && r.json('order_id') !== undefined,
    });
  });
}

Три детали, на которых спотыкаются.

check() не валит тест. Он только считает статистику. Провалить прогон (код выхода 99) может только thresholds. Если в CI у вас стоит check() и нет порогов, ваш «нагрузочный тест в пайплайне» зелёный при любом результате.

Теги — это ось разрезов. group и tags: { name } дают возможность писать пороги вида http_req_duration{group:::checkout} и смотреть перцентили по эндпоинтам отдельно. Без них у вас один общий котёл, в котором быстрый /health разбавляет медленный /checkout и p95 выглядит прилично.

sleep() — часть модели, а не «пауза». В открытой модели think time не снижает интенсивность прибытия, но удлиняет итерацию, а значит, по закону Литтла увеличивает требуемое число VU. Забыли think time — получили нереалистично агрессивный профиль и завышенную нагрузку на кэши.

Смешанный профиль: несколько сценариев в одном прогоне

Реальный трафик — не один эндпоинт. Доли снимаются с прод-логов и воспроизводятся параллельными сценариями:

const base = { executor: 'constant-arrival-rate', timeUnit: '1s', duration: '20m' };

export const options = {
  scenarios: {                                          // доли сняты с прод-логов
    browse:   { ...base, rate: 400, preAllocatedVUs: 300, maxVUs: 2000,   // 80%
                exec: 'browse',   tags: { flow: 'browse' } },
    checkout: { ...base, rate: 75,  preAllocatedVUs: 150, maxVUs: 1500,   // 15%
                exec: 'checkout', tags: { flow: 'checkout' } },
    reports:  { ...base, rate: 25,  preAllocatedVUs: 100, maxVUs: 800,    // 5%
                duration: '10m', startTime: '5m',   // включается посреди плато
                exec: 'report',   tags: { flow: 'report' } },
  },
  thresholds: {                     // у каждого потока свой бюджет задержки
    'http_req_duration{flow:browse}':   ['p(99)<300'],
    'http_req_duration{flow:checkout}': ['p(99)<1500'],
    'http_req_duration{flow:report}':   ['p(99)<10000'],
  },
};

export function browse() { /* ... */ }   // и так далее для checkout и report

startTime у третьего сценария — не украшение. Так проверяют интерференцию: включение тяжёлых отчётов посреди ровного плато сразу показывает, вытесняют ли они горячие данные из буферного пула и как это бьёт по p99 лёгких запросов. Это буквально эксперимент про кэши и локальность, только на уровне системы.

Анатомия одного запроса: из чего складывается http_req_duration

Разложение времени по фазам — самый информативный инструмент диагностики: он говорит, где искать причину, ещё до того, как вы полезли в серверные метрики.

Метрика Что означает Аномалия говорит о
http_req_blocked ожидание свободного соединения и DNS исчерпан пул сокетов генератора, эфемерные порты, медленный резолвер
http_req_connecting TCP handshake keep-alive не работает, каждый запрос — новое соединение
http_req_tls_handshaking TLS handshake нет переиспользования сессий, дорогой ECDSA/RSA на каждый запрос
http_req_waiting TTFB — сервер думает собственно ваша система: CPU, база, внешние вызовы
http_req_receiving чтение тела ответа тяжёлые ответы, узкая полоса, TCP-окно, отсутствие сжатия
iteration_duration вся итерация, включая sleep() по нему считают требуемые VU через закон Литтла
dropped_iterations итерации, которые не стартовали генератор не удержал заданный темп — прогон недействителен

Диагностическая ценность в пропорциях. waiting = 280 мс при connecting = 0 — проблема внутри сервиса, идите профилировать. waiting = 20 мс, а blocked = 300 мс — проблема у генератора или в сети до сервиса, сервис ни при чём. Механику установки соединений и переиспользования разбирали в «Сетевой производительности» и в статье про TCP.

Чтение отчёта: сводка k6 построчно

Вот сводка десятиминутного прогона по открытой модели с целевым темпом 100 итераций/с (форматирование в разных версиях слегка отличается, набор метрик — нет; в k6 1.x сводку перекомпоновали по секциям и добавили --summary-mode):

     ✓ каталог 200
     ✗ есть order_id
      ↳  98% — ✓ 54004 / ✗ 1789

     checks.........................: 98.40% ✓ 109797     ✗ 1789
     data_received..................: 2.1 GB  3.5 MB/s
     dropped_iterations.............: 4207    7.011667/s
     http_req_blocked...............: avg=112.3µs med=2.1µs   p(95)=4.9µs   max=1.02s
     http_req_connecting............: avg=41.2µs  med=0s      p(95)=0s      max=612ms
     http_req_duration..............: avg=284.1ms med=131.2ms p(95)=1.42s   p(99)=6.81s  max=29.99s
       { expected_response:true }...: avg=241.6ms med=128.7ms p(95)=1.11s   p(99)=4.02s  max=9.98s
     http_req_failed................: 1.60%  ✓ 1789       ✗ 109797
     http_req_receiving.............: avg=1.9ms   med=112µs   p(95)=4.8ms   max=1.31s
     http_req_waiting...............: avg=282.2ms med=129.9ms p(95)=1.41s   p(99)=6.80s  max=29.98s
     http_reqs......................: 111586  185.98/s
     iteration_duration.............: avg=1.29s   med=1.11s   p(95)=2.71s   max=31.2s
     iterations.....................: 55793   92.99/s
     vus............................: 500     min=0        max=500
     vus_max........................: 500     min=500      max=500

Читать это надо не сверху вниз, а по убыванию влияния на валидность.

Теперь — что видно в конкретной сводке выше.

dropped_iterations = 4207 — прогон недействителен как измерение ёмкости. Планировалось 60 000 итераций, стартовало 55 793. Планировщик не смог удержать 100 итераций/с, потому что упёрся в maxVUs = 500. Проверим законом Литтла: при iteration_duration до 31 с для удержания темпа нужно было бы под 3000 VU. Всё, что ниже в отчёте, описывает нагрузку меньше заявленной. Именно поэтому в предыдущем скрипте стоит порог dropped_iterations: count<1 с abortOnFail.

http_req_failed: 1.60% ✓ 1789 ✗ 109797 — читайте наоборот. Это метрика типа Rate: «галочка» означает, что условие «запрос провалился» выполнилось. 1789 — число упавших запросов. Каждый раз, когда кто-то радостно показывает «✓ 1789», стоит уточнить, что именно он прочитал.

Строка { expected_response:true } — детектор ошибки выжившего. p99 по всем ответам 6.81 с, по успешным — 4.02 с. Разница в два с лишним раза означает, что медленные запросы систематически заканчивались ошибкой: таймауты обрубались раньше, чем сервис отвечал. Если бы вы смотрели только на подмножество успешных, картина была бы куда оптимистичнее реальности. Обратный случай ещё коварнее: сервис отдаёт мгновенные 503 из circuit breaker, они попадают в общую выборку и улучшают перцентили. Быстрые ошибки — самый эффективный способ «оптимизировать» p99.

avg=284 мс при med=131 мс и p99=6.8 с. Классическая тяжелохвостая картина, для которой среднее бессмысленно (подробно — в «Измерении»). Отдельно отметьте max=29.99s: круглое число у максимума почти всегда означает не задержку, а сработавший таймаут. И наконец, http_reqs = 185.98/s против плановых 200 (две итерации по два запроса при цели 100 итераций/с) — расхождение согласовано с дропами.

Перцентили по фазам, а не по прогону

Сводка k6 агрегирует весь прогон целиком: разогрев, рампы, плато и спад в одном котле. Перцентиль такой смеси не имеет физического смысла — это перцентиль по системе, которая за время измерения была четырьмя разными системами; плюс перцентили в принципе нельзя усреднять и складывать. Правильный путь — считать статистику по окнам. Сырой поток точек выгружается через k6 run --out json=raw.json (JSON Lines, по строке на точку; сотни мегабайт за прогон) или, для живых графиков, через --out experimental-prometheus-rw с K6_PROMETHEUS_RW_SERVER_URL.

"""Перцентили http_req_duration по окнам прогона k6 (вывод --out json).

Сложность: O(n log n) по времени (сортировка внутри окон), O(n) по памяти.
Для многочасовых soak-прогонов все точки в памяти держать нельзя — там нужен
потоковый HdrHistogram или t-digest с фиксированной памятью O(1).
"""
import json
from collections import defaultdict
from datetime import datetime
from statistics import quantiles

WINDOW_SEC = 30  # окно агрегации; должно быть много меньше длины плато
buckets: dict[int, list[float]] = defaultdict(list)

with open("raw.json", encoding="utf-8") as fh:
    for line in fh:                      # JSON Lines: файл целиком не читаем
        rec = json.loads(line)
        if rec.get("type") != "Point" or rec.get("metric") != "http_req_duration":
            continue
        d = rec["data"]
        if d["tags"].get("expected_response") != "true":
            continue                     # неуспешные считаем отдельным потоком
        ts = datetime.fromisoformat(d["time"].replace("Z", "+00:00")).timestamp()
        buckets[int(ts) // WINDOW_SEC * WINDOW_SEC].append(d["value"])

print(f"{'окно':>10} {'N':>7} {'p50, мс':>9} {'p95, мс':>9} {'p99, мс':>9}")
for bucket in sorted(buckets):
    vals = buckets[bucket]
    if len(vals) < 100:                  # мало данных — перцентиль шумит
        continue
    # quantiles(n=100) даёт 99 разрезов: индекс 49 -> p50, 94 -> p95, 98 -> p99
    q = quantiles(vals, n=100, method="inclusive")
    stamp = datetime.fromtimestamp(bucket).strftime("%H:%M:%S")
    print(f"{stamp:>10} {len(vals):>7} {q[49]:>9.1f} {q[94]:>9.1f} {q[98]:>9.1f}")

Получившаяся таблица — и есть настоящий результат ступенчатого теста. По ней видно, на какой ступени p99 сорвался, и это конкретное число становится вашей измеренной ёмкостью. Одна усреднённая цифра «выдерживаем 5000 RPS» такой информации не несёт и почти всегда завышена.

Режимы системы под нагрузкой

Ступенчатый и breakpoint-тесты нужны, чтобы увидеть переходы между режимами и понять, обратимы ли они.

Переход S4 → S5 — не про мощность, а про обратные связи. Клиент таймаутится и повторяет запрос, сервер продолжает считать уже никому не нужный ответ, ретраи умножают нагрузку, очереди наполняются устаревшими заданиями. Система остаётся в отказе даже после того, как исходная нагрузка вернулась к норме, — это и есть метастабильный отказ, описанный в HotOS 2021. Лечится не железом, а сбросом нагрузки и ограничением конкурентности: очереди с дедлайнами и отбрасыванием протухших заданий, адаптивные лимиты конкурентности (разбор Netflix), бюджет ретраев вместо безусловных повторов, circuit breaker. Архитектурная сторона — в статье про паттерны устойчивости, операционная — в «Handling Overload» из SRE-книги Google. И главное: тест, который останавливается на пороге отказа «чтобы ничего не сломать», не отвечает на самый дорогой вопрос — сломается ли оно необратимо. Ломайте на стенде осознанно.

Генератор — тоже система, и он упирается первым

Самая частая причина недействительных результатов: кривая пропускной способности заворачивается вниз, все радостно фиксируют «нашли предел сервиса», а на самом деле задохнулся k6.

# 1. Потолок генератора: тот же профиль в заглушку, отдающую 200 мгновенно.
docker run -d -p 8080:80 nginx:alpine
k6 run -e BASE_URL=http://localhost:8080 tests/load/steps.js
# Заглушка держит 12k RPS => тест сервиса на 10k RPS недостоверен.
# Рабочая зона — примерно до трети измеренного потолка генератора.

# 2. Лимиты, в которые упирается генератор
ulimit -n 262144                     # по умолчанию часто 1024: хватит на пару сотен сокетов
sysctl -w net.ipv4.ip_local_port_range="10000 65535"   # эфемерные порты
sysctl -w net.ipv4.tcp_tw_reuse=1    # переиспользование сокетов в TIME_WAIT

# 3. Во время прогона: генератор не должен быть в потолке по CPU
top -H -p "$(pgrep -x k6)"
ss -s                                # сколько сокетов, сколько в TIME_WAIT

# 4. Не хватает одной машины — раскладываем нагрузку по сегментам
k6 run --execution-segment "0:1/4" --execution-segment-sequence "0,1/4,2/4,3/4,1" s.js
# либо k6-operator в Kubernetes: https://github.com/grafana/k6-operator

Чеклист «генератор не врёт»: он не в потолке по CPU (запас минимум 2×); хватает дескрипторов и эфемерных портов; он не сидит за тем же прокси или NAT, что и продовый трафик; DNS резолвится один раз, а не на каждый запрос; сеть между генератором и сервисом заведомо шире полезной нагрузки; сам генератор не соревнуется с сервисом за ядра одной машины. Последнее особенно обидно: docker compose up на ноутбуке, где k6 и приложение делят четыре ядра, измеряет исключительно ваш ноутбук.

Данные и реализм: где тест расходится с продом

Технически безупречный прогон может измерять фантазию. Типичные расхождения:

  • Один пользователь на всех. 500 VU ходят с одним user_id — все данные в буферном пуле и во всех кэшах, база отвечает за 0.2 мс. В проде так не будет никогда. Нужен реалистичный по перекосу набор ключей: не равномерный (он убивает кэш) и не одноточечный (он его обожествляет), а близкий к степенному распределению — см. разбор в «Кэшировании».
  • Пустая база. 10 000 строк против 300 миллионов в проде — это разные планы запросов и разная глубина B-дерева; наполняйте хотя бы до порядка, где планировщик выбирает те же планы («Производительность БД»).
  • Стерильный стенд и только счастливые пути. В проде параллельно идут бэкапы, репликация и аналитические джобы, а трафик содержит 404, ботов, ретраи, невалидные токены и обрывы соединений; обработка ошибок часто дороже успешного пути.
  • Уборка. Тест на 20 минут при 75 checkout/с создаёт 90 000 заказов. Если их не убирать, второй прогон идёт по базе другого размера и несравним с первым: нужен teardown, отдельная схема или восстановление снапшота между прогонами.
  • Аутентификация. Логин на каждой итерации нагружает bcrypt так, что остального профиля становится не видно. В проде токены живут часами — переиспользуйте их, но не сводите к одному на всех: это скроет проблемы с кэшем сессий.

Профиль лучше не выдумывать, а снимать. Доли эндпоинтов, форму суточной кривой и распределение параметров достают из access-логов или из уже имеющихся метрик RED:

# Доли эндпоинтов за сутки — основа для весов сценариев.
# sed схлопывает идентификаторы в /{id}, иначе каждый URL уникален.
awk '{print $7}' access.log | sed -E 's#/[0-9a-f-]{8,}#/{id}#g' \
  | sort | uniq -c | sort -rn | head -20

Более честный вариант — теневой трафик: копия реального потока направляется на кандидатскую версию, ответы отбрасываются. Он снимает вопрос реализма полностью, но требует аккуратности с побочными эффектами (записи, письма, платежи) и удваивает нагрузку на общие зависимости.

Наблюдаемость во время прогона: тест говорит «что», метрики — «почему»

Отчёт k6 — это взгляд клиента: он сообщит, что p99 вырос до 6 секунд, но никогда не скажет, почему. Прогон без снятия серверной телеметрии за то же временное окно — потраченное впустую время стенда. Синхронно с тестом собирают: USE по ресурсам каждого узла и RED по сервисам (расхождение серверных чисел с клиентскими числами k6 — это и есть время, проведённое в сети и очередях; методики — в «Измерении»); насыщение пулов — занятые соединения к БД, длину очереди воркеров, глубину accept-очереди сокета; pg_stat_statements со сбросом перед прогоном; на soak-прогонах — тренд RSS и пауз GC («Память»). Самое ценное — снять pprof или perf именно на плато, а не на холостом ходу: флеймграф под продовым профилем нагрузки показывает совсем другие горячие пути, чем микробенчмарк («Профилирование CPU»).

Помечайте прогоны: пишите test_id в тег каждого запроса и в аннотации Grafana, чтобы потом сопоставить окно теста с графиками. Про инфраструктуру телеметрии — «Наблюдаемость и дежурства» и «Наблюдаемость распределённых систем».

Каталог того, как нагрузочный тест врёт

Самообман Как выглядит Как ловить
Coordinated omission closed-модель, красивый p99 при явных зависаниях открытая модель, dropped_iterations, wrk2, HdrHistogram
Генератор в потолке кривая заворачивается ровно там же на разных сервисах прогон против заглушки, мониторинг CPU генератора
Одна цифра на весь прогон «выдерживаем 5000 RPS» перцентили по окнам, отдельно по каждому плато
Ошибка выжившего перцентили только по 2xx сравнить общий срез с {expected_response:true}
Быстрые ошибки p99 «улучшился», доля 5xx выросла смотреть latency и error rate только вместе
Кэш прогрет одним ключом hit rate 99.9%, база спит реалистичное распределение ключей, отчёт по hit rate
Ретраи внутри скрипта ошибок «нет», задержка занижена не ретраить в тесте; ретраи — часть измеряемого поведения
Стенд в 10 раз меньше прода линейная экстраполяция ёмкости закон Амдала и USL: масштабирование сублинейно
Разные условия у прогонов «стало быстрее» после переезда стенда базовая линия в тех же условиях, повтор прогонов
Тестируется не то 80% продового трафика — статика, а тест бьёт в API веса сценариев из логов

Отдельно про экстраполяцию. Соблазн «на стенде из 2 узлов получили 1000 RPS, значит на 20 узлах будет 10 000» разбивается о закон Амдала и универсальный закон масштабируемости Ганта: с ростом параллелизма растут и доля сериализации, и стоимость когерентности, поэтому кривая сначала отклоняется от прямой, а затем разворачивается вниз (материалы Нила Ганта, разбор — в «Производительности конкурентного кода»). Экстраполировать можно только после того, как вы измерили хотя бы три точки и увидели форму кривой.

Пороги, CI и базовая линия

Нагрузочное тестирование редко бывает одним прогоном. Типичная кампания перед сезонным пиком идёт в фиксированном порядке: ступени дают базовую линию и ёмкость, breakpoint — характер отказа, spike — реакцию автоскейлинга, soak — накопительные эффекты, невидимые за 20 минут. Правки делаются после всего блока измерений, а не параллельно с ним: иначе непонятно, какая из пяти правок что дала. Заканчивается кампания закреплением — порогами и smoke в CI, иначе через два месяца всё вернётся.

Прогон, результат которого никто не сравнивает с прошлым, — разовая эмоция; автоматизация начинается с порогов, дающих ненулевой код возврата, и хранимой базовой линии.

# .github/workflows/perf.yml
name: perf
on:
  pull_request:            # быстрый дым на каждый PR
  schedule: [{ cron: '0 2 * * *' }]   # полный ступенчатый прогон ночью

jobs:
  smoke:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker compose up -d --wait
      - uses: grafana/setup-k6-action@v1
      # Пороги внутри скрипта. Провал порога => код возврата 99 => красный PR.
      - run: k6 run --quiet tests/load/smoke.js
        env: { BASE_URL: 'http://localhost:8080' }

  nightly:
    if: github.event_name == 'schedule'
    runs-on: perf-runner    # выделенный узел: shared-раннеры слишком шумные
    steps:
      - uses: actions/checkout@v4
      - uses: grafana/setup-k6-action@v1
      - run: k6 run --out json=raw.json tests/load/steps.js
        env: { BASE_URL: '${{ secrets.STAGING_URL }}' }
      - run: python3 tools/compare_baseline.py raw.json baselines/steps.json
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: k6-raw, path: raw.json }

Два важных ограничения. Первое: на PR гоняется только smoke. Полноценный нагрузочный тест на общем CI-раннере даёт разброс в разы и будет флапать — про природу этого шума подробно в «Бенчмаркинге честно» и в статье про тесты в CI. Второе: сравнение с базовой линией должно учитывать разброс. Порог «p95 не хуже базовой линии» будет краснеть через раз; работающий вариант — «p95 не хуже базовой линии более чем на 10% по медиане трёх прогонов». Как строить такие ворота и как не превратить их в источник постоянного шума — тема следующей статьи.

Чеклист перед тем, как поверить отчёту

  1. Сформулирован вопрос и выбран профиль под него; в отчёте написано, какой именно профиль гонялся.
  2. Модель открытая, если только контур в реальности не замкнут.
  3. dropped_iterations = 0; фактический RPS совпал с плановым.
  4. Измерен потолок генератора, рабочая точка не выше трети от него; генератор не делит ресурсы с тестируемой системой.
  5. Плато достаточно длинное; разогрев отброшен, статистика считается по окнам.
  6. Приведены p50, p95, p99 и максимум по каждой ступени, а не одним числом.
  7. Доля и природа ошибок приведены рядом с задержками; сравнены общий срез и срез по успешным ответам.
  8. Данные и распределение ключей реалистичны; база сопоставима с продовой по объёму.
  9. Синхронно сняты серверные метрики и хотя бы один профиль на плато.
  10. Условия прогона зафиксированы: версия, конфиг, размер стенда, время суток, дата.
  11. Известно, что произошло на спаде: система вернулась в норму сама или потребовалось вмешательство.

Если пункты 2–4 не выполнены, вы измерили генератор. Если 5–7 — вы получили число, но не знаете, что оно значит.

Мини-итог

  • Нагрузочный тест начинается с вопроса и профиля, а не с запуска инструмента. «Провести нагрузочное» — не задача.
  • Открытая модель по умолчанию: интенсивность прибытия задаёт мир, а не ваш сервис. Закрытая — только для действительно замкнутых контуров.
  • Закон Литтла $L = \lambda W$ работает как калькулятор VU, ёмкости и числа инстансов, а заодно как детектор внутренне противоречивых отчётов.
  • В k6 тест валит только thresholds, а не check(); теги и группы — единственный способ получить осмысленные разрезы. Разложение http_req_duration по фазам показывает, где искать причину, до похода в серверные метрики; waiting — это ваш сервис, blocked и connecting — чаще всего нет.
  • Отчёт читается по убыванию влияния на валидность: сначала дропы и фактический RPS, потом ошибки, только потом перцентили — и всегда по окнам, а не агрегатом за прогон.
  • Генератор упирается первым чаще, чем сервис. Пока вы не измерили его потолок, вы не знаете, что измеряете.
  • Самое ценное в breakpoint-тесте — не число, а ответ на вопрос, возвращается ли система из отказа сама; а прогон без серверной телеметрии за то же окно говорит «медленно», но никогда — «почему».

Источники

Что дальше

Мы научились получать честные числа под нагрузкой. Осталось встроить их в работу так, чтобы производительность не деградировала между релизами и чтобы оптимизация когда-нибудь заканчивалась. Следующая статья — про бюджеты производительности, ловлю регрессий в CI без бесконечных флапов, приоритизацию работ по измеренной выгоде и главный вопрос инженерной зрелости: как понять, что пора остановиться.

Рабочий процесс оптимизации: бюджеты, регрессии в CI, когда остановиться

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов