Производительность: карта трека и методика «измерь, потом чини»
Есть два способа сделать систему быстрее. Первый: прочитать статью «10 приёмов ускорения», применить их все и надеяться. Второй: измерить, где уходит время, починить самое дорогое, измерить снова и убедиться. Первый способ выглядит продуктивнее — коммитов много, и код везде «оптимизирован». Второй способ работает.
Разница между ними не в усердии, а в источнике решения. В первом случае решение приходит из памяти инженера: «джойны медленные», «регулярки дорогие», «надо переписать на Rust». Во втором — из данных о конкретной системе на конкретном железе под конкретной нагрузкой. Память инженера хранит средние по индустрии за прошлое десятилетие; данные описывают вашу систему сегодня. Совпадают они реже, чем хочется.
Этот трек — про вторую дисциплину. Он устроен как путешествие по стеку: от того, как считать метрики, вниз до промахов кэша и обратно вверх до нагрузочного теста и бюджетов в CI. Каждая статья учит измерять свой слой, и только потом — чинить. Если из всего трека вы унесёте одну привычку, пусть это будет вопрос «а какое число до и после?». Обзорная статья даёт словарь, метод, критический взгляд на инструменты и бенчмарки и карту остальных двенадцати материалов.
1. «Быстро» — это три разные величины
Слово «производительность» склеивает как минимум три несовместимые характеристики. Пока их не разделить, разговор невозможен: один говорит про отклик, другой про пропускную способность, третий про счёт за облако.
Задержка (latency) — сколько времени занимает одна операция. Единица измерения — время. Это то, что чувствует человек.
Пропускная способность (throughput) — сколько операций система выполняет за единицу времени. Единица — «штук в секунду». Это то, за что платит бизнес.
Утилизация и эффективность — какая доля ресурса занята и во что обходится одна операция. Это то, что видно в счёте от провайдера: CPU-секунды на запрос, байты трафика, IOPS.
Эти величины не следуют друг из друга. Батчинг увеличивает пропускную способность и ухудшает задержку: чтобы собрать пачку, надо подождать. Добавление потоков увеличивает пропускную способность до точки насыщения, после которой задержка растёт вертикально, а пропускная способность даже падает из-за контеншна. Кэш улучшает и то и другое, но увеличивает потребление памяти и добавляет класс отказов — устаревшие данные.
Связывает их закон Литтла — единственная формула, которую стоит помнить наизусть:
$$L = \lambda \cdot W$$
Здесь $L$ — среднее число запросов, одновременно находящихся в системе (конкурентность), $\lambda$ — темп поступления (пропускная способность в установившемся режиме), $W$ — среднее время пребывания запроса в системе (задержка). Закон верен для любой стабильной системы без предположений о распределениях — это утверждение о сохранении, а не о вероятности.
Практическая ценность огромна. Сервис держит 2000 запросов в секунду со средней задержкой 50 мс — значит, внутри него в среднем живут $2000 \cdot 0{,}05 = 100$ запросов одновременно. Если пул соединений к базе на 20, а каждый запрос держит соединение всё время обработки, то пул — узкое место, и вы это узнали арифметикой, а не экспериментом. Обратно: если хотите держать 5000 запросов в секунду при задержке 40 мс, нужна конкурентность 200 — и надо проверить, что её выдержат все пулы, лимиты файловых дескрипторов и число воркеров.
Среднее — самая бесполезная метрика
Две системы с одинаковой средней задержкой 100 мс:
| Система | p50 | p95 | p99 | p99.9 | max |
|---|---|---|---|---|---|
| A | 98 мс | 115 мс | 130 мс | 160 мс | 210 мс |
| B | 12 мс | 190 мс | 2100 мс | 9400 мс | 31 с |
Среднее одинаковое, опыт пользователя — принципиально разный. У B каждый сотый запрос ждёт две секунды, а страница, собирающая данные из десяти таких вызовов, почти наверняка поймает хотя бы один хвостовой: вероятность «ни одного медленного» равна $0{,}99^{10} \approx 0{,}90$, то есть каждая десятая страница отрисуется через две секунды. Это эффект хвостовой задержки (tail latency), и он усиливается с ветвлением вызовов.
Поэтому в этом треке метрика задержки — всегда распределение: перцентили, гистограмма, heatmap. Никогда — среднее. Подробный разбор того, как считать перцентили, почему их нельзя усреднять между инстансами и что такое USE и RED, — в статье «Измерение».
Простейший расчёт перцентилей из лога, чтобы видеть, о чём речь:
"""Разбор лога задержек: среднее врёт, перцентили — нет.
Сложность: O(n log n) по времени (сортировка), O(n) по памяти."""
import math
import statistics
from pathlib import Path
def percentile(sorted_values: list[float], q: float) -> float:
"""Метод ближайшего ранга — без интерполяции, зато без сюрпризов.
sorted_values уже отсортирован по возрастанию, q задаётся долей: 0.99."""
n = len(sorted_values)
if n == 0:
raise ValueError("пустая выборка")
rank = min(max(1, math.ceil(q * n)), n) # ранг от 1 до n
return sorted_values[rank - 1]
def report(path: Path) -> None:
# в файле по одному числу в строке — длительность запроса в миллисекундах
xs = sorted(float(s) for s in path.read_text().split() if s.strip())
print(f"запросов: {len(xs)}")
print(f"среднее: {statistics.fmean(xs):8.1f} мс <- почти всегда бесполезно")
for q in (0.50, 0.95, 0.99, 0.999):
print(f"p{q * 100:<5g} {percentile(xs, q):8.1f} мс")
print(f"максимум: {xs[-1]:8.1f} мс")
# доля запросов вне бюджета — единственная формулировка, понятная продукту
print(f"вне бюджета 300 мс: {sum(x > 300 for x in xs) / len(xs):.3%}")
На проде так, конечно, не считают: хранить все значения дорого. Используют гистограммы с фиксированными корзинами (Prometheus) или HDR-гистограммы с логарифмическими корзинами и заявленной точностью — HdrHistogram. Цена — приближённость, выигрыш — O(1) памяти на поток и возможность складывать гистограммы между инстансами (чего нельзя делать с готовыми перцентилями).
2. Метод: измерь, локализуй, почини, докажи
Оптимизация — это расследование, а не ремонт. У расследования есть протокол.
метрика + перцентиль + нагрузка"] --> B["Воспроизвести сценарий"] B --> C{"Воспроизводится?"} C -- "нет" --> B2["Чинить наблюдаемость,
а не код"] B2 --> B C -- "да" --> D["Измерить сверху вниз:
какой слой держит время"] D --> E["Локализовать: профиль,
трассировка, план запроса"] E --> F{"Причина, а не симптом?"} F -- "нет" --> D F -- "да" --> G{"Закон Амдала:
выигрыш стоит сложности?"} G -- "нет" --> I["В журнал и не трогать"] G -- "да" --> J["Внести ОДНО изменение"] J --> K["Перемерить тем же способом"] K --> L{"Улучшение значимо
статистически?"} L -- "нет" --> M["Откатить: гипотеза неверна"] M --> D L -- "да" --> N["Зафиксировать бюджет
и регрессионный тест в CI"] N --> O{"Цель достигнута?"} O -- "нет" --> D O -- "да" --> P["Остановиться"]
Разберём неочевидные узлы.
Цель — это число, а не прилагательное. «Сделать быстрее» — не цель. «p99 времени ответа POST /checkout меньше 300 мс при 800 запросах в секунду на текущем железе» — цель. В ней есть метрика, перцентиль, эндпоинт, нагрузка и окружение. Без любого из пяти элементов задача не имеет проверяемого решения.
Воспроизводимость важнее скорости. Если проблема ловится «иногда на проде», сначала стройте наблюдаемость: трассировку с сэмплированием хвостов, гистограммы по эндпоинтам, профили по требованию. Оптимизировать невоспроизводимое — гадание с лишними шагами.
Сверху вниз. Начинайте с того слоя, который ближе к пользователю, и спускайтесь только туда, куда указало измерение. Обратный порядок («давайте посмотрим промахи кэша») почти всегда заканчивается героической оптимизацией кода, который занимает 2% времени запроса.
Одно изменение за раз. Два изменения одновременно дают неинтерпретируемый результат: одно ускорило, другое замедлило, суммарно ничего. Хуже — оба замедлили, но вы оставили оба, потому что «в целом стало быстрее» на шумном прогоне.
Закон Амдала как арифметика приоритета
Прежде чем оптимизировать, посчитайте потолок. Если доля времени, которую занимает оптимизируемая часть, равна $p$, а ускорить её удалось в $k$ раз, общий выигрыш равен
$$S = \frac{1}{(1 - p) + \dfrac{p}{k}}$$
Ускорить вдвое ($k = 2$) фрагмент, занимающий 10% времени ($p = 0{,}1$), — это $S = 1/(0{,}9 + 0{,}05) \approx 1{,}05$, то есть 5%. Полностью убрать этот фрагмент ($k \to \infty$) — 11%. Ни одно из этих чисел не оправдывает неделю работы и усложнение кода.
И обратное: если что-то занимает 70% времени, даже скромное ускорение в полтора раза даёт $1/(0{,}3 + 0{,}467) \approx 1{,}30$ — тридцать процентов. Профиль нужен именно для того, чтобы узнать $p$ до начала работы, а не после.
Тот же закон в многопоточном виде — потолок распараллеливания — разбирается в статье «Производительность конкурентного кода».
Что на самом деле сказал Кнут
Фразу «преждевременная оптимизация — корень всех зол» цитируют как индульгенцию на безразличие к производительности. Оригинал (Donald Knuth, «Structured Programming with go to Statements», ACM Computing Surveys, 1974) говорит обратное:
Программисты тратят чудовищно много времени, размышляя о скорости некритических частей своих программ… Нам следует забыть о малых улучшениях, скажем, в 97% случаев: преждевременная оптимизация — корень всех зол. Но мы не должны упускать свои возможности в этих критических 3%.
Смысл абзаца: не угадывай, где критические 3% — измеряй. Кнут в той же работе настаивает на использовании профилировщиков. Цитата — призыв к измерению, а не к бездействию.
При этом есть класс решений, которые дешёвы заранее и почти неисправимы потом: формат хранения данных, схема БД, границы сервисов, синхронный или асинхронный протокол, размер единицы работы. Это не «преждевременная оптимизация», а проектирование под порядок величин. Разница простая: оптимизация — это переписывание работающего кода ради процентов; проектирование под нагрузку — выбор между вариантами, отличающимися на порядки, когда цена выбора ещё нулевая.
3. Куда уходит время: карта стека
Главный вывод из этой картинки — каждый инструмент слеп к соседним слоям. CPU-профилировщик показывает, где процессор что-то делал; он не покажет полсекунды ожидания ответа от базы, потому что в это время ваш поток не был на процессоре. Профиль будет выглядеть «чистым», а запрос — медленным.
Отсюда фундаментальное деление:
- ON-CPU время — код реально исполняется. Инструменты:
perf record,pprof, async-profiler, flame graphs. - OFF-CPU время — поток заблокирован: ждёт диск, сеть, мьютекс, планировщик, страницу памяти. Инструменты: трассировка, off-CPU-профилирование через eBPF, блокировочные профили (
block/mutexв Go, JFR в JVM).
Типичный веб-запрос проводит на процессоре 5–20% своей длительности. Значит, начинать с CPU-профиля — статистически неверный ход. Начинать надо с распределённой трассировки одного медленного запроса: она показывает, какой отрезок времени чей.
Классический сценарий, который ловится только так: сервис показывает 3% утилизации CPU и p99 в три секунды. CPU-профиль пуст. Трассировка показывает: 2,9 секунды запрос ждёт свободного соединения в пуле. Причина — пул на 10 соединений и один медленный запрос, который держит соединение 300 мс. По закону Литтла пул насыщается при 33 запросах в секунду; дальше растёт очередь. Чинится это не оптимизацией кода, а размером пула и убийством медленного запроса — и обе правки находятся арифметикой, а не профилировщиком.
4. Инструменты: что каждый из них показывает
| Инструмент | Слой | Отвечает на вопрос | Не видит |
|---|---|---|---|
perf / flame graph |
ядро + пользователь | какой код был на CPU | ожидание, блокировки |
pprof, async-profiler, JFR |
рантайм | CPU, аллокации, блокировки в терминах языка | ядро, соседей по машине |
| распределённая трассировка | приложение | какой участок запроса сколько занял | почему внутри участка медленно |
EXPLAIN (ANALYZE, BUFFERS) |
СУБД | как исполнялся план, где ошиблась оценка | нагрузку от других запросов |
strace / bpftrace |
ядро | какие syscalls, сколько раз, как долго | логику приложения |
iostat, vmstat, pidstat |
ОС и железо | насыщение и очереди устройств | конкретный запрос |
| k6, wrk2, Gatling | снаружи | как система ведёт себя под профилем нагрузки | внутренние причины |
| DevTools Performance, Lighthouse | браузер | рендер, JS, layout, сеть | сервер |
Дальше — как выглядит их вывод на практике. Читать вывод инструмента — отдельный навык, и он важнее умения инструмент запустить.
perf: кто был на процессоре
# Профиль работающего процесса: 99 Гц, со стеками, 30 секунд.
# Частота 99, а не 100, — чтобы не попадать в резонанс с таймерами ядра.
$ sudo perf record -F 99 -g --call-graph dwarf -p $(pgrep -f api-server) -- sleep 30
[ perf record: Woken up 43 times to write data ]
[ perf record: Captured and wrote 11.284 MB perf.data (~493k samples) ]
$ sudo perf report --stdio --sort=overhead,symbol | head -14
# Overhead Symbol
# ........ ..........................................
31.42% [.] encoding/json.(*decodeState).object
18.07% [.] runtime.mallocgc
9.88% [.] runtime.scanobject
6.15% [k] copy_user_enhanced_fast_string
4.02% [.] crypto/sha256.block
3.71% [k] __softirqentry_text_start
# Те же сэмплы в виде flame graph: ширина — доля времени, высота — глубина стека
$ sudo perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > cpu.svg
Как это читать. Половина времени на CPU — разбор JSON и последствия его аллокаций (mallocgc + scanobject — это уже работа сборщика мусора, вызванная тем же разбором). Гипотеза звучит как «слишком много промежуточных объектов», а не «JSON медленный», и проверяется потоковым разбором или переиспользованием буферов — с обязательным перемером. Символы [k] — код ядра, [.] — пользовательское пространство; если доминирует [k], идти надо в сторону syscalls и копирования данных («Ввод-вывод»), а не в прикладной код.
Инструмент и методика — FlameGraph Брендана Грегга; как их читать и где они врут (инлайнинг, обрезанные стеки, сэмплирование только выполняющихся потоков) — в статье «Профилирование CPU».
pprof: тот же принцип внутри рантайма
$ go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/profile?seconds=30"
(pprof) top10
Showing nodes accounting for 7.36s, 73.31% of 10.04s total
flat flat% sum% cum cum%
2.31s 23.01% 23.01% 3.12s 31.08% encoding/json.(*decodeState).object
1.44s 14.34% 37.35% 1.44s 14.34% runtime.memmove
1.02s 10.16% 47.51% 4.98s 49.60% api/handler.(*Orders).List
0.89s 8.86% 56.37% 0.89s 8.86% runtime.scanobject
Ключ к чтению — разница между flat и cum. flat — время в самой функции, cum — включая вызванные. Функция с огромным cum и нулевым flat — не виновник, а маршрут; спускаться надо ниже. Оптимизировать имеет смысл там, где велик flat, либо там, где велик cum и его можно не вызывать вовсе (кэш, батч, ленивое вычисление).
EXPLAIN ANALYZE: план вместо догадок
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid' AND o.created_at >= now() - interval '1 day';
Nested Loop (cost=0.43..8421.77 rows=1 width=64)
(actual time=0.061..842.113 rows=1204 loops=1)
Buffers: shared hit=4812 read=38104
-> Seq Scan on orders o (actual time=0.014..96.402 rows=1204 loops=1)
Filter: ((status = 'paid') AND (created_at >= (now() - '1 day'::interval)))
Rows Removed by Filter: 1998796
-> Index Scan using users_pkey on users u
(actual time=0.610..0.616 rows=1 loops=1204)
Planning Time: 0.284 ms
Execution Time: 843.907 ms
Три сигнала в одном выводе. Первый: rows=1 в оценке против rows=1204 фактических — планировщик ошибся на три порядка, и из-за этого выбрал Nested Loop вместо хеш-соединения; лечится статистикой, а не переписыванием запроса. Второй: Rows Removed by Filter: 1998796 — прочитали два миллиона строк, чтобы вернуть тысячу; просится составной индекс. Третий: loops=1204 у внутреннего узла — тысяча двести отдельных обращений, тот самый N+1, только внутри плана. И read=38104 против hit=4812 — данные не в кэше, значит, к времени CPU добавится время диска.
Официальная документация — PostgreSQL: Using EXPLAIN. Подробности — в «Производительности БД» и, со стороны СУБД, в треке «Базы данных»: «Индексы и планы запросов».
k6: нагрузка по открытой модели
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
// Открытая модель: генератор шлёт 500 запросов в секунду независимо от того,
// успевает ли сервис отвечать. Именно так ведут себя реальные пользователи.
steady: {
executor: 'constant-arrival-rate',
rate: 500, timeUnit: '1s', duration: '5m',
preAllocatedVUs: 200, maxVUs: 2000,
},
},
// Бюджет как код: тест падает, если нарушен перцентиль или доля ошибок
thresholds: {
'http_req_duration{expected_response:true}': ['p(99)<300'],
http_req_failed: ['rate<0.001'],
},
};
export default function () {
const res = http.get('https://api.example.internal/orders?limit=20');
check(res, { 'статус 200': (r) => r.status === 200 });
}
http_req_duration..............: avg=48.2ms med=31.7ms p(90)=92.4ms p(95)=141ms p(99)=1.21s
http_req_failed................: 0.04% ✓ 118 ✗ 149882
iterations.....................: 150000 499.6/s
dropped_iterations.............: 1843
Самая важная строка здесь — dropped_iterations. Она означает, что генератору не хватило виртуальных пользователей, чтобы удержать заданный темп: система уже не успевает. Отчёт, в котором эта строка ненулевая, а p99 «выглядит нормально», — отчёт о том, что вы измерили не то. Документация — Grafana k6. Разбор профилей нагрузки и чтения результатов — «Нагрузочное тестирование», а также «Тестирование производительности» в треке QA.
USE и RED: два чек-листа
Чтобы не смотреть на дашборд наугад, есть два взаимодополняющих метода.
USE (Брендан Грегг) — про ресурсы. Для каждого ресурса (CPU, память, диск, сеть, а также программные ресурсы вроде пулов и очередей) проверьте три вещи: Utilization (насколько занят), Saturation (какова очередь ожидающих), Errors (сколько отказов). Метод хорош тем, что находит узкое место за конечное число шагов. Описание — The USE Method.
RED (Том Уилки) — про сервисы. Для каждого эндпоинта: Rate (запросов в секунду), Errors (доля ошибок), Duration (распределение задержек). Описание — The RED Method.
Правило выбора простое: RED говорит, что плохо с точки зрения пользователя; USE говорит, из-за чего. Оба метода детально разобраны в «Измерении», а их место в эксплуатации — в «Наблюдаемости и дежурствах».
5. Как врут бенчмарки
Бенчмарк — это эксперимент, а эксперименты бывают неверно поставлены. Ниже — способы обмануть себя, отсортированные по частоте встречаемости в реальных обсуждениях.
5.1 Разогрев
Первые прогоны почти любой системы нерепрезентативны:
- JIT-компилятор (JVM, .NET, V8) сначала интерпретирует, потом компилирует, потом переоптимизирует по профилю; разница между первой и тысячной итерацией — часто десятки раз;
- кэши процессора и TLB холодные;
- page cache пуст: первое чтение файла идёт с диска, второе — из памяти;
- пулы соединений пусты, TLS-сессии не переиспользуются, DNS не закэширован;
- у СУБД холодный buffer pool и неразогретые планы;
- масштабирование частоты процессора: под нагрузкой процессор сначала уходит в turbo, потом упирается в тепловой пакет и сбрасывает частоту.
Из этого следует не только «нужен разогрев», но и вопрос «а что репрезентативно?». Если сервис перезапускается при каждом деплое и первые тридцать секунд обслуживает реальный трафик, то холодное состояние — тоже продакшн-режим, и мерить его надо отдельно, а не выкидывать.
5.2 Шум и статистика
Классическая работа Mytkowicz et al., «Producing Wrong Data Without Doing Anything Obviously Wrong!» (ASPLOS 2009) показала: размер переменных окружения и порядок линковки объектных файлов меняют время исполнения программы на единицы и даже десятки процентов — просто из-за выравнивания кода и данных относительно границ кэш-линий и страниц. Причём смещение систематическое, а не случайное: оно не усредняется повторами.
Отсюда практические правила:
- Не сравнивайте одиночные прогоны. Минимум 10 повторов каждой версии, желательно вперемежку (A, B, A, B, …), а не блоками — иначе дрейф температуры машины запишется в различие версий.
- Сравнивайте распределения, а не числа. Для Go есть benchstat, который считает доверительные интервалы и p-value; для JVM — режимы JMH со статистикой; в общем случае — тест Манна–Уитни.
- Фиксируйте окружение: отключите turbo и энергосбережение, закрепите потоки за ядрами (
taskset), отключите гипертрединг-соседей, выключите ASLR, если сравниваете микроуровень. - Не верьте CI-раннерам. Общие виртуалки в облаке дают разброс в 20–50%. Регрессионные тесты производительности либо запускаются на выделенном железе, либо измеряют не время, а детерминированные прокси: число аллокаций, число выполненных инструкций (
perf stat -e instructions), количество запросов к базе.
# Прогон бенчмарков десять раз и статистически честное сравнение
$ go test -run '^$' -bench 'BenchmarkDecode' -count 10 > old.txt
# ... вносим изменение ...
$ go test -run '^$' -bench 'BenchmarkDecode' -count 10 > new.txt
$ benchstat old.txt new.txt
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Decode-8 1.412µ ± 2% 1.021µ ± 1% -27.69% (p=0.000 n=10)
│ B/op │ B/op vs base │
Decode-8 1.203Ki ± 0% 0.297Ki ± 0% -75.31% (p=0.000 n=10)
Строка p=0.000 здесь важнее строки -27.69%. Улучшение на 3% с p=0.42 — это шум, о котором нельзя писать в changelog.
5.3 Микробенчмарки: код, которого нет
Микробенчмарк измеряет функцию в вакууме — и вакуум меняет функцию. Компилятор видит, что результат не используется, и удаляет вычисление (dead code elimination); видит константный вход и вычисляет всё на этапе компиляции (constant folding); JIT видит мономорфный вызов, которого в проде не будет, и инлайнит агрессивнее.
// ПЛОХО: результат никуда не идёт — компилятор вправе выбросить вычисление,
// а вход настолько мал, что всё живёт в L1 и не похоже на реальность.
func BenchmarkHashBad(b *testing.B) {
data := []byte("payload")
for i := 0; i < b.N; i++ {
sha256.Sum256(data)
}
}
// ЛУЧШЕ: результат утекает в переменную пакета, вход реалистичного размера,
// подготовка не входит в измерение, аллокации видны в отчёте.
var sink [32]byte
func BenchmarkHashGood(b *testing.B) {
data := bytes.Repeat([]byte("payload"), 512) // ~3,5 КБ — как реальное тело запроса
b.ReportAllocs()
b.ResetTimer() // всё, что выше, в измерение не попадает
for i := 0; i < b.N; i++ {
sink = sha256.Sum256(data)
}
}
Но даже «правильный» микробенчмарк отвечает на вопрос «сколько стоит эта функция, когда она — единственное, что делает машина». В проде она делит кэш с чужим кодом, вытесняется планировщиком и конкурирует за память. Микробенчмарк полезен как относительное сравнение двух реализаций одного и того же, и почти бесполезен как предсказание системного эффекта. Правило: микробенчмарк открывает гипотезу, системный тест её закрывает.
5.4 Coordinated omission — самая дорогая ошибка
Гил Тене назвал этот эффект в докладе «How NOT to Measure Latency». Суть: если генератор нагрузки отправляет следующий запрос только после ответа на предыдущий (закрытая модель), то во время затыка он перестаёт нагружать систему и не фиксирует запросы, которые в реальности пришли бы и встали в очередь.
сто запросов просто не отправлены G->>S: запрос C, время 1010 мс S-->>G: ответ через 10 мс Note over G,S: в отчёте три запроса, из них один медленный →
p99 выглядит прекрасно Note over G,S: реальность: сто пользователей ждали от 10 до 1000 мс
Правильно измеренный p99 в этом примере — сотни миллисекунд, измеренный наивно — десять. Ошибка в два порядка, и всегда в оптимистичную сторону.
Лечение: открытая модель нагрузки (constant-arrival-rate в k6, wrk2 вместо wrk, --rate в oha) и фиксация времени старта по расписанию, а не по факту отправки. Плюс поправка на стороне анализа: HdrHistogram умеет достраивать пропущенные значения при известном ожидаемом интервале.
5.5 Ошибка выжившего
Считаются только те запросы, которые дошли до конца. Отвалившиеся по таймауту, оборванные клиентом, отбитые rate limiter’ом или упавшие на 502 — исключаются из статистики задержек. И чем хуже система, тем красивее выглядят её перцентили: медленные запросы аккуратно превращаются в ошибки и покидают выборку.
Ту же природу имеют:
- выжившие пользователи: те, у кого приложение тормозило, ушли и больше не создают событий в RUM;
- выжившие сессии: анализ «активных пользователей» исключает тех, кто не дождался загрузки;
- выжившие серверы: агрегат по флоту скрывает один инстанс с деградировавшим диском, который и держит p99.9.
Правило: всегда смотрите задержку вместе с долей ошибок и таймаутов, и всегда — по инстансам, а не только в агрегате. Метрика задержки без метрики ошибок бессмысленна.
5.6 Приоритизация: что вообще чинить
Квадрант «делать сегодня» почти всегда состоит из одного и того же: убрать лишнюю работу. Не сделать работу быстрее, а не делать её вовсе — не запрашивать ненужные поля, не сериализовать то, что не отдаётся, не ходить в базу за тем, что уже в памяти, не открывать соединение заново. Это скучно, дёшево и даёт больше, чем любая героическая оптимизация.
6. Числа, которые полезно помнить (и почему они врут)
Знаменитый список «Latency numbers every programmer should know» приписывают Джеффу Дину; ходовая версия датируется 2012 годом и с тех пор широко копируется без обновления. Пользоваться им можно, но с тремя оговорками.
Первая: абсолютные числа устарели. С 2012 года случились NVMe (случайное чтение 4 КБ — уже не 150 мкс, а 20–80 мкс на потребительском SSD и единицы микросекунд на Optane-подобных), сети 25/100 Гбит/с внутри дата-центров, DDR5, кратно выросшие кэши и совсем другие CPU. Интерактивная версия с поправками по годам — колонка Колина Скотта; она же наглядно показывает, что именно устарело сильнее всего.
Вторая: то, что не устарело, — это порядки и соотношения. L1 быстрее DRAM примерно в 100 раз. DRAM быстрее NVMe примерно в 1000 раз. Локальная сеть медленнее памяти на три-четыре порядка. Межконтинентальный RTT упирается в скорость света в стекле — примерно 200 000 км/с — и не улучшится: 150 мс между Европой и США останутся, пока не изменится физика или география. Именно эти соотношения нужны для прикидок.
Третья: числа зависят от вашего окружения сильнее, чем от года. Виртуализация, соседи по хосту, cgroup-лимиты, throttling CPU, сетевой оверлей Kubernetes, шифрование дисков — каждый из этих факторов легко даёт разницу в разы. Единственный способ узнать свои числа — измерить свои числа.
Практическая ценность порядков — в прикидке до эксперимента. Пример: эндпоинт отдаёт 500 объектов, для каждого делается отдельный запрос в Redis, RTT до которого 0,3 мс. Значит, только на сетевые обходы уйдёт $500 \cdot 0{,}3 = 150$ мс — и это нижняя граница, даже если Redis мгновенный. Замена на один MGET убирает 150 мс до написания кода. Такие расчёты в этом треке будут постоянно: сначала арифметика на салфетке, потом эксперимент для проверки.
Похожая иерархия с точки зрения архитектуры машины разобрана в «Иерархии памяти», а её влияние на выбор структур данных — в обзоре трека «Структуры данных».
7. Бюджеты вместо «сделать быстрее»
Оптимизация без критерия остановки продолжается бесконечно и заканчивается выгоранием. Критерий даёт бюджет производительности — заранее согласованное число, нарушение которого считается дефектом.
Бюджет верхнего уровня приходит из продукта и обычно формулируется как цель уровня обслуживания (SLO): «99% запросов GET /catalog быстрее 400 мс за скользящие 30 дней». Дальше он разбивается по слоям — и это самый полезный документ, который может быть у команды:
| Участок | Бюджет | Как проверяется |
|---|---|---|
| DNS + TCP + TLS (при холодном соединении) | 60 мс | синтетический мониторинг из регионов |
| CDN и балансировщик | 15 мс | метрики ingress |
| Приложение: своя работа | 80 мс | трассировка, span handler |
| Запросы к БД (суммарно, не более 4 штук) | 120 мс | трассировка + pg_stat_statements |
| Кэш и внешние вызовы | 45 мс | трассировка, span redis / http-client |
| Сериализация и сжатие ответа | 20 мс | профиль CPU |
| Резерв на хвосты и GC | 60 мс | — |
| Итого p99 | 400 мс | нагрузочный тест в CI |
Разбиение делает разговор конкретным. Не «база тормозит», а «база съедает 210 мс при бюджете 120». И оно же задаёт критерий остановки: как только все участки в бюджете, оптимизация прекращается, даже если «ещё можно ускорить». Оставшееся время идёт на то, что приносит пользы больше.
Формальная сторона SLO, бюджетов ошибок и их связи с надёжностью — глава SRE-книги Google. Как встроить бюджеты в CI, как ловить регрессии и как понять, что пора остановиться, — в «Рабочем процессе оптимизации».
8. Карта трека
Что именно даёт каждая статья:
| Статья | Главный вопрос | Инструменты |
|---|---|---|
| 01. Измерение | что считать и как не соврать себе перцентилями | Prometheus, HdrHistogram, трассировка |
| 02. Бенчмаркинг честно | как поставить эксперимент, которому можно верить | JMH, testing.B, benchstat, hyperfine |
| 03. Профилирование CPU | какой код действительно занимает процессор | perf, flame graphs, pprof, async-profiler |
| 04. Память | почему аллокации дороже, чем кажутся | heap-профили, GC-логи, massif, jemalloc |
| 05. Кэши и локальность | почему одинаковая сложность даёт десятикратную разницу | perf stat, cachegrind, счётчики PMU |
| 06. Ввод-вывод | что стоит переход в ядро и как его избегать | strace, bpftrace, iostat, io_uring |
| 07. Конкурентность | почему больше потоков не значит быстрее | профили блокировок, perf lock, Амдал |
| 08. Производительность БД | как читать план и чинить N+1 | EXPLAIN ANALYZE, pg_stat_statements |
| 09. Кэширование | где кэшировать и как не устроить stampede | hit rate, TTL, singleflight, Redis |
| 10. Сетевая производительность | как перестать платить за RTT | keep-alive, HTTP/2, сжатие, батчинг |
| 11. Нагрузочное тестирование | как воспроизвести прод и прочитать отчёт | k6, wrk2, Gatling, профили нагрузки |
| 12. Рабочий процесс | как удержать результат и когда остановиться | бюджеты, регрессии в CI, журнал решений |
Маршруты чтения. Подряд — оптимальный вариант: статьи выстроены от общего к частному и ссылаются назад. Если времени мало, есть короткие пути:
- бэкенд-инженер, у которого «тормозит API»: 01 → 08 → 09 → 06 → 12;
- разработчик библиотеки или горячего пути: 02 → 03 → 04 → 05 → 07;
- тимлид, которому нужен процесс, а не микрооптимизации: 01 → 11 → 12;
- фронтенд: 01 → 10 → 09, плюс «Веб-производительность» в треке фронтенда.
Соседние треки, которые дополняют этот: операционные системы — наблюдаемость со стороны ядра; алгоритмы — практическая оптимизация алгоритмов; распределённые системы — почему в распределённой системе хвосты складываются и умножаются. Отдельного трека про сети на портале пока нет; сетевые аспекты производительности разбираются здесь, в статье 10.
9. Типичные ошибки
- Оптимизировать без профиля. Самая частая и самая дорогая. Инженерная интуиция о том, «где медленно», сбывается примерно в трети случаев — это хуже монетки при трёх вариантах.
- Мерить на ноутбуке, чинить для прода. Другой процессор, другая память, нет соседей, нет cgroup-лимитов, нет сети. Локальные числа годятся только для сравнения «до/после» на той же машине.
- Смотреть на среднее. См. раздел 1. Среднее скрывает ровно то, из-за чего жалуются пользователи.
- Оптимизировать участок, занимающий 3% времени. Закон Амдала обесценивает любую героическую работу вне горячего пути.
- Забыть про долю ошибок. Красивый p99 при 5% таймаутов — это не «быстро», это «мы отбрасываем медленные запросы».
- Три изменения сразу. Результат неинтерпретируем, виноватое не откатить. И верить одному прогону — особенно если он подтверждает то, во что вы уже верили.
- Ускорять то, что можно не делать. Часто самый быстрый код — удалённый: не запросить лишнее поле, не сходить в базу, не отрисовать невидимое.
- Не фиксировать результат и не останавливаться. Оптимизация без регрессионного теста живёт до следующего рефакторинга; а после выполнения бюджета начинается коллекционирование процентов ценой сложности, которую кто-то будет сопровождать.
10. Первое упражнение: тридцать минут на реальном сервисе
Не откладывайте до конца трека. Возьмите свой сервис и пройдите цикл целиком — на маленьком масштабе, но по-настоящему.
# 1. Снять распределение задержек одного эндпоинта за сутки: p50, p95, p99, доля ошибок
$ curl -sG 'http://prometheus:9090/api/v1/query' --data-urlencode 'query=histogram_quantile(
0.99, sum by (le) (rate(http_request_duration_seconds_bucket{route="/orders"}[1h])))'
# 2. Найти в трассировке один медленный запрос: какой span занимает больше половины времени?
# 3. Снять 30-секундный CPU-профиль под реальной нагрузкой
$ go tool pprof -top -nodecount=15 "http://localhost:6060/debug/pprof/profile?seconds=30"
# 4. Проверить гипотезу о базе: топ запросов по СУММАРНОМУ времени, а не по среднему
$ psql -c "SELECT calls, round(total_exec_time::numeric, 1) AS total_ms, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;"
# 5. Записать в файл три числа: p99 сейчас, гипотезу, ожидаемый выигрыш.
# После правки перемерить тем же способом и сравнить с записанным.
Пункт 5 — самый важный. Записанная заранее гипотеза не даёт задним числом переобъяснить результат («ну, зато код стал чище»). Журнал таких записей за полгода — лучший учебник по производительности вашей конкретной системы, потому что в нём видно, как часто интуиция ошибалась.
Мини-итог
- Производительность — это три разные величины: задержка, пропускная способность, эффективность. Они связаны законом Литтла $L = \lambda W$ и часто конфликтуют.
- Пользователь живёт в хвосте распределения. Работайте с перцентилями и гистограммами, среднее — выбросьте.
- Метод один: цель как число → воспроизведение → измерение сверху вниз → локализация → одна правка → перемер → фиксация в CI.
- Закон Амдала считается до работы: он говорит, стоит ли вообще начинать.
- Каждый инструмент слеп к соседним слоям. CPU-профиль не видит ожидания; трассировка не видит, почему медленно внутри участка.
- Бенчмарки врут через разогрев, шум, dead code elimination, coordinated omission и ошибку выжившего. Повторы, статистика и открытая модель нагрузки — обязательны.
- Табличные числа задержек полезны порядками, а не абсолютами; свои числа надо измерять на своём железе.
- Бюджет производительности задаёт и приоритет, и критерий остановки.
Источники
- Brendan Gregg. Systems Performance, 2nd ed. — https://www.brendangregg.com/systems-performance-2nd-edition-book.html — плюс USE Method и Flame Graphs
- Tom Wilkie. The RED Method — https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/
- Gil Tene. How NOT to Measure Latency — https://www.youtube.com/watch?v=lJ8ydIuPFeU, и сам HdrHistogram
- T. Mytkowicz et al. Producing Wrong Data Without Doing Anything Obviously Wrong! ASPLOS 2009 — https://dl.acm.org/doi/10.1145/1508284.1508275
- D. Knuth. Structured Programming with go to Statements. ACM Computing Surveys, 1974 — https://dl.acm.org/doi/10.1145/356635.356640
- Colin Scott. Interactive Latency Numbers — https://colin-scott.github.io/personal_website/research/interactive_latency.html
- Google SRE Book: Service Level Objectives — https://sre.google/sre-book/service-level-objectives/
- Документация инструментов: perf, pprof, bpftrace, benchstat, k6, PostgreSQL EXPLAIN, Chrome DevTools Performance
Что дальше
Измерение: метрики, перцентили, latency vs throughput, USE и RED — как устроены гистограммы и перцентили, почему их нельзя усреднять, чем задержка отличается от пропускной способности на графике насыщения и как за десять минут собрать дашборд, по которому видно, что чинить.