Производительность систем Измерение: метрики, перцентили, latency vs throughput, USE и RED
0%

Измерение: метрики, перцентили, latency vs throughput, USE и RED

Измерение: метрики, перцентили, latency vs throughput, USE и RED

Почти каждая история про оптимизацию, которая закончилась плохо, начинается одинаково: кто-то посмотрел на код, сказал «вот тут явно узкое место» и потратил три недели на переписывание. Потом выкатили — и ничего не изменилось, потому что переписанный кусок занимал 2 % времени запроса, а остальные 98 % уходили на ожидание пула соединений, о существовании которого никто не вспомнил.

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

Мы разберём: что вообще является величиной в разговоре о производительности; почему задержка и пропускная способность связаны нелинейно и почему улучшение одной обычно портит другую; почему среднее арифметическое — самая вредная метрика в отрасли; как устроены перцентили, как их считать и почему их категорически нельзя усреднять; что такое coordinated omission — систематическая ошибка, из-за которой ваш нагрузочный тест показывает 2 мс там, где пользователи видят секунду; почему p99 одного сервиса становится p50 пользователя; и два метода — USE и RED, — которые превращают «всё тормозит» в конечный список проверок.

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

Что вообще является величиной

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

Задержка (latency). Время между началом и концом одной операции. Ключевой вопрос — между какими именно началом и концом. Брендан Грегг в Systems Performance разделяет:

  • service time — сколько операция реально обслуживалась;
  • wait time — сколько она пролежала в очереди, ничего не делая;
  • response time = wait + service — то, что чувствует вызывающая сторона.

В индустрии слово «latency» употребляют для всех трёх, и это порождает диалоги вида «у меня 12 мс» — «а у меня 190 мс» — «ты меряешь неправильно». Оба меряют правильно, просто разные отрезки.

Анатомия одного запроса: куда уходит время и что видит ваш таймер

Посмотрите на схему внимательно. Таймер внутри HTTP-хендлера (start := time.Now() в первой строке обработчика) видит 80 мс. Access-log обратного прокси видит 119 мс. Пользователь ждёт 190 мс. Все три числа корректны, и все три отвечают на разные вопросы. Метрика, которую вы кладёте на дашборд, обязана иметь явно названные границы — иначе она не метрика, а повод для спора.

Самое коварное здесь — очередь ожидания воркера. Она невидима изнутри приложения: когда обработчик начал выполняться, запрос уже отстоял своё. Под нагрузкой именно она растёт быстрее всего, и именно она объясняет классическую картину «на графиках всё зелёное, а пользователи жалуются».

Пропускная способность (throughput). Количество завершённых операций в единицу времени: rps, запросов в секунду, мегабайт в секунду, транзакций в минуту. Это свойство системы, а не запроса.

Утилизация (utilization). Доля времени, в течение которой ресурс был занят. Для CPU — доля непростаивающего времени, для диска — доля времени с хотя бы одной операцией в полёте.

Насыщение (saturation). Объём работы, которая ресурсу поставлена, но которую он не успевает выполнить: длина очереди, число ждущих потоков, глубина очереди диска. Утилизация упирается в 100 % и дальше не растёт — насыщение растёт неограниченно, поэтому именно оно является ранним индикатором беды.

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

Обратите внимание на состояние «Отброшен». Запросы, оборвавшиеся по таймауту, в большинстве стандартных инструментаций не попадают в гистограмму задержек — они попадают в счётчик ошибок. Это означает, что чем хуже системе, тем лучше выглядит её p99. Мы вернёмся к этому в разделе про ошибку выжившего; запомните пока как самое частое искажение в проде.

Задержка и пропускная способность — не обратные величины

Соблазнительно думать: если один запрос обрабатывается 10 мс, значит система выдаст 100 rps. Это верно ровно для однопоточной системы без очередей, то есть ни для одной реальной.

Правильная связь — закон Литтла, доказанный Джоном Литтлом в 1961 году и справедливый для любой стабильной системы независимо от распределений:

$$L = \lambda \cdot W$$

где $L$ — среднее число заявок, находящихся в системе одновременно (concurrency), $\lambda$ — интенсивность потока (throughput), $W$ — среднее время пребывания в системе (latency).

Это самая полезная формула в арсенале перформанс-инженера, и не потому, что по ней что-то вычисляют, а потому что по ней проверяют, не врут ли метрики. Пример из практики: дашборд показывает 5000 rps и среднюю задержку 20 мс. По Литтлу в системе одновременно должно находиться $5000 \cdot 0{,}02 = 100$ запросов. Смотрим в конфиг: пул воркеров — 50. Числа несовместимы. Значит, одно из трёх: rps считается не там, где задержка; задержка меряется не для всех запросов; или половина работы уходит в фоновые горутины, не учтённые в пуле. Любой из этих ответов ценнее, чем неделя оптимизаций.

Второе соотношение — закон утилизации: $U = X \cdot S$, где $X$ — пропускная способность, $S$ — среднее время обслуживания одной операции. Диск, обслуживающий 2000 IOPS по 0,4 мс каждая, утилизирован на $2000 \cdot 0{,}0004 = 0{,}8$, то есть на 80 %.

А дальше начинается то, из-за чего производительность нельзя планировать линейно. Для простейшей модели очереди M/M/1 среднее время отклика:

$$R = \frac{S}{1 - \rho}$$

где $\rho$ — утилизация ресурса. При $\rho = 0{,}5$ время отклика вдвое больше времени обслуживания. При $\rho = 0{,}8$ — впятеро. При $\rho = 0{,}95$ — в двадцать раз. При $\rho = 0{,}99$ — в сто. Реальные системы ведут себя не совсем как M/M/1, но качественная картина та же: кривая «утилизация — задержка» имеет колено, и после него небольшой прирост нагрузки даёт катастрофический прирост задержки.

Отсюда практический вывод, который многим кажется расточительством: планировать ёмкость надо не «до 100 % загрузки», а до колена — обычно 60–75 % по критическому ресурсу. Оставшиеся 25–40 % — это не простаивающее железо, это купленная предсказуемость хвоста.

И отсюда же следует главное: задержка и пропускная способность обычно находятся в противофазе. Батчинг увеличивает throughput (амортизируется фиксированная стоимость операции) и одновременно ухудшает latency (первый элемент батча ждёт последнего). Группировка коммитов в БД, буферизация записи, склейка сетевых пакетов алгоритмом Нагла, векторизация запросов к модели — везде один и тот же обмен. Поэтому вопрос «как ускорить систему» некорректен, пока не назван оптимизируемый показатель.

Среднее — самая вредная метрика в отрасли

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

Причина фундаментальна: время ответа — это смесь режимов. Попадание в кэш — 2 мс. Промах кэша с походом в БД — 40 мс. Промах с блокировкой на строке — 300 мс. Попадание в паузу сборщика мусора — 900 мс. Ретрай после таймаута соединения — 1,2 с. Это не «шум вокруг среднего», это пять разных физических процессов, механически сложенных в один график.

Реальное распределение задержек: мода, среднее и перцентили живут в разных местах

Здесь среднее — 27 мс. В бакете, куда оно попадает, лежит меньше десятой части запросов. Типичный пользователь видит 12 мс. Недовольный пользователь — 190 мс. Тот, кто напишет в поддержку, — 850 мс. Число 27 не описывает никого и при этом активно вредит: оно успокаивает.

Второй смертный грех — среднее по времени. Метрика с окном агрегации в минуту физически не может показать всплеск длиной 5 секунд: он размажется в шестнадцатую часть высоты. Если у вас есть задача, которая раз в минуту на 3 секунды блокирует пул, на минутном графике вы не увидите ничего, а пользователи в эти секунды увидят таймауты. Правило: окно агрегации должно быть меньше длительности события, которое вы хотите поймать, иначе события не существует для вашего мониторинга.

Перцентили: определение и вычисление

$q$-й перцентиль выборки — значение, ниже которого лежит $q$ процентов наблюдений. Простейшее корректное определение (nearest-rank): отсортировать выборку по возрастанию и взять элемент с индексом $\lceil q \cdot n / 100 \rceil$.

import math
import random

def percentile_nearest_rank(values: list[float], q: float) -> float:
    """q-й перцентиль методом ближайшего ранга. q задаётся в процентах.

    Время: O(n log n) из-за сортировки, память: O(n).
    Через quickselect можно получить O(n) в среднем, но на практике
    сортировка выигрывает за счёт кэш-дружелюбности — см. статью про кэши.
    """
    if not values:
        raise ValueError("пустая выборка")
    ordered = sorted(values)
    rank = math.ceil(q / 100 * len(ordered))
    return ordered[max(rank - 1, 0)]


