Time-series БД: TimescaleDB и VictoriaMetrics

Две школы хранения временных рядов — PostgreSQL с гипертаблицами TimescaleDB и метрические движки Prometheus и VictoriaMetrics — на одном стенде: как continuous aggregate отстаёт от свежих и опоздавших данных и стирает историю после ретеншна, во что обходятся точка и ряд в каждой системе и почему increase() в Prometheus и VictoriaMetrics отвечает по-разному

Временной ряд выглядит обманчиво просто: время, значение, пара меток. Нагрузка под ним особая. Запись идёт почти только в конец и почти без обновлений. Читают диапазонами по времени и агрегатами по окнам. Данные стареют предсказуемо, и старое надо дёшево удалять или прореживать. Обычная таблица PostgreSQL всё это выдерживает, но платит за каждую часть: индекс по времени растёт вместе с таблицей, построчный DELETE оставляет мёртвые строки, а каждая точка весит десятки байт.

Специализированные хранилища решают это двумя способами. TimescaleDB остаётся PostgreSQL: режет таблицу на чанки по времени, сжимает старые чанки по колонкам и поддерживает агрегаты по окнам. Метрические движки — Prometheus TSDB и VictoriaMetrics — устроены вокруг другой модели данных: ряд — это имя метрики плюс набор лейблов, и всё хранилище оптимизировано под поток таких рядов.

Ниже обе школы проверены на стенде databases/timeseries: TimescaleDB 2.30.2 на PostgreSQL 18.6, Prometheus 3.15.0 и VictoriaMetrics 1.153.0, по одному узлу, и общий генератор на Go, который пишет одни и те же точки во все три системы. Сжатие и continuous aggregate на аналитических событиях уже сравнивались с ClickHouse и DuckDB в карте выбора аналитической БД; здесь речь о том, что специфично для временных рядов.

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

В статье

Что делает нагрузку особой

Четыре свойства отличают временные ряды от обычных данных, и каждое бьёт по своему месту в обычной СУБД.

  • Запись в конец. Новые точки почти всегда новее старых. B-tree по времени растёт только правым краем, а «горячая» часть данных — последние часы — мала по сравнению со всей таблицей.
  • Чтение диапазонами. «Последний час», «вчера», «среднее по пятиминуткам за сутки». Запрос почти всегда ограничен по времени, и хранилищу выгодно знать, где лежит нужный кусок, не заглядывая в остальные.
  • Предсказуемое старение. Данные старше N дней удаляются или прореживаются, и это не исключение, а штатный режим. Построчный DELETE — самый дорогой способ это сделать.
  • Однородность. Миллионы точек одной формы: время с почти постоянным шагом и значение, которое меняется плавно. Такие данные сжимаются в разы лучше произвольных, если хранилище знает, как их упаковать.

Обе школы используют эти свойства, но по-разному. TimescaleDB добавляет к PostgreSQL разбиение по времени и колоночное сжатие, оставляя SQL, транзакции и джойны с обычными таблицами. Метрический движок отказывается от SQL и произвольной схемы ради модели «ряд = имя + лейблы» и упаковки точек плотнее, чем это возможно в строках.

Гипертаблица: какой запрос читает все чанки

Гипертаблица — обычная с виду таблица, которую TimescaleDB делит на чанки — дочерние таблицы, каждая со своим интервалом времени. На стенде таблица metrics с чанком в сутки получает 30 суток данных: 200 рядов, точка в минуту, 8,64 млн строк. Рядом лежит обычная таблица metrics_plain с теми же строками и теми же индексами. Это сравнение разбиения по времени с таблицей без разбиения. Нативное декларативное партиционирование PostgreSQL тоже отсекает разделы и быстро удаляет целые партиции; TimescaleDB добавляет к этому автоматическое создание чанков, политики ретеншна, сжатие и continuous aggregate.

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT count(*) AS chunks, min(range_start) AS first, max(range_end) AS last FROM timescaledb_information.chunks WHERE hypertable_name = 'metrics'"
 chunks |         first          |          last
--------+------------------------+------------------------
     31 | 2026-09-05 00:00:00+00 | 2026-10-06 00:00:00+00
(1 row)

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

$ docker exec tsdb-ts psql -U postgres -d ts -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT avg(value) FROM metrics WHERE time > now() - INTERVAL '1 hour'" | grep -E 'Chunks excluded|_hyper_'
         Chunks excluded during startup: 0
         ->  Bitmap Heap Scan on _hyper_1_31_chunk (actual rows=11600.00 loops=1)
               ->  Bitmap Index Scan on _hyper_1_31_chunk_metrics_time_idx (actual rows=11600.00 loops=1)

now() — не константа, и PostgreSQL сам по себе не может отсечь по ней разделы при планировании. TimescaleDB умеет: здесь лишние 30 чанков исчезли из плана ещё до исполнения, поэтому счётчик отсечения при старте — ноль. Когда границу задаёт выражение посложнее, отсечение переносится на старт исполнения и видно в том же счётчике.

