4. Cardinality и стоимость метрик

Кардинальность — главный враг Monitoring Backend

Если вы спросите опытного SRE-инженера или архитектора распределенных систем, какая техническая проблема чаще всего приводит к аварийной гибели кластеров Prometheus, VictoriaMetrics или Grafana Mimir, ответ будет дан мгновенно и безоговорочно: High Cardinality (Взрыв кардинальности временных рядов).

Для разработчика на Go глубокое понимание природы кардинальности — это не абстрактная инфраструктурная теория, а вопрос выживания собственного сервиса. Непродуманное добавление казалось бы безобидного лейбла в метрику способно не просто обрушить централизованную базу данных мониторинга, но и физически уничтожить сам сервис на Go через неумолимый механизм OutOfMemory Killer (OOM) и многосекундные блокировки рантайма.


Что такое Кардинальность?

В теории реляционных баз данных кардинальность (Cardinality) определяет количество уникальных значений в определенном столбце таблицы. В контексте мониторинга и баз данных временных рядов (Time Series Databases, TSDB) под кардинальностью понимают суммарное число одновременно существующих уникальных временных рядов (Time Series), непрерывно порождаемых вашим приложением.

Каждый уникальный набор пар «имя метки — значение метки» формирует физически отдельный временной ряд со своим собственным жизненным циклом. Математическая модель кардинальности описывается формулой декартова произведения:

$$ \text{Cardinality} = \text{Metric Name} \times \prod (\text{Unique Values of Label}_1, \text{Label}_2, \dots) $$

Пример: Низкая vs Высокая кардинальность

  1. Низкая кардинальность (Low Cardinality / Good):
    • Метрика: http_requests_total.
    • Лейблы: method="GET", status="200".
    • Уникальные значения: method (GET, POST, PUT — 3 значения), status (200, 400, 404, 500 — 4 значения).
    • Итого временных рядов: $1 \times 3 \times 4 = 12$.
    • Влияние на систему: Пренебрежимо мало. Занимает пару килобайт RAM, парсится за микросекунды.
  2. Высокая кардинальность (High Cardinality / Bad):
    • Метрика: http_requests_total.
    • Лейблы: method="GET", user_id="<id>".
    • Уникальные значения: method (3 значения), user_id (1 000 000 зарегистрированных пользователей).
    • Итого временных рядов: $1 \times 3 \times 1,000,000 = 3,000,000$.
    • Влияние на систему: Колоссальная нагрузка. База данных захлебывается, память процесса исчерпана.

Under the Hood: Как это ломает систему

Почему миллионы рядов разрушают инфраструктуру, если речь идет всего лишь о простых числовых счетчиках? Механическая проблема кроется в структуре памяти на обоих концах провода.

1. Проблема на стороне Prometheus (TSDB)

Ядро базы данных Prometheus TSDB спроектировано вокруг структур данных, близких к LSM-деревьям:

  • Для каждого активного временного ряда в оперативной памяти аллоцируется специальный буфер — голова ряда (Head Chunk), куда записываются входящие замеры за последние 2 часа.
  • Каждый активный ряд удерживает в RAM от 1 до 2 КБ системных метаданных, структур кэша и дескрипторов инвертированного индекса.
  • При наличии 10 000 000 активных рядов серверу мониторинга требуется $\approx$ 10–20 ГБ чистой оперативной памяти исключительно для поддержания буферов в RAM, не считая тяжелых структур инвертированного индекса (Inverted Index Postings List).
  • Срыв интервала скрейпинга: При очередном опросе эндпоинта /metrics сервер Prometheus обязан распарсить гигантский текстовый поток. Если парсинг 3 миллионов строк занимает 25 секунд при интервале опроса (scrape_interval) в 15 секунд, Prometheus начинает пропускать итерации (Scrape Timeout), теряя данные и генерируя ложные срабатывания алертов.

2. Проблема на стороне Go-приложения (Client)

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

В официальной библиотеке prometheus/client_golang регистрация метрик с лейблами через promauto.NewGaugeVec или NewCounterVec создает внутреннюю потокобезопасную мапу (sync.Map или map, защищенный sync.RWMutex).

Go
// ОПАСНЫЙ КОД
var userRequests = promauto.NewCounterVec(
    prometheus.CounterOpts{
        Name: "user_requests_total",
    },
    []string{"user_id"}, // High Cardinality Label!
)

func handler(w http.ResponseWriter, r *http.Request) {
    userID := r.Header.Get("X-User-ID") // Например, 1 млн уникальных ID
    // Для каждого userID создается НОВЫЙ объект в памяти приложения
    userRequests.WithLabelValues(userID).Inc()
}

