---
title: "Кардинальность: тихий убийца Prometheus"
description: "Кардинальность лейблов в Prometheus: userID в лейбле и тысячи серий, счётчик prometheus_tsdb_head_series, границы разумного и спасение метрик от утопления."
course: "Мониторинг Go с Prometheus: метрики, Grafana, алерты"
url: "https://golangmaster.ru/courses/prometheus/lessons/17-kardinalnost/"
published: "2026-09-19"
updated: "2026-09-24"
minutes: 18
---

# Кардинальность: тихий убийца Prometheus

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

Кардинальность лейблов — число уникальных комбинаций значений, из которых рождается серия, — тихо убивает мониторинг: один userID, добавленный в лейбл ради отладки, превращает один счётчик в пятьдесят тысяч серий и осушает память Prometheus. Разберём, как считать свои серии, где проходит граница разумного и что делать с высококардинальными данными, которые всё-таки нужны.

История, которая повторяется в каждой команде: сервис падал по ошибке у конкретного пользователя, кто-то быстро добавил user_id лейблом в http_requests_total, проблема нашлась, все довольны. Через месяц Prometheus начинает съедать гигабайты, скрейп длится секунды, а запросы на дашборде из [урока про Grafana](/courses/prometheus/lessons/13-grafana-obzor/) тормозят. Виновник — кардинальность лейблов: каждая уникальная комбинация значений лейблов создаёт отдельную серию, которую Prometheus хранит, индексирует и жмёт в каждый момент жизни. Метрика с четырьмя лейблами из коротких словарей — это десятки серий; та же метрика с идентификатором пользователя — минус пятьдесят тысяч серий, умноженных на число инстансов. Коварство в том, что деградация нарастает неделями: на тестовом стенде с тремя пользователями всё летает, а в проде с реальной базой пользователей мониторинг умирает сам и тянет за собой всё остальное. Граница разумного здесь — не число лейблов, а произведение вариантов значений, и этому произведению посвящён весь урок.

### Кардинальность лейблов: умножение серий

Серия в Prometheus — это имя метрики плюс каждая пара лейбл-значение; количество серий метрики равно произведению количества значений по всем лейблам. Счётчик запросов с лейблами route (пять маршрутов), method (четыре метода) и status (три класса) даёт шестьдесят серий на инстанс — это нормально и именно так работают срезы из [урока про лейблы](/courses/prometheus/lessons/06-labels/). Добавьте четвёртый лейбл user_id с пятьюдесятью тысячами значений — получите два с половиной миллиона серий с одного счётчика на одном инстансе. Произведение — коварная арифметика: два безобидных лейбла по десять значений дают сотню, а лейбл со свободным текстом — бесконечность, потому что значения приходят от пользователей и маршрутизаторов. Отдельный сезон боли — churn: когда старые серии постоянно умирают, а новые рождаются, индекс не успевает остывать. К этой категории относятся лейблы из времени, номера задачи и ID пода: их значения ограничены, но меняются слишком часто.

### Один лейбл — и счётчик стал базой пользователей

```go
// Плохо: user_id в лейбле
httpRequests := prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total", Help: "Запросы"},
        []string{"route", "method", "status", "user_id"},
)

// Что видит Prometheus в /metrics:
// http_requests_total{route="/orders",method="POST",status="200",user_id="8c1f"} 3
// http_requests_total{route="/orders",method="POST",status="200",user_id="91a2"} 1
// http_requests_total{route="/orders",method="POST",status="200",user_id="b47e"} 7
// ... повторить 50 000 раз
```

Каждый новый пользователь после первого запроса рождает серию для каждой комбинации route, method и status, где он бывал. База растёт незаметно: на стенде три строки, в проде — сотни тысяч. Отдельная мелочь: почти у каждой серии значение равно единице, а такие данные бесполезны для агрегаций — они и не срезы, они список пользователей, живущий в памяти Prometheus.

### Как посчитать серии: метрики самого Prometheus

У Prometheus есть собственные серии о себе, и главный счётчик здесь — prometheus_tsdb_head_series: число серий в голове базы на момент скрейпа. Стабильная линия говорит, что набор лейблов стабилен; наклонная вверх — что серии плодятся, и именно так выглядел тот самый user_id на графике. Второй запрос — разложение по метрикам: count by (__name__)({__name__=~".+"}) вернёт число серий на каждую метрику и покажет чемпиона за один запрос. Третий признак — scrape_duration_seconds: время скрейпа вашего /metrics, которое при высокой кардинальности растёт вместе с объёмом ответа: экспозиция из сотен тысяч строк передаётся по сети каждые тридцать секунд, и это не гипотеза, а арифметика. В коде тот же счётчик можно завести себе: реестр умеет отдавать свой набор метрик, и по нему считается число серий без сервера Prometheus — сниппет ниже превращается в тест, который падает в CI, когда серия перешагивает границу.

### Счётчик серий в тесте: предохранитель в CI

```go
func countSeries(reg *prometheus.Registry) int {
        mfs, err := reg.Gather()
        if err != nil {
                return -1
        }
        total := 0
        for _, mf := range mfs {
                total += len(mf.GetMetric())
        }
        return total
}

func TestSeriesBudget(t *testing.T) {
        if got := countSeries(appRegistry); got > 500 {
                t.Fatalf("series budget exceeded: %d > 500", got)
        }
}
```

reg.Gather() возвращает набор метрик реестра, и len(mf.GetMetric()) для каждой семьи — это число серий, которое увидел бы Prometheus. Тест прогоняет типичный сценарий на собственном реестре приложения и фиксирует бюджет серий числом: пятьсот — условная граница, которую каждая команда выбирает под себя. Новый лейбл с сотнями значений ломает бюджет до ревью, а не в проде.

### Что ломается на стороне Prometheus

Кардинальность бьёт по самому Prometheus, а не по вашему сервису, и это делает её тихой: приложение чувствует себя прекрасно, пока не падает мониторинг. Первая жертва — память: каждая серия живёт в индексе головы, и миллионы серий означают гигабайты, которые не освобождаются простым рестартом дня. Вторая — время скрейпа: экспорт растёт, и одновременно опрашиваются сотни таргетов, поэтому scrape_duration_seconds ползёт вверх у всех. Третья — запросы: агрегация по миллионам серий занимает секунды, дашборды подвисают, и тот же эффект ловит любой алерт с тяжёлым выражением. Сервер защищается лимитами конфигурации: sample_limit и series_limit на таргет обрывают скрейп с ошибкой, когда бюджет исчерпан, — и это правильное поведение, потому что падение одного таргета лучше падения всего Prometheus. О настройках скрейпа и лимитов подробнее будет в [уроке про prometheus.yml](/courses/prometheus/lessons/23-scrape-config/); для кода важнее вывод: лимит на стороне сервера — предохранитель, а не решение, решение живёт в лейблах.

### Сколько серий стоит встроенный набор go_

Справедливости ради посчитаем и встроенные серии: набор go_ и process_ из GoCollector и ProcessCollector — это несколько десятков серий с постоянным составом: лейблов почти нет, значения не меняются, и вклад кардинальности от них нулевой. Несколько десятков против сотен тысяч от одного неудачного лейбла — разница в четыре порядка, поэтому чинить надо не встроенный набор, а собственные метрики. Реальный риск в другом: пакет collectors позволяет включить расширенные метрики рантайма из runtime/metrics, и их набор уже заметно больше базового — включайте осознанно, а не флажком на всякий случай. Второй источник спокойствия — именно постоянство: серии go_ не рождаются и не умирают в процессе жизни, то есть не создают churn. Отсюда простое правило для ревью: серия оправдана, если вы можете назвать её количество заранее; всё, что рождается от входящих данных — идентификаторов, путей, текстов, — серия не оправдана почти никогда.

### Кандидаты на вынос из лейблов

- user_id, email, номер телефона и любые идентификаторы пользователей
- Сырой путь запроса: /api/users/12345 вместо шаблона маршрута
- Текст ошибки: он же путь к сотням уникальных значений
- trace_id и request_id: их место в логах и трейсах
- IP-адреса и номера портов клиентов
- Идентификаторы платежей, заказов, задач и прочих сущностей

### Спасение: убрать, агрегировать, отдать логам

Три способа справиться с высококардинальными данными, и первый — единственный настоящий. Убрать: идентификатор уходит из лейбла в лог с request id, где поиск по пользователю работает и без Prometheus. Агрегировать на клиенте: вместо серии на пользователя — серия на класс, например код ответа или тип операции; срез по конкретному пользователю теряется, но он и не задача метрик. Агрегировать на сервере: sum by (route) спрячет user_id в результате запроса, но серии продолжат храниться и жрать память — это косметика, и применять её стоит только когда лейбл уже нельзя убрать из кода до следующего релиза. Спорное мнение с границей: отдельные системы — Loki для логов, трейсинг — существуют ровно для высококардинальных вопросов, и попытка воспроизвести их в лейблах метрик всегда проигрывает по цене. Если вопрос звучит как «что случилось с этим пользователем», это вопрос к логам из [урока про request id](/courses/zap/lessons/14-request-id/), а не к гистограмме.

### Хорошая версия: срезы в лейблах, пользователь в логах

```go
httpRequests := prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total", Help: "Запросы"},
        []string{"route", "method", "status"},
)

func ordersHandler(w http.ResponseWriter, r *http.Request) {
        uid := userID(r.Context())
        logger.Info("order created",
                zap.String("user_id", uid),
                zap.String("request_id", reqID(r.Context())),
        )
        // ... бизнес-логика и Inc по трём лейблам
}
```

Лейблы остались из коротких словарей: маршрутов десятки, методов четыре, классов статуса несколько — счётчик живёт в пределах сотни серий. Идентификатор пользователя уходит в структурный лог вместе с request id: по ним поиск работает мгновенно и без нагрузки на Prometheus. Кардинальность метрики фиксирована с момента старта — новые пользователи не добавляют ни одной серии.

> **Типичная ошибка: путь как лейбл.** Лейбл path со значением r.URL.Path выглядит безобидно, пока на сервис не заходит робот с миллионом вариантов query-строк. Каждый уникальный путь — новая серия, и через неделю мониторинг живёт в индексе длиной в гигабайты. Правильное значение — шаблон маршрута из роутера: chi даёт его по правилу маршрута, у gin это c.FullPath(), который вернёт /api/users/:id вместо реального пути. Лейбл остаётся, кардинальность становится конечной.

### Лейбл и его законное место

| Данные | В лейбле метрики | Где им место |
| --- | --- | --- |
| Маршрут из шаблона | да | — |
| HTTP-статус и метод | да | — |
| Версия API или релиза | да, если значений десятки | — |
| user_id и email | никогда | структурные логи, трейсы |
| Сырой путь с идентификатором | никогда | шаблон маршрута плюс логи |
| Текст ошибки | никогда | тип ошибки в лейбле, текст в логах |

### Проверка: арифметика серий

**Вопрос:** Лейблы: route с 5 значениями, method с 4, status с 3. Сколько серий на одном инстансе?

- ✅ 60 — произведение вариантов
- 12 — сумма вариантов
- 5 — по числу маршрутов
- Зависит от трафика

Число серий равно произведению количеств значений: 5 умножить на 4 умножить на 3 — шестьдесят серий на инстанс, независимо от трафика. Трафик заполняет значения счётчиков, но серии рождаются от комбинаций лейблов, а не от количества запросов.

### Проверка: где место userID

**Вопрос:** Команде нужен срез ошибок по конкретному пользователю. Где правильное место для user_id?

- ✅ В структурном логе с request id
- В лейбле счётчика ошибок
- В имени метрики
- В значении лейбла status

Идентификатор пользователя — высококардинальные данные, их место в логах и трейсах, где поиск по значению не рождает серий. В лейбле счётчика каждый пользователь создаст серии на каждую комбинацию остальных лейблов, и мониторинг заплатит памятью за запрос, который и так быстрее делается по логам.

### Сколько станет серий

```go
// Было: 2 значения лейбла status = 2 серии
// Добавили лейбл env с 4 значениями

http_requests_total{status="200",env="prod"} 10
http_requests_total{status="200",env="stage"} 3
http_requests_total{status="500",env="prod"} 1
// ...
```

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

- ✅ 8 серий — 2 умножить на 4
- 6 серий — 2 плюс 4
- 2 серии — лейбл env не влияет
- 4 серии — по числу значений env

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

### Разведите метрику и логи

Уберите высококардинальный лейбл из счётчика и отправьте идентификатор в лог.

```go
// Плохо: тысячи серий на каждый маршрут
[]string{"route", "method", "status", "___"}

// Хорошо: идентификатор уходит в лог
logger.___("order created", zap.String("user_id", uid))
```