Теперь один и тот же вопрос — «среднее за вчерашние сутки» — тремя способами. Диапазон по самой колонке time:

$ docker exec tsdb-ts psql -U postgres -d ts -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT avg(value) FROM metrics WHERE time >= date_trunc('day', now()) - INTERVAL '1 day' AND time < date_trunc('day', now())" \
    | grep -E 'Chunks excluded|Seq Scan|Index Scan' | sed -E 's/_hyper_[0-9]+_[0-9]+_chunk/_hyper_N_chunk/' | sort | uniq -c
   1                      ->  Bitmap Index Scan on _hyper_N_chunk_metrics_time_idx (actual rows=288000.00 loops=1)
   1          Chunks excluded during startup: 30

И условие через функцию от колонки — так этот запрос пишут очень часто:

$ docker exec tsdb-ts psql -U postgres -d ts -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT avg(value) FROM metrics WHERE date_trunc('day', time) = date_trunc('day', now()) - INTERVAL '1 day'" \
    | grep -E 'Chunks excluded|Seq Scan|Index Scan' | sed -E 's/_hyper_[0-9]+_[0-9]+_chunk/_hyper_N_chunk/' | sort | uniq -c
  28                      ->  Parallel Seq Scan on _hyper_N_chunk (actual rows=0.00 loops=1)
   1                      ->  Parallel Seq Scan on _hyper_N_chunk (actual rows=0.00 loops=2)
   1                      ->  Parallel Seq Scan on _hyper_N_chunk (actual rows=0.00 loops=3)
   1                      ->  Parallel Seq Scan on _hyper_N_chunk (actual rows=288000.00 loops=1)
   1                Chunks excluded during startup: 0

Ответ тот же, но прочитаны все 31 чанк, и 30 из них впустую — rows=0.00. Вариант WHERE time::date = current_date - 1 даёт ровно такой же план. По времени разница — 30,5 мс против 445,1 мс, в 14,6 раза, и она растёт с глубиной истории. Причина не в TimescaleDB: отсечение работает по условию на саму колонку разбиения, а date_trunc('day', time) или time::date — это уже другое выражение, о котором планировщик не знает, что оно монотонно по time. Правило простое: условие на время пишут диапазоном по колонке, а функции применяют к правой части — к now(), к параметрам, к литералам.

Второе, что даёт разбиение, — дешёвый ретеншн. Удалить всё старше границы суток из обеих таблиц:

$ docker exec tsdb-ts psql -U postgres -d ts -c '\timing on' -c "DELETE FROM metrics_plain WHERE time < date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '29 days'"
Timing is on.
DELETE 148000
Time: 45.549 ms
$ docker exec tsdb-ts psql -U postgres -d ts -c '\timing on' -c "SELECT count(*) FROM drop_chunks('metrics', older_than => date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '29 days')"
Timing is on.
 count
-------
     1
(1 row)

Time: 9.039 ms

Время здесь не главное: на 148 тысячах строк оба способа быстры. Главное — что осталось после. Обычная таблица не уменьшилась ни на мегабайт (971 МБ до и после), а в ней теперь 148 000 мёртвых строк, которые предстоит убрать autovacuum. Гипертаблица похудела с 938 до 921 МБ сразу: drop_chunks удаляет чанк как файл, без мёртвых строк. Обратная сторона — удаляются только чанки целиком: если граница ретеншна проходит внутри чанка, он останется до тех пор, пока не устареет весь. Как это выглядит на большой мониторинговой базе и какую ловушку прячет связка ретеншна со сжатием, разобрано в статье про TimescaleDB под Zabbix; общая механика партиционирования и bloat в PostgreSQL — в статье про партиционирование и VACUUM.

Continuous aggregate: чего он не показывает

Continuous aggregate — материализованное представление, которое TimescaleDB обновляет инкрементально: пересчитывает только окна, где изменились сырые данные, а не всё целиком. Агрегат «по часам» на стенде строится над гипертаблицей cpu: 14 суток, 50 рядов, точка в минуту. Все моменты времени отсчитываются от h — начала часа на момент запуска, записанного в таблицу anchor.

CREATE MATERIALIZED VIEW cpu_hourly WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', time) AS bucket, series_id, avg(value) AS avg, count(*) AS n
FROM cpu GROUP BY bucket, series_id WITH NO DATA;

Дальше — три ситуации, в которых агрегат расходится с сырыми данными.

Свежие данные. После refresh до h − 2ч приходит ещё час данных. Агрегат его не видит:

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT view_name, materialized_only FROM timescaledb_information.continuous_aggregates"
 view_name  | materialized_only
------------+-------------------
 cpu_hourly | t
(1 row)

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT max(bucket) AS last_bucket, count(*) AS rows FROM cpu_hourly"
      last_bucket       | rows
------------------------+-------
 2026-10-05 08:00:00+00 | 16800
(1 row)

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT max(time_bucket('1 hour', time)) AS last_bucket_raw FROM cpu"
    last_bucket_raw
------------------------
 2026-10-05 09:00:00+00
(1 row)

materialized_only = t — значение по умолчанию начиная с TimescaleDB 2.13: агрегат отдаёт только то, что уже материализовано. До 2.13 по умолчанию было наоборот, и многие примеры в сети написаны в расчёте на старое поведение. Чтобы агрегат досчитывал хвост на лету из сырых данных, режим real-time aggregation включают явно:

$ docker exec tsdb-ts psql -U postgres -d ts -c "ALTER MATERIALIZED VIEW cpu_hourly SET (timescaledb.materialized_only = false)"
ALTER MATERIALIZED VIEW
$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT max(bucket) AS last_bucket, count(*) AS rows FROM cpu_hourly"
      last_bucket       | rows
------------------------+-------
 2026-10-05 09:00:00+00 | 16850
(1 row)

Цена — каждый запрос к агрегату теперь включает агрегацию сырых строк после последней материализации. Если refresh отстаёт, этот хвост растёт, и запрос к «быстрому» агрегату замедляется.

Опоздавшие данные. Real-time aggregation досчитывает только то, что новее границы материализации. Точки, пришедшие с опозданием в уже материализованный час, агрегат не увидит. На стенде в час h − 3 суток у ряда 0 дописаны 60 точек со значением 1000:

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT (SELECT round(avg::numeric, 2) FROM cpu_hourly WHERE series_id = 0 AND bucket = (SELECT h FROM anchor) - INTERVAL '3 days') AS in_aggregate, (SELECT round(avg(value)::numeric, 2) FROM cpu WHERE series_id = 0 AND time >= (SELECT h FROM anchor) - INTERVAL '3 days' AND time < (SELECT h FROM anchor) - INTERVAL '3 days' + INTERVAL '1 hour') AS from_raw"
 in_aggregate | from_raw
--------------+----------
        39.51 |   519.76
(1 row)

Фоновая политика обновления не всегда это исправит. Её окно задаёт start_offset: политика с start_offset => INTERVAL '2 days' пересчитывает только последние двое суток. После CALL run_job(...) в этом часе по-прежнему 39.51 против 519.76, и только ручной refresh_continuous_aggregate по этому часу выравнивает значения. Отсюда правило: start_offset должен покрывать то, насколько у вас реально опаздывают данные, — буферы агентов, повторы отправки, догрузку после аварии.

Ретеншн сырых данных. Частый приём — хранить сырые точки две недели, а часовые агрегаты — год. Агрегат действительно переживает удаление сырых чанков. Но если после этого сделать refresh по области, где сырых данных уже нет, TimescaleDB пересчитает агрегат «по пустоте»:

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT count(*) AS aggregate_rows FROM cpu_hourly WHERE bucket < date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '10 days'"
 aggregate_rows
----------------
           4350
(1 row)

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT count(*) AS dropped FROM drop_chunks('cpu', older_than => date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '10 days')"
 dropped
---------
       4
(1 row)

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT count(*) AS aggregate_rows FROM cpu_hourly WHERE bucket < date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '10 days'"
 aggregate_rows
----------------
           4350
(1 row)

$ docker exec tsdb-ts psql -U postgres -d ts -c "CALL refresh_continuous_aggregate('cpu_hourly', NULL, '2026-10-05 10:00:00+00')"
CALL
$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT count(*) AS aggregate_rows FROM cpu_hourly WHERE bucket < date_trunc('day', (SELECT h FROM anchor)) - INTERVAL '10 days'"
 aggregate_rows
----------------
              0
(1 row)

Это документированное поведение, а не сбой: refresh приводит агрегат в соответствие с сырыми данными. Но refresh_continuous_aggregate(..., NULL, ...) — самый естественный способ «пересчитать всё», и он молча стирает годовую историю агрегатов. Окно политики обновления (start_offset) должно быть короче срока хранения сырых данных, а ручной refresh — только по явному диапазону.

У той же медали есть третья сторона. Агрегат, созданный WITH NO DATA и оставленный на одну политику, никогда не материализует историю старше её start_offset:

$ docker exec tsdb-ts psql -U postgres -d ts -c "SELECT (SELECT min(bucket) FROM cpu_hourly_policy) AS aggregate_from, (SELECT count(DISTINCT bucket) FROM cpu_hourly_policy) AS aggregate_hours, (SELECT min(time_bucket('1 hour', time)) FROM cpu) AS raw_from, (SELECT count(DISTINCT time_bucket('1 hour', time)) FROM cpu) AS raw_hours"
     aggregate_from     | aggregate_hours |        raw_from        | raw_hours
------------------------+-----------------+------------------------+-----------
 2026-10-03 12:00:00+00 |              46 | 2026-09-25 00:00:00+00 |       250
(1 row)

46 часов в агрегате против 250 часов сырых данных, а с materialized_only = t по умолчанию остальные 204 часа в агрегате просто отсутствуют. Начальное заполнение — отдельный шаг: один refresh_continuous_aggregate по явному диапазону истории, а потом уже политика.

Метрики: имя, лейблы и цена ряда

Метрическая школа начинается с модели данных. Ряд — это имя метрики и набор лейблов: http_requests_total{service="api", method="GET", status="200"}. Каждая уникальная комбинация значений лейблов — отдельный ряд со своим потоком точек. Схемы нет: новый лейбл или новое значение лейбла просто создаёт новые ряды.

Prometheus TSDB держит последние два-три часа в памяти — в head-блоке, где для каждого активного ряда есть своя структура с лейблами и текущим чанком точек. Раз в два часа head сбрасывается на диск неизменяемым блоком с собственным индексом, блоки затем сливаются в более крупные. Prometheus рассчитан на один узел и локальный диск. Хранить на нём можно и долго — сколько позволяют диск и память узла, — но кластера, репликации и даунсэмплинга у него нет: масштабирование и отказоустойчивость он отдаёт наружу — через remote_write в другое хранилище или через надстройки вроде Thanos и Mimir. Как выбирать сами метрики и лейблы, разобрано в статье про RED, USE и Prometheus.

VictoriaMetrics принимает те же данные — remote_write, формат экспозиции Prometheus, OpenMetrics — и отвечает на тот же HTTP API запросов, поэтому встаёт за Prometheus или вместо него без переделки дашбордов. Внутри она устроена иначе: данные складываются в парты по месяцам и сливаются в фоне, как в LSM-дереве, а индекс рядов живёт отдельно и кешируется. Есть single-node и кластерная версия с раздельными vminsert, vmselect и vmstorage. Язык запросов — MetricsQL, надмножество PromQL с отличиями в семантике, о которых ниже.

Главный враг обеих систем — кардинальность, то есть число рядов. Типичная ошибка — положить в лейбл идентификатор: user_id, request_id, полный URL с параметрами. На стенде метрика demo_requests{user_id="u-…"} получает ступенями 10 тысяч, 100 тысяч, 300 тысяч и миллион рядов, по одной свежей точке у каждого, и после каждой ступени снимаются собственные метрики обоих процессов. Так выглядит Prometheus на миллионе рядов:

$ docker exec tsdb-prom wget -qO- http://127.0.0.1:9090/metrics | grep -E '^(prometheus_tsdb_head_series|process_resident_memory_bytes|go_memstats_heap_inuse_bytes) '
go_memstats_heap_inuse_bytes 8.87816192e+08
process_resident_memory_bytes 1.09852672e+09
prometheus_tsdb_head_series 1e+06

Сводка по ступеням — RSS процесса в мегабайтах, собранная из таких же снимков после каждой ступени (сырые значения — в fixtures/cardinality.tsv стенда):

Активных рядов 0 10 тыс. 100 тыс. 300 тыс. 1 млн
Prometheus 101 103 193 404 1099
VictoriaMetrics 29 94 176 269 347

Условия замера важны для чтения этих чисел. Лимит памяти контейнера — 3 ГБ у Prometheus и 2 ГБ у VictoriaMetrics. У каждого ряда одна точка, запросов во время замера нет, снимок делается через 30 секунд после ступени. Это цена ряда в момент его появления, а не расход памяти при долгой записи и запросной нагрузке.

В этих условиях Prometheus платит за ряд линейно — около 1 КБ памяти на активный ряд; с реальными чанками на 120 точек — больше. VictoriaMetrics на том же миллионе обходится 347 МБ, в 3,2 раза меньше, и прирост от 300 тысяч до миллиона у неё 78 МБ против 695 МБ у Prometheus. Коэффициент 3,2 не стоит переносить на свою систему без замера: у VictoriaMetrics в RSS заметную долю дают кеши, размер которых зависит от лимита памяти, и её значения между ступенями шумные. Это разница в устройстве, а не в «оптимизации»: у Prometheus все активные ряды живут в head, а VictoriaMetrics держит в памяти кеши индекса, размер которых ограничен долей доступной памяти. Но и у неё кардинальность не бесплатна: растёт индекс, медленнее ищутся ряды, а запрос, который трогает их все, отвечает пропорционально их числу. И у Prometheus ряд, переставший получать точки, остаётся в head до ближайшей компакции — всплеск кардинальности на минуту держит память часами. Как ограничивать кардинальность на стороне приложения и сколько она стоит в деньгах, разобрано в статье про кардинальность и стоимость телеметрии.

Откуда берётся сжатие и от чего оно зависит

Классическая схема сжатия временных рядов описана в статье Facebook о хранилище Gorilla (2015), и на ней стоят Prometheus и сжатие TimescaleDB:

  • Время — delta-of-delta. При постоянном шаге разность между соседними разностями равна нулю и кодируется одним битом.
  • Значение — XOR с предыдущим. Одинаковые значения дают ноль и тоже один бит. Разные — XOR, из которого хранятся только значащие биты между ведущими и хвостовыми нулями.

VictoriaMetrics делает иначе: переводит float64 в десятичную пару «целая мантисса + показатель степени», дальше работает с целыми числами — разности, разности разностей — и дожимает результат zstd. Разница между подходами хорошо видна на одинаковых данных.

Стенд пишет один и тот же набор — 200 рядов × сутки × шаг 15 с, 1 152 000 точек — в четырёх формах: счётчик с целыми приращениями, gauge (синусоида с шумом), тот же gauge, округлённый до сотых, и константа. Что именно измеряется:

  • Окружение. Один узел каждой системы в Docker Desktop на macOS arm64, VM Docker 7,7 ГБ. Лимит памяти контейнера — 2 ГБ у TimescaleDB, 3 ГБ у Prometheus, 2 ГБ у VictoriaMetrics.
  • Prometheus — двухчасовые блоки, созданные promtool tsdb create-blocks-from openmetrics, без последующей компакции сервером. Такими же блоками Prometheus сбрасывает head сам; слияние в крупные блоки в замер не входит.
  • VictoriaMetrics — после принудительных force_flush и force_merge, то есть в максимально слитом состоянии.
  • TimescaleDB — hypertable_size() после VACUUM ANALYZE, до и после compress_chunk.
  • Что входит в размер. Данные и индекс выбранных структур хранения. WAL, кеши и временные файлы работающего сервиса не входят — это не полный расход диска.

Вот, например, итог для округлённого gauge в Prometheus и в VictoriaMetrics:

$ docker run --rm -v "$PWD/work:/work" --entrypoint promtool prom/prometheus:v3.15.0 tsdb list /work/prom-gauge2 | awk 'NR > 1 { n += $5; b += $8 } END { printf "samples=%d bytes=%d bytes/sample=%.2f\n", n, b, b / n }'
samples=1152000 bytes=8396261 bytes/sample=7.29
$ docker exec tsdb-vm wget -qO- http://127.0.0.1:8428/metrics | grep -E '^vm_(rows|data_size_bytes)\{type="(storage|indexdb)/' | grep -v ' 0$'
vm_data_size_bytes{type="storage/small"} 1742114
vm_data_size_bytes{type="storage/metaindex"} 56
vm_data_size_bytes{type="indexdb/inmemory"} 28672
vm_data_size_bytes{type="indexdb/metaindex"} 72
vm_rows{type="storage/small"} 1152000
vm_rows{type="indexdb/inmemory"} 2534

Вся сводка, байт на точку:

Форма ряда Prometheus VictoriaMetrics TimescaleDB без сжатия TimescaleDB со сжатием
счётчик, целые приращения 2,07 0,67 61,60 2,90
gauge, округлён до 0,01 7,29 1,54 61,60 8,94
gauge, полный float64 7,30 6,46 61,60 8,94
константа 0,67 0,03 61,60 0,63

У таблицы есть оговорка: размер сравнивается при одинаковом входе, но не обязательно при одинаковом выходе. Prometheus хранит float64 без потерь — это проверено ниже; сжатие TimescaleDB по устройству тоже без потерь (XOR-кодирование обратимо), но стенд его обратным чтением не проверял. VictoriaMetrics хранит десятичную мантиссу ограниченной длины, и значения, у которых значащих цифр больше 12, возвращает с округлением. Стенд проверяет это напрямую: пишет 40 000 точек каждой формы в обе системы и читает их обратно.

$ docker run --rm --network tsdb tsdb-gen check -url http://prom:9090 -shape gauge -series 200 -span 50m -step 15s -end 2026-10-05T12:29:00Z
check: записано 40000, получено 40000, сопоставлено 40000, пропусков 0, лишних 0, дубликатов 0
check: совпали побитно 40000 из 40000, max |ошибка| 0, max относительная 0
$ docker run --rm --network tsdb tsdb-gen check -url http://vm:8428 -shape gauge -series 200 -span 50m -step 15s -end 2026-10-05T12:29:00Z
check: записано 40000, получено 40000, сопоставлено 40000, пропусков 0, лишних 0, дубликатов 0
check: совпали побитно 20 из 40000, max |ошибка| 7.57e-11, max относительная 1e-12

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

Для gauge с полной точностью VictoriaMetrics вернула побитно точными 20 точек из 40 000, относительная ошибка остальных — до 10⁻¹². Округлённый gauge и счётчик она вернула точно, как и Prometheus. Для мониторинга такая ошибка несущественна. Для показаний приборов, где важен каждый разряд, и тем более для денег критерий другой. Размер 6,46 байта на точку в таблице получен при хранении с этой потерей точности; сколько байт она экономит, стенд отдельно не измерял. Округлённый gauge, наоборот, вернулся точно — его выигрыш в 4,2 раза на этом наборе обошёлся без потери точности.

Из таблицы следуют три вывода — для этого набора данных и этих условий.

  • Строка PostgreSQL дорога. В этой таблице — (time, series_id, value) с индексом по времени — точка занимает 61,6 байта: заголовок кортежа, выравнивание, указатель на строку, запись в индексе. С другими колонками и индексами число будет другим, но порядок тот же. Сжатие TimescaleDB превращает это в 2,9–8,9 байта для счётчика и gauge — в 7–21 раз меньше, и ради этого его и включают.
  • На этом наборе округление помогло только десятичной схеме. В Prometheus gauge с двумя знаками после запятой занимает столько же, сколько с полной точностью: 7,29 против 7,30. В TimescaleDB размеры совпали с точностью до страницы. Число 49,37 в двоичном float64 имеет такую же «шумную» мантиссу, как 49,3714…, и на этой шумной синусоиде округление заметного выигрыша для XOR не дало: важен не сам факт округления, а то, сколько бит меняется между соседними значениями. В VictoriaMetrics округление сжало ряд в 4,2 раза: 1,54 против 6,46 байта, потому что мантисса превращается в небольшое целое. Для XOR-схем округление окупается в другом случае — когда после него соседние значения начинают повторяться: медленно меняющаяся температура с шагом 0,1 даст длинные серии одинаковых точек по одному биту. Практический вывод — не отдавать в метрики больше знаков, чем нужно, и проверять эффект на своих рядах.
  • Форма ряда важнее системы. Константа стоит от 0,03 до 0,67 байта, шумный gauge — 6,5–9. Средняя цифра «байт на точку» из чужого бенчмарка ничего не говорит о ваших данных, пока не известно, какие у вас ряды.

Даунсэмплинг — следующий шаг после сжатия: старые данные хранят реже, чем писали. Под этим словом скрываются две разные операции. Агрегация заменяет точки окна на avg, min, max за окно — так работает continuous aggregate в TimescaleDB, плюс ретеншн сырых данных с ловушками из предыдущего раздела. Прореживание оставляет одну исходную точку на окно, а остальные удаляет. Так устроен даунсэмплинг VictoriaMetrics: флаг -downsampling.period=30d:5m оставляет для данных старше 30 суток последнюю точку каждого пятиминутного интервала. Для монотонных счётчиков без сбросов прореживание сохраняет накопленные значения, но снижает временное разрешение, и результаты increase() и rate() по старым данным могут измениться. Со сбросами хуже: если из точек 100 → 120 → 0 → 130 после прореживания остались 100 → 130, сброс пропал, и видимый прирост — 30 вместо 150. У gauge пропадает всё, что было внутри интервала, включая пики: кратковременный всплеск нагрузки CPU в старых данных исчезнет. В Prometheus даунсэмплинга нет совсем. В VictoriaMetrics он есть, но платный — об этом ниже.

Один запрос — два ответа

VictoriaMetrics отвечает на Prometheus API, и легко решить, что на те же данные она отвечает так же. На стенде один счётчик — час истории с шагом 15 с и случайными целыми приращениями — записан по remote_write в обе системы. Затем одна и та же команда спрашивает обе в один момент T, через 7 секунд после очередной точки:

$ docker exec tsdb-prom promtool query instant --time=1791197407 http://127.0.0.1:9090 'demo_counter_total'
demo_counter_total{dc="dc1", host="h-000"} => 1908 @[1791197407]
$ docker exec tsdb-prom promtool query instant --time=1791197407 http://127.0.0.1:9090 'demo_counter_total offset 5m'
demo_counter_total{dc="dc1", host="h-000"} => 1736 @[1791197407]
$ docker exec tsdb-prom promtool query instant --time=1791197407 http://127.0.0.1:9090 'increase(demo_counter_total[5m])'
{dc="dc1", host="h-000"} => 162.10526315789474 @[1791197407]
$ docker exec tsdb-prom promtool query instant --time=1791197407 http://vm:8428 'increase(demo_counter_total[5m])'
{dc="dc1", host="h-000"} => 172 @[1791197407]

Prometheus находится в 127.0.0.1:9090, VictoriaMetrics — в vm:8428, команда promtool одна и та же. Значения самого счётчика VictoriaMetrics возвращает те же: 1908 в момент T и 1736 по запросу offset 5m. 1736 — это не значение ровно в T − 5m, а последняя точка перед этой границей. Сколько событий случилось между точными границами окна, по дискретным точкам однозначно не восстановить, поэтому «правильного» ответа здесь нет — есть разные определения. Разность крайних выбранных точек — 1908 − 1736 = 172, и именно её возвращает MetricsQL. PromQL оценивает прирост на полном интервале экстраполяцией и отвечает 162,105…: дробное число для счётчика с целыми приращениями, что семантикой increase() предусмотрено.

Дело в двух решениях PromQL. Во-первых, increase() смотрит только на точки внутри окна (T − 5m, T]. Рост между последней точкой до окна (1736) и первой точкой в окне (1754) в расчёт не входит. Во-вторых, оставшийся рост — 1908 − 1754 = 154 за 285 секунд между крайними точками — экстраполируется на всё окно в 300 секунд: 154 × 300 / 285 = 162,1. MetricsQL берёт последнюю точку перед окном и не экстраполирует — это прямо описано в документации MetricsQL.

Ни один ответ не «сломан»: экстраполяция Prometheus — осознанный выбор для оценки скорости по редким точкам. Но если вы переносите дашборды и алерты с Prometheus на VictoriaMetrics, пороги на increase() и rate() могут сработать иначе: на редко растущих счётчиках расхождение заметнее всего. Сравнивать ответы старой и новой системы стоит до переключения, а не после.

Что в бесплатных сборках

У всех трёх систем граница бесплатного проходит по-разному, и её стоит знать до того, как на функцию завязан процесс.

TimescaleDB поставляется в двух сборках одной версии. Сборка под лицензией Apache-2.0 содержит гипертаблицы, time_bucket и ручной drop_chunks. Всё остальное, о чём шла речь выше, — под Timescale License (TSL):

$ docker exec tsdb-ts-oss psql -U postgres -d ts -c "CREATE MATERIALIZED VIEW cpu_hourly WITH (timescaledb.continuous) AS SELECT time_bucket('1 hour', time) AS bucket, avg(value) FROM cpu GROUP BY bucket WITH NO DATA"
ERROR:  functionality not supported under the current "apache" license. Learn more at https://tsdb.co/pdbir1r3
HINT:  To access all features and the best time-series experience, try out Timescale Cloud.
$ docker exec tsdb-ts-oss psql -U postgres -d ts -c "ALTER TABLE cpu SET (timescaledb.compress, timescaledb.compress_segmentby = 'series_id')"
ERROR:  functionality not supported under the current "apache" license. Learn more at https://tsdb.co/pdbir1r3
HINT:  To access all features and the best time-series experience, try out Timescale Cloud.
$ docker exec tsdb-ts-oss psql -U postgres -d ts -c "SELECT add_retention_policy('cpu', INTERVAL '30 days')"
ERROR:  function "add_retention_policy" is not supported under the current "apache" license
HINT:  Upgrade your license to 'timescale' to use this free community feature.

TSL бесплатна, но запрещает предлагать TimescaleDB третьим лицам как сервис базы данных. Поэтому сторонний managed PostgreSQL может дать вам только Apache-сборку: без сжатия, continuous aggregate и политик. Это проверяется одной командой — SHOW timescaledb.license вернёт apache или timescale. Полная TimescaleDB как сервис есть у самого вендора, в Tiger Cloud.

VictoriaMetrics в открытой сборке (Apache-2.0) не знает флагов даунсэмплинга и раздельного срока хранения для разных рядов:

$ docker run --rm victoriametrics/victoria-metrics:v1.153.0 -downsampling.period=30d:5m
flag provided but not defined: -downsampling.period

victoria-metrics is a time series database and monitoring solution.

See the docs at https://docs.victoriametrics.com/victoriametrics/

Run "/victoria-metrics-prod -help" in order to see the description for all the available flags
$ docker run --rm victoriametrics/victoria-metrics:v1.153.0 -retentionFilter='{env="dev"}:7d'
flag provided but not defined: -retentionFilter

victoria-metrics is a time series database and monitoring solution.

See the docs at https://docs.victoriametrics.com/victoriametrics/

Run "/victoria-metrics-prod -help" in order to see the description for all the available flags

Обе функции — в VictoriaMetrics Enterprise, вместе с автоматизацией бэкапов и рядом функций кластера. Цены публично не опубликованы, есть пробная лицензия. Без Enterprise долгую историю в VictoriaMetrics хранят с исходным шагом — с учётом сжатия из предыдущего раздела это часто приемлемо — или собирают агрегаты снаружи. Recording rules в vmalert считают, например, avg_over_time и max_over_time за 5 минут и пишут результат новыми рядами. Сами исходные точки они не трогают: место освобождается, только если агрегаты пишутся в отдельный экземпляр VictoriaMetrics с долгим сроком хранения, а сырые данные живут в экземпляре с коротким. Эта схема описана в документации vmalert; тот же приём даёт streaming aggregation в vmagent.

Prometheus — Apache-2.0 целиком, платных редакций нет. Срок хранения задаётся флагом, и по документации локально можно держать и годы данных — в пределах диска и памяти одного узла. Чего нет — встроенного кластерного и реплицируемого хранилища и даунсэмплинга. Масштабирование и отказоустойчивость — задачи Thanos, Mimir или того же VictoriaMetrics как удалённого хранилища.

Граница с ClickHouse

ClickHouse тоже часто называют хранилищем временных рядов, и на «сырых» событиях с меткой времени он сильнее обеих школ. Граница проходит по форме данных, а не по наличию колонки time.

  • Метрики — заранее агрегированные числа с небольшим набором лейблов: запросы по окнам, rate, последнее значение, алерты. Это территория Prometheus и VictoriaMetrics.
  • Временные ряды рядом с реляционными данными — показания датчиков, которые надо джойнить со справочником устройств, биллинг по интервалам, история цен. Здесь выигрывает TimescaleDB: SQL, транзакции, внешние ключи и все инструменты PostgreSQL.
  • События — каждая строка самостоятельна, колонок много, кардинальность высокая (пользователь, URL, сессия), запросы произвольные. Это не ряды, а аналитическая таблица, и её место — в ClickHouse. Положить такие данные в метрический движок — та самая ошибка с user_id в лейбле из раздела про кардинальность.

Цифры сравнения ClickHouse, TimescaleDB и DuckDB на одном агрегате — в карте выбора аналитической БД, а когда вообще нужен колоночный OLAP — в статье про ClickHouse и PostgreSQL.

Как выбирать

  • У вас уже есть PostgreSQL, и ряды связаны с реляционными данными — TimescaleDB. Проверьте, какая сборка вам доступна: в стороннем managed PostgreSQL без TSL от неё остаются только гипертаблицы. Пишите условия на время диапазоном по колонке. Для continuous aggregate явно решите про materialized_only, сделайте начальный refresh истории и согласуйте start_offset политики с опозданием данных и сроком хранения сырых точек.
  • Метрики мониторинга в экосистеме Prometheus — Prometheus для сбора и короткого окна, VictoriaMetrics — если история перерастает один узел Prometheus или нужно меньше диска и памяти на ряд. Заменить ею связку Prometheus + Thanos одним узлом можно, если достаточно одного узла и локального хранения. Если связка давала отказоустойчивость или хранение в объектном хранилище, эти требования нужно решать отдельно: для доступности single-node VictoriaMetrics документация предлагает несколько экземпляров с дублированием записи, либо кластерную версию. Если в метриках есть значения, где важен каждый разряд, проверьте, что VictoriaMetrics возвращает их с нужной точностью. При переезде сверьте ответы increase() и rate() на своих алертах.
  • Любая метрическая система — следите за кардинальностью раньше, чем за объёмом точек: ряд стоит памяти и индекса, точка — единиц байт.
  • Сырые события с высокой кардинальностью — не TSDB, а колоночная аналитическая база.

Запись в обе школы из Go на стенде занимает несколько десятков строк. В TimescaleDB — обычный COPY через pgx, гипертаблица для клиента ничем не отличается от таблицы:

n, err := conn.CopyFrom(ctx, pgx.Identifier{table}, []string{"time", "series_id", "value"},
	pgx.CopyFromFunc(func() ([]any, error) {
		r, ok := <-rows
		if !ok {
			return nil, nil
		}
		return r, nil
	}))

В Prometheus и VictoriaMetrics — запрос remote_write: protobuf-сообщение WriteRequest, сжатое snappy. Чтобы не тянуть в зависимости модуль Prometheus целиком, сообщение кодируется вручную через protowire — в нём всего четыре типа:

//	WriteRequest { repeated TimeSeries timeseries = 1; }
//	TimeSeries   { repeated Label labels = 1; repeated Sample samples = 2; }
//	Label        { string name = 1; string value = 2; }
//	Sample       { double value = 1; int64 timestamp = 2; }

func encode(ss []series) []byte {
	var req []byte
	for _, s := range ss {
		var ts []byte
		for _, l := range s.labels {
			var lb []byte
			lb = protowire.AppendTag(lb, 1, protowire.BytesType)
			lb = protowire.AppendString(lb, l[0])
			lb = protowire.AppendTag(lb, 2, protowire.BytesType)
			lb = protowire.AppendString(lb, l[1])
			ts = protowire.AppendTag(ts, 1, protowire.BytesType)
			ts = protowire.AppendBytes(ts, lb)
		}
		for _, p := range s.samples {
			var sb []byte
			sb = protowire.AppendTag(sb, 1, protowire.Fixed64Type)
			sb = protowire.AppendFixed64(sb, math.Float64bits(p.v))
			sb = protowire.AppendTag(sb, 2, protowire.VarintType)
			sb = protowire.AppendVarint(sb, uint64(p.ts))
			ts = protowire.AppendTag(ts, 2, protowire.BytesType)
			ts = protowire.AppendBytes(ts, sb)
		}
		req = protowire.AppendTag(req, 1, protowire.BytesType)
		req = protowire.AppendBytes(req, ts)
	}
	return snappy.Encode(nil, req)
}

func push(url string, ss []series) error {
	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(encode(ss)))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Encoding", "snappy")
	req.Header.Set("Content-Type", "application/x-protobuf")
	req.Header.Set("X-Prometheus-Remote-Write-Version", "0.1.0")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode/100 != 2 {
		return fmt.Errorf("%s: %s: %s", url, resp.Status, bytes.TrimSpace(body))
	}
	return nil
}

Метки ряда должны быть отсортированы по имени, с __name__ первым, — генератор так их и складывает.

Один и тот же код пишет в http://prom:9090/api/v1/write и в http://vm:8428/api/v1/write — так на стенде и проверялась разница в ответах на одинаковые данные.

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

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

Комментарии