Кардинальность и стоимость телеметрии: wide events, сэмплинг, бюджет

Наблюдаемость незаметно становится одной из самых дорогих статей инфраструктуры: один label с user_id взрывает кардинальность метрик, debug-логи растут гигабайтами, трейсы пишутся на 100%. Откуда берётся стоимость каждого столпа, как её резать без потери сигнала (сэмплинг, агрегация, дропы в пайплайне), и почему wide events («observability 2.0») меняют компромисс между кардинальностью и пользой

Восемь статей серии описывали, как сделать наблюдаемость полезной. Эта — про то, сколько она стоит и что резать, когда счёт приходит. Причём дорого не «много данных вообще»: у каждого столпа своя механика стоимости, и путать их — верный способ резать не то.

Это восьмая, заключительная по технике статья серии «Наблюдаемость на практике». Она подводит счёт: берёт числа предыдущих семи и складывает в одну картину.

Главное, что стоит унести из неё: метрики платят за кардинальность, события — за объём, и это принципиально разные модели. Один добавленный лейбл превратил у меня 2 серии в 509 — рост в 254 раза от одного поля. То же самое поле в структурированном событии не создало ни одной постоянной серии: событие целиком заняло 488 байт полезной нагрузки. Сравниваются при этом не два способа записать одно и то же, а полное событие и агрегированный сигнал — и в этом различии весь выбор.

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

В статье

Кардинальность — это умножение, а не сложение

Time series в Prometheus — это уникальная комбинация имени метрики и всех значений лейблов. Растёт число серий не как сумма мощностей лейблов, а как их произведение — точнее, произведение даёт верхнюю границу, которой число серий достигает, если встречаются все комбинации. Реально серий столько, сколько комбинаций действительно встретилось, — ниже на стенде один order_id даёт 509 серий, а не произведение мощностей. Но и граница опасна: каждый новый лейбл умножает её, а не прибавляет.

метрика с лейблами: service (3 значения) × endpoint (20) × status (5)
серий: 3 × 20 × 5 = 300

добавили region (4 значения):
серий: 3 × 20 × 5 × 4 = 1200        <- +1 лейбл, ×4 серий

добавили user_id (100 000 значений):
серий: 3 × 20 × 5 × 4 × 100 000 = 120 000 000

Последняя строка и есть причина, по которой «положим-ка user_id в лейбл» кладёт Prometheus. Не потому что данных много, а потому что каждое новое значение множится на всё остальное.

Тут стоит вспомнить цену гистограммы из статьи 4: гистограмма латентности — это 15 серий на каждую комбинацию лейблов против одной у счётчика. Множитель работает и здесь, только его вносит не лейбл, а тип метрики.

Замер: 2 серии против 509

Теория выше — арифметика, спорить с ней не о чем. Интереснее посмотреть, как это выглядит на живом стенде. Бизнес-счётчик заказов получает лейбл с идентификатором заказа — уникальным на каждый запрос:

attrsOrders := []attribute.KeyValue{attribute.Bool("reserved", order.Reserved)}
// Верхняя граница обязательна: без неё серии множатся до исчерпания памяти
// Prometheus, и замер не состоится — вместо результата будет мёртвый контейнер.
if s.CardinalityBombMax > 0 {
    attrsOrders = append(attrsOrders,
        attribute.String("order_id", fmt.Sprintf("ord-%d", seq%uint64(s.CardinalityBombMax))))
}
s.orders.Add(ctx, 1, metric.WithAttributes(attrsOrders...))

Три прогона на чистых томах, 600 запросов (544 успешных заказа), предел уникальных идентификаторов — 500:

прогон серий у счётчика всего серий сумма счётчика
base 2 3162 544
bomb 509 3669 544
cut 2 3164 544

Один лейбл — рост в 254 раза у одной метрики.

Почему 509, а не ровно 500? Серия — комбинация всех лейблов. Один и тот же order_id, встретившийся и с reserved=true, и с reserved=false, даёт две серии; девять идентификаторов попали в обе категории. Мелочь, но она же и есть механика умножения в миниатюре.

Что подорожало не там, где ожидалось

А теперь неудобная часть, без которой замер был бы рекламой.

прогон память Prometheus время запроса
base 53,3 МиБ 1305 мс
bomb 52,1 МиБ 1289 мс
cut 56,4 МиБ 1247 мс

У прогона со взрывом память меньше, чем у базового. Время запроса тоже без внятной зависимости от числа серий.

Соблазн подать это как «смотрите, +N МиБ» был велик, но выводить из таких чисел нечего: на тысячах серий (не миллионах) разница между режимами того же порядка, что разброс между повторами. Единственное, что заметно, — Collector в прогоне со взрывом занял 82,8 МиБ против 67,7 базовых; правдоподобно (больше точек между экспортами), но это один замер без повторов, и как результат он не подаётся.

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

Стоимость логов: 2 стрима против 1202

У логов другая механика: платят за объём и за индекс. В Loki индексируются только метки, и та же ошибка «положим уникальное значение в метку» стоит ровно так же дорого. Замер из статьи 3:

ПРОГОН     СТРИМОВ  ОЗУ LOKI  ЗАПРОС  ЛЕЙБЛЫ ИНДЕКСА
normal           2   82.3 МиБ    39мс  deployment_environment, service_name
highcard      1202   85.9 МиБ    52мс  + trace_id_label

601-кратный рост числа стримов — и снова память с временем запроса почти не двинулись, потому что объём стендовый. Механика та же, следствие то же.

И отдельная ловушка оттуда же: в дефолтном списке индексных лейблов Loki есть service.instance.id, который меняется при каждом перезапуске процесса. Три рестарта Java-сервиса дали 3, 4, 5, 6 стримов — по новому на каждый старт; с явным безопасным набором лейблов оставалось 2. Никто не «кладёт уникальное значение в метку» намеренно — достаточно принять список по умолчанию.

Тот же механизм всплыл в статье 7, но уже на метриках: resource_to_telemetry_conversion переносит в лейблы каждый ресурсный атрибут, включая process_pid и service_instance_id — 33 лейбла вместо 7. Каждый рестарт сервиса порождает полный новый набор серий, а серия дороже стрима.

Стоимость трейсов и цена сэмплинга

Трейсы дороги объёмом: полный сбор означает хранение каждого спана каждого запроса. Сэмплирование — основной рычаг, и у него два вида, с разной ценой и разными последствиями.

Head sampling решает на входе, по trace_id, до того как трейс собран. Дёшево (данные не собираются вовсе) и слепо: выбросы и ошибки отбрасываются с той же вероятностью, что рутина.

Tail sampling решает в Collector после сбора трейса. Значит приложения отправили всё, и на передаче до Collector и его буферизации не сэкономлено ничего — а вот дальше по конвейеру экономия полная: не экспортируется, не принимается бэкендом и не хранится всё, что политики отбраковали. Зато решение осмысленное. Замер из статьи 2, три политики (все ошибки, всё дольше 100 мс, остальное 10 %):

policy=errors  sampled=35    <- ровно столько ошибок и было в плане: 18 + 17
policy=slow    sampled=81    <- намеренно медленных было 34
policy=rest    sampled=33    <- 8,1 % при заявленных 10 %
global         sampled=138 из 405

Ошибки сохранены все. А политика латентности отобрала 81 трейс вместо ожидаемых 34: 47 «быстрых» запросов реально заняли больше 100 мс из-за конкуренции за ядра и GC. Это не дефект политики — так она и работает, ловя фактическую латентность, а не задуманную.

Цена сэмплинга не только в потерянных трейсах. Статья 7 измерила её на метриках: при head-сэмплинге 10 % span-метрики учли 106 запросов из 1000 — занижение в 9,43 раза. Экономия на трейсах молча испортила метрики, посчитанные из трейсов.

Резать в пайплайне, а не в приложении

Резать высококардинальный лейбл можно в трёх местах, и место выбирается не по удобству.

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

В Collector — там, где реально приходится: код не ваш, а поток уже в руках.

В бэкенде (relabel при скрейпе) — последний рубеж: данные доехали, оплачена передача, но не хранение.

processors:
  metrics_transform/drop_order_id:
    transforms:
      - include: khorost_tech.orders.created
        action: update
        operations:
          - action: aggregate_labels
            label_set: [reserved]      # что оставляем
            aggregation_type: sum       # что делаем с остальным

Наивная резка ломает значение, сохраняя вид исправности

Очевидное решение выглядит проще: взять процессор transform и удалить лейбл.

processors:
  transform/drop_order_id:
    metric_statements:
      - context: datapoint
        statements:
          - delete_key(attributes, "order_id")

Работает ровно наполовину. Замерено это на отдельном прогоне при сборке модуля, до основного замера: серий действительно становится 2 из 507 — выглядит успехом, — а значение счётчика падает до 1 вместо сотен.

Про 507 против 509 из таблицы выше: это разные прогоны. Наивную резку я мерил тогда, когда она ещё стояла в конфиге; к основному замеру там уже был правильный вариант, и повторять поломку ради круглого числа я не стал. Оба значения — от одного и того же предела в 500 уникальных идентификаторов, а расходятся на единицы потому, что в каждом прогоне свой набор совпадений order_id с двумя значениями reserved.

Причина: точки, ставшие после удаления лейбла неразличимыми, не складываются сами. Экспортёр берёт последнюю. Метрика получается дешёвой и неверной, причём внешне полностью исправной: серии есть, значения есть, график рисуется, алерт на ней «работает».

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

Wide events: платить за объём вместо кардинальности

Подход, который иногда называют «observability 2.0»: вместо набора метрик и логов на каждый запрос писать одно широкое структурированное событие со всеми полями, а метрики считать из него по надобности.

Собираясь мерить, я обнаружил, что стенд это уже делает: структурированный лог с контекстом запроса и есть wide event, просто он так не называется. Тело записи почти пустое, всё содержание — в полях.

Первое, что пришлось поправить в самом замере, — постановку. Событие «заказ создан» пишется только при успешном заказе: на 404 и 502 заказа нет, значит нет и записи. Считать объём «на запрос» по обычной смешанной нагрузке — значит делить объём событий на число запросов, часть которых событий не порождает. Поэтому здесь нагрузка целиком успешная, а проверка требует точного равенства: сколько заказов создано, столько событий и должно оказаться в хранилище.

отправлено: 200, создано заказов: 200
событий этого прогона в Loki: 200

ГОДЕН   событий = числу созданных заказов 200 (ожидалось 200)
ГОДЕН   все запросы успешны              200 (ожидалось 200)

Записи отбираются по метке прогона, а не «за последние десять минут»: иначе в выборку попадают события прошлых прогонов, и замер молча описывает не тот опыт.

Теперь сам объём — медиана по всем двумстам событиям, а не по одной случайной записи:

тело:               23 байта  («заказ создан»)
поля:              465 байт   (20 полей)
всего:             488 байт   (разброс по 200 событиям: от 488 до 490)
из них поля заказа: 180 байт  (order_id, sku, quantity, reserved, trace_id, span_id)

Медиана здесь не педантизм: у разных заказов разной длины идентификатор, и одиночная запись давала от прогона к прогону то 488, то 509 байт. Разброс по двум сотням — две единицы, и на такое число уже можно опираться.

Что именно посчитано. Длина тела плюс сумма длин ключей и значений полей, всё в UTF-8. Это полезная нагрузка, а не размер на диске: за её пределами остались заголовки протокола, временная метка, накладные расходы формата хранения — и сжатие, которое размер уменьшает. Со счётом за хранилище это сравнивать нельзя; с другим представлением тех же заказов — можно, потому что считается одинаково.

Поля хранятся как structured metadata: Loki возвращает их в результатах запроса, но не индексирует. Уникальный order_id здесь не создаёт ни стрима, ни серии — в отличие от того, что случилось бы с ним в лейбле.

Те же двести заказов в метриках занимают 17 постоянных серий: 2 у счётчика заказов плюс 13 бакетов гистограммы длительности плюс sum и count. (В замере кардинальности выше серий было больше — там нагрузка смешанная, и у гистограммы два значения outcome вместо одного.)

Это не равнозначные представления, и в этом вся суть

Сравнивать 488 байт с 17 сериями как «две цены за одно и то же» было бы подлогом. Событие несёт order_id, trace_id, span_id, sku — то, чего в метриках нет и быть не должно: положить order_id в лейбл значит получить те самые 509 серий из замера выше.

Правильная формулировка — полное событие против агрегированного сигнала:

что отвечает чем платит
событие «что было с этим заказом» объёмом: 488 байт на заказ, истекает по ретеншену
метрики «как система в целом» кардинальностью: 17 серий живут постоянно

Метрики дешевле именно потому, что деталей не хранят. Это не преимущество и не недостаток — это разные вопросы к системе.

Как растёт каждая сторона

Событие: линейно по числу заказов. Двести заказов — около 95 КиБ полезной нагрузки; миллион — порядка 465 МиБ до сжатия. Это иллюстрация линейности, а не измеренная стоимость хранения: ни сжатия, ни накладных расходов формата здесь нет.

С метриками сложнее, и одной фразой не отделаться. Число серий от количества запросов действительно не зависит: хоть сто запросов в час, хоть сто тысяч — те же 17 серий. Но «не зависит» относится только к кардинальности, а расход складывается минимум из трёх слагаемых:

Слагаемое От чего растёт Пример со стенда
кардинальность от новых значений лейблов один order_id: 2 → 509 серий
частота сэмплов от интервала скрейпа, не от трафика Prometheus скрейпит раз в 5 с; сэмплы копятся по времени
объём на событие у метрик отсутствует у событий это и есть основная статья

Плюс то, чего мой замер не касался вовсе, — работа самого инструментирования. Запись значения в счётчик или гистограмму SDK делает на каждом запросе, и эта работа растёт с трафиком. А вот передача метрик до Collector и обработка в нём — нет: SDK экспортирует агрегаты раз в интервал, и объём экспорта определяется числом серий, а не числом запросов. С трафиком растут передача и обработка у трейсов и логов, где на каждый запрос уходит отдельная запись.

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

Бюджет наблюдаемости: чем платит каждый столп

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

Механика Замер Где
Тип метрики множит серии гистограмма 15 серий на комбинацию против 1 у счётчика ст. 4
Лейбл множит серии 2 → 509 от одного order_id здесь
Метка множит стримы логов 2 → 1202 от trace_id в индексе ст. 3
Дефолтный список лейблов +1 стрим на каждый рестарт (service.instance.id) ст. 3
Ресурсные атрибуты в лейблах 33 лейбла вместо 7 ст. 7
Tail sampling сохраняет важное 35 ошибок из 35, 138 трейсов из 405 ст. 2
Сэмплинг искажает span-метрики занижение в 9,43 раза при доле 10 % ст. 7
Цена профилирования по латентности не измеряется, память Go +16,8 МиБ ст. 6
Событие против серий 488 байт полезной нагрузки (20 полей, медиана по 200 событиям) против 17 постоянных серий здесь

Порядок действий, который из этого следует:

  1. Считать серии, а не мегабайты. Число серий — то, что растёт умножением и что первым упирается в лимит. Мегабайты на стенде обманывают.
  2. Инвентаризовать лейблы, а не добавлять. Каждый лейбл обязан отвечать на вопрос, который кто-то реально задаёт. Лейбл, по которому никто не группировал ни разу, — чистый множитель.
  3. Проверять списки по умолчанию. Самая дорогая кардинальность приходит не от смелых решений, а от принятых дефолтов: service.instance.id в индексе Loki, resource_to_telemetry_conversion в экспортёре.
  4. Резать высококардинальное, а не важное. Уникальный идентификатор нужен — но в событии или в атрибуте спана, где он стоит байты, а не серии.
  5. Резать с агрегацией и проверять значение. Удаление лейбла без агрегации оставляет дешёвую неверную метрику, которая выглядит исправной.
  6. Сэмплировать осмысленно. Head — дёшево и слепо, tail — умно и дороже собрать. И помнить, что сэмплинг портит метрики, посчитанные из трейсов.
  7. Ретеншен по столпам разный. Метрики дёшево держать долго (серии те же), логи и трейсы — дорого: их объём линеен по трафику.

Итог

Что стоит забрать из всей серии в части денег:

  1. Кардинальность — умножение. Каждое новое значение лейбла множится на все комбинации остальных. user_id в лейбле — не «немного больше данных», а другой порядок величины.
  2. Замерено: один лейбл дал рост в 254 раза — 2 серии против 509 у одной метрики. Не ровно 500, потому что серия это комбинация всех лейблов.
  3. Цена кардинальности не видна на стенде. Память Prometheus при взрыве оказалась даже меньше базовой, время запроса не изменилось. На тысячах серий это шум; планировать надо по механике умножения, а не по стендовым мегабайтам.
  4. У логов та же механика на метках: 2 стрима против 1202 от одного trace_id в индексе.
  5. Дефолты дороже смелых решений. service.instance.id в индексных лейблах Loki добавлял стрим на каждый рестарт процесса; resource_to_telemetry_conversion тащил в лейблы process_pid. Никто этого не выбирал — просто согласились с настройками по умолчанию.
  6. Head sampling дёшев и слеп, tail — осмыслен и дороже. Tail сохранил все 35 ошибок из 35; политика латентности отобрала 81 трейс вместо задуманных 34, потому что ловит фактическую латентность.
  7. Сэмплинг трейсов портит метрики из трейсов — занижение в 9,43 раза при доле 10 %. Экономия в одном месте молча ломает точность в другом.
  8. Резать надо в пайплайне, когда код не ваш, и с агрегацией: metrics_transform с aggregate_labels, а не delete_key.
  9. Наивная резка даёт дешёвую неверную метрику: 2 серии из 507 — и значение 1 вместо сотен (число из отдельного, более раннего прогона: в основном замере в конфиге уже стояла правильная резка). Внешне исправна полностью. Критерий проверки — значение, а не число серий.
  10. События платят за объём, метрики — за кардинальность. 488 байт полезной нагрузки на событие с 20 полями (медиана по 200 событиям, без сжатия и накладных расходов хранилища) против 17 постоянных серий; order_id в событии стоит байты, в лейбле метрики — сотни серий. Сравниваются при этом полное событие и агрегированный сигнал, а не два способа записать одно и то же.
  11. Wide events — это про то, какой вопрос вы задаёте. Событие отвечает «что было с этим запросом», метрика — «как система в целом». Вторая дешевле именно потому, что деталей не хранит.
  12. «Метрики не зависят от трафика» верно про хранение, но не про всё. Число серий от числа запросов не растёт; сэмплов при том же интервале скрейпа за тот же период столько же — их число растёт с длительностью хранения, а не с нагрузкой. С трафиком растёт работа инструментирования (запись в агрегат на каждом запросе), а экспорт агрегатов идёт по интервалу. Дорожают метрики от новых значений лейблов и от времени хранения.
  13. Стоимость запросов по высококардинальным полям я не измерял — для этого нужно колоночное хранилище, которого на стенде нет. Вывод об объёме на цену запросов не переносится.

На этом техническая часть серии закончена. Осталась статья про выбор бэкенда логов — там та же тема стоимости, но в разрезе «Loki против OpenSearch против VictoriaLogs».

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии