Развертывания ClickHouse для обсервабилити неизбежно связаны с большими объёмами данных, которыми необходимо управлять. ClickHouse предлагает ряд возможностей для управления данными.
Партиции
Партиционирование в ClickHouse позволяет логически разделять данные на диске по столбцу или SQL-выражению. Благодаря такому логическому разделению с каждой партицией можно работать независимо, например удалять её. Это позволяет эффективно перемещать партиции, а значит и подмножества данных, между уровнями хранения по времени или удалять устаревшие данные/эффективно удалять данные из кластера.
Партиционирование задаётся для таблицы при её первоначальном определении с помощью условия PARTITION BY. Это условие может содержать SQL-выражение для любого столбца или набора столбцов, результат которого определяет, в какую партицию будет направлена строка.

Части данных логически связаны (через общий префикс имени папки) с каждой партицией на диске и могут запрашиваться изолированно. В примере ниже схема otel_logs по умолчанию использует партиционирование по дням с выражением toDate(Timestamp). По мере вставки строк в ClickHouse это выражение вычисляется для каждой строки, и данные направляются в соответствующую партицию, если она уже существует (если строка для этого дня первая, партиция будет создана).
CREATE TABLE default.otel_logs
(
...
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)С партициями можно выполнять ряд операций, включая резервные копии, операции со столбцами, мутации для изменения/удаления данных по строкам) и очистку индексов (например, вторичных индексов).
Например, предположим, что таблица otel_logs разбита на партиции по дням. Если она заполнена структурированным набором журнальных данных, она будет содержать данные за несколько дней:
SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-26 │ 1986456 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
5 rows in set. Elapsed: 0.058 sec. Processed 10.37 million rows, 82.92 MB (177.96 million rows/s., 1.42 GB/s.)
Peak memory usage: 4.41 MiB.Текущие партиции можно посмотреть с помощью простого запроса к системной таблице:
SELECT DISTINCT partition
FROM system.parts
WHERE `table` = 'otel_logs'┌─partition──┐
│ 2019-01-22 │
│ 2019-01-23 │
│ 2019-01-24 │
│ 2019-01-25 │
│ 2019-01-26 │
└────────────┘
5 rows in set. Elapsed: 0.005 sec.У нас может быть и другая таблица — otel_logs_archive, которую мы используем для хранения более старых данных. Данные можно эффективно перемещать в эту таблицу по партициям (это лишь изменение метаданных).
CREATE TABLE otel_logs_archive AS otel_logs
--move data to archive table
ALTER TABLE otel_logs
(MOVE PARTITION tuple('2019-01-26') TO TABLE otel_logs_archive
--confirm data has been moved
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 2333977 │
│ 2019-01-23 │ 2326694 │
│ 2019-01-24 │ 1896255 │
│ 2019-01-25 │ 1821770 │
└────────────┴─────────┘
4 rows in set. Elapsed: 0.051 sec. Processed 8.38 million rows, 67.03 MB (163.52 million rows/s., 1.31 GB/s.)
Peak memory usage: 4.40 MiB.SELECT Timestamp::Date AS day,
count() AS c
FROM otel_logs_archive
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-26 │ 1986456 │
└────────────┴─────────┘
1 row in set. Elapsed: 0.024 sec. Processed 1.99 million rows, 15.89 MB (83.86 million rows/s., 670.87 MB/s.)
Peak memory usage: 4.99 MiB.В отличие от других методов, для которых потребовались бы INSERT INTO SELECT и перезапись данных в новую целевую таблицу.
Кроме того, данные можно эффективно удалять на уровне партиций. Это значительно менее затратно по ресурсам, чем альтернативные методы (мутации или легковесные удаления), поэтому этому варианту следует отдавать предпочтение.
ALTER TABLE otel_logs
(DROP PARTITION tuple('2019-01-25'))
SELECT
Timestamp::Date AS day,
count() AS c
FROM otel_logs
GROUP BY day
ORDER BY c DESC┌────────day─┬───────c─┐
│ 2019-01-22 │ 4667954 │
│ 2019-01-23 │ 4653388 │
│ 2019-01-24 │ 3792510 │
└────────────┴─────────┘Применение
Выше показано, как данные можно эффективно перемещать и обрабатывать по партициям. На практике в сценариях обсервабилити операции с партициями чаще всего используются в двух случаях:
- Многоуровневые архитектуры - перемещение данных между уровнями хранения (см. Уровни хранения), что позволяет строить архитектуру «горячего» и «холодного» хранения.
- Эффективное удаление - когда данные достигают заданного TTL (см. Управление данными с помощью TTL)
Ниже мы подробно рассмотрим оба случая.
Производительность запросов
Хотя партиции могут помочь повысить производительность запросов, это сильно зависит от характера доступа к данным. Если запросы затрагивают только несколько партиций (в идеале — одну), производительность может улучшиться. Обычно это имеет смысл только в том случае, если ключ партиционирования не входит в первичный ключ и фильтрация выполняется по нему. Однако запросы, которым нужно охватить много партиций, могут работать хуже, чем без партиционирования (поскольку частей может оказаться больше). Преимущество обращения к одной партиции будет еще менее заметным или вовсе исчезнет, если ключ партиционирования уже находится в начале первичного ключа. Партиционирование также можно использовать, чтобы оптимизировать запросы GROUP BY, если значения в каждой партиции уникальны. Однако в общем случае следует убедиться, что первичный ключ оптимизирован, и рассматривать партиционирование как метод оптимизации запросов только в исключительных случаях, когда характер доступа предполагает обращение к определенному предсказуемому подмножеству данных, например партиционирование по дням, когда большинство запросов выполняется по данным за последний день. Здесь приведен пример такого поведения.
Управление данными с помощью TTL (Time-to-live)
Time-to-Live (TTL) — крайне важная возможность в решениях для обсервабилити на базе ClickHouse, обеспечивающая эффективное хранение и управление данными, особенно с учетом того, что постоянно генерируются огромные объемы данных. Использование TTL в ClickHouse позволяет автоматически удалять устаревшие данные по истечении заданного срока, обеспечивая оптимальное использование хранилища и поддерживая производительность без ручного вмешательства. Эта возможность необходима для того, чтобы база данных оставалась компактной, затраты на хранение снижались, а запросы выполнялись быстро и эффективно за счет работы с наиболее актуальными и свежими данными. Кроме того, TTL помогает соблюдать политики хранения данных благодаря системному управлению их жизненным циклом, что повышает общую устойчивость и масштабируемость решения для обсервабилити.
TTL можно задавать в ClickHouse как на уровне таблицы, так и на уровне столбца.
TTL на уровне таблицы
Схема по умолчанию и для журналов, и для трассировок включает TTL, чтобы данные удалялись по истечении заданного срока. Это указывается в экспортере ClickHouse с помощью ключа ttl, например.
exporters:
clickhouse:
endpoint: tcp://localhost:9000?dial_timeout=10s&compress=lz4&async_insert=1
ttl: 72hЭтот синтаксис в настоящее время поддерживает синтаксис Golang Duration. Мы рекомендуем использовать h и следить за тем, чтобы значение соответствовало периоду партиционирования. Например, если таблица партиционируется по дням, убедитесь, что значение кратно количеству дней, например 24h, 48h, 72h. Это автоматически обеспечит добавление в таблицу предложения TTL, например при ttl: 96h.
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + toIntervalDay(4)
SETTINGS ttl_only_drop_parts = 1По умолчанию данные с истекшим TTL удаляются, когда ClickHouse объединяет части данных. Когда ClickHouse обнаруживает, что срок TTL данных истек, он выполняет внеплановое слияние.
**Важно: мы рекомендуем использовать настройку ttl_only_drop_parts=1 ** (она применяется в схеме по умолчанию). Когда эта настройка включена, ClickHouse удаляет часть целиком, если в ней истекли все строки. Удаление частей целиком вместо частичной очистки строк с истекшим TTL (которая выполняется с помощью ресурсоемких мутаций при ttl_only_drop_parts=0) позволяет использовать меньшие значения merge_with_ttl_timeout и снижает влияние на производительность системы. Если данные партиционированы по той же единице, по которой выполняется истечение TTL, например по дням, части естественным образом будут содержать данные только из заданного интервала. Это гарантирует, что ttl_only_drop_parts=1 можно будет применять эффективно.
TTL на уровне столбца
В приведённом выше примере срок хранения данных задаётся на уровне таблицы. Вы также можете настроить истечение срока хранения данных на уровне столбца. По мере старения данных это можно использовать для удаления столбцов, чья ценность при расследовании не оправдывает затраты ресурсов на их хранение. Например, мы рекомендуем сохранять столбец Body на случай, если будут добавлены новые динамические метаданные, которые не были извлечены во время вставки, например новая метка Kubernetes. Через некоторое время, например через 1 месяц, может стать очевидно, что эти дополнительные метаданные не приносят пользы, — и тогда хранение столбца Body теряет смысл.
Ниже показано, как удалить столбец Body через 30 дней.
CREATE TABLE otel_logs_v2
(
`Body` String TTL Timestamp + INTERVAL 30 DAY,
`Timestamp` DateTime,
...
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)Повторное сжатие данных
Хотя для наборов данных обсервабилити мы обычно рекомендуем ZSTD(1), вы можете поэкспериментировать с другими алгоритмами сжатия или более высокими уровнями сжатия, например ZSTD(3). Помимо возможности указать это при создании схемы, сжатие можно настроить так, чтобы оно менялось по истечении определённого времени. Это может быть уместно, если кодек или алгоритм сжатия обеспечивает лучшее сжатие, но снижает производительность запросов. Такой компромисс может быть приемлем для старых данных, к которым обращаются реже, но не для свежих данных, которые чаще используются при расследованиях.
Пример показан ниже: вместо удаления данных через 4 дня мы начинаем сжимать их с помощью ZSTD(3).
CREATE TABLE default.otel_logs_v2
(
`Body` String,
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`Status` UInt16,
`RequestProtocol` LowCardinality(String),
`RunTime` UInt32,
`Size` UInt32,
`UserAgent` String,
`Referer` String,
`RemoteUser` String,
`RequestType` LowCardinality(String),
`RequestPath` String,
`RemoteAddress` IPv4,
`RefererDomain` String,
`RequestPage` String,
`SeverityText` LowCardinality(String),
`SeverityNumber` UInt8,
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
TTL Timestamp + INTERVAL 4 DAY RECOMPRESS CODEC(ZSTD(3))Дополнительные сведения и примеры по настройке TTL можно найти здесь. Примеры того, как TTL можно добавлять и изменять для таблиц и столбцов, можно найти здесь. О том, как TTL позволяют создавать иерархии хранения, например архитектуры hot-warm, см. раздел Уровни хранения.
Уровни хранения
В ClickHouse можно создавать уровни хранения на разных дисках, например хранить горячие/недавние данные на SSD, а более старые — в S3. Такая архитектура позволяет использовать для старых данных более дешёвое хранилище, поскольку из-за их редкого использования при расследованиях к запросам к ним предъявляются менее строгие SLA.
Чтобы создать уровни хранения, сначала нужно создать диски, а затем на их основе определить политики хранения с томами, которые можно указать при создании таблицы. Данные могут автоматически перемещаться между дисками в зависимости от уровня заполнения, размера частей и приоритетов томов. Более подробную информацию можно найти здесь.
Хотя данные можно вручную перемещать между дисками с помощью команды ALTER TABLE MOVE PARTITION, перемещением данных между томами также можно управлять с помощью TTL. Полный пример можно найти здесь.
Управление изменениями схемы
Схемы Log и трассировок неизбежно будут меняться на протяжении жизненного цикла системы — например, когда пользователи начинают отслеживать новые системы с другими метаданными или метками подов. Если формировать данные в соответствии со схемой OTel и сохранять исходные данные событий в структурированном формате, схемы ClickHouse будут устойчивы к таким изменениям. Однако по мере появления новых метаданных и изменения шаблонов доступа к данным в запросах вам потребуется обновлять схемы, чтобы отразить эти изменения.
Чтобы избежать простоя при изменении схемы, у пользователей есть несколько вариантов, которые мы рассмотрим ниже.
Использование значений по умолчанию
Столбцы можно добавлять в схему с помощью значений DEFAULT. Указанное значение по умолчанию будет использоваться, если оно не задано при INSERT.
Изменения в схему можно внести до изменения логики преобразования в materialized view или конфигурации OTel collector, из-за которых начнётся отправка новых столбцов.
После изменения схемы можно перенастроить OTel collectors. Если пользователи применяют рекомендуемый процесс, описанный в "Извлечение структуры с помощью SQL", при котором OTel collectors отправляют данные в движок таблицы Null, а materialized view отвечает за извлечение целевой схемы и отправку результатов в целевую таблицу для хранения, представление можно изменить с помощью синтаксиса ALTER TABLE ... MODIFY QUERY. Предположим, у нас есть приведённая ниже целевая таблица и соответствующее ей materialized view (аналогичное тому, что используется в "Извлечение структуры с помощью SQL") для извлечения целевой схемы из структурированных журналов OTel:
CREATE TABLE default.otel_logs_v2
(
`Body` String,
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`Status` UInt16,
`RequestProtocol` LowCardinality(String),
`RunTime` UInt32,
`UserAgent` String,
`Referer` String,
`RemoteUser` String,
`RequestType` LowCardinality(String),
`RequestPath` String,
`RemoteAddress` IPv4,
`RefererDomain` String,
`RequestPage` String,
`SeverityText` LowCardinality(String),
`SeverityNumber` UInt8
)
ENGINE = MergeTree
ORDER BY (ServiceName, Timestamp)
CREATE MATERIALIZED VIEW otel_logs_mv TO otel_logs_v2 AS
SELECT
Body,
Timestamp::DateTime AS Timestamp,
ServiceName,
LogAttributes['status']::UInt16 AS Status,
LogAttributes['request_protocol'] AS RequestProtocol,
LogAttributes['run_time'] AS RunTime,
LogAttributes['user_agent'] AS UserAgent,
LogAttributes['referer'] AS Referer,
LogAttributes['remote_user'] AS RemoteUser,
LogAttributes['request_type'] AS RequestType,
LogAttributes['request_path'] AS RequestPath,
LogAttributes['remote_addr'] AS RemoteAddress,
domain(LogAttributes['referer']) AS RefererDomain,
path(LogAttributes['request_path']) AS RequestPage,
multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logsПредположим, мы хотим извлечь новый столбец Size из LogAttributes. Мы можем добавить его в схему с помощью ALTER TABLE, указав значение по умолчанию:
ALTER TABLE otel_logs_v2
(ADD COLUMN `Size` UInt64 DEFAULT JSONExtractUInt(Body, 'size'))В приведённом выше примере мы указываем значение по умолчанию как ключ size в LogAttributes (если его нет, будет 0). Это означает, что запросы, обращающиеся к этому столбцу для строк, в которые это значение не было вставлено, должны обращаться к Map и, следовательно, будут выполняться медленнее. Мы также могли бы легко указать здесь константу, например 0, снизив стоимость последующих запросов к строкам, в которых это значение отсутствует. Запрос к этой таблице показывает, что значение заполняется из Map, как и ожидалось:
SELECT Size
FROM otel_logs_v2
LIMIT 5┌──Size─┐
│ 30577 │
│ 5667 │
│ 5379 │
│ 1696 │
│ 41483 │
└───────┘
5 строк в наборе. Затрачено: 0.012 сек.Чтобы это значение добавлялось ко всем будущим данным, мы можем изменить нашу materialized view с помощью синтаксиса ALTER TABLE, как показано ниже:
ALTER TABLE otel_logs_mv
MODIFY QUERY
SELECT
Body,
Timestamp::DateTime AS Timestamp,
ServiceName,
LogAttributes['status']::UInt16 AS Status,
LogAttributes['request_protocol'] AS RequestProtocol,
LogAttributes['run_time'] AS RunTime,
LogAttributes['size'] AS Size,
LogAttributes['user_agent'] AS UserAgent,
LogAttributes['referer'] AS Referer,
LogAttributes['remote_user'] AS RemoteUser,
LogAttributes['request_type'] AS RequestType,
LogAttributes['request_path'] AS RequestPath,
LogAttributes['remote_addr'] AS RemoteAddress,
domain(LogAttributes['referer']) AS RefererDomain,
path(LogAttributes['request_path']) AS RequestPage,
multiIf(Status::UInt64 > 500, 'CRITICAL', Status::UInt64 > 400, 'ERROR', Status::UInt64 > 300, 'WARNING', 'INFO') AS SeverityText,
multiIf(Status::UInt64 > 500, 20, Status::UInt64 > 400, 17, Status::UInt64 > 300, 13, 9) AS SeverityNumber
FROM otel_logsВ последующих строках столбец Size будет заполняться при вставке.
Создание новых таблиц
В качестве альтернативы описанному выше процессу можно просто создать новую целевую таблицу с новой схемой. Затем любые materialized view можно настроить на использование новой таблицы с помощью приведённой выше команды ALTER TABLE MODIFY QUERY. При таком подходе можно версионировать таблицы, например otel_logs_v3.
При таком подходе пользователям придётся выполнять запросы к нескольким таблицам. Чтобы выполнять запросы сразу к нескольким таблицам, можно использовать функцию merge, которая поддерживает шаблоны с подстановочными знаками в имени таблицы. Ниже это показано на примере запроса к версиям v2 и v3 таблицы otel_logs:
SELECT Status, count() AS c
FROM merge('otel_logs_v[2|3]')
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.137 sec. Processed 41.46 million rows, 82.92 MB (302.43 million rows/s., 604.85 MB/s.)Если вы хотите избежать использования функции merge и предоставить конечным пользователям таблицу, объединяющую несколько таблиц, можно использовать движок таблицы Merge. Ниже мы покажем, как это сделать:
CREATE TABLE otel_logs_merged
ENGINE = Merge('default', 'otel_logs_v[2|3]')
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 38319300 │
│ 304 │ 1360912 │
│ 302 │ 799340 │
│ 404 │ 420044 │
│ 301 │ 270212 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.073 sec. Processed 41.46 million rows, 82.92 MB (565.43 million rows/s., 1.13 GB/s.)Это можно обновлять каждый раз при добавлении новой таблицы с помощью синтаксиса EXCHANGE. Например, чтобы добавить таблицу v4, можно создать новую таблицу и атомарно поменять её местами с предыдущей версией.
CREATE TABLE otel_logs_merged_temp
ENGINE = Merge('default', 'otel_logs_v[2|3|4]')
EXCHANGE TABLE otel_logs_merged_temp AND otel_logs_merged
SELECT Status, count() AS c
FROM otel_logs_merged
GROUP BY Status
ORDER BY c DESC
LIMIT 5┌─Status─┬────────c─┐
│ 200 │ 39259996 │
│ 304 │ 1378564 │
│ 302 │ 820118 │
│ 404 │ 429220 │
│ 301 │ 276960 │
└────────┴──────────┘
5 rows in set. Elapsed: 0.068 sec. Processed 42.46 million rows, 84.92 MB (620.45 million rows/s., 1.24 GB/s.)