Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +20 XP

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

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

Содержание урока · 10 разделов

История, которая повторяется в каждой команде: сервис падал по ошибке у конкретного пользователя, кто-то быстро добавил 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

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; для кода важнее вывод: лимит на стороне сервера — предохранитель, а не решение, решение живёт в лейблах.

Сколько серий стоит встроенный набор 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, а не к гистограмме.

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

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. Кардинальность метрики фиксирована с момента старта — новые пользователи не добавляют ни одной серии.

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

ДанныеВ лейбле метрикиГде им место
Маршрут из шаблонада—
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
// ...

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

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

// Плохо: тысячи серий на каждый маршрут []string{"route", "method", "status", ""} // Хорошо: идентификатор уходит в лог logger.("order created", zap.String("user_id", uid))

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Серия

нажмите, чтобы перевернуть
Определение

Уникальная комбинация имени метрики и значений всех лейблов. Число серий метрики — произведение вариантов значений по всем лейблам.

1 / 5

Практика снова локальная: песочница сайта не исполняет 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 · Кардинальность: тихий убийца Prometheus)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

WebSocket25 уроков~13 чпрактика25 уроков уже доступны

WebSocket в Go: real-time чаты и уведомления

Курс WebSocket на Go: gorilla/websocket, эхо-сервер, hub-паттерн, комнаты, авторизация соединений, ping/pong, reconnect и масштабирование. 25 уроков.

  • Поднимать WS-сервер и апгрейдить HTTP-соединения
  • Писать readPump/writePump без гонок на каждое соединение
  • Строить hub: регистрацию, рассылку и комнаты
  • Авторизовывать соединения и ограничивать сообщения