Что физически происходит внутри рантайма Go?

  1. Неустранимая утечка памяти (Memory Leak):
    Каждый вызов WithLabelValues(userID) при новом значении ID аллоцирует в куче структуру дескриптора счетчика. Поскольку в реестре метрик ссылки на эти объекты удерживаются глобально (Counter по определению монотонен и вечен), сборщик мусора Go (GC) никогда не сможет освободить эту память. Сервис будет неуклонно раздуваться в куче, пока процесс не прибьет Linux OOM Killer.
  2. Блокировка ввода-вывода и Stop-The-World:
    Когда скрейпер обращается к /metrics, библиотека выполняет обход всего многомиллионного дерева структур, конвертируя числа в текстовые строки. Этот процесс аллоцирует гигабайты мусора, вызывает дичайшее давление на GC и на секунды блокирует обработчики мьютексами. В результате приложение перестает отвечать на реальные бизнес-запросы клиентов!

Mechanical Sympathy: Работа с лейблами

Чтобы метрики оставались дешевыми и приносили максимальную пользу, архитектура их проектирования опирается на три фундаментальных правила:

Правило 1: Избегайте неограниченных лейблов

В метках метрик категорически запрещено использовать:

  • Уникальные ID сущностей (user_id, order_id, device_uuid, session_token).
  • Адреса электронной почты (email).
  • Произвольные IP-адреса клиентов.
  • Метки времени высокой точности (Timestamp).
  • Неструктурированные тексты ошибок (Error messages).

Любые сущности с открытым пространством значений принадлежат Логам или Трейсам, где механизмы полнотекстового индексирования и сэмплирования изначально рассчитаны на высокую кардинальность.


Правило 2: Ограничение кардинальности (Bounded Cardinality)

Каждая метка должна принадлежать строго замкнутому, заранее известному множеству значений:

  • status_code: 200, 400, 401, 403, 404, 500 (Ограничено ~10 значениями — безопасно).
  • region: us-east-1, eu-central-1 (Ограничено списком дата-центров — безопасно).
  • http_endpoint: /users/{id} — смертельно опасно, если передается «сырой» путь из URL (/users/41251). Пути обязаны быть нормализованы до шаблона маршрутизатора: /users/:id (веб-фреймворки Gin, Echo, Chi делают это из коробки).

Правило 3: Buckets vs Labels

Гистограммы (Histogram) в Prometheus — скрытый мультипликатор кардинальности.
Каждая гистограмма автоматически создает отдельный временной ряд для каждого бакета (_bucket{le="..."}), плюс базовые ряды _sum и _count.

Если ваша гистограмма latency содержит 12 бакетов, и вы навесили на нее лейблы method (4 значения) и status (5 значений), то одна-единственная метрика породит:
$$(12 \text{ (бакетов)} + 2 \text{ (sum/count)}) \times 4 \times 5 = 280 \text{ временных рядов}.$$
Если же неосторожно добавить туда еще один лейбл на 100 значений, одна метрика раздуется до 28 000 рядов!


Как обнаружить проблему?

В арсенале инженера есть мощные диагностические запросы на языке PromQL, позволяющие мгновенно выявить проблемные метрики:

PROMQL
topk(10, count by (__name__)({job="my-go-service"}))

Этот запрос выводит десятку метрик с максимальным количеством активных временных рядов. Если для любой метрики показатель превышает 10 000 (а тем более 100 000) — в коде допущена критическая архитектурная ошибка выбора лейблов.

В передовых платформах хранения временных рядов (VictoriaMetrics, Grafana Mimir) реализованы встроенные механизмы Cardinality Limiter. Они позволяют на лету принудительно отбрасывать или агрегировать (rollup) серии, превышающие безопасный лимит, защищая центральную инфраструктуру мониторинга от падения.


Итог

  1. Кардинальность (Cardinality) — это суммарное число уникальных временных рядов, порождаемое декартовым произведением всех значений меток метрики.
  2. Низкая кардинальность (Low Cardinality): Метрики компактны, дешевы в вычислении и идеальны для построения скоростных графиков и критических алертов.
  3. Высокая кардинальность (High Cardinality): Раздувает Head Chunks базы TSDB, порождает утечки памяти в sync.Map клиентской библиотеки Go и способна заблокировать рантайм приложения во время скрейпинга /metrics.
  4. Золотое правило System Design: любые уникальные пользовательские идентификаторы (ID, токены, email, IP) должны направляться исключительно в логи и трейсы. Метрики оперируют только компактными агрегатами и дискретными множествами.

1. Типы метрик. Counter, Gauge, Histogram