Содержание урока · 10 разделов
- Prometheus histogram: go-пример с Observe
- DefBuckets и свои сетки
- Инцидент: гистограмма в миллисекундах
- Латентность метрики: что уезжает в /metrics
- Чем строить сетку бакетов
- Спорное мнение: одиннадцати бакетов хватает
- Вариация: гистограмма размеров, а не времени
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Prometheus histogram: go-пример с Observe#
Представьте два сервиса с одинаковой средней латентностью 120 мс. У первого всё ровно: минимум 80, максимум 180. У второго девять запросов из десяти укладываются в 40 мс, а десятый висит две секунды. Средняя одинаковая — опыт пользователей противоположный. Среднее скрывает форму распределения, а форма и есть главное: медиана показывает типового пользователя, p99 — самого несчастливого из ста, и именно его словами описывают SLO. Обычный Counter здесь бессилен: он умеет только инкременты и не помнит, сколько длился каждый запрос. Histogram решает задачу хитрее: приложение не хранит каждое наблюдение, а раскладывает значения по заранее заданным интервалам — бакетам — и отдаёт Prometheus накопленные счётчики по каждому интервалу. Квантиль считается уже из этих счётчиков, в момент запроса. Компромисс виден сразу: сырые значения теряются, точность ограничена границами бакетов — и границами придётся управлять, это половина урока.
Создаётся гистограмма функцией prometheus.NewHistogram с HistogramOpts, где кроме Name и Help задаётся Buckets — возрастающая сетка границ в секундах. Если её не указать, возьмётся prometheus.DefBuckets: одиннадцать значений от 5 мс до 10 секунд, сетка заточена под типичный веб-сервис. Наблюдение — один вызов hist.Observe(value); передавать принято секунды float64, поэтому у времени ответа пишут time.Since(start).Seconds(). Observe потокобезопасен: его можно звать из любого числа горутин без мьютекса, внутри атомарные операции. Паттерн в хендлере одинаковый всегда: запомнить время в начале, отдать ответ, наблюдать разницу — удобнее всего это делает middleware, про готовые обёртки promhttp будет девятый урок. Здесь пример держим ручным, чтобы была видна механика: замер — это две строки вокруг вашей бизнес-логики, всё остальное берёт на себя client_golang.
Гистограмма латентности с сеткой по умолчанию
GOpackage main
import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var requestDuration = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "Время обработки запроса",
Buckets: prometheus.DefBuckets,
},
)
func handler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
requestDuration.Observe(time.Since(start).Seconds())
}()
w.Write([]byte("ok"))
}
func main() {
prometheus.MustRegister(requestDuration)
http.HandleFunc("/api", handler)
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":8080", nil)
}Buckets: prometheus.DefBuckets — сетка по умолчанию; её можно не указывать вовсе, но в коде её обычно пишут явно, чтобы читатель видел, что меряется. Observe в defer гарантирует замер и при панике в хендлере: латентность неудачных ответов тоже попадёт в распределение. Суффикс _seconds в имени — конвенция: единицу стоит называть в имени, иначе PromQL-запросы читать тяжело.
DefBuckets и свои сетки#
DefBuckets — это {0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10} секунд. Обратите внимание на пропорцию: сетка логарифмическая, каждый шаг в полтора-два с половиной раза шире предыдущего, потому что латентности живут на порядках. Но она полезна только сервису, у которого время ответа реально лежит от десятков миллисекунд до секунд. Быстрому API, где всё заканчивается за 40 мс, девять из одиннадцати бакетов не пригодятся, а p99 посчитается грубо. Медленной генерации отчётов, наоборот, нужна сетка выше десяти секунд. Свои границы задаются слайсом float64 по возрастанию — либо хелперами: prometheus.LinearBuckets(start, width, count) даёт равномерную сетку, prometheus.ExponentialBuckets(start, factor, count) — экспоненциальную. Учтите: границы зашиты в релиз. Поменять сетку на живом дашборде нельзя, а после смены графики получат разрыв, потому что счётчики стартуют заново. Планируйте сетку под свой SLO, а не копируйте дефолт по привычке.
Инцидент: гистограмма в миллисекундах#
Инцидент, который стоит знать до первого дашборда с p99. В одном сервисе замер писали в миллисекундах: Observe(float64(elapsed.Milliseconds())) при сетке DefBuckets от пяти миллисекунд до десяти секунд. Почти все наблюдения попадали в первый бакет: p99 показывал стабильные пять миллисекунд, график был образцово зелёным, а пользователи жаловались на медленный поиск. Ошибка интерпретации в обратную сторону: график честен — врать стали единицы. Диагностика заняла минуты, когда кто-то посмотрел на сырые бакеты: le=0.005 равнялся le=+Inf, значит, весь трафик быстрее первой границы — либо сервис летает, либо мера не в тех единицах. Противоядие: секунды и time.Since(start).Seconds() по умолчанию; если единица другая — она обязана быть в имени метрики. Проверку le=+Inf равно _count вы уже знаете — она же проверка здравого смысла, и этой истории она бы хватило.
Сетка под свой SLO
GOvar apiDuration = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "api_duration_seconds",
Help: "Время ответа API",
Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5},
},
)
// очередь может ждать и секунды, и минуты — сетка экспоненциальная
var queueWait = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "queue_wait_seconds",
Help: "Ожидание в очереди",
Buckets: prometheus.ExponentialBuckets(0.1, 2, 8),
},
)Первая сетка построена под SLO «99 процентов быстрее 300 мс»: границы сгущаются вокруг ожидаемого p95 и p99, поэтому квантиль будет точным именно там, где принимаются решения. ExponentialBuckets(0.1, 2, 8) строит 0.1, 0.2, 0.4 и так далее — удобно для очередей, где время ожидания живёт на порядках, от миллисекунд до минут.
Латентность метрики: что уезжает в /metrics#
Гистограмма экспортирует три семейства серий. Первое — _bucket с лейблом le: для каждой границы — накопленное число наблюдений, которые были меньше либо равны ей, включая последний псевдобакет le="+Inf", равный общему числу наблюдений. Кумулятивность важна: это не «сколько попало в интервал», а «сколько не превысило границу с начала процесса»; разность соседних бакетов даёт интервал. Второе — _sum: сумма всех наблюдений. Третье — _count: их количество. Средняя латентность считается делением sum на count без всяких квантилей, а p99 — функцией histogram_quantile в PromQL: она находит бакет, в котором лежит квантиль, и интерполирует внутри него. Всё это считается на стороне сервера Prometheus в момент запроса. Следствие приятное: можно менять запросы, срезать по любым лейблам и агрегировать инстансы, не трогая ни строчки Go-кода — к этому свойству мы ещё вернёмся в следующем уроке, потому что summary ведёт себя ровно наоборот.
Экспозиция гистограммы
GOapi_duration_seconds_bucket{le="0.05"} 318
api_duration_seconds_bucket{le="0.1"} 388
api_duration_seconds_bucket{le="0.25"} 397
api_duration_seconds_bucket{le="+Inf"} 400
api_duration_seconds_sum 6.14
api_duration_seconds_count 400Четыреста запросов: 318 уложились в 50 мс, ещё 70 — между 50 и 100 мс (388 минус 318), между 100 и 250 мс — девять. Бакет +Inf равен count — встроенная проверка целостности. Средняя: sum на count — 6.14 секунды на 400 запросов, около 15 мс на запрос. Все три семейства серий начинаются с одного имени метрики.
Квантиль не может быть точнее сетки бакетов — и это не мелочь, а главная ловушка гистограмм. Если ваш p99 реально равен 0.6 секунды, а в сетке после 0.5 сразу идёт 1.0, Prometheus интерполирует внутри бакета и выдаст что-то между 0.5 и 1.0 — оптимизацию с 0.9 до 0.55 вы на графике не увидите. Хуже: если хвост уползает за последнюю границу, p99 замирает на ней, и график показывает стабильность там, где всё деградировало. Правило: границу ставьте рядом с целевым значением SLO, а верх сетки — с запасом, но не в десять раз больше реального хвоста.
Гистограмма — не одна серия, а количество бакетов плюс два. Одиннадцать границ DefBuckets — это двенадцать серий _bucket вместе с +Inf, плюс _sum и _count. Теперь умножьте на лейблы: гистограмма с route и status на десять маршрутов и пять статусов — уже семьсот серий с одного процесса. Не плодите бакеты на всякий случай: тридцать границ вместо одиннадцати почти утраивают объём, а точность выигрывают только около выбранных границ.
Чем строить сетку бакетов#
| Сетка | Как задаётся | Когда брать |
|---|---|---|
| DefBuckets | не указывать Buckets вовсе | типичный веб-сервис с латентностью от миллисекунд до секунд |
| []float64{...} | возрастающий слайс границ | свои границы вокруг SLO и реального хвоста распределения |
| LinearBuckets(start, width, count) | равномерный шаг | величины с узким диапазоном: размеры ответов, числа элементов |
| ExponentialBuckets(start, factor, count) | экспоненциальный рост | разброс на порядки: очереди, ретраи, тяжёлые файлы |
Спорное мнение: одиннадцати бакетов хватает#
Спорное мнение с границей: в большинстве сервисов сетку не надо трогать. Дефолтные одиннадцать границ критикуют за грубость, забывая, зачем квантиль вообще существует: p99 нужен не для физики, а для решений — сравнить релизы, поймать деградацию, проверить цель по надёжности. Для этих задач важно, чтобы граница лежала рядом с целевым значением, а не чтобы квантиль сходился до четвёртого знака. Я сужаю сетку в двух случаях: быстрый внутренний API, где весь трафик живёт в первом бакете, и фоновые задачи с хвостом за десять секунд. И не сужаю «на всякий случай»: тридцать границ утраивают объём серий, а выигрыш достаётся интерполяции, которой никто не доверяет. Граница спора проста: если у вас есть цель в миллисекундах и алерт по ней — сетка под неё; если есть только любопытство — оставьте дефолт и потратьте время на что-то другое.
Вариация: гистограмма размеров, а не времени#
Вариация примера, расширяющая применение: гистограмма не обязана мерить секунды. Размер ответа в байтах — тот же Observe с сеткой LinearBuckets от тысячи до шестидесяти четырёх тысяч: из неё получаются медианный размер страницы и хвост тяжёлых выгрузок, которые раздувают трафик. Число элементов в пачке — ExponentialBuckets от единицы: видно, как часто очередь таскает по одной задаче вместо сотни. Даже возраст данных в кэше влезает в распределение. Правило единственное: единица измерения — в имени метрики, а сетка — по реальному диапазону величины, который проще всего снять с продовых данных за неделю. После этого распределения вроде размерных перестают быть экзотикой и становятся обычным инструментом наравне с латентностью.
Проверка: кумулятивность
Что именно хранит серия _bucket{le="0.5"}?
Проверка: почему p99 приближённый
Почему p99 по гистограмме — всегда приближение?
Средняя из гистограммы
Прочитайте код и выберите, что он выведет:
api_duration_seconds_sum 6.14
api_duration_seconds_count 400Сколько в интервале?
Прочитайте код и выберите, что он выведет:
api_duration_seconds_bucket{le="0.1"} 388
api_duration_seconds_bucket{le="0.25"} 397
api_duration_seconds_bucket{le="+Inf"} 400Соберите гистограмму
Допишите создание гистограммы с сеткой по умолчанию и замер латентности.
Замер вокруг бизнес-операции
Расставьте строки замера латентности: от старта таймера до Observe.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: гистограммы
Гистограмма и лейблы из предыдущего урока работают в паре: гистограмма с лейблом route даёт p99 по каждому маршруту отдельно — типовой дашборд API начинается именно с этого. В следующем уроке сравним гистограмму с Summary — вторым типом для распределений, который считает квантили на стороне приложения, и поймём, почему для HTTP почти всегда берут histogram. Запросы к метрикам этого урока — rate, histogram_quantile, sum by (le) — разобраны в уроке про PromQL, там же появятся готовые формулы латентности. Полная программа — на странице курса Prometheus. Как и раньше, песочница сайта client_golang не исполняет: нужен локальный Go и go get github.com/prometheus/client_golang/prometheus. Домашняя работа урока как раз про это — и про чтение экспозиции руками, которое заменяет половину дашбордов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Histogram раскладывает наблюдения по кумулятивным бакетам; квантиль считается позже, на стороне Prometheus.
- Observe принимает секунды float64: time.Since(start).Seconds(); потокобезопасен из любого числа горутин.
- DefBuckets — 11 границ от 5 мс до 10 с; под быстрый API или медленные отчёты сетку задают своей.
- _sum и _count дают среднюю без квантилей; _bucket плюс histogram_quantile дают p99.
- Квантиль не точнее ближайшей границы бакета: границы ставят вокруг целевого SLO.
- Бакеты умножают серии: 11 границ плюс лейблы — считайте кардинальность заранее.
Домашнее задание
Заведите в учебном сервисе гистограмму с сеткой под ваш целевой SLO, снимите /metrics curl-ом и посчитайте руками: среднюю из sum и count, число наблюдений в одном интервале разностью бакетов. Затем определите, между какими границами лежит ваш p99, и оцените, насколько грубым будет его приближение. Если поднят Prometheus — проверьте себя запросом histogram_quantile. Нужен локальный Go: песочница client_golang не исполняет.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Observe принимает секунды, а не миллисекунды?
Такова конвенция client_golang: DefBuckets задан в секундах, и смешивать единицы в одном графе мониторинга опасно. Если нужны миллисекунды, назовите метрику с суффиксом _milliseconds и постройте свою сетку — но сначала попробуйте просто секунды.
Можно ли менять сетку бакетов без релиза?
Нет: границы зашиты в HistogramOpts и меняются вместе с кодом. После смены сетки счётчики стартуют с нуля, и графики получат разрыв — поэтому сетку планируют под SLO заранее и меняют осознанно.
Гистограмма или summary для латентности HTTP?
По умолчанию гистограмма: её бакеты агрегируются по инстансам и любым лейблам, p99 по ферме считается запросом. Summary берут за точный квантиль одиночного процесса — полный разбор в следующем уроке.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
WebSocket в Go: real-time чаты и уведомления
Курс WebSocket на Go: gorilla/websocket, эхо-сервер, hub-паттерн, комнаты, авторизация соединений, ping/pong, reconnect и масштабирование. 25 уроков.
- Поднимать WS-сервер и апгрейдить HTTP-соединения
- Писать readPump/writePump без гонок на каждое соединение
- Строить hub: регистрацию, рассылку и комнаты
- Авторизовывать соединения и ограничивать сообщения