Подсказка: Из лейблов уходят идентификаторы; у SugaredLogger метод Info принимает сообщение и пары ключ-значение.

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

### Порядок действий, когда серии полезли

Расставьте шаги разбора высокой кардинальности.

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

1. `Посмотрите график prometheus_tsdb_head_series за неделю`
2. `Найдите метрику-чемпиона через count by (__name__)`
3. `Проверьте лейблы чемпиона на идентификаторы и свободный текст`
4. `Уберите высококардинальный лейбл из кода, отдав данные логам`
5. `Дождитесь, пока старые серии устареют после перезапуска`
6. `Зафиксируйте бюджет серий тестом в CI`

### Карточки: кардинальность

- **Серия** — Уникальная комбинация имени метрики и значений всех лейблов. Число серий метрики — произведение вариантов значений по всем лейблам.
- **prometheus_tsdb_head_series** — Метрика самого Prometheus: число серий в голове базы. Наклонная вверх — серии плодятся; стабильная линия — набор лейблов стабилен.
- **Бюджет серий в CI** — Тест считает len(mf.GetMetric()) по reg.Gather() и падает при превышении границы. Новый лейбл с сотнями значений ловится до ревью.
- **sum by против удаления лейбла** — Агрегация в запросе прячет лейбл в результате, но серии продолжают храниться и стоить памяти. Настоящее решение — не хранить лейбл.
- **Куда уходят userID и trace_id** — В структурные логи и трейсы: поиск по значению там не рождает серий. Метрика отвечает «сколько», лог отвечает «что именно у этого пользователя».

Практика снова локальная: песочница сайта не исполняет client_golang, поэтому тест бюджета серий и сниппеты реестра проверяются на своём Go. Возьмите счётчик из [урока про HTTP-метрики](/courses/prometheus/lessons/09-http-metriki/), посчитайте его серии запросом count by (__name__), затем добавьте лейбл с идентификатором и посмотрите, как быстро prometheus_tsdb_head_series наберёт высоту. После этого уберите лейбл, добавьте тест бюджета серий и положите его рядом с хендлерами. Следующий [урок про свой Collector](/courses/prometheus/lessons/18-svoj-collector/) — о способе считать метрику ровно в момент скрейпа, а [шестнадцатый урок](/courses/prometheus/lessons/16-metriki-gorutin/) напоминает: датчики пулов тоже размножаются лейблами, и их словари должны быть короткими. Весь маршрут — на [странице Prometheus](/courses/prometheus/), а следующий за этим [урок про реестры и namespace](/courses/prometheus/lessons/22-reestry-i-namespaces/) наведёт дисциплину в именах, когда метрик станет много.

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

- Серия — комбинация имени и значений лейблов; их число равно произведению вариантов.
- Идентификаторы, сырые пути, тексты ошибок и trace_id в лейблах не живут никогда.
- prometheus_tsdb_head_series — главный детектор: наклон вверх значит, что серии плодятся.
- count by (__name__)({__name__=~".+"}) находит метрику-чемпиона одним запросом.
- Тест бюджета серий через reg.Gather() ловит кардинальность в CI, а не в проде.
- sum by прячет лейбл в результате, но не удаляет серии — решение всегда в коде.
- Встроенный набор go_ стоит несколько десятков серий и в кардинальности не виноват.

## Домашнее задание

Посчитайте серии своих метрик запросом count by (__name__)({__name__=~".+"}) и найдите чемпиона. Затем добавьте в учебный счётчик лейбл user_id с фейковыми идентификаторами и посмотрите на рост prometheus_tsdb_head_series за десять минут трафика. Уберите лейбл, отправив идентификатор в лог с request id, и напишите тест бюджета серий через reg.Gather() с границей, которую выбрала ваша команда. Повторите разбор на лейбле path со сырыми URL и сравните с шаблоном маршрута из роутера.

## Вопросы и ответы по уроку

### Сколько серий — это уже много?

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

### Можно ли ограничить кардинальность настройками Prometheus?

Можно смягчить последствия: sample_limit и series_limit обрывают скрейп таргета при превышении бюджета и спасают сервер от падения. Это предохранитель, а не решение: таргет всё равно перестаёт отдавать данные. Решение живёт в коде — в списке лейблов каждой метрики.

### Агрегация sum by удаляет лишние серии?

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