Содержание урока · 10 разделов
- Кардинальность лейблов: умножение серий
- Как посчитать серии: метрики самого Prometheus
- Что ломается на стороне Prometheus
- Сколько серий стоит встроенный набор go_
- Кандидаты на вынос из лейблов
- Спасение: убрать, агрегировать, отдать логам
- Лейбл и его законное место
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
История, которая повторяется в каждой команде: сервис падал по ошибке у конкретного пользователя, кто-то быстро добавил user_id лейблом в http_requests_total, проблема нашлась, все довольны. Через месяц Prometheus начинает съедать гигабайты, скрейп длится секунды, а запросы на дашборде из урока про Grafana тормозят. Виновник — кардинальность лейблов: каждая уникальная комбинация значений лейблов создаёт отдельную серию, которую Prometheus хранит, индексирует и жмёт в каждый момент жизни. Метрика с четырьмя лейблами из коротких словарей — это десятки серий; та же метрика с идентификатором пользователя — минус пятьдесят тысяч серий, умноженных на число инстансов. Коварство в том, что деградация нарастает неделями: на тестовом стенде с тремя пользователями всё летает, а в проде с реальной базой пользователей мониторинг умирает сам и тянет за собой всё остальное. Граница разумного здесь — не число лейблов, а произведение вариантов значений, и этому произведению посвящён весь урок.
Кардинальность лейблов: умножение серий#
Серия в Prometheus — это имя метрики плюс каждая пара лейбл-значение; количество серий метрики равно произведению количества значений по всем лейблам. Счётчик запросов с лейблами route (пять маршрутов), method (четыре метода) и status (три класса) даёт шестьдесят серий на инстанс — это нормально и именно так работают срезы из урока про лейблы. Добавьте четвёртый лейбл 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
GOfunc 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; для кода важнее вывод: лимит на стороне сервера — предохранитель, а не решение, решение живёт в лейблах.
Сколько серий стоит встроенный набор 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, а не к гистограмме.
Хорошая версия: срезы в лейблах, пользователь в логах
GOhttpRequests := 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. Сколько серий на одном инстансе?
Проверка: где место userID
Команде нужен срез ошибок по конкретному пользователю. Где правильное место для user_id?
Сколько станет серий
Прочитайте код и выберите, что он выведет:
// Было: 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
// ...Разведите метрику и логи
Уберите высококардинальный лейбл из счётчика и отправьте идентификатор в лог.
Порядок действий, когда серии полезли
Расставьте шаги разбора высокой кардинальности.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: кардинальность
Практика снова локальная: песочница сайта не исполняет client_golang, поэтому тест бюджета серий и сниппеты реестра проверяются на своём Go. Возьмите счётчик из урока про HTTP-метрики, посчитайте его серии запросом count by (__name__), затем добавьте лейбл с идентификатором и посмотрите, как быстро prometheus_tsdb_head_series наберёт высоту. После этого уберите лейбл, добавьте тест бюджета серий и положите его рядом с хендлерами. Следующий урок про свой Collector — о способе считать метрику ровно в момент скрейпа, а шестнадцатый урок напоминает: датчики пулов тоже размножаются лейблами, и их словари должны быть короткими. Весь маршрут — на странице Prometheus, а следующий за этим урок про реестры и namespace наведёт дисциплину в именах, когда метрик станет много.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Серия — комбинация имени и значений лейблов; их число равно произведению вариантов.
- Идентификаторы, сырые пути, тексты ошибок и 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 удаляет лишние серии?
Нет: она меняет только результат запроса. Серии с высококардинальным лейблом продолжают рождаться, храниться и жрать память и время скрейпа. Единственное настоящее удаление — убрать лейбл из кода, тогда серии не появятся вовсе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
WebSocket в Go: real-time чаты и уведомления
Курс WebSocket на Go: gorilla/websocket, эхо-сервер, hub-паттерн, комнаты, авторизация соединений, ping/pong, reconnect и масштабирование. 25 уроков.
- Поднимать WS-сервер и апгрейдить HTTP-соединения
- Писать readPump/writePump без гонок на каждое соединение
- Строить hub: регистрацию, рассылку и комнаты
- Авторизовывать соединения и ограничивать сообщения