ClickHouse и S3: многоуровневое хранение и внешние данные

Две грани S3 в ClickHouse: многоуровневое хранение (hot/cold-тиринг MergeTree через storage policies и TTL MOVE на S3, separation of storage/compute) и S3 table function для запроса и загрузки Parquet прямо из объектного хранилища. С живым стендом на MinIO

Седьмая статья серии «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-оверлей, накатываемый поверх общего кластера серии только для этого стенда.

ClickHouse и S3: hot/cold-тиринг на MinIO и S3 table function для Parquet — прозрачно для SELECT

В статье

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, а его typeObjectStorage, не буквально строка 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-26disk_name=‘s3’, rows=300000, bytes_on_disk=6.37 МиБ;
  • партиция 2026-07-10disk_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 до настоящего облачного объектного хранилища через интернет, где счёт может идти на десятки миллисекунд, а не единицы.

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

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
Тиринг MergeTree: свежая партиция на локальном диске, старая — на S3 по TTL (фон или явный ALTER), запрос работает одинаково с обеими

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_eventscount()=600000 (обе партиции разом, 300 000 + 300 000), sum(revenue)*100=15042627167 центов. Запись в MinIO — 246,5 мс. Чтение обратно через s3(...)count()=600000, sum(revenue)*100=15042627167 центов, совпадает с источником один в один. Ingestion в свежую таблицу demo.s3_events_from_parquetcount()=600000 за 248,6 мс, снова точное совпадение. Разница чтения и записи в реальном времени здесь несущественна (обе операции заняли примерно четверть секунды на 600 000 строк) — таймингом стоит пользоваться так же осторожно, как и в разделе про тиринг: MinIO в той же локальной сети, реальный S3 добавит сетевую составляющую.

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 (== источнику)

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 (== источнику)
S3 table function: запись Parquet в MinIO, чтение обратно, отдельно — загрузка того же файла в MergeTree

Честный контраст: здесь получилось показать, у 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.

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

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

Комментарии