def synthetic_latencies(n: int = 200_000) -> list[float]:
    """Смесь режимов: кэш, БД, блокировка, пауза GC. Всё в миллисекундах."""
    out = []
    for _ in range(n):
        roll = random.random()
        if roll < 0.70:                      # попадание в кэш
            out.append(random.gauss(3, 0.8))
        elif roll < 0.96:                    # поход в БД
            out.append(random.gauss(22, 6))
        elif roll < 0.995:                   # конкуренция за блокировку
            out.append(random.gauss(180, 60))
        else:                                # пауза сборщика мусора
            out.append(random.gauss(850, 200))
    return [max(v, 0.1) for v in out]


data = synthetic_latencies()
mean = sum(data) / len(data)
print(f"среднее   {mean:8.1f} мс")
for q in (50, 90, 99, 99.9):
    print(f"p{q:<6}   {percentile_nearest_rank(data, q):8.1f} мс")

Типичный вывод:

среднее       27.4 мс
p50            5.5 мс
p90           27.9 мс
p99          214.6 мс
p99.9        961.3 мс

Среднее в пять раз больше медианы. Между p90 и p99 — рост в семь раз. Между p99 и p99.9 — ещё в четыре с половиной. Именно этот разрыв и есть предмет работы: «оптимизировать систему» почти всегда означает «понять, чем занят хвост».

Для потоковых данных сортировать всё нельзя. Два практических подхода:

  • HdrHistogramHigh Dynamic Range Histogram Гила Тене: бакеты с фиксированной относительной точностью (например, 3 значащие цифры) на диапазоне от микросекунд до часов. Запись — константное время и без аллокаций, память — единицы-десятки килобайт, ошибка перцентиля ограничена сверху и известна заранее. Стандарт де-факто для нагрузочных инструментов.
  • t-digestструктура Теда Даннинга: адаптивные центроиды, плотные на краях распределения. Даёт высокую точность именно там, где она нужна (p99, p99.9), сливается ассоциативно, поэтому пригоден для распределённой агрегации.

Оба дают приближённые ответы. Это нормально: вам не нужен точный p99, вам нужен p99 с известной погрешностью.

Перцентили нельзя усреднять

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

Пусть у вас 10 подов. Девять отвечают быстро, p99 у каждого 20 мс. Десятый деградировал: p99 = 2000 мс. Дашборд показывает avg(p99) = (9·20 + 2000)/10 = 218 мс. Реальный p99 всех запросов вместе — где-то около 40 мс, если на больной под приходится десятая часть трафика, потому что 99-й перцентиль объединённой выборки определяется общей формой распределения, а не средним из перцентилей частей. А если больной под получает 1 % трафика, реальный p99 вообще может быть 20 мс, при том что дашборд показывает 218.

Ошибка симметрична и работает в обе стороны: усреднение перцентилей может как выдумать проблему, так и спрятать её. Математическая суть проста: перцентиль — не линейный функционал, и $\text{p99}(A \cup B) \ne \frac{\text{p99}(A) + \text{p99}(B)}{2}$ в общем случае.

Правильный способ ровно один: агрегировать не перцентили, а распределения, и считать перцентиль на объединённом распределении.

В Prometheus это делается через кумулятивные гистограммы. Каждый бакет ..._bucket{le="0.1"} — обычный счётчик, счётчики складываются, поэтому сумма гистограмм — корректная гистограмма:

# ПРАВИЛЬНО: сначала суммируем бакеты по всем подам, потом берём квантиль
histogram_quantile(
  0.99,
  sum by (le, route) (rate(http_request_duration_seconds_bucket[5m]))
)

# НЕПРАВИЛЬНО: усреднение уже посчитанных перцентилей
avg by (route) (http_request_duration_seconds{quantile="0.99"})

Вторая форма встречается, когда используется тип Summary — он считает квантили на стороне приложения, и результат принципиально не агрегируется между инстансами. Именно поэтому документация Prometheus рекомендует histogram, а не summary, для всего, что живёт больше чем в одном экземпляре.

У гистограмм своя цена — ошибка интерполяции внутри бакета. histogram_quantile предполагает равномерное распределение внутри бакета, что для тяжёлого хвоста неверно. Если ваши границы [0.1, 0.25, 0.5, 1, 2.5, 5, 10], а SLO — 300 мс, то p99 будет интерполироваться внутри бакета 0,25–0,5 с, и точность окажется около 100 мс. Практическое правило: бакеты выбираются вокруг порога, который вам важен, с уплотнением рядом с ним:

