Восемь статей серии описывали, как сделать наблюдаемость полезной. Эта — про то, сколько она стоит и что резать, когда счёт приходит. Причём дорого не «много данных вообще»: у каждого столпа своя механика стоимости, и путать их — верный способ резать не то.
Это восьмая, заключительная по технике статья серии «Наблюдаемость на практике». Она подводит счёт: берёт числа предыдущих семи и складывает в одну картину.
Главное, что стоит унести из неё: метрики платят за кардинальность, события — за объём, и это принципиально разные модели. Один добавленный лейбл превратил у меня 2 серии в 509 — рост в 254 раза от одного поля. То же самое поле в структурированном событии не создало ни одной постоянной серии: событие целиком заняло 488 байт полезной нагрузки. Сравниваются при этом не два способа записать одно и то же, а полное событие и агрегированный сигнал — и в этом различии весь выбор.
В статье
- Кардинальность — это умножение, а не сложение
- Замер: 2 серии против 509
- Что подорожало не там, где ожидалось
- Стоимость логов: 2 стрима против 1202
- Стоимость трейсов и цена сэмплинга
- Резать в пайплайне, а не в приложении
- Наивная резка ломает значение, сохраняя вид исправности
- Wide events: платить за объём вместо кардинальности
- Бюджет наблюдаемости: чем платит каждый столп
- Итог
- Документация и первоисточники
Кардинальность — это умножение, а не сложение
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_label601-кратный рост числа стримов — и снова память с временем запроса почти не двинулись, потому что объём стендовый. Механика та же, следствие то же.
И отдельная ловушка оттуда же: в дефолтном списке индексных лейблов 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 постоянных серий | здесь |
Порядок действий, который из этого следует:
- Считать серии, а не мегабайты. Число серий — то, что растёт умножением и что первым упирается в лимит. Мегабайты на стенде обманывают.
- Инвентаризовать лейблы, а не добавлять. Каждый лейбл обязан отвечать на вопрос, который кто-то реально задаёт. Лейбл, по которому никто не группировал ни разу, — чистый множитель.
- Проверять списки по умолчанию. Самая дорогая кардинальность приходит не от смелых решений, а от принятых дефолтов:
service.instance.idв индексе Loki,resource_to_telemetry_conversionв экспортёре. - Резать высококардинальное, а не важное. Уникальный идентификатор нужен — но в событии или в атрибуте спана, где он стоит байты, а не серии.
- Резать с агрегацией и проверять значение. Удаление лейбла без агрегации оставляет дешёвую неверную метрику, которая выглядит исправной.
- Сэмплировать осмысленно. Head — дёшево и слепо, tail — умно и дороже собрать. И помнить, что сэмплинг портит метрики, посчитанные из трейсов.
- Ретеншен по столпам разный. Метрики дёшево держать долго (серии те же), логи и трейсы — дорого: их объём линеен по трафику.
Итог
Что стоит забрать из всей серии в части денег:
- Кардинальность — умножение. Каждое новое значение лейбла множится на все комбинации остальных.
user_idв лейбле — не «немного больше данных», а другой порядок величины. - Замерено: один лейбл дал рост в 254 раза — 2 серии против 509 у одной метрики. Не ровно 500, потому что серия это комбинация всех лейблов.
- Цена кардинальности не видна на стенде. Память Prometheus при взрыве оказалась даже меньше базовой, время запроса не изменилось. На тысячах серий это шум; планировать надо по механике умножения, а не по стендовым мегабайтам.
- У логов та же механика на метках: 2 стрима против 1202 от одного
trace_idв индексе. - Дефолты дороже смелых решений.
service.instance.idв индексных лейблах Loki добавлял стрим на каждый рестарт процесса;resource_to_telemetry_conversionтащил в лейблыprocess_pid. Никто этого не выбирал — просто согласились с настройками по умолчанию. - Head sampling дёшев и слеп, tail — осмыслен и дороже. Tail сохранил все 35 ошибок из 35; политика латентности отобрала 81 трейс вместо задуманных 34, потому что ловит фактическую латентность.
- Сэмплинг трейсов портит метрики из трейсов — занижение в 9,43 раза при доле 10 %. Экономия в одном месте молча ломает точность в другом.
- Резать надо в пайплайне, когда код не ваш, и с агрегацией:
metrics_transformсaggregate_labels, а неdelete_key. - Наивная резка даёт дешёвую неверную метрику: 2 серии из 507 — и значение 1 вместо сотен (число из отдельного, более раннего прогона: в основном замере в конфиге уже стояла правильная резка). Внешне исправна полностью. Критерий проверки — значение, а не число серий.
- События платят за объём, метрики — за кардинальность. 488 байт полезной нагрузки на событие с 20 полями (медиана по 200 событиям, без сжатия и накладных расходов хранилища) против 17 постоянных серий;
order_idв событии стоит байты, в лейбле метрики — сотни серий. Сравниваются при этом полное событие и агрегированный сигнал, а не два способа записать одно и то же. - Wide events — это про то, какой вопрос вы задаёте. Событие отвечает «что было с этим запросом», метрика — «как система в целом». Вторая дешевле именно потому, что деталей не хранит.
- «Метрики не зависят от трафика» верно про хранение, но не про всё. Число серий от числа запросов не растёт; сэмплов при том же интервале скрейпа за тот же период столько же — их число растёт с длительностью хранения, а не с нагрузкой. С трафиком растёт работа инструментирования (запись в агрегат на каждом запросе), а экспорт агрегатов идёт по интервалу. Дорожают метрики от новых значений лейблов и от времени хранения.
- Стоимость запросов по высококардинальным полям я не измерял — для этого нужно колоночное хранилище, которого на стенде нет. Вывод об объёме на цену запросов не переносится.
На этом техническая часть серии закончена. Осталась статья про выбор бэкенда логов — там та же тема стоимости, но в разрезе «Loki против OpenSearch против VictoriaLogs».
Документация и первоисточники
- Prometheus: cardinality is key — почему лейблы это произведение, а не сумма.
- Prometheus: storage — сколько стоит серия и как устроен TSDB.
- Loki: labels best practices — какие метки индексировать, а какие держать в теле.
- metricstransform processor —
aggregate_labelsи почему без него значения теряются. - transform processor (OTTL) —
delete_keyи границы его применимости. - tailsampling processor — политики и их цена.
- Charity Majors: observability 2.0 — постановка про wide events из первых рук.
- Стенд, конфиги и сырые выводы замеров —
digital-cookbook/observability/practice-stack.
Комментарии