---
title: "Histogram: латентность и bucket'ы"
description: "Prometheus histogram на Go: латентность метрики по бакетам — Observe, DefBuckets, свои границы и p99 через histogram_quantile, ловушки хвоста распределения."
course: "Мониторинг Go с Prometheus: метрики, Grafana, алерты"
url: "https://golangmaster.ru/courses/prometheus/lessons/07-histogram/"
published: "2026-09-19"
updated: "2026-09-24"
minutes: 20
---

# Histogram: латентность и bucket'ы

*Курс «Мониторинг Go с Prometheus: метрики, Grafana, алерты», урок 7 из 25. ~20 минут. Обновлено 24.09.2026. Источник: https://golangmaster.ru/courses/prometheus/lessons/07-histogram/*

Гистограмма — главный тип метрик prometheus для латентности: histogram в go-сервисе хранит не среднее, а распределение наблюдений по бакетам, из которого PromQL потом достаёт p99. Разберём Observe, DefBuckets, свои границы сетки и ловушки квантилей, о которых больно узнают на дежурстве.

### 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.

### Гистограмма латентности с сеткой по умолчанию

```go
package 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

```go
var 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 ведёт себя ровно наоборот.

### Экспозиция гистограммы

```go
api_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: график лжёт молча.** Квантиль не может быть точнее сетки бакетов — и это не мелочь, а главная ловушка гистограмм. Если ваш 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, где весь трафик живёт в первом бакете, и фоновые задачи с хвостом за десять секунд. И не сужаю «на всякий случай»: тридцать границ утраивают объём серий, а выигрыш достаётся интерполяции, которой никто не доверяет. Граница спора проста: если у вас есть цель в миллисекундах и [алерт по ней](/courses/prometheus/lessons/14-alerts-obzor/) — сетка под неё; если есть только любопытство — оставьте дефолт и потратьте время на что-то другое.

### Вариация: гистограмма размеров, а не времени

Вариация примера, расширяющая применение: гистограмма не обязана мерить секунды. Размер ответа в байтах — тот же Observe с сеткой LinearBuckets от тысячи до шестидесяти четырёх тысяч: из неё получаются медианный размер страницы и хвост тяжёлых выгрузок, которые раздувают трафик. Число элементов в пачке — ExponentialBuckets от единицы: видно, как часто очередь таскает по одной задаче вместо сотни. Даже возраст данных в кэше влезает в распределение. Правило единственное: единица измерения — в имени метрики, а сетка — по реальному диапазону величины, который проще всего снять с продовых данных за неделю. После этого распределения вроде размерных перестают быть экзотикой и становятся обычным инструментом наравне с латентностью.

### Проверка: кумулятивность

**Вопрос:** Что именно хранит серия _bucket{le="0.5"}?

- Число наблюдений в интервале 0–0.5 за последний скрейп
- ✅ Накопленное с начала процесса число наблюдений, не превысивших 0.5
- Сумму значений, попавших до 0.5
- Среднее время среди быстрых запросов

Бакеты кумулятивны: счётчик растёт с начала процесса и включает всё, что не превысило границу. Разность соседних бакетов даёт интервал, а le="+Inf" всегда равен count — встроенная проверка целостности.

### Проверка: почему p99 приближённый

**Вопрос:** Почему p99 по гистограмме — всегда приближение?

- Prometheus опрашивает редко и пропускает значения
- ✅ Сырые значения потеряны: есть только счётчики по бакетам, квантиль интерполируют внутри бакета
- Observe округляет значения до миллисекунд
- p99 считается только по последним 99 наблюдениям

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

### Средняя из гистограммы

```go
api_duration_seconds_sum 6.14
api_duration_seconds_count 400
```

**Что выведет программа?**

- ✅ Около 0.015 с на запрос
- Около 0.15 с на запрос
- 6.14 с на запрос
- Среднюю из гистограммы посчитать нельзя

sum 6.14 секунды на 400 запросов — деление даёт 0.0154 с. _sum и _count экспортируются всегда, поэтому средняя доступна без квантилей и без бакетов: часто её достаточно, чтобы увидеть порядок величины.

### Сколько в интервале?

```go
api_duration_seconds_bucket{le="0.1"} 388
api_duration_seconds_bucket{le="0.25"} 397
api_duration_seconds_bucket{le="+Inf"} 400
```

**Что выведет программа?**

- ✅ 9
- 79
- 388
- 397

Бакеты кумулятивны: 397 минус 388 — девять наблюдений в интервале (0.1, 0.25]. Интервал открыт слева: значение ровно 0.1 уже внутри младшего бакета, потому определение и звучит как «меньше либо равно».

### Соберите гистограмму

Допишите создание гистограммы с сеткой по умолчанию и замер латентности.

```go
var requestDuration = prometheus.___(
        prometheus.HistogramOpts{
                Name:    "http_request_duration_seconds",
                Help:    "Время ответа",
                Buckets: prometheus.___,
        },
)

start := time.Now()
defer func() {
        requestDuration.___(time.Since(start).Seconds())
}()
```

Подсказка: Гистограмму создаёт NewHistogram, сетку по умолчанию задаёт DefBuckets, а значение наблюдают через Observe.

Правильные ответы в пропусках: `NewHistogram`, `DefBuckets`, `Observe`

### Замер вокруг бизнес-операции

Расставьте строки замера латентности: от старта таймера до Observe.

Правильный порядок строк:

1. `start := time.Now()`
2. `err := processOrder(r)`
3. `if err != nil {`
4. `        http.Error(w, err.Error(), http.StatusInternalServerError)`
5. `}`
6. `requestDuration.Observe(time.Since(start).Seconds())`

### Карточки: гистограммы

- **Histogram** — Метрика-распределение: Observe раскладывает значения по кумулятивным бакетам. Экспортирует _bucket, _sum и _count; квантиль считается на стороне Prometheus.
- **Бакет и le** — Интервал с границей le (less or equal): счётчик наблюдений, не превысивших границу с начала процесса. Разность соседних бакетов даёт интервал.
- **DefBuckets** — Сетка по умолчанию: {0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10} секунд. Заточена под веб-сервис; под быстрый API сетку задают своей.
- **+Inf** — Псевдобакет с бесконечной границей: всегда равен _count. Используется histogram_quantile как опора и служит проверкой целостности.
- **histogram_quantile** — PromQL-функция: histogram_quantile(0.99, sum by (le) (rate(metric_bucket[5m]))) считает квантиль из бакетов на сервере. Точность ограничена сеткой.
- **Observe** — Единственный метод наблюдения: hist.Observe(seconds). Потокобезопасен, принимает float64; у латентности передают time.Since(start).Seconds().

Гистограмма и лейблы из предыдущего урока работают в паре: гистограмма с лейблом 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. Домашняя работа урока как раз про это — и про чтение экспозиции руками, которое заменяет половину дашбордов.

## Коротко: что запомнить

- 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 берут за точный квантиль одиночного процесса — полный разбор в следующем уроке.