// Go, prometheus/client_golang. SLO = 300 мс, поэтому бакеты сгущены
// вокруг 0,3 с: так histogram_quantile и SLI-запрос дают приемлемую точность.
var requestDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name: "http_request_duration_seconds",
        Help: "Время обработки HTTP-запроса, секунды.",
        Buckets: []float64{
            0.005, 0.01, 0.025, 0.05, 0.1,
            0.2, 0.25, 0.3, 0.35, 0.4, // сгущение вокруг SLO
            0.6, 1, 2.5, 5, 10,
        },
        // В Prometheus 2.40+ доступны нативные гистограммы: экспоненциальные
        // бакеты с заданной относительной точностью, границы выбирать не нужно.
        NativeHistogramBucketFactor: 1.1,
    },
    []string{"route", "method", "status"},
)

func instrument(route string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(rec, r)
        // Наблюдение делаем ВСЕГДА, включая ошибки и таймауты:
        // иначе гистограмма будет описывать только выживших.
        requestDuration.
            WithLabelValues(route, r.Method, strconv.Itoa(rec.status)).
            Observe(time.Since(start).Seconds())
    })
}

Два нюанса в этом коде важнее самих бакетов. Во-первых, Observe вызывается для всех ответов, включая пятисотки, — иначе гистограмма систематически смещается. Во-вторых, лейбл route — это шаблон маршрута (/orders/{id}), а не конкретный URL: иначе кардинальность взорвётся, и Prometheus умрёт раньше, чем ваш сервис.

Coordinated omission: как измерение прячет самое интересное

Это самая тонкая систематическая ошибка в нагрузочном тестировании, и до доклада Гила Тене «How NOT to Measure Latency» её массово не замечали. Она способна занизить p99 на два порядка.

Механика. Большинство нагрузочных инструментов работают в закрытом контуре: поток отправляет запрос, ждёт ответ, отправляет следующий. Если сервер завис на секунду, поток тоже ждёт секунду — и за эту секунду он не отправил те запросы, которые должен был отправить. В гистограмму попадает один замер в 1000 мс вместо ста замеров, растянутых от 1000 до 10 мс. Именно те измерения, которые описывают плохое поведение, оказываются не сделаны, потому что система вела себя плохо. Отсюда название: пропуск замеров скоординирован с самим явлением, которое вы измеряете.

Лечится двумя способами, и оба нужны.

Открытый контур нагрузки. Запросы отправляются по расписанию, независимо от того, ответил сервер или нет. В k6 это исполнители constant-arrival-rate и ramping-arrival-rate:

// k6: открытый контур. Частота задана извне, VU выделяются по потребности.
import http from 'k6/http';
import { Trend } from 'k6/metrics';

const e2e = new Trend('e2e_latency', true);

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-arrival-rate',
      rate: 500,                 // 500 итераций в секунду — это план, а не следствие
      timeUnit: '1s',
      duration: '10m',
      preAllocatedVUs: 200,      // если не хватит, k6 начнёт ДРОПАТЬ итерации
      maxVUs: 2000,
    },
  },
  thresholds: {
    // Порог по дропам обязателен: dropped_iterations — прямое свидетельство,
    // что генератор не смог выдержать план, то есть тест сам стал узким местом.
    dropped_iterations: ['count<10'],
    'http_req_failed': ['rate<0.01'],
    'http_req_duration': ['p(99)<300'],
  },
};

export default function () {
  const res = http.get('https://api.local/v1/orders?limit=20');
  e2e.add(res.timings.duration);
}

Компенсация в гистограмме. Если инструмент умеет, он дописывает «пропущенные» замеры сам. wrk2 делает это на основе заданной частоты:

$ wrk2 -t4 -c200 -d60s -R2000 --latency http://api.local/v1/orders
Running 1m test @ http://api.local/v1/orders
  4 threads and 200 connections
  Thread calibration: mean lat.: 3.412ms, rate sampling interval: 12ms
  Latency Distribution (HdrHistogram - Recorded Latency)
 50.000%    2.98ms
 90.000%    9.87ms
 99.000%  312.19ms
 99.900%    1.42s
 99.990%    2.61s
100.000%    2.95s

Строка Recorded Latency против Uncorrected Latency в выводе wrk2 — ровно об этом: первая учитывает пропущенные отправки, вторая нет. Разница между ними на нагруженной системе легко достигает 50–100 раз.

Как проверить, что вы попались? Простейший индикатор: максимум сопоставим с длительностью теста, а p99 подозрительно близок к медиане. Если max = 3 с при p99 = 3 мс, а тест шёл 60 секунд — почти наверняка coordinated omission. Второй индикатор — счётчик дропнутых итераций больше нуля при «отличных» перцентилях. Подробнее про построение профилей нагрузки — в статье про нагрузочное тестирование и в обзоре нагрузочного тестирования в треке тестирования.

Хвост правит бал: почему p99 сервиса — это p50 пользователя

Пусть страница собирается из ответов 50 микросервисов, и каждый независимо с вероятностью 1 % отвечает медленно. Вероятность, что ни один не окажется медленным:

$$P(\text{все быстрые}) = (1 - 0{,}01)^{50} \approx 0{,}605$$

То есть 40 % страниц содержат хотя бы один медленный ответ. Показатель, который на уровне отдельного сервиса называется «одна сотая процента случаев, не страшно», на уровне пользователя превращается в «каждый второй раз».

Число зависимостей Доля медленных ответов у каждой Доля медленных страниц
1 1 % 1 %
10 1 % 9,6 %
50 1 % 39,5 %
100 1 % 63,4 %
100 0,1 % 9,5 %

Это ядро статьи Джеффа Дина и Луиса Барросо «The Tail at Scale» (CACM, 2013) — обязательного чтения. Практические следствия:

  • Меряйте не только сервис, но и пользовательский путь целиком. Метрика сервиса «p99 = 20 мс» ничего не говорит о том, что видит человек, если путь состоит из тридцати таких сервисов.
  • Метрикой качества становится p99.9, а не p99, как только фан-аут превышает несколько десятков.
  • Хеджирующие запросы (отправить дубль в другую реплику, если первая молчит дольше p95) — стандартный способ срезать хвост ценой нескольких процентов лишней нагрузки. Требует идемпотентности; связь с распределёнными системами разобрана в статье про наблюдаемость распределённых систем.

USE: метод обхода ресурсов

Метод Брендана Грегга (2012) отвечает на вопрос «где узкое место в железе». Для каждого ресурса проверяем три вещи: Utilization, Saturation, Errors. Ресурсы: процессорные ядра, память, дисковые устройства, сетевые интерфейсы, контроллеры, шины, а в контейнерах — ещё и лимиты cgroup.

Ценность метода в его конечности: список ресурсов исчерпаем, поэтому обход гарантированно завершается, а не превращается в блуждание по дашбордам.

Ресурс Утилизация Насыщение Ошибки
CPU mpstat -P ALL 1, %usr + %sys vmstat 1 колонка r, PSI /proc/pressure/cpu, nr_throttled в cgroup machine check в dmesg
Память free -m, RSS процессов vmstat колонки si/so, PSI /proc/pressure/memory, OOM в dmesg ECC-ошибки, edac-util
Диск iostat -x 1 колонка %util aqu-sz, await, PSI /proc/pressure/io /sys/devices/.../ioerr_cnt, dmesg
Сеть sar -n DEV 1, байты против ёмкости линка netstat -s — ретрансмиссии, дропы, переполнение очередей ip -s link — errors, dropped
Пул соединений БД занятые/размер длина очереди ожидания таймауты получения соединения

Реальный вывод и как его читать:

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 9  0      0 1024236  15328 823104    0    0     0  4096 18234 42311 71 12  4 13  0

Колонка r = 9 при 8 ядрах означает: в очереди готовых к исполнению больше потоков, чем ядер, — насыщение CPU. Обратите внимание, что id (idle) = 4, то есть «утилизация 96 %», но именно r говорит, что уже поздно. wa = 13 — тринадцать процентов времени ждём диск. Столбец cs (переключения контекста) в 42 тысячи в секунду при 18 тысячах прерываний — повод посмотреть на конкуренцию за блокировки, см. производительность конкурентного кода.

$ iostat -x 1
Device     r/s   rkB/s  r_await  w/s    wkB/s  w_await  aqu-sz  %util
nvme0n1  1842.0 29472.0    0.42  310.0 12400.0    1.18    0.85  62.40

Здесь %util = 62 % — и вот классическая ловушка: для NVMe с внутренним параллелизмом %util не означает загрузку. Устройство обслуживает десятки команд одновременно, поэтому «занято хотя бы одной операцией» и «загружено» — разные вещи. Смотреть надо на aqu-sz (средняя глубина очереди) и await. Для вращающихся дисков %util был осмысленным, для NVMe — почти нет; это ровно та ситуация, когда метрика пережила железо, для которого её придумали.

Отдельно про утилизацию CPU — метрику, которой доверяют больше всех и зря. Грегг посвятил этому отдельную заметку: «80 % CPU» означает лишь «80 % времени процессор не был в idle». Он мог всё это время стоять в ожидании памяти (stall), и тогда добавление ядер не поможет ничем, а поможет улучшение локальности данных — см. кэши и локальность. Отличить помогает IPC (instructions per cycle):

$ perf stat -a -- sleep 10

 Performance counter stats for 'system wide':

      80 214,31 msec cpu-clock                 #    8,000 CPUs utilized
    16 842 331 902      cycles                  #    3,500 GHz
     8 104 993 118      instructions            #    0,48  insn per cycle
       612 224 810      cache-misses            #   34,12 % of all cache refs

IPC = 0,48 на процессоре, способном выдавать 3–4 инструкции за такт, — это не «загруженный CPU», это «процессор, который две трети времени ждёт память». 34 % промахов последнего уровня кэша подтверждают диагноз. Подробнее о снятии таких профилей — в профилировании CPU.

И ещё один сюжет, специфичный для контейнеров: троттлинг cgroup. Приложение может показывать 40 % утилизации CPU и при этом стоять колом, потому что упирается в квоту:

$ cat /sys/fs/cgroup/cpu.stat
usage_usec 184203114
nr_periods 90210
nr_throttled 41022     # 45 % периодов уткнулись в квоту
throttled_usec 9812004 # почти 10 секунд принудительного простоя

nr_throttled — обязательная метрика на дашборде любого сервиса в Kubernetes. Её отсутствие стоило отрасли бесконечных расследований «почему p99 пилит, хотя CPU свободен».

Наконец, PSI (Pressure Stall Information) — то, чего Linux долго не хватало: прямая метрика насыщения, а не косвенная.

$ cat /proc/pressure/io
some avg10=27.31 avg60=19.02 avg300=8.44 total=41230991
full avg10=11.90 avg60=8.11 avg300=3.02 total=18220314

some avg10=27.31 — за последние 10 секунд 27 % времени хотя бы одна задача была заблокирована на вводе-выводе. full — всё в системе стояло. Это разработка Facebook, и это лучший из существующих быстрых индикаторов «ресурс насыщен» для CPU, памяти и диска.

RED: метод обхода сервисов

USE смотрит снизу, со стороны железа. RED, сформулированный Томом Уилки, смотрит сверху, со стороны запросов. Для каждого сервиса (и каждого важного эндпоинта) — три сигнала:

  • Rate — запросов в секунду;
  • Errors — доля неуспешных;
  • Duration — распределение времени обработки.

Родственная формулировка — четыре золотых сигнала из книги Google SRE: latency, traffic, errors, saturation. Отличие ровно одно — добавленное насыщение, то есть кусочек USE. На практике удобно так: RED для всего, что принимает запросы, USE для всего, что запросы потребляет, плюс насыщение по пулам и очередям как связующее звено.

Критический нюанс в Duration: распределение задержек надо считать отдельно для успешных и неуспешных ответов. Пятисотка, возвращённая за 2 мс проверкой лимита, и таймаут в 30 с — оба «ошибки», но в общей гистограмме они дают шизофреническую картину. Разделите по status — это один лейбл, а информации даёт больше, чем половина дашборда.

Обратите внимание на нижнюю стрелку: цикл замыкается. Изменение без повторного измерения тем же способом — это не оптимизация, а вера.

Числа, которые полезно знать, и почему им нельзя верить

Существует знаменитая табличка «Latency numbers every programmer should know», которую популяризировал Джефф Дин около 2009 года (корнями она уходит к более ранней таблице Питера Норвига). Она полезна не как справочник, а как тренажёр порядков величин: разница между 100 нс и 100 мкс — это миллион против миллиарда, и интуиция здесь не работает без калибровки.

Оговорка обязательна и важнее самой таблицы. Исходные числа описывают железо конца 2000-х: вращающиеся диски, гигабитную сеть, процессоры до эпохи агрессивного турбо. За это время «чтение с диска» подешевело на три порядка (10 мс у HDD → 20–100 мкс у NVMe), а «случайное чтение из памяти» почти не изменилось — соотношения перевернулись. Интерактивная версия Колина Скотта, показывающая эволюцию чисел по годам, хороша именно тем, что делает устаревание наглядным.

Ориентиры, полезные сегодня (порядок величины, не истина):

Операция Порядок Комментарий
Обращение в L1 ~1 нс около 4 тактов
Обращение в L3 ~15–25 нс зависит от топологии, на многосокетных хуже
Промах в DRAM ~70–120 нс межсокетный доступ NUMA — в полтора-два раза дороже
Взятие незанятого мьютекса ~15–25 нс при конкуренции — на порядки больше
Системный вызов ~100–600 нс после митигаций Spectre/Meltdown выросло заметно
Переключение контекста ~1–5 мкс плюс невидимая цена: холодные кэши после переключения
Случайное чтение с NVMe, глубина 1 ~20–100 мкс у SATA SSD — 100–500 мкс
RTT внутри дата-центра ~0,1–0,5 мс между зонами доступности — 0,5–2 мс
RTT между регионами ~30–150 мс ограничено скоростью света и маршрутизацией
Пауза сборщика мусора от 100 мкс до секунд зависит от сборщика и размера кучи

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

# Задержка памяти и пропускная способность копирования
perf bench mem memcpy -s 512MB
perf bench sched pipe          # цена пары переключений контекста + IPC

# Задержка диска: iodepth=1 меряет ЗАДЕРЖКУ, а не пропускную способность.
# Большая глубина очереди измеряет совсем другое — не путайте цели.
fio --name=lat --rw=randread --bs=4k --iodepth=1 --numjobs=1 \
    --direct=1 --runtime=30 --time_based --filename=/dev/nvme0n1

# Сетевой RTT без искажений TCP-слоя
ping -c 100 -i 0.2 db-primary.internal | tail -2

# Цена системного вызова на вашем ядре и с вашими митигациями
strace -c -f ./your-binary 2>&1 | head -20

Разница между «я читал, что syscall стоит 100 нс» и «я померил, у нас 480 нс, потому что включён KPTI» — это разница между инженером и цитатором.

Как измерения врут

Отдельная статья трека посвящена самообманам бенчмаркинга — «Бенчмаркинг честно». Но часть искажений живёт не в бенчмарках, а в проде, и знать их надо сразу.

Ошибка выжившего — номер один. Гистограмма задержек описывает только те запросы, которые завершились и были записаны. Из неё систематически выпадают:

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

Итог парадоксален и опасен: при деградации системы p99 улучшается. Дежурный видит падающий график задержек, растущий график ошибок и делает вывод «стало быстрее, но ошибочнее». На самом деле стало медленнее настолько, что медленное перестало доезжать. Защита: всегда смотреть Duration и Errors на одном экране, а таймауты считать отдельной серией и класть их в гистограмму по значению таймаута, а не выбрасывать.

Наблюдатель меняет систему. Инструментация стоит денег: каждый Observe — атомарные инкременты по бакетам, каждый спан — аллокация, каждая строка лога — сериализация и системный вызов. На горячем пути с миллионом операций в секунду наивная инструментация легко съедает 10–20 % CPU и, что хуже, добавляет конкуренцию за кэш-линии между ядрами. Правило: инструментировать границы (запрос, батч, обращение к БД), а не тело внутреннего цикла; на внутренних циклах использовать сэмплирующий профайлер, который не требует инструментации вовсе.

Клиентские и серверные метрики обязаны расходиться. Если сервер говорит 80 мс, а браузер — 190, это не ошибка: это ровно те очереди, сеть и рукопожатия с первой схемы. Расхождение — не проблема, а источник информации: его величина показывает, сколько времени живёт вне вашего кода. Тревожно, когда расхождение внезапно выросло. Про измерения на стороне браузера подробно — в статье о веб-производительности.

Разогрев. Первые тысячи операций почти всегда медленнее: холодные кэши процессора, пустой кэш приложения, ленивая инициализация, JIT ещё не скомпилировал горячие методы, страницы памяти ещё не затронуты и получают page fault при первом обращении. Измерение без отбрасывания разогрева измеряет старт, а не работу. Обратная ошибка не менее популярна: если ваш прод перезапускается двадцать раз в день из-за деплоев, разогрев — это и есть значительная часть реальной жизни системы, и выбрасывать его нельзя.

Шум соседей. В облаке вы делите физическое ядро, кэш L3 и канал памяти с чужими нагрузками. Разброс между двумя одинаковыми прогонами на разных виртуальных машинах легко достигает 20–30 %. Одиночный замер в облаке не является измерением — это одно наблюдение случайной величины, и обходиться с ним надо соответственно (доверительные интервалы, повторы, парные сравнения — см. вероятность и статистику).

От измерения к решению: SLI, SLO и бюджет ошибок

Метрика становится инструментом управления, только когда к ней привязано решение. Механика проста и описана в SRE Workbook.

