Седьмая статья серии «ClickHouse и аналитические БД». Предыдущая статья разбирала эксплуатацию и тюнинг — parts, merges, mutations на одной ноде. Здесь речь о том, что происходит, когда данные становятся слишком велики или слишком холодны для локального диска, а S3 в ClickHouse — это на самом деле две разные, не связанные друг с другом грани.
Первая грань — многоуровневое хранение (тиринг): свежие данные лежат на быстром локальном диске, старые автоматически переезжают в объектное хранилище по TTL, а SQL-слой не видит разницы — SELECT работает одинаково независимо от того, где физически лежит часть таблицы. Вторая грань — S3 как источник и приёмник данных напрямую: s3() — табличная функция для чтения и записи Parquet/CSV-файлов в объектном хранилище без предварительной загрузки в MergeTree, то есть ClickHouse как инструмент для разовых операций над data lake, а не только как СУБД со своим хранилищем. Обе грани разобраны на живом стенде с MinIO — включая честную находку о том, как именно TTL-механизм переместил партицию до того, как об этом попросили явно.
Все числа и код ниже — из живого стенда clickhouse/s3 в публичном репозитории digital-cookbook: single-node ClickHouse с добавленным S3-диском поверх MinIO (minio/minio:RELEASE.2025-09-07T16-13-09Z), отдельный compose-оверлей, накатываемый поверх общего кластера серии только для этого стенда.
В статье
- S3-диск и storage policy hot_cold
- TTL MOVE TO и явный ALTER: живой тиринг
- Separation of storage/compute и стоимость
- S3 table function: Parquet round-trip и ingestion
- Честный контраст: здесь получилось показать, у Kafka — нет
- Граница: один источник, не федеративный запрос
S3-диск и storage policy hot_cold
Тиринг в ClickHouse строится на двух конфигурационных сущностях: диск (disk) — физическое место хранения с адресом и учётными данными, и storage policy — именованный набор томов (volume), каждый из которых ссылается на один или несколько дисков в порядке приоритета. Таблица не привязывается к диску напрямую — она указывает storage_policy, а куда конкретно положить часть, решает ClickHouse на основе правил тома и, если они заданы, TTL-выражений.
<clickhouse>
<storage_configuration>
<disks>
<s3>
<type>s3</type>
<endpoint>http://minio:9000/chdata/</endpoint>
<access_key_id>minioadmin</access_key_id>
<secret_access_key>minioadmin123</secret_access_key>
<metadata_type>local</metadata_type>
<skip_access_check>true</skip_access_check>
</s3>
</disks>
<policies>
<hot_cold>
<volumes>
<hot>
<disk>default</disk>
</hot>
<cold>
<disk>s3</disk>
</cold>
</volumes>
</hot_cold>
</policies>
</storage_configuration>
</clickhouse>skip_access_check=true здесь — не общая рекомендация, а честная деталь именно этого стенда: конфиг диска смонтирован в общий compose всей серии безусловно, а MinIO поднимается только оверлеем этого конкретного стенда. Без skip_access_check ClickHouse проверяет доступность каждого сконфигурированного диска при старте сервера — и, если бы MinIO в этот момент не работал (а для остальных семи статей серии он и не должен), сервер отказался бы стартовать вовсе, ломая все стенды разом, а не только этот. Реальная проверка доступности диска — не декларация в конфиге, а живой запрос к system.disks уже после того, как MinIO поднят.
Том hot ссылается на существующий локальный диск default (тот же, что используют все остальные стенды серии), том cold — на s3-диск выше. storage_policy=hot_cold подключается к таблице через SETTINGS:
CREATE TABLE demo.s3_events
(
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
url String,
duration_ms UInt32,
country LowCardinality(String),
revenue Decimal(10,2)
)
ENGINE = MergeTree
ORDER BY (event_time, user_id)
PARTITION BY toDate(event_time)
TTL event_time + INTERVAL 30 DAY TO VOLUME 'cold'
SETTINGS storage_policy = 'hot_cold', index_granularity = 8192Партиционирование здесь — toDate(event_time), а не привычное по прошлым статьям серии toYYYYMM — намеренно: партиция должна переезжать на s3 целиком, и чем она у́же, тем точнее граница между «горячим» и «холодным» проходит по реальному возрасту данных, а не по календарному месяцу, в который могли попасть и совсем свежие, и уже просроченные строки.
Живой прогон подтверждает конфигурацию через системные таблицы, а не просто «сервер не упал при старте»: system.disks содержит диск с именем s3, а его type — ObjectStorage, не буквально строка s3 (живая деталь: тип диска в этой версии ClickHouse — обобщённая категория хранилища, а не имя конкретного протокола). system.storage_policies для hot_cold возвращает ровно два тома — hot с диском default и cold с диском s3 — именно то, что задано в storage.xml.
TTL MOVE TO и явный ALTER: живой тиринг
Стенд вставляет две группы строк в разные партиции: 300 000 «старых» строк с event_time на 45 дней раньше момента прогона — на 15 дней старше 30-дневного TTL, и 300 000 «свежих» строк с текущим event_time. Конкретные даты партиций ниже — снимок одного прогона (2026-07-10): стенд берёт event_time относительно time.Now(), поэтому при запуске в другой день границы сдвинутся, но их взаимное расположение относительно 30-дневного TTL сохранится. В том прогоне «старая» партиция — 2026-05-26, «свежая» — 2026-07-10. После вставки — попытка переместить старую партицию на s3 явно, командой ALTER TABLE ... MOVE PARTITION ... TO DISK 's3', форсированной ради детерминизма результата в рамках одного прогона: тот же принцип, что OPTIMIZE ... FINAL в TTL-демонстрации статьи о материализованных представлениях — не полагаться на угаданный момент срабатывания фона.
Именно здесь всплыла честная находка: TTL event_time + INTERVAL 30 DAY TO VOLUME 'cold' проверяет условие не по расписанию, а при каждом фоновом проходе — а старая партиция уже была на 45 дней старше «сейчас» в момент самой вставки, то есть условие TTL выполнялось сразу, без какого-либо ожидания 30 дней. На простаивающей single-node машине фоновый merge scheduler успел подобрать это условие и переместить партицию на s3 сам, ещё до того, как стенд дошёл до явного ALTER. TTL в ClickHouse «ленив» — гарантированного момента срабатывания нет, — но это не значит «долго ждать»: на незанятой ноде фон может сработать быстрее, чем клиент успевает выполнить следующую команду.
Стенд обрабатывает это не как исключение, а как ожидаемый исход: перед явным ALTER он сверяется с system.parts.disk_name по частям старой партиции, и если все они уже на s3, ALTER TABLE ... MOVE PARTITION не вызывается вовсе — команда всё равно идемпотентна (ClickHouse отвечает кодом ошибки 479, "All parts ... are already on disk 's3'", если её реально выполнить над уже перемещённой партицией), но проверка disk_name до вызова — детерминированное подтверждение факта, а не вера фону на слово.
oldPartsSeenBefore, oldPartsOnS3Before := 0, 0
for _, p := range partsBefore {
if p.partition == oldPartitionKey {
oldPartsSeenBefore++
if p.diskName == "s3" {
oldPartsOnS3Before++
}
}
}
oldAlreadyOnS3 := oldPartsSeenBefore > 0 && oldPartsSeenBefore == oldPartsOnS3Before
if oldAlreadyOnS3 {
// фон уже переместил партицию по правилу TTL ... TO VOLUME 'cold' —
// explicit ALTER пропущен как no-op (ClickHouse отдал бы код 479)
} else {
ch.Exec(ctx, fmt.Sprintf(
"ALTER TABLE demo.%s MOVE PARTITION '%s' TO DISK 's3'", tblEvents, oldPartitionKey))
}Итог, зафиксированный system.parts (детерминировано, повторный прогон с нуля через docker compose down -v дал те же числа):
- партиция
2026-05-26— disk_name=‘s3’,rows=300000,bytes_on_disk=6.37 МиБ; - партиция
2026-07-10— disk_name=‘default’, шесть частей поrows=50000,bytes_on_disk=1.04 МиБкаждая (по одной части на батч вставки — фон ещё не успел их слить на момент замера, тот же эффект «много частей на idle-ноде», что уже разбирался в статье об эксплуатации).
Запрос по обеим партициям корректен: SELECT count(), sum(revenue) по перемещённой на s3 партиции даёт count()=300000, sum(revenue)*100=7511679381 центов — побайтово совпадает с суммой, независимо посчитанной в Go при генерации строк. Различие — в измеренном времени чтения: median по пяти прогонам SELECT count() для s3-партиции — 5,99 мс, для локальной партиции на default — 2,97 мс, то есть s3-чтение здесь примерно в два раза медленнее локального. Эту цифру не стоит переносить на реальный удалённый S3 буквально: MinIO в этом стенде живёт в той же compose-сети, что и сам ClickHouse, — разница отражает лишь дополнительный сетевой прыжок внутри одного хоста, а не latency до настоящего облачного объектного хранилища через интернет, где счёт может идти на десятки миллисекунд, а не единицы.
disk_name=default (SSD)"] HOT -->|"TTL event_time + 30 дней
TO VOLUME 'cold'"| MOVE{"фон или
ALTER MOVE PARTITION"} MOVE -->|"partition уже старше TTL"| COLD["Старая партиция
disk_name=s3 (MinIO/S3)"] HOT -->|"SELECT"| Q["Запрос по обеим
партициям одинаково"] COLD -->|"SELECT (чтение с s3-диска)"| Q style COLD fill:#f4d9c6,stroke:#c67a4a style Q fill:#e8e2d5,stroke:#6d8a99
flowchart LR
INSERT["INSERT события"] --> HOT["Свежая партиция
disk_name=default (SSD)"]
HOT -->|"TTL event_time + 30 дней
TO VOLUME 'cold'"| MOVE{"фон или
ALTER MOVE PARTITION"}
MOVE -->|"partition уже старше TTL"| COLD["Старая партиция
disk_name=s3 (MinIO/S3)"]
HOT -->|"SELECT"| Q["Запрос по обеим
партициям одинаково"]
COLD -->|"SELECT (чтение с s3-диска)"| Q
style COLD fill:#f4d9c6,stroke:#c67a4a
style Q fill:#e8e2d5,stroke:#6d8a99
Separation of storage/compute и стоимость
Практический смысл тиринга — не только «переложить старые файлы подальше», а разъединить две вещи, которые в классической однодисковой MergeTree-таблице связаны жёстко: объём хранимых данных и размер локального диска на вычислительной ноде. Без storage policy рост retention (сколько истории держать) напрямую означает рост локального SSD на каждой ноде — а SSD дорог и конечен. С hot_cold-политикой холодные партиции переезжают в объектное хранилище, которое масштабируется по объёму независимо от вычислительных нод и по цене за гигабайт обычно ощутимо дешевле локального NVMe/SSD — тот же общий принцип, что и в куда более развитых архитектурах с полным разделением storage и compute (облачные DWH-системы, где вычислительные кластеры можно останавливать и пересоздавать, а данные продолжают жить в объектном хранилище отдельно). ClickHouse с storage_configuration реализует ослабленную, но рабочую версию этой же идеи: локальный диск нужен только для «горячих» данных и самой ноды, а не для всего исторического объёма.
Цена этого разъединения — та самая разница в latency чтения, отмеченная выше: локальный SSD быстрее сетевого объектного хранилища при прочих равных, и чем «холоднее» и реже запрашиваются данные, тем меньше это имеет значение на практике. TTL ... TO VOLUME 'cold' — способ формализовать эту границу декларативно, одной строкой в DDL, вместо того чтобы вручную мониторить возраст партиций и переносить их скриптом.
S3 table function: Parquet round-trip и ingestion
Вторая грань S3 в ClickHouse не связана с тирингом ни партиций, ни томов — это табличная функция s3(url, access_key, secret_key, format): способ прочитать или записать файл в объектном хранилище прямо в SELECT/INSERT, минуя постоянную таблицу вообще. Рядом существует и ENGINE = S3 — постоянная таблица, определённая поверх файла/префикса в S3, тот же протокол чтения/записи под капотом, но с сохраняемым DDL; стенд использует именно функцию, потому что для разовых операций (round-trip, разовая загрузка) заводить постоянный объект ради одного файла избыточно.
Стенд прогоняет полный цикл: пишет содержимое demo.s3_events (обе партиции, физически лежащие на разных дисках, — для табличной функции это неважно, она читает логические строки через обычный SELECT, а не байты конкретного диска) в Parquet-объект в MinIO через INSERT INTO FUNCTION s3(...) SELECT ..., затем читает этот же объект обратно через SELECT ... FROM s3(...), и отдельно — грузит его в свежую MergeTree-таблицу через INSERT INTO ... SELECT * FROM s3(...).
writeSQL := fmt.Sprintf(
"INSERT INTO FUNCTION s3('%s', '%s', '%s', 'Parquet') SELECT event_time, user_id, event_type, url, duration_ms, country, revenue FROM demo.%s",
objectURL, accessKey, secretKey, tblEvents)
ch.Exec(ctx, writeSQL)
// прочитано обратно из того же объекта
roundtripCount, roundtripCents := countAndCentsS3(ctx, ch, objectURL, accessKey, secretKey)
// countAndCentsS3: SELECT count(), toInt64(sum(revenue)*100) FROM s3(url, accessKey, secretKey, 'Parquet')
// ingestion: S3 (Parquet) -> свежая MergeTree-таблица
ingestSQL := fmt.Sprintf(
"INSERT INTO demo.%s SELECT * FROM s3('%s', '%s', '%s', 'Parquet')",
tblFromParquet, objectURL, accessKey, secretKey)
ch.Exec(ctx, ingestSQL)Живой прогон (побайтово детерминировано): источник demo.s3_events — count()=600000 (обе партиции разом, 300 000 + 300 000), sum(revenue)*100=15042627167 центов. Запись в MinIO — 246,5 мс. Чтение обратно через s3(...) — count()=600000, sum(revenue)*100=15042627167 центов, совпадает с источником один в один. Ingestion в свежую таблицу demo.s3_events_from_parquet — count()=600000 за 248,6 мс, снова точное совпадение. Разница чтения и записи в реальном времени здесь несущественна (обе операции заняли примерно четверть секунды на 600 000 строк) — таймингом стоит пользоваться так же осторожно, как и в разделе про тиринг: MinIO в той же локальной сети, реальный S3 добавит сетевую составляющую.
count()=600000 (== источнику)
sequenceDiagram
participant App as Клиент
participant CH as ClickHouse
participant S3 as MinIO (S3)
App->>CH: INSERT INTO FUNCTION s3(url,...) SELECT ... FROM demo.s3_events
CH->>S3: запись Parquet-объекта
S3-->>CH: 246.5мс
App->>CH: SELECT ... FROM s3(url,...)
CH->>S3: чтение Parquet-объекта
S3-->>CH: count()=600000 (== источнику)
CH-->>App: результат совпал побайтово
App->>CH: INSERT INTO demo.s3_events_from_parquet SELECT * FROM s3(url,...)
CH->>S3: чтение Parquet-объекта
S3-->>CH: 248.6мс
Note over CH: ingestion S3 -> MergeTree,
count()=600000 (== источнику)
Честный контраст: здесь получилось показать, у Kafka — нет
В статье про хранение в Kafka описана та же по духу идея — tiered storage (KIP-405), разделение лога партиции на горячие локальные сегменты и холодные, уходящие в объектное хранилище. Демонстрация этого механизма на живом кластере серии честно не удалась: remote.log.storage.system.enable=false на использованном образе, а плагина RemoteStorageManager, который физически перекладывал бы сегменты, в поставке apache/kafka:4.3.1 попросту нет — только базовые интерфейсы без реализации.
Здесь, с ClickHouse и MinIO, вышло наоборот: S3-тиринг воспроизведён живьём целиком — часть таблицы физически переехала на объектное хранилище (system.parts.disk_name='s3', конкретный размер 6,37 МиБ), запрос к ней остался корректным, а разница во времени чтения — измерена, пусть и не репрезентативна для реального удалённого S3. Честность здесь работает в обе стороны: там, где Kafka-стенд признал ограничение образа как факт, а не спрятал его, ClickHouse-стенд признал и зафиксировал свою собственную деталь — TTL-фон опередил явный ALTER в обоих независимых прогонах, и именно эта деталь, а не гладкая демонстрация «по методичке», доказывает, что тиринг здесь настоящий, а не инсценированный: идемпотентность MOVE PARTITION проверена не в теории, а тем, что реальный фон вмешался в сценарий раньше, чем его успел вызвать стенд.
Граница: один источник, не федеративный запрос
Табличная функция s3() и ENGINE = S3 читают и пишут один источник — конкретный файл или префикс в одном бакете, заданный одним URL. Это не федеративный движок: нельзя одним SELECT объединить данные из S3, PostgreSQL и Kafka так, будто это одна база, — ClickHouse здесь не претендует на роль универсального query-слоя поверх разнородных систем. Для этого класса задач существует отдельная категория инструментов — федеративные движки и lakehouse-платформы (Trino, Presto и близкие), которые как раз специализируются на запросе по многим разнородным источникам одним SQL, обычно поверх метаданных open table format — Iceberg, Delta Lake, Hudi — разобранных отдельно в статье «Open table formats: Iceberg, Delta и Hudi»Скоро. Это сознательная граница темы, а не пробел: сам факт, что ClickHouse умеет прочитать чужой Parquet напрямую из S3, ещё не делает его lakehouse-движком, и путать эти два уровня — источник завышенных ожиданий от s3().
Следующая, заключительная статья серии сводит все системы вместе — карта выбора: ClickHouse против TimescaleDB и DuckDB, где тот же аналитический сценарий из первой статьи серии прогоняется на четырёх системах разом.
Версии в прогоне: ClickHouse 26.6.1.1193, minio/minio RELEASE.2025-09-07T16-13-09Z, clickhouse-go v2.47.0, github.com/shopspring/decimal v1.4.0.
Комментарии