Схемы для лог-аналитики
Elasticsearch (ELK stack) и Grafana Loki — два доминирующих решения для лог-аналитики. Оба имеют существенные ограничения: ELK требует Java heap и инвертированные индексы на каждое поле (высокая стоимость хранения), Loki не индексирует содержимое логов (медленный полнотекстовый поиск).
ClickHouse предлагает третий путь: столбцовое хранение + text index + агрессивный TTL. Логи занимают в 5-10 раз меньше места, чем в Elasticsearch, при сопоставимой скорости поиска.
Архитектура log pipeline
Схема таблицы логов
CREATE TABLE logs (
-- Временная метка с миллисекундной точностью
timestamp DateTime64(3) CODEC(Delta, ZSTD(1)),
-- Структурированные поля с LowCardinality
level LowCardinality(String), -- INFO, WARN, ERROR, DEBUG, TRACE
service LowCardinality(String), -- api-gateway, auth-service, payment
host LowCardinality(String), -- web-01, worker-03
environment LowCardinality(String), -- production, staging
-- Текстовые поля
message String,
-- Tracing
trace_id String,
span_id String,
-- Переменные атрибуты (Kubernetes labels, HTTP headers)
attributes Map(LowCardinality(String), String),
-- Индексы
INDEX idx_message message TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4,
INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 1
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service, level, timestamp)
TTL timestamp + INTERVAL 30 DAY;
Разберём каждое решение в этой схеме.
LowCardinality для повторяющихся полей
Логи содержат множество столбцов с низкой кардинальностью: уровень (5 значений), сервис (десятки), хост (сотни), environment (2-3). LowCardinality заменяет строковые значения на целочисленные индексы словаря:
-- Без LowCardinality: каждая строка хранит полную строку "production"
environment String -- 10 байт на строку
-- С LowCardinality: словарь ["production", "staging"] + индекс UInt8
environment LowCardinality(String) -- 1 байт на строку + словарь
При миллиардах строк логов экономия — гигабайты. Фильтрация по LowCardinality-столбцу работает на целочисленных индексах, а не на строковом сравнении.
LowCardinality эффективен при кардинальности до ~10 000 уникальных значений. Для столбцов с миллионами уникальных значений (user_id, request_id) используйте обычный String или UInt64.
Text index для полнотекстового поиска
Начиная с ClickHouse 26.2, text index (inverted index) — GA-фича для полнотекстового поиска. Это замена legacy-индексов ngrambf_v1 и tokenbf_v1.
-- Text index (рекомендуется для ClickHouse 26.2+)
INDEX idx_message message TYPE full_text GRANULARITY 1
-- Legacy bloom-based index (для совместимости с более ранними версиями)
INDEX idx_message message TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4
В схеме выше использован tokenbf_v1, который работает во всех версиях ClickHouse. Для 26.2+ рассмотрите замену на full_text index, обеспечивающий более точный поиск (без false positives bloom filter).
Text index ускоряет запросы с LIKE, hasToken(), multiSearchAny():
-- Поиск по ключевому слову в message
SELECT timestamp, service, level, message
FROM logs
WHERE service = 'payment'
AND level = 'ERROR'
AND message LIKE '%timeout%'
AND timestamp >= now() - INTERVAL 1 HOUR
ORDER BY timestamp DESC
LIMIT 100;
Без индекса ClickHouse сканирует все гранулы. С text/bloom-индексом — только гранулы, которые могут содержать совпадение.
Bloom filter для trace_id
Trace ID — строка высокой кардинальности (миллионы уникальных значений). Full text index для неё избыточен. Bloom filter — компактная вероятностная структура данных, которая точно отвечает “этой гранулы точно НЕ содержит значение” и с малой вероятностью ошибки “эта гранула МОЖЕТ содержать значение”.
INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 1
-- 0.01 = 1% false positive rate
-- GRANULARITY 1 = bloom filter на каждую гранулу (максимальная точность)
Типичный запрос по trace_id:
-- Найти все логи конкретного запроса
SELECT timestamp, service, level, message
FROM logs
WHERE trace_id = '4bf92f3577b34da6a3ce929d0e0e4736'
ORDER BY timestamp;
Bloom filter с 1% false positive rate и GRANULARITY 1 читает ~1% гранул вместо 100%.
Map для переменных атрибутов
Kubernetes labels, HTTP headers, custom metadata — набор ключей варьируется от записи к записи. Map(LowCardinality(String), String) хранит произвольные пары key-value без фиксации схемы:
attributes Map(LowCardinality(String), String)
Доступ к атрибутам:
-- Фильтрация по Kubernetes namespace
SELECT timestamp, message
FROM logs
WHERE attributes['k8s.namespace'] = 'production'
AND timestamp >= now() - INTERVAL 1 HOUR;
-- Извлечение HTTP status code
SELECT
attributes['http.status_code'] AS status,
count() AS cnt
FROM logs
WHERE service = 'api-gateway'
GROUP BY status
ORDER BY cnt DESC;
Map-столбцы не поддерживают skip indexes. Фильтрация по Map-ключу выполняется как полный scan гранулы. Для часто фильтруемых атрибутов (namespace, status_code) рассмотрите выделение в отдельный LowCardinality-столбец.
Дневные партиции и TTL
Логи — данные с коротким жизненным циклом. В отличие от метрик (месяцы/годы), логи обычно хранятся 7-90 дней. Дневные партиции обеспечивают:
- Точный TTL: удаление данных с точностью до дня (не месяца)
- Быстрый DROP PARTITION: моментальное удаление целого дня без фонового merge
- Управляемый размер партиции: при 100 миллионах строк/день одна партиция ~ 1-5 ГБ в сжатом виде
PARTITION BY toYYYYMMDD(timestamp)
TTL timestamp + INTERVAL 30 DAY
Дифференцированный TTL по уровню:
-- Для разных retention policies используйте отдельные таблицы
-- или TTL с условием:
CREATE TABLE logs_tiered (
timestamp DateTime64(3),
level LowCardinality(String),
message String,
...
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service, level, timestamp)
TTL
timestamp + INTERVAL 7 DAY DELETE WHERE level = 'DEBUG',
timestamp + INTERVAL 30 DAY DELETE WHERE level IN ('INFO', 'TRACE'),
timestamp + INTERVAL 90 DAY DELETE WHERE level IN ('WARN', 'ERROR');
ORDER BY: service first для multi-tenant фильтрации
ORDER BY (service, level, timestamp)
Почему service первым?
В микросервисной архитектуре подавляющее большинство запросов к логам начинается с фильтра по сервису: “покажи ошибки payment-service за последний час”. Service первым в ORDER BY означает, что sparse index отсекает все гранулы других сервисов.
Level вторым: после выбора сервиса часто фильтруют по уровню (ERROR, WARN). Timestamp последним для хронологической сортировки внутри группы (service, level).
ClickHouse vs. ELK vs. Loki
| Аспект | Elasticsearch | Loki | ClickHouse |
|---|---|---|---|
| Хранение (на 1 ТБ логов) | 1-1.5 ТБ (JSON + inverted index) | 200-300 ГБ (chunks + index) | 100-200 ГБ (columnar + codecs) |
| Полнотекстовый поиск | Быстрый (inverted index на всё) | Медленный (brute-force) | Быстрый (text index / bloom) |
| Structured queries | KQL / DSL | LogQL | SQL |
| Стоимость RAM | Высокая (Java heap) | Низкая | Средняя |
| TTL/Retention | Index Lifecycle Management | Table Manager | TTL + PARTITION |
| Масштабирование | Горизонтальное (шарды) | Горизонтальное | Горизонтальное (шарды) |
Ключевые выводы
- LowCardinality — обязательно для level, service, host и других столбцов с низкой кардинальностью. Экономия хранения и ускорение фильтрации.
- Text index (GA 26.2) — современная замена ngrambf_v1/tokenbf_v1 для полнотекстового поиска по message.
- Bloom filter — для точного поиска по высококардинальным столбцам (trace_id, request_id).
- Map(LowCardinality(String), String) — для переменных атрибутов (Kubernetes labels, HTTP headers). Но без skip-index поддержки.
- Дневные партиции + TTL — для агрессивного retention. Дифференцированный TTL по уровню позволяет хранить ERROR дольше, чем DEBUG.
- ORDER BY (service, level, timestamp) — service первым для multi-tenant фильтрации в микросервисной архитектуре.