SLI — индикатор, отношение хороших событий к общему числу. Важная тонкость: SLI по перцентилю («p99 < 300 мс») хуже, чем SLI по доле («доля запросов быстрее 300 мс»). Причины:

  • доля агрегируется тривиально по времени и по инстансам, перцентиль — нет;
  • доля напрямую переводится в бюджет ошибок;
  • доля устойчива к изменению объёма трафика, а перцентиль на низком трафике скачет.

В Prometheus, если бакет с нужной границей существует, SLI считается точно, без всякой интерполяции:

# Доля запросов быстрее 300 мс за 30 дней. Точное значение: бакет le="0.3"
# существует, поэтому интерполяция не нужна.
sum(rate(http_request_duration_seconds_bucket{route="/orders", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{route="/orders"}[30d]))

SLO — цель по SLI, например 99,5 % за 30 дней. Бюджет ошибок — разрешённое отклонение: 0,5 % от, скажем, 300 млн запросов в месяц — это 1,5 млн «плохих» запросов. Бюджет и есть механизм принятия решений: пока он не израсходован, команда двигает функциональность; когда израсходован — приоритет автоматически переходит к надёжности и производительности. Это превращает вечный спор «фичи или качество» в арифметику.

И главное, что связывает раздел с темой статьи: SLO задаёт, что именно и с какой точностью надо измерять. Цель «99,9 % быстрее 300 мс» требует бакета ровно на 0,3 с, требует, чтобы таймауты считались нарушением, а не пропадали, и требует измерения на границе, максимально близкой к пользователю. Порядок именно такой: сначала цель, потом инструментация под неё, а не «настроим мониторинг, потом придумаем цели».

Рабочий чеклист измерения

  1. Сформулируйте вопрос до того, как открыли дашборд. «Успевает ли оформление заказа за 300 мс в 99 % случаев в часы пик» — вопрос. «Посмотреть, как дела с производительностью» — не вопрос.
  2. Назовите границы измерения. Между какими двумя событиями. Кто ставит таймер. Что происходит с таймаутами.
  3. Измеряйте распределение, а не одно число. Гистограмма или HdrHistogram. Среднее допустимо только рядом с перцентилями и никогда вместо них.
  4. Агрегируйте распределения, а не перцентили. Никаких avg(p99).
  5. Проверьте закон Литтла. Concurrency, throughput и latency обязаны сходиться. Не сходятся — метрики измеряют разные вещи.
  6. Разделите успехи и ошибки в распределении задержек и держите их на одном экране.
  7. Проверьте открытость контура нагрузки и наличие дропнутых итераций, если это тест.
  8. Пройдите USE по ресурсам, если Duration вырос без роста Rate.
  9. Убедитесь, что окно агрегации меньше длительности искомого явления.
  10. Повторите измерение после изменения тем же методом. Одно измерение не является измерением: нужны хотя бы «до», «после» и оценка шума.

Мини-итог

  • Производительность — не одна величина, а пять: задержка, пропускная способность, утилизация, насыщение, ошибки. Спор о производительности без указания величины бессмыслен.
  • Задержка и пропускная способность связаны законом Литтла $L = \lambda W$ и находятся в противофазе: батчинг покупает throughput за latency, и наоборот.
  • Кривая «утилизация — задержка» имеет колено в районе 70–80 %. Планировать ёмкость надо до колена, а не до 100 %.
  • Среднее по задержкам не описывает ни одного реального запроса, потому что распределение мультимодально и имеет тяжёлый хвост. Работайте с перцентилями и распределениями.
  • Перцентили не усредняются. Складывать надо гистограммы, а квантиль брать в конце.
  • Closed-loop нагрузка систематически прячет хвост (coordinated omission). Нужны открытый контур и компенсация в гистограмме.
  • При фан-ауте в 50 зависимостей одно-процентный хвост каждой даёт 40 % медленных страниц. За хвостом следят по p99.9, а не по p99.
  • USE отвечает на вопрос «какой ресурс упёрся», RED — «какой сервис деградировал». Вместе они дают конечный, завершающийся алгоритм диагностики.
  • Табличные числа задержек — калибровка интуиции, а не справочник. Перемеряйте на своём железе.
  • SLO определяет, что и как измерять. Сначала цель, потом инструментация.

Источники

Что дальше

Мы научились получать число. Следующий шаг — научиться не обманывать себя при его получении: разогрев, дисперсия, микробенчмарки, измеряющие работу оптимизатора вместо вашего кода, и статистика, которая отличает улучшение от шума.

Бенчмаркинг честно: разогрев, шум, статистика, типичные самообманы

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

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

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

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