Time-series databases — Prometheus + VictoriaMetrics architecture with high-cardinality scrape ingestion, PromQL range query, downsampling pipeline (1m -> 5m -> 1h -> 1d) with retention drops, and ADR comparing Prometheus / InfluxDB / TimescaleDB / ClickHouse / Mimir for a Kubernetes platform with 3M active series.
Метрики — это особый вид данных: append-only по времени, миллионы samples/sec, тысячи дашбордов спрашивают «что было за последний час / день / месяц». На generic RDBMS этот workload разваливается за месяц: B-tree на монотонно растущем timestamp фрагментируется в bloat, INSERT нагрузка убивает WAL, retention требует ручных DELETE WHERE ts <, range scan по миллиарду строк лагает в секунды. KV-сторы (Redis, Dynamo) хорошо отдают «последнее значение», но плохо считают rate(...)[5m] или 99-й перцентиль по таймсерии. Именно поэтому существует отдельный класс — time-series databases (TSDB): они спроектированы вокруг трёх вещей — очень дешёвый append-by-time, column-oriented per-series compression (Gorilla XOR, delta-of-delta) и downsample/retention pipeline, который автоматически уменьшает разрешение и дропает старое.
Без понимания TSDB-моделей случаются типовые провалы: лейбл request_id=<uuid> в Prometheus → OOM на 10M series и часовой WAL replay при рестарте; год метрик в одном Prometheus + «федерация не помогает» (она для шардинга, не для истории); миграция InfluxDB v1 → v2 → v3 с переписыванием алертов с PromQL на Flux. Этот раздел учит смотреть на метрики-workload и выбирать стек — а не «возьмём что-нибудь модное».
TSDB = specialized columnar store + time-bucketed compression + downsampling pipeline. Writes — append-only по timestamp, reads — range scan по
(series_key, t1, t2). Главный враг — cardinality: взрыв уникальных series от плохо выбранных лейблов.
Глубже: каждая метрика идентифицируется series_id = hash(metric_name, sorted(labels)). http_requests_total{method="GET",status="200",path="/api"} — это одна series; добавьте лейбл request_id=<uuid> и получите по series на каждый запрос. Хранилище раскладывает данные по series в виде колонок (массивы [t1=v1, t2=v2, ...]), а не по строкам — соседние значения почти всегда близки, поэтому Gorilla XOR + delta-of-delta жмут floats до ~1-2 байт на sample (vs 16 в raw). Файлы делятся на time-bucketed blocks (Prometheus — 2h, потом compaction): каждый блок — самодостаточная единица с собственным index'ом series → offset, чанками сжатых данных, тумбстонами и meta. Старые блоки либо downsample'ятся в более грубое разрешение (1m → 5m → 1h → 1d), либо дропаются ретеншеном — это встроенный pipeline, а не cron-скрипт.
Запомните три цифры, по которым TSDB-стеки расходятся на порядки:
| Prometheus | VictoriaMetrics | TimescaleDB | InfluxDB v3 | |
|---|---|---|---|---|
| Compression | ~1.3 B/sample | ~0.4-0.8 B/sample (10× лучше) | ~2-3 B/sample | Parquet, ~1 B/sample |
| Active series на ноду | ~10M (комфортно) | ~50M+ | зависит от шардинга | зависит от шардинга |
| Query language | PromQL | PromQL + MetricsQL (superset) | SQL + hyperfunctions | SQL / InfluxQL / Flux |
Канвас собран как типовой production-стек, не игрушка. Слева — scrape targets: три приложения, отдающие /metrics в Prometheus text format, и app-bad (помечен warning) — он эмитит метрику с лейблом request_id, провоцируя cardinality explosion в Scenario 1.
В центре — Prometheus server как одна большая группа из 5 нод: scraper (тикает каждые 15s), wal (head-блок в памяти + Write-Ahead Log на диске), tsdb-blocks (immutable 2h-блоки на диске после flush), rules (recording + alert rules) и promql (engine для запросов). Edges внутри группы показывают жизненный цикл sample'а: scrape → WAL → flush в block → range scan при query.
Снизу — long-term store на VictoriaMetrics cluster: vminsert принимает remote_write от Prometheus, шардит по series_id в vmstorage (3 реплики, capacity 5M RPS), vmselect фан-аутит PromQL/MetricsQL query по шардам, cold — S3-tier для блоков старше 7 дней. Это разделение hot/cold — Prometheus держит свежие 15 дней локально, VM хранит год+, S3 хранит «навсегда».
Справа вверху — downsample/retention pipeline: raw-1m (хранится 15 дней) → agg-5m (90 дней) → agg-1h (1 год) → agg-1d (5 лет). Нода retention — TTL-drop. Это continuous aggregates в терминах TimescaleDB / recording rules в Prometheus / streaming aggregation в VM. Справа внизу — consumers: Grafana (дашборды), Alertmanager → PagerDuty/Slack (paging), SRE с ad-hoc PromQL.
ADR-001 закреплён на корне Prometheus — там разобран выбор стека под workload «3M series, 30d hot / 1y cold, on-prem, команда из 3 SRE».
Ingest + cardinality explosion. scraper тикает каждые 15s, параллельно дёргает /metrics у app-1/2/3 (pull-модель — Prometheus сам ходит к targets, не наоборот). Samples складываются в wal (head-блок в памяти + append-only WAL для durability) — ответ за ~50μs, без disk seek. Каждые 2 часа head-блок flush'ится одним sequential write в tsdb-blocks: float'ы кодируются Gorilla XOR (значения соседних timestamps XOR'ятся, leading/trailing zeros энкодятся), timestamps — delta-of-delta (разности разностей чаще всего ноль). Результат: ~1-2 байта на sample против 16 в raw.
Параллельно wal через remote_write (батчами) отгружает samples в vminsert long-term-стора. vminsert хеширует series_id и шардит в vmstorage-реплики.
И тут вступает app-bad: он эмитит http_requests_total{request_id="<uuid>"} — каждый запрос порождает новую series. Через несколько минут head-блок wal распухает до 10M уникальных series, RSS Prometheus лезет в небо, OOMkiller срабатывает, WAL replay при рестарте — около часа (на больших setup'ах). Fix: в scrape_configs через relabel_config дропнуть request_id лейбл; per-request трассы — это exemplars (link metric→trace) или Tempo/Jaeger, не лейблы метрики.
PromQL query. SRE стучится в promql с sum by (status) (rate(http_requests_total{job="api"}[5m])). Engine идёт в postings index блоков (inverted index по лейблам) — получает список series_id для job=api и трёх values status=2xx|4xx|5xx. Потом для каждой series декомпрессирует чанки в range [now-1h, now], считает rate() (производная counter'а с учётом resets), и складывает в sum by(status). На наборе 12k series × 240 samples — ~80ms, 50KB ответ.
Grafana с 30-дневным панелем идёт не в Prometheus, а в vmselect. Тот fan-out'ит запрос по всем шардам vmstorage. Если данные старше 7 дней — vmstorage подтягивает чанки из cold (S3, warm cache miss = первый запрос медленный). Партиалы мерджатся обратно в vmselect, результат возвращается в Grafana, панель рендерится. Это типовой паттерн scatter-gather в распределённых TSDB.
Downsample + retention. Raw samples с 1m разрешением хранятся 15 дней в raw-1m (hot). Continuous aggregate автоматически считает p95/avg/min/max в 5-минутных бакетах → agg-5m хранится 90 дней. Дальше каскад: agg-5m → agg-1h (1 год) → agg-1h → agg-1d (5 лет). Каждый уровень — 12-24× реже точек, поэтому дашборд за 5 лет грузится за миллисекунды, а не выкачивает миллиард строк.
Параллельно retention дропает старые чанки по TTL: raw старше 15d, 5m-агрегаты старше 90d. Это встроенный механизм TSDB — не cron на DELETE WHERE. В TimescaleDB это drop_chunks() на hypertable, в Prometheus — --storage.tsdb.retention.time, в VM — -retentionPeriod.
Параллельно rules evaluate recording rules (например, job:http_5xx:rate5m) и alert rules. Если rate(http_5xx[5m]) > 0.05 — fire alert в alertmanager, тот делает dedup + group + route → pager будит on-call. Recording rules критичны для дорогих запросов: лучше посчитать 1 раз каждые 30s и сохранить как метрику, чем 1000 раз при каждом рендере дашборда.
Контекст. Платформа Kubernetes: ~3M active series, 30 дней hot retention, 1 год cold, sub-second дашборды, периодический ad-hoc PromQL, on-prem, команда из 3 SRE. Кандидаты: vanilla Prometheus, VictoriaMetrics cluster, InfluxDB v3 (DataFusion + Parquet), TimescaleDB (Postgres extension), ClickHouse (general OLAP), Grafana Mimir.
Решение. Prometheus для scrape + recording rules + 24h-окно запросов. VictoriaMetrics cluster (vminsert / vmstorage / vmselect) как long-term store через remote_write. InfluxDB / TimescaleDB / ClickHouse отвергнуты — не дают ROI на ЭТОМ workload.
Почему так:
vmstorage replicas. Mimir пришлось бы дёргать 10+ компонент + Keeper + object storage — для команды из 3 SRE это перебор.Альтернативы и почему отвергнуты:
| Опция | Плюсы | Минусы | Почему нет |
|---|---|---|---|
| Только Prometheus | Единый бинарь, ноль инфры | Default 15d retention; federation для шардинга, не истории; OOM на 10M+ series | Compliance требует 1y; cardinality уже 3M |
| InfluxDB v3 | Отличный ingest, Parquet, SQL/Flux/InfluxQL | Migration v1→v2→v3 painful; PromQL alerts → SQL/InfluxQL = месяцы переписывания | Стоимость смены экосистемы > выигрыш в storage |
| TimescaleDB | Full SQL + JOIN с бизнес-таблицами; continuous aggregates; зрелый Postgres ops | Нет PromQL; remote_write адаптер лоссовый; row-store хуже жмёт на нашей cardinality | Метрики не co-located с OLTP — JOIN-преимущество не используется |
| ClickHouse | Sub-second по миллиардам строк; high cardinality; unified logs+metrics+traces | Не «metrics-shaped» из коробки (нужна схема + Vector/OTel pipeline); нет PromQL (нужен promql2sql прокси); Keeper + parts merging | Right answer, если бы строили ещё и logging/tracing на нём — но мы строим только метрики |
| Grafana Mimir | Multi-tenant out-of-the-box; PromQL native; та же родословная что Cortex/Thanos | 10+ микросервисов; object storage day-1; тяжёлые ops | Single-tenant deployment; команда из 3 не потянет Mimir комфортно |
Trade-offs принятого решения:
vmstorage-реплик.Pull-модель / Prometheus-семейство:
Push-модель:
SQL-style / OLAP-смежные:
Managed: Datadog / New Relic / Honeycomb (proprietary backends), Grafana Cloud (multi-tenant Mimir), Azure Data Explorer / Kusto (logs + metrics, KQL).
request_id, user_id, full URL path, dynamic IPs). Главный убийца Prometheus — каждое уникальное value = новая series. Per-request тэги — это exemplars (link metric→trace) или Tempo/Jaeger, не лейблы метрики.http_requests_total без [range] = full series scan. Всегда ограничивайте время.rate(). Используйте правильные типы метрик.rate() даёт огромное negative. Prometheus умеет детектить — не отключайте.remote_write./metrics.scrape_timeout ≥ scrape_interval. Scrape отменится не успев запустить следующий, дашборд покажет дыры. Default 10s vs 15s — соблюдайте.prometheus_remote_storage_*.histogram_quantile(0.99, ...) каждые 30s — посчитайте один раз в recording rule.Не TSDB вообще, когда:
Не Prometheus, когда: нет /metrics и нельзя инструментировать (InfluxDB / Telegraf push проще); retention >30d (нужен Mimir / Thanos / VM); multi-tenant SaaS (Mimir / Cortex).
Не TimescaleDB, когда: нет существующего Postgres-инвестмента; PromQL/Grafana-стек уже работает (переписывание алертов на SQL = месяцы).
Не InfluxDB, когда: команда на PromQL; чувствительны к major-version migrations (v1→v2→v3 painful).
Papers:
Production docs:
Engineering blogs:
Книги:
Talks:
Связанные темы: