VictoriaMetrics: Churn Rate, High cardinality, метрики та IndexDB

Автор |  01/11/2025
 

З’явився цей пост в принципі випадково.

Прилетів мені один з дефолтних алертів 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

Що таке 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} ми маємо три різні таймсерії:

  1. 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
  2. cpu_usage{server="web01", core="1"}
    1. в час 1753857852 значення було 82.3
  3. cpu_usage{server="web02", core="0"}
    1. в час 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 LeHow 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 строк:

Reading and Parsing Data

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, і процес йде далі:

 

Cache miss triggers IndexDB lookup

Якщо ж це абсолютно нові імена метрики і лейбл з їхніми значеннями – генерується новий 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 та часом (власне, тайм-серії):

vmstorage’s main storage

Далі вони записуються в пам’яті в “raw-row shards”, після чого формують in-memory LSM parts (див. Log-structured merge-tree і LSM tree and Sorted string tables (SST)):

How vmstorage handles data ingestionЯкі потім записуються на диск:

How vmstorage organizes data within a partition

І на диску, як і для даних 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:

LSM parts organized into columnar data files

“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 files
  • vm-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.