Моделирование временных рядов
Метрики инфраструктуры, IoT-телеметрия, финансовые котировки — все эти данные представляют собой временные ряды: последовательности значений, привязанных к метке времени. ClickHouse создавался именно для этого класса нагрузки: высокая скорость записи, эффективное сжатие монотонных последовательностей, быстрые range-сканы по временному диапазону.
Правильный выбор схемы определяет разницу между 10x и 100x сжатием, между секундами и миллисекундами на запрос.
Базовая схема time-series
CREATE TABLE metrics (
ts DateTime64(3) CODEC(Delta, ZSTD(1)),
metric_name LowCardinality(String),
host LowCardinality(String),
value Float64 CODEC(Gorilla, LZ4),
tags Map(String, String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(ts)
ORDER BY (metric_name, host, ts);
Каждое решение в этой схеме имеет конкретную причину. Разберём по порядку.
Codec chains: сжатие для каждого типа столбца
ClickHouse позволяет назначить цепочку кодеков (codec chain) каждому столбцу индивидуально. Первый кодек — препроцессор (Delta, DoubleDelta, Gorilla), второй — компрессор (ZSTD, LZ4). Препроцессор трансформирует данные так, чтобы компрессор работал эффективнее.
Как работает Delta
Delta кодирует разницу между соседними значениями вместо абсолютных значений:
Исходные timestamps: [1000, 1001, 1002, 1003, 1004]
После Delta: [1000, 1, 1, 1, 1]
Для регулярных временных рядов (метрика каждую секунду) delta — константа. ZSTD сжимает последовательность одинаковых значений в несколько байт.
Как работает Gorilla
Gorilla (из Facebook Gorilla paper, 2015) XOR-кодирует float-значения:
Исходные значения: [72.5, 72.6, 72.5, 72.7, 72.6]
XOR с предыдущим: [72.5, 0x...small, 0x...small, ...]
Для медленно меняющихся метрик (CPU utilization, temperature) XOR между соседними значениями — малое число с большим количеством нулевых бит. LZ4 сжимает это эффективно, при этом обеспечивая быструю декомпрессию.
Как работает DoubleDelta
DoubleDelta — разница разниц (вторая производная):
Исходные значения: [100, 200, 300, 400, 500]
Delta: [100, 100, 100, 100, 100]
DoubleDelta: [100, 0, 0, 0, 0]
Идеален для монотонно растущих счётчиков с постоянным приростом.
ORDER BY: metric first, timestamp second
ORDER BY (metric_name, host, ts)
Почему metric_name первым, а не timestamp?
Кардинальность и фильтрация: типичный запрос к time-series — “покажи CPU utilization хоста X за последний час”. Первый столбец ORDER BY (metric_name) отсекает все остальные метрики на уровне гранул. Второй столбец (host) сужает до конкретного хоста. Третий (ts) обеспечивает range-scan внутри отфильтрованной группы.
Если поставить timestamp первым:
-- Плохой ORDER BY для time-series
ORDER BY (ts, metric_name, host)
Гранулы будут содержать все метрики всех хостов за один временной интервал. Фильтрация по конкретной метрике потребует чтения всех гранул — sparse index бесполезен.
Правило для time-series ORDER BY: dimension-столбцы (metric, host, region) перед timestamp. Timestamp всегда последним для range-сканов внутри группы.
PARTITION BY: lifecycle, не оптимизация запросов
PARTITION BY toYYYYMM(ts)
Партиционирование в ClickHouse — инструмент управления жизненным циклом данных, а не оптимизации запросов (для этого есть ORDER BY и sparse index).
Месячные партиции для метрик:
- TTL:
ALTER TABLE metrics MODIFY TTL ts + INTERVAL 90 DAY— автоматическое удаление старых партиций - DROP PARTITION:
ALTER TABLE metrics DROP PARTITION '202401'— моментальное удаление целого месяца - Detach/Attach: перенос холодных данных на S3 через tiered storage
Дневные партиции подходят только при очень высоком объёме записи (более 100 миллионов строк в день), когда месячная партиция становится слишком крупной для одного merge.
Избыточное партиционирование (по часу, по минуте) создаёт тысячи мелких parts, которые не успевают сливаться. Это замедляет SELECT и увеличивает потребление ZooKeeper/Keeper ресурсов в распределённых конфигурациях.
DateTime vs DateTime64: выбор гранулярности
| Тип | Гранулярность | Хранение | Когда использовать |
|---|---|---|---|
| DateTime | 1 секунда | 4 байта | Метрики инфраструктуры (Prometheus-style, scrape interval более 1 секунды) |
| DateTime64(3) | 1 миллисекунда | 8 байт | Трейсинг, финансовые данные, IoT с sub-second записью |
| DateTime64(6) | 1 микросекунда | 8 байт | High-frequency trading, научные измерения |
| DateTime64(9) | 1 наносекунда | 8 байт | Очень специализированные сценарии (network packet timing) |
DateTime64 занимает 8 байт вместо 4 для DateTime. При миллиардах строк это удваивает размер timestamp-столбца. Выбирайте минимальную достаточную гранулярность.
-- Prometheus-style метрики: секундной гранулярности достаточно
CREATE TABLE infra_metrics (
ts DateTime CODEC(Delta, ZSTD(1)),
...
);
-- Distributed tracing: нужны миллисекунды для span ordering
CREATE TABLE traces (
ts DateTime64(3) CODEC(Delta, ZSTD(1)),
...
);
Пример: полная схема метрик с TTL
CREATE TABLE system_metrics (
ts DateTime64(3) CODEC(Delta, ZSTD(1)),
metric_name LowCardinality(String),
host LowCardinality(String),
dc LowCardinality(String),
value Float64 CODEC(Gorilla, LZ4),
min_1m Float64 CODEC(Gorilla, LZ4),
max_1m Float64 CODEC(Gorilla, LZ4),
count_1m UInt64 CODEC(DoubleDelta, ZSTD(1)),
tags Map(String, String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(ts)
ORDER BY (metric_name, dc, host, ts)
TTL ts + INTERVAL 90 DAY;
-- Запрос: CPU за последний час для конкретного хоста
SELECT
toStartOfMinute(ts) AS minute,
avg(value) AS avg_cpu,
max(max_1m) AS peak_cpu
FROM system_metrics
WHERE metric_name = 'cpu.usage'
AND host = 'web-01'
AND ts >= now() - INTERVAL 1 HOUR
GROUP BY minute
ORDER BY minute;
ORDER BY (metric_name, dc, host, ts) обеспечивает: sparse index отсекает все метрики кроме cpu.usage, затем dc и host сужают до конкретного сервера, ts обеспечивает range-scan по последнему часу.
Ключевые выводы
- Codec chains — назначайте индивидуально: Delta+ZSTD для timestamps, Gorilla+LZ4 для float, DoubleDelta+ZSTD для счётчиков.
- ORDER BY — dimension-столбцы (metric, host) перед timestamp. Timestamp всегда последним.
- PARTITION BY toYYYYMM — для lifecycle management (TTL, DROP PARTITION), не для оптимизации запросов.
- DateTime vs DateTime64 — выбирайте минимальную достаточную гранулярность. DateTime (4 байта) вдвое компактнее DateTime64 (8 байт).
- Типичное сжатие time-series в ClickHouse: 10-50x по сравнению с несжатыми данными, благодаря комбинации codec chains и столбцового хранения.