З’явився цей пост в принципі випадково.
Прилетів мені один з дефолтних алертів VictoriaMetrics, які створюються під час деплою Helm-чарту victoria-metrics-k8s-stack:
Думав написати коротенький пост типу “що таке Churn Rate і як його пофіксати”, але в результаті вийшло доволі глибоко зануритись в те, як взагалі VictoriaMetrics працює з даними – і це виявилось дуже цікавою темою.
Давайте спочатку коротко розберемо що таке “метрика” і тайм-серія взагалі, і потім подивимось як вони впливають на ресурси системи – CPU, пам’ять та диск.
Зміст
Metric vs Time Series vs Sample
Всі ми маємо справу з метриками в моніторингу – будь то Prometheus, чи VictoriaMetrics, чи InfluxDB, і ці метрики ми потім використовуємо в наших дашбордах Grafana або в алерт-рулах VMAlert.
Але що таке власне “метрика”? А що таке тайм-серія, sample чи data point? І як кількість різних значень однієї label для метрики впливає на використання диску та пам’яті?
Бо, наприклад, я в постах зазвичай просто використовую слово “метрика”, бо в 99% цього достатньо, аби описати об’єкт, про який йде мова.
Але для повноцінної роботи з системами моніторингу треба добре уявляти різницю між цими поняттями.
Що таке Metric?
Метрика (Metric): що вимірюється
Наприклад – cpu_usage, memory_free, http_requests_total, database_connections.
В документації VictoriaMetrics є дуже точний вираз – це як імена змінних, через які ми передаємо дані, див. Structure of a metric.
Метрика має власне ім’я, та опціонально набір labels (лейбл або тегів), які дозволяють додати більше контексту для конкретного вимірювання – але без значень цих лейбл.
Крім того, лейбли впливають на те, як дані по ції метриці будуть зберігатись і шукатись.
Тобто метрика – це “схема”, яка описує що ми вимірюємо, та за якими ознаками (лейблами) можемо групувати дані.
Приклад:
Metric: "cpu_usage{server, core}"
Тут:
- ім’я метрики:
cpu_usage- ім’я label:
server - ім’я label:
core
- ім’я label:
Що таке Time Series?
Таймсерія (Time Series): послідовність даних
Це повна послідовність записів, які згруповані для конкретної метрики та її labels зі значеннями – тобто набору metric_name{label_name="label_value"}, і які впорядковані за часом.
Приклад:
Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│ ├── 1753857852, 75.5
│ ├── 1753857912, 76.2
│ ├── 1753857972, 74.8
│ └── 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│ ├── 1753857852, 82.3
│ ├── 1753857912, 81.7
│ └── ...
└── Time series: cpu_usage{server="web02", core="0"}
├── 1753857852, 45.2
├── 1753857912, 47.8
└── ...
Тут для метрики cpu_usage{server, core} ми маємо три різні таймсерії:
cpu_usage{server="web01", core="0"}- в час 1753857852 (Wed Jul 30 2025 06:44:12 GMT) значення було 75.5
- в час 1753857912 (Wed Jul 30 2025 06:45:12 GMT) значення було 76.2
- …
cpu_usage{server="web01", core="1"}- в час 1753857852 значення було 82.3
- …
cpu_usage{server="web02", core="0"}- в час 1753857852 значення було 45.2
- …
Що таке Sample та Data Points?
Семпл (Sample): конкретний запис у послідовності даних (таймсерії).
Sample і Data Point – синоніми, і являють собою окреме значення метрики у певний момент часу.
Має вигляд (timestamp, value), наприклад “1753857852 75.5” – тобто, в Unix timestamp 1753857852 значення було 75.5%.
Приклад:
Metric: "cpu_usage{server, core}"
├── Time series: cpu_usage{server="web01", core="0"}
│ ├── Sample: 1753857852, 75.5
│ ├── Sample: 1753857912, 76.2
│ ├── Sample: 1753857972, 74.8
│ └── Sample: 1753858032, 73.1
├── Time series: cpu_usage{server="web01", core="1"}
│ ├── Sample: 1753857852, 82.3
│ ├── Sample: 1753857912, 81.7
│ └── ...
└── Time series: cpu_usage{server="web02", core="0"}
├── Sample: 1753857852, 45.2
├── Sample: 1753857912, 47.8
└── ...
Тут:
- для таймсерії
cpu_usage{server="web01", core="0"}маємо чотири семпла:- 1753857852, 75.5
- 1753857912, 76.2
- 1753857972, 74.8
- 1753858032, 73.1
І дані за весь період спостережень по кожній унікальній комбінації cpu_usage{server="some_server", core="some_core"} будуть формувати одну і ту ж таймсерію, навіть якщо ці дані збираються роками – допоки не зміниться значення або в server, або в core.
High Cardinality vs High Churn rate
Обидві проблеми мають однакове “походження”, але трохи відрізняються по суті.
High cardinality – це “persistent проблема”, яка впливає на зберігання, індексацію та пошук даних.
Вона виникає, коли у нас є багато унікальних комбінацій лейблів, навіть якщо значення самих метрик надходять рідко або перестають надходити.
Це призводить до великої кількості живих та неактивних серій, що збільшує розмір IndexDB, використання памʼяті та час пошуку. Про IndexDB детальніше будемо говорити далі.
Див. Cardinality explorer в блогах VictoriaMetrics.
High churn rate – це “online проблема”, коли у нас постійно створюються нові тайм-серії через зміну значень лейблів, особливо короткоживучих або динамічних (як у Kubernetes – pod_name, container_id, job_id, або щось типу client_ip).
Це створює великий потік нових записів у IndexDB, завантажуючи CPU, пам’ять, та диск.
“Життя метрики”
Є дуже класне відео, яке побачив багато років тому – The Inner Life of the Cell, чомусь воно тут згадалось.
Аби зрозуміти як кількість лейбл (точніше – значення в них) впливають на розмір даних в системі і на використання CPU та пам’яті – давайте подивимось як у VictoriaMetrics взагалі відбувається весь процес “під капотом”.
Допоможе нам в цьому чудова серія постів від Phuong Le – How vmagent Collects and Ships Metrics Fast with Aggregation, Deduplication, and More.
Там 7 частин, і для дійсно “глибокого занурення” у внутрішню архітектуру VictoriaMetrics дуже рекомендую їх прочитати.
Але зараз ми відносно швидко пройдемося по процесу додавання нових даних і їхньому пошуку, і більше сконцентруємось саме на питанні Churn Rate.
“Write-path”: vminsert та vmstorage
Отже – почнемо з початку: vmagent збирає метрики з експортерів, і далі ці дані через vminsert треба записати до vmstorage.
У випадку vmsingle у на всі компоненти працюють в одному процесі, але для кращої картини – давайте їх розділяти.
vminsert збирає дані до себе в пам’ять, після чого відправляє до vmstorage блоками до 100 мегабайт.
На початку кожного блоку від vminsert задається загальний розмір блоку, після чого vmstorage починає зчитувати дані в ньому блоками по 24+n байт, строкам (row):
- в перших 8 байтах вказується розмір n – розмір наступного сектору, який містить в собі ім’я метрики та її лейбли
- другий сектор – ці n байт з іменем метрики і лейблами
- третій сектор розміром 8 байт містить в собі значення семпла (“75.5” з прикладів вище)
- четвертий містить Timestamp, ще 8 байт
В результаті формується row із 8*3 байт (24) + n байт, де n – це довжина імені метрики і її лейбл.
vmstorage формує власні блоки, в кожному максимум 10,000 строк:
vmstorage, IndexDB та TSID
Після чого починає сама цікава магія – це Time Series ID, або TSID.
Для кожної унікальної комбінації метрика+лейбли+значення лейбл VictoriaMetrics має власний унікальний ідентифікатор, який використовується для збереження даних та при подальшому пошуку даних.
Сам TSID це ідентифікатор (див type TSID struct), суто внутрішній механізм самої VictoriaMetrisc, який ми, нажаль, ніде побачити не можемо:
// TSID is unique id for a time series.
//
// Time series blocks are sorted by TSID.
type TSID struct {
MetricGroupID uint64
JobID uint32
InstanceID uint32
// MetricID is the unique id of the metric (time series).
//
// All the other TSID fields may be obtained by MetricID.
MetricID uint64
}
Маючи набір з імені метрики та її тегів (лейбл), vmstorage спершу перевіряє свій TSID Cache. Якщо для ції комбінації ми вже маємо згенерований TSID – використовуємо його.
Якщо в кеші даних нема (значення vm_slow_row_inserts_total росте) – vmstorage звертається до IndexDB, і починає пошук TSID там.
Якщо в IndexDB знайдений TSID – він додається в кеш vmstorage, і процес йде далі:
Якщо ж це абсолютно нові імена метрики і лейбл з їхніми значеннями – генерується новий TSID, який реєструється в кеші vmstorage.
IndexDB зберігає два індекси, в кожному кілька мапінгів між полями та ID, описано в частині How IndexDB is Structured:
- 1 – Tag to metric IDs (Global index): кожен тег (лейбла) мапиться на ім’я метрики (її ID)
- 2 – Metric ID to TSID (Global index): ID кожної метрики мапиться на TSID
- 3 – Metric ID to metric name (Global index): мапінг власне імені метрики на її ID
- 4 – Deleted metric ID: трекер видалених metric IDs.
- 5 – Date to metric ID (Per-day index): мапінг дат на metric ID для швидкого пошуку по датам (“чи є за цей день дані по цій метриці”)
- 6 – Date with tag to metric IDs (Per-day index): аналогічний до першого Tag to metric IDs мапінгу, але по датам
- 7 – Date with metric name to TSID (Per-day index): схожий на другого Metric ID to TSID мапінгу, але по іменам метрик і датам
Ці індекси тримаються як в пам’яті, і періодично записуються на диск (flush) в persistant storage IndexDB в каталог indexdb/, де – як і в каталозі data/, в якому зберігають самі тайм-серії – виконується merge даних для оптимізації зберігання та пошуку.
Детальніше див. в 3 частині в блогах VictoriaMetrcis – How vmstorage Processes Data: Retention, Merging, Deduplication.
І повертаючись до питання Churn Rate та High cardinality – кожна окрема метрика+лейбли створюють окремі TSID, для кожної лейбли створюються мапінги в індексах, при великій кількості нових даних, які постійно записуються з пам’яті в диск – частіше викликаються дискові операції – маємо навантаження на CPU, пам’ять, I/O операції диска.
vmstorage та збереження даних на диску
В принципі, саме цікаве ми вже побачили – ролі IndexDB та TSID, але давайте пройдемось по решті процесу.
З отриманих від vminsert даних прочитали дані, сформували власні block з rows.
В кожній row vmstorage зберігає вже не ім’я метрики – а її TSID, а для кожного TSID містить записи з values та часом (власне, тайм-серії):
Далі вони записуються в пам’яті в “raw-row shards”, після чого формують in-memory LSM parts (див. Log-structured merge-tree і LSM tree and Sorted string tables (SST)):
Які потім записуються на диск:
І на диску, як і для даних IndexDB, аналогічно відбуваються Merge Process, Deduplication та Downsampling.
Але з того, що нам цікаво – це як воно виглядає на диску:
$ kk exec -ti vmsingle-vm-k8s-stack-ff6f9bf4c-qt2mj -- tree victoria-metrics-data/data
victoria-metrics-data/data
├── big
│ ├── 2025_09
│ │ └── 18688A4D78E7FBFB
│ │ ├── index.bin
│ │ ├── metadata.json
│ │ ├── metaindex.bin
│ │ ├── timestamps.bin
│ │ └── values.bin
│ ├── 2025_10
│ │ ├── 186A34EE1061F960
│ │ │ ├── index.bin
│ │ │ ├── metadata.json
│ │ │ ├── metaindex.bin
│ │ │ ├── timestamps.bin
│ │ │ └── values.bin
│ │ ├── 186CDDD43EA4892F
...
── small
├── 2025_09
│ ├── 18688A4D78E8044E
│ │ ├── index.bin
│ │ ├── metadata.json
│ │ ├── metaindex.bin
│ │ ├── timestamps.bin
│ │ └── values.bin
│ ├── 18688A4D78E80B8F
│ │ ├── index.bin
│ │ ├── metadata.json
│ │ ├── metaindex.bin
│ │ ├── timestamps.bin
│ │ └── values.bin
...
Тут в small “скидаються” дані з in-memory parts, і small потім merge в big parts.
Кожен part містить в собі власний індекс, який відповідає за мапінг даних на timestamps та values:
“Read-path”: пошук даних з vmselect та vmstorage
Коли ж ми робимо пошук по даним – то vmselect передає до vmstorage запит з метрикою, лейблами (тегами) та датою, за яку треба виконати пошук.
vmstorage в IndexDB по tag to metric IDs знаходить відповідні MetricIDs – для всіх метрик, які має цей тег.
Далі по Metric ID IndexDB в записах metric ID to TSID знаходить відповідні TSID, які повертає до vmstorage.
Маючи TSID – vmtorage вже перевіряє in-memory, small та big parts, шукаючи потрібний TSID в файлах metaindex.bin.
А знайшовши потрібний metadata.bin – він читає відповідний index.bin, який вже каже в яких строках timestamp.bin та values.bin знайти потрібні дані, які потім повертаються до vmselect.
Практичний приклад: запис 10,000 метрик і 10,000 labels
Це все цікаво почитати в теорії – але давайте трохи практики, бо завжди ж цікаво подивитись як воно виглядає в реальності.
Що будемо робити:
- запустимо два контейнери з VictoriaMetrics
- в кожен через API запишемо 10,000 метрик, але:
- в один інстанс для всіх метрик лейбла буде мати однакове значення
- в другий інстанс значення label буде постійно змінюватись
А потім глянемо як це вплинуло на розмір даних.
Створюємо директорії:
$ mkdir vm-data-light $ mkdir vm-data-heavy
Запускаємо два контейнери – vm-light та vm-heavy, кожному підключаємо відповідний каталог – ./vm-data-light та ./vm-data-heavy, кожен слухає власний TCP-порт:
$ docker run --rm --name vm-light -p 8428:8428 -v ./vm-data-light:/victoria-metrics-data victoriametrics/victoria-metrics $ docker run --rm --name vm-heavy -p 8429:8428 -v ./vm-data-heavy:/victoria-metrics-data victoriametrics/victoria-metrics
Перевіряємо розмір каталогів зараз:
$ du -sh vm-data-light/ 76K vm-data-light/ $ du -sh vm-data-heavy/ 76K vm-data-heavy/
І кількість файлів в них:
$ find vm-data-light/ -type f | wc -l 5 $ find vm-data-heavy/ -type f | wc -l 5
Всюди все однаково.
Тепер пишемо два скрипти – теж “light” та “heavy”.
Спочатку “light” версія:
#!/usr/bin/env bash
for i in $(seq 1 10000); do
echo "my_metric{label=\"value-1\"} $i" | curl -s \
--data-binary @- \
http://localhost:8428/api/v1/import/prometheus
done
echo "DONE: stable series sent"
Тут в циклі від 1 до 10000 виконуємо запис метрики my_metric{label="value-1"}, але з кожним разом просто збільшуємо значення, яке зберігаємо.
Другий скрипт – “heavy” версія:
#!/usr/bin/env bash
for i in $(seq 1 10000); do
echo "my_metric{label=\"value-$i\"} $i" | curl -s \
--data-binary @- \
http://localhost:8429/api/v1/import/prometheus
done
echo "DONE: high churn series sent"
Він аналогічний, але тут значення змінної $i використовуємо ще і для зміни значення в label – my_metric{label="value-$i"} $i.
Запускаємо тести:
$ bash light.sh $ bash heavy.sh
І порівнюємо дані.
Розмір даних в data/:
$ du -sh vm-data-light/data/ 152K vm-data-light/data/ $ du -sh vm-data-heavy/data/ 372K vm-data-heavy/data/
Розмір даних в indexdb/:
$ du -sh vm-data-light/indexdb/ 56K vm-data-light/indexdb/ $ du -sh vm-data-heavy/indexdb/ 764K vm-data-heavy/indexdb/
Кількість файлів в data/:
$ find vm-data-light/data/ -type f | wc -l 26 $ find vm-data-heavy/data/ -type f | wc -l 26
Кількість файлів в indexdb/:
$ find vm-data-light/indexdb/ -type f | wc -l 8 $ find vm-data-heavy/indexdb/ -type f | wc -l 53
8 vs 53!
Дерево каталогів і файлів в vm-data-light/data/ і vm-data-heavy/data/ буде однаковим, але давайте глянемо на IndexDB.
У vm-data-light/indexdb/:
$ tree vm-data-light/indexdb/ vm-data-light/indexdb/ ├── 1872FB055ACC4FF8 │ └── parts.json ├── 1872FB055ACC4FF9 │ ├── 1872FB055C5E523F │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ └── parts.json ├── 1872FB055ACC4FFA │ └── parts.json └── snapshots 6 directories, 8 files
Тоді як у vm-data-heavy/indexdb/ картина вже зовсім інша:
$ tree vm-data-heavy/indexdb/ vm-data-heavy/indexdb/ ├── 1872FB05F8C559B2 │ └── parts.json ├── 1872FB05F8C559B3 │ ├── 1872FB05FA9633D4 │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633D5 │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633D6 │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633D8 │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DA │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DB │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DC │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DD │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DE │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ ├── 1872FB05FA9633DF │ │ ├── index.bin │ │ ├── items.bin │ │ ├── lens.bin │ │ ├── metadata.json │ │ └── metaindex.bin │ └── parts.json ├── 1872FB05F8C559B4 │ └── parts.json └── snapshots 15 directories, 53 files
Тобто:
vm-data-light/indexdb: 6 directories, 8 filesvm-data-heavy/indexdb: 15 directories, 53 files
І на додачу можемо порівняти статистику з /api/v1/status/tsdb.
Light-версія:
$ curl -s http://localhost:8428/prometheus/api/v1/status/tsdb | jq
{
"status": "success",
"data": {
"totalSeries": 1,
"totalLabelValuePairs": 2,
"seriesCountByMetricName": [
{
"name": "my_metric",
"value": 1,
"requestsCount": 0,
"lastRequestTimestamp": 0
}
],
"seriesCountByLabelName": [
{
"name": "__name__",
"value": 1
},
{
"name": "label",
"value": 1
}
],
"seriesCountByFocusLabelValue": [],
"seriesCountByLabelValuePair": [
{
"name": "__name__=my_metric",
"value": 1
},
{
"name": "label=value-1",
"value": 1
}
],
"labelValueCountByLabelName": [
{
"name": "__name__",
"value": 1
},
{
"name": "label",
"value": 1
}
]
}
}
Тоді як в “heavy-версії” просто всього більше:
$ curl -s http://localhost:8429/prometheus/api/v1/status/tsdb | jq
{
"status": "success",
"data": {
"totalSeries": 10000,
"totalLabelValuePairs": 20000,
"seriesCountByMetricName": [
{
"name": "my_metric",
"value": 10000,
"requestsCount": 0,
"lastRequestTimestamp": 0
}
],
"seriesCountByLabelName": [
{
"name": "__name__",
"value": 10000
},
{
"name": "label",
"value": 10000
}
],
"seriesCountByFocusLabelValue": [],
"seriesCountByLabelValuePair": [
{
"name": "__name__=my_metric",
"value": 10000
},
...
{
"name": "label=value-1003",
"value": 1
},
{
"name": "label=value-1004",
"value": 1
}
],
"labelValueCountByLabelName": [
{
"name": "label",
"value": 10000
},
{
"name": "__name__",
"value": 1
}
]
}
}
Власне, на цьому все.
Піду переписувати конфіги для vmagent, аби дропати частину лейбл, особливо від Karpenter (див. Karpenter: моніторинг та Grafana dashboard для Kubernetes WorkerNodes) – бо там їх просто десятки на кожну метрику. Див. Relabeling cookbook.






