Эксплуатация и тюнинг ClickHouse: merges, mutations, мониторинг, backup

Что нужно знать про ClickHouse в проде: parts и фоновые merges, почему mutations (ALTER UPDATE/DELETE) дорогие и асинхронные, мониторинг через system.*-таблицы, backup/restore и тюнинг — codec-сжатие колонок, гранулярность индекса, память запроса. С живыми числами и продакшн-чеклистом

Шестая статья серии «ClickHouse и аналитические БД». Предыдущая статья разбирала шардирование и репликацию — то, как ClickHouse масштабируется вширь. Здесь речь про то, что происходит на каждой отдельной ноде под операционной нагрузкой изо дня в день: части таблиц (parts) и фоновые слияния, дорогие асинхронные mutations, мониторинг через system.*, бэкапы и тюнинг сжатия. Это специфика, которую не видно на первом же INSERT, но которая определяет, насколько предсказуемо ведёт себя кластер под реальной нагрузкой месяцы спустя.

MergeTree (и ReplicatedMergeTree из предыдущей статьи) физически хранит данные не единым файлом, а набором неизменяемых частей (parts) — каждая вставка (батч) создаёт новую часть, а фоновый процесс постоянно сливает более мелкие части в более крупные. Из этой же неизменяемости вытекает то, что ALTER TABLE ... UPDATE/DELETE — не точечное редактирование строки, а перезапись целых частей; и то, почему бэкап в ClickHouse — не копирование файлов вручную, а согласованная операция над частями. Все числа и код ниже — из живого стенда clickhouse/ops-stand в публичном репозитории digital-cookbook: single-node ClickHouse, выделенный CSV-датасет на 750 000 строк, разбитый на непересекающиеся диапазоны под разные фазы стенда.

Эксплуатация ClickHouse: фоновые merges, дорогие mutations, мониторинг system.*, backup и codec-тюнинг

В статье

Parts и фоновые merges

Каждый INSERT в MergeTree-таблицу создаёт новую часть (part) — самостоятельный набор файлов на диске, отсортированный по ORDER BY. Часть неизменяема: после записи в неё уже нельзя добавить строку или подправить значение на месте. Если вставлять часто и мелкими порциями, частей становится много — а слишком много частей означает больше файлов на чтение при каждом SELECT, больше метаданных в памяти и, в пределе, отказ сервера с ошибкой Too many parts. Решает это фоновый merge scheduler: он постоянно, без участия клиента, сливает несколько мелких частей в одну более крупную по тем же правилам сортировки.

Стенд специально выключает фон командой SYSTEM STOP MERGES, чтобы вообще увидеть промежуточное состояние «много частей», — и это не искусственный приём ради красивой демонстрации, а необходимость: на простаивающей single-node машине фоновый merge scheduler успевает схлопнуть свежесозданные части раньше, чем их вообще получится сосчитать. Та же особенность уже всплывала в статье про материализованные представления, где SummingMergeTree и TTL схлопывались до явного OPTIMIZE FINAL. Это свойство MergeTree на незанятом хосте, а не пауза, придуманная ради теста.

ch.Exec(ctx, fmt.Sprintf("SYSTEM STOP MERGES demo.%s", opsTable))

rows, loadDur, _ := loadManySmallBatches(ctx, ch, opsTable, csvPath, smallBatches, smallBatchSize)
partsBefore, _ := activeParts(ctx, ch, opsTable)
// 200 батчей по 1000 строк -> partsBefore

ch.Exec(ctx, fmt.Sprintf("SYSTEM START MERGES demo.%s", opsTable))
// наблюдение 10s: system.parts + system.merges, честно — может схлопнуться раньше опроса

ch.Exec(ctx, fmt.Sprintf("OPTIMIZE TABLE demo.%s FINAL", opsTable))
partsAfterForced, _ := activeParts(ctx, ch, opsTable)

Живой прогон: 200 батчей по 1000 строк (SYSTEM STOP MERGES держит фон выключенным на всё время загрузки) — 200 000 строк за 14,12 с, 600 активных частей (в среднем по 3 части на батч — 90-дневное окно строк одного батча задевает 2–3 месячные партиции, PARTITION BY toYYYYMM(event_time)). После SYSTEM START MERGES фон уже через секунду схлопывает их до 10 активных частей — из 600 в 10 за одну секунду на незанятой ноде. Явный OPTIMIZE TABLE ... FINAL доводит это до 3 активных частей — по одной на каждую из трёх календарных месячных партиций, которые задевают эти 200 000 строк. OPTIMIZE ... FINAL — синхронная и тяжёлая операция (полное слияние всех частей партиции разом, а не постепенное фоновое), в проде её место — плановое обслуживание или явная необходимость (например, перед бэкапом), а не рутинная замена фонового merge scheduler.

flowchart LR INSERT["INSERT батчами
200×1000 строк"] --> PARTS["Много мелких частей
600 active parts"] PARTS -->|"фоновый merge scheduler
system.merges, ~1с на idle-ноде"| MERGED["Меньше частей
10 active parts"] MERGED -->|"OPTIMIZE TABLE ... FINAL
синхронно, форсированно"| FINAL["По части на партицию
3 active parts"] PARTS -->|"ALTER UPDATE/DELETE
затрагивает часть целиком"| MUT["Mutation:
parts_to_do в system.mutations"] MUT -->|"перечитывает + переписывает
КАЖДУЮ затронутую часть"| NEWPARTS["Новые части
is_done=1"] style FINAL fill:#f4d9c6,stroke:#c67a4a style NEWPARTS fill:#f4d9c6,stroke:#c67a4a

flowchart LR
  INSERT["INSERT батчами
200×1000 строк"] --> PARTS["Много мелких частей
600 active parts"] PARTS -->|"фоновый merge scheduler
system.merges, ~1с на idle-ноде"| MERGED["Меньше частей
10 active parts"] MERGED -->|"OPTIMIZE TABLE ... FINAL
синхронно, форсированно"| FINAL["По части на партицию
3 active parts"] PARTS -->|"ALTER UPDATE/DELETE
затрагивает часть целиком"| MUT["Mutation:
parts_to_do в system.mutations"] MUT -->|"перечитывает + переписывает
КАЖДУЮ затронутую часть"| NEWPARTS["Новые части
is_done=1"] style FINAL fill:#f4d9c6,stroke:#c67a4a style NEWPARTS fill:#f4d9c6,stroke:#c67a4a
Жизненный цикл части: вставка создаёт части, фон и OPTIMIZE FINAL их сливают, mutation переписывает часть целиком

Mutations: почему точечные UPDATE/DELETE — анти-паттерн

ALTER TABLE ... UPDATE/ALTER TABLE ... DELETE в ClickHouse называются mutations — и это принципиально не то же самое, что UPDATE/DELETE в OLTP-базе. Часть неизменяема, поэтому мутация не правит строку на месте: она перечитывает и переписывает целиком каждую часть, в которой есть хоть одна подходящая под WHERE строка. Без mutations_sync (реальное поведение по умолчанию) ALTER возвращает управление клиенту почти мгновенно — сама работа выполняется асинхронно в фоне, а прогресс отслеживается через system.mutations по полям is_done/parts_to_do.

submitStart := time.Now()
conn.Exec(ctx, alterSQL) // ALTER ... UPDATE/DELETE возвращается почти мгновенно
submitDur := time.Since(submitStart)

var mutationID string
var isDone uint8
var partsToDo int64
conn.QueryRow(ctx, `
    SELECT mutation_id, is_done, parts_to_do
    FROM system.mutations
    WHERE database = 'demo' AND table = ?
    ORDER BY create_time DESC LIMIT 1`, table).Scan(&mutationID, &isDone, &partsToDo)

pollStart := time.Now()
for isDone != 1 {
    if time.Since(pollStart) > timeout {
        return out, fmt.Errorf("mutation did not finish within timeout %s", timeout)
    }
    time.Sleep(200 * time.Millisecond)
    conn.QueryRow(ctx, `SELECT is_done FROM system.mutations
        WHERE database = 'demo' AND table = ? AND mutation_id = ?`, table, mutationID).Scan(&isDone)
}
completionDuration := time.Since(submitStart) // реальная стоимость мутации

Живой прогон на 200 000-строчной таблице ops_events (после фазы merges выше):

  • ALTER TABLE ... UPDATE revenue = 0 WHERE country = 'KZ' (5961 строка подпадает под условие): submit = 5,7 мс (выглядит мгновенно), completion = 211,2 мс (parts_to_do на момент первого чтения system.mutations = 3) — вот она, реальная стоимость.
  • ALTER TABLE ... DELETE WHERE country = 'JP' (6115 строк): submit = 5,3 мс, completion = 7,7 мс, parts_to_do на момент submit = 0.

Строки после: before=200000, after=193885 — ровно before - deleted(6115), как и ожидалось.

parts_to_do=0 у DELETE — не ошибка стенда, а честная гонка: на небольшом поднаборе (несколько затронутых частей) мутация успела дойти до is_done=1 быстрее, чем клиент сделал первый SELECT из system.mutations. Сама completion-стоимость (submit → is_done=1) от этого не искажается — она измерена корректно, просто окно наблюдения parts_to_do оказалось у́же одного цикла опроса. Тот же принцип стоимости уже встречался в статье о том, когда нужен OLAP: точечный UPDATE по первичному ключу там дал completion порядка секунды — и в обоих случаях дело не в числе изменённых строк, а в объёме партиций, которые задевает WHERE. Мутация по некластерному предикату (country, не входящему в ORDER BY) почти неизбежно затрагивает бо́льшую часть партиций таблицы, а значит переписывает бо́льшую часть данных на диске — независимо от того, меняются ли реально одна строка или сто тысяч.

Отсюда и практический вывод: частые точечные UPDATE/DELETE в ClickHouse — анти-паттерн, потому что стоимость операции растёт с размером затронутых партиций, а не с числом строк, которые действительно изменились. Для аналитических паттернов удаления/переразметки данных обычно эффективнее не мутация по предикату, а переливка нужного поднабора в новую партицию (или таблицу) с последующим REPLACE PARTITION/переключением, либо TTL-выражения, которые ClickHouse сам применяет во время планового merge, а не отдельной синхронной командой.

Ещё одна деталь стенда, которую стоит знать заранее: is_done=1 в system.mutations означает «команда мутации выполнена», но не гарантирует, что новый набор активных частей уже виден следующему запросу той же сессии — system.parts, источник числа строк, может отстать от этого флага на несколько сотен миллисекунд. Короткий поллинг с таймаутом после is_done=1 — не избыточная осторожность, а честная проверка вместо гонки на одном SELECT.

sequenceDiagram participant App as Клиент participant CH as ClickHouse App->>CH: ALTER ... UPDATE revenue=0 WHERE country='KZ' CH-->>App: submit=5.7мс (команда принята) Note over CH: фон переписывает 3 части (parts_to_do=3 на первом опросе) App->>CH: поллинг system.mutations.is_done (200мс интервал) CH-->>App: is_done=1 за completion=211.2мс App->>CH: ALTER ... DELETE WHERE country='JP' CH-->>App: submit=5.3мс (команда принята) App->>CH: первый SELECT system.mutations CH-->>App: is_done уже =1, parts_to_do=0 (честная гонка) Note over App,CH: completion=7.7мс измерен корректно (submit -> is_done=1),
просто окно на parts_to_do>0 не поймано

sequenceDiagram
    participant App as Клиент
    participant CH as ClickHouse

    App->>CH: ALTER ... UPDATE revenue=0 WHERE country='KZ'
    CH-->>App: submit=5.7мс (команда принята)
    Note over CH: фон переписывает 3 части (parts_to_do=3 на первом опросе)
    App->>CH: поллинг system.mutations.is_done (200мс интервал)
    CH-->>App: is_done=1 за completion=211.2мс

    App->>CH: ALTER ... DELETE WHERE country='JP'
    CH-->>App: submit=5.3мс (команда принята)
    App->>CH: первый SELECT system.mutations
    CH-->>App: is_done уже =1, parts_to_do=0 (честная гонка)
    Note over App,CH: completion=7.7мс измерен корректно (submit -> is_done=1),
просто окно на parts_to_do>0 не поймано
Таймлайн двух мутаций: submit дешёв всегда, completion — реальная стоимость; DELETE успел завершиться до первого опроса parts_to_do

Мониторинг: query_log, parts_columns, метрики

Три источника покрывают большую часть операционных вопросов: сколько стоил конкретный запрос (system.query_log), сколько места на диске занимает конкретная таблица/колонка (system.parts/system.parts_columns), что происходит с сервером прямо сейчас (system.metrics/system.asynchronous_metrics).

queryID := fmt.Sprintf("ops-monitoring-%d", time.Now().UnixNano())
qctx := clickhouse.Context(ctx, clickhouse.WithQueryID(queryID))
ch.QueryRow(qctx, fmt.Sprintf("SELECT count() FROM demo.%s WHERE country IN ('RU','US','DE')", table)).Scan(&cnt)

ch.Exec(ctx, "SYSTEM FLUSH LOGS") // query_log буферизован, без flush запись может ещё не появиться

ch.QueryRow(ctx, `
    SELECT query_duration_ms, read_rows, memory_usage
    FROM system.query_log
    WHERE query_id = ? AND type = 'QueryFinish'
    ORDER BY event_time DESC LIMIT 1`, queryID).Scan(&durMs, &readRows, &memUsage)

На живом прогоне SELECT count() FROM ops_events WHERE country IN ('RU','US','DE') дал count()=115877, а system.query_log для того же query_idquery_duration_ms=3, read_rows=193885, memory_usage=683,30 КиБ. read_rows здесь равен всей таблице целиком: country не входит в ORDER BY (event_time, user_id), поэтому разреженный первичный индекс (см. статью про MergeTree и гранулы) фильтр по нему не ускоряет — читаются все части полностью, а фильтрация происходит уже после чтения.

Для размера по колонкам стенд специально форсирует Wide-формат частей: SETTINGS ... min_bytes_for_wide_part = 0 в DDL. Причина — живая находка при разработке стенда: у небольших таблиц (десятки-сотни тысяч строк) части по умолчанию остаются в Compact-формате (порог — 10 МиБ на часть), а в Compact-формате ClickHouse хранит все колонки части в одном физическом файле. data_compressed_bytes, запрошенный «по колонке» из system.columns/system.parts_columns, в этом случае возвращает размер всей части — один и тот же ответ для любой запрошенной колонки, а не честный вклад именно этой колонки. min_bytes_for_wide_part=0 форсирует отдельный файл на колонку с первой же части — без этого сравнение кодеков в разделе про тюнинг ниже было бы попросту невозможно проверить честно.

CREATE TABLE demo.ops_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 toYYYYMM(event_time)
SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0

На прогоне из пяти опрошенных колонок (event_time, url, revenue, country, user_id) system.parts_columns в этот момент вернул одинаковые числа для каждой: compressed=3,70 МиБ, uncompressed=8,12 МиБ, ratio=2,20×. Такое совпадение стоит воспринимать не как факт «все колонки весят одинаково», а как повод свериться с system.parts.part_type перед выводами о вкладе конкретной колонки в размер на диске — именно та ловушка Compact-формата, ради которой и нужен явный min_bytes_for_wide_part; раздел про codec ниже, где event_time с разными кодеками честно даёт разные числа, показывает, как выглядит корректное сравнение.

Ещё одна живая деталь того же семейства всплыла уже после RESTORE (раздел ниже): system.columns.data_compressed_bytes сразу после восстановления таблицы показал 0 — метаданные-кеш этого системного представления не обновился синхронно с самим восстановлением. system.parts_columns, который агрегирует напрямую по активным частям на диске, а не по кешируемому представлению таблицы, в тот же момент уже показывал реальные ненулевые байты — поэтому стенд везде использует его, а не system.columns.

system.metrics даёт мгновенный снимок (Query, TCPConnection, MemoryTracking — сколько запросов/соединений/памяти под трекингом прямо сейчас), system.asynchronous_metrics — те же данные, что обновляются периодически фоном, а не при каждом запросе (Uptime, NumberOfTables, TotalPartsOfMergeTreeTables — последняя особенно полезна как единая точка присмотра за общим числом частей по всему серверу, а не по одной таблице).

Backup/restore: побайтовый round-trip

BACKUP/RESTORE в ClickHouse — не «скопировать файлы части руками», а согласованная серверная операция: снимок метаданных таблицы (DDL) и её активных частей, записанный в указанное место (диск, S3, отдельный сервер). По умолчанию она синхронна — запрос блокируется до готовности и возвращает (id, status), в отличие от мутаций здесь нет отдельного is_done для поллинга.

beforeCount, beforeChecksum, _ := tableChecksum(ctx, ch, table)

backupSQL := fmt.Sprintf("BACKUP TABLE demo.%s TO File('%s')", table, backupPath)
var backupID, backupStatus string
ch.QueryRow(backupCtx, backupSQL).Scan(&backupID, &backupStatus)

ch.Exec(ctx, fmt.Sprintf("DROP TABLE demo.%s", table)) // симуляция потери данных

restoreSQL := fmt.Sprintf("RESTORE TABLE demo.%s FROM File('%s')", table, backupPath)
var restoreID, restoreStatus string
ch.QueryRow(restoreCtx, restoreSQL).Scan(&restoreID, &restoreStatus)
// DDL таблицы восстановлен из метаданных бэкапа, руками CREATE TABLE не выполнялся

afterCount, afterChecksum, _ := tableChecksum(ctx, ch, table)

tableChecksum — не просто count(), а порядко-независимая контрольная сумма всей таблицы: SELECT count(), sum(cityHash64(*)) FROM demo.ops_events, тот же приём, что уже применялся для сверки драйверов в статье про драйверы (там — CRC32 по канонической строке, здесь — построчный хеш всех колонок сразу).

Живой прогон: BACKUP TABLE ... TO File(...) — 152 мс; DROP TABLE (симуляция потери); RESTORE TABLE ... FROM File(...) — 25,3 мс. До бэкапа: count()=193885, checksum(sum(cityHash64(*)))=1464479518751944986. После восстановления — те же самые count()=193885 и checksum=1464479518751944986, совпадение побайтовое. Таблица физически удалялась (DROP TABLE, не просто очищалась), а восстановилась включая DDL — не только строки, но и структура таблицы поднялась из метаданных самого бэкапа.

Практический вывод не только в том, что BACKUP/RESTORE работает, а в том, что стоит проверять именно так — по контрольной сумме, а не только по числу строк: count() совпадёт и в случае, если часть значений в колонках побилась при восстановлении, а хеш по всем колонкам такую порчу поймает. Общие принципы бэкапов «дорогих» данных — периодичность, тестовые восстановления, разделение хранения от источника — разобраны отдельно в статье «Бэкапы для важных данных»; здесь же — конкретный механизм ClickHouse, которым эти принципы реализуются.

Тюнинг: codec, index_granularity, память запроса

Codec. По умолчанию ClickHouse сжимает колонки LZ4 — быстро, но не максимально плотно. CODEC(ZSTD(N)) — более сильное сжатие ценой CPU, разумный выбор по умолчанию для холодных данных. Для колонок, которые внутри одной части почти монотонны (а event_time в таблице с ORDER BY (event_time, user_id) — ровно такая), есть более узкий инструмент: CODEC(Delta, ZSTD(N)) сначала кодирует разности соседних значений (для монотонного ряда это преимущественно маленькие числа), а уже потом ZSTD сжимает этот остаток.

CREATE TABLE demo.ops_codec_zstd  (..., event_time DateTime CODEC(ZSTD(3)), ...)
  ENGINE = MergeTree ORDER BY (event_time, user_id)
  PARTITION BY toYYYYMM(event_time) SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0;

CREATE TABLE demo.ops_codec_delta (..., event_time DateTime CODEC(Delta, ZSTD(3)), ...)
  ENGINE = MergeTree ORDER BY (event_time, user_id)
  PARTITION BY toYYYYMM(event_time) SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0;

На 500 000 строках одного и того же CSV-диапазона (OPTIMIZE TABLE ... FINAL перед замером, чтобы сравнивать уже слитые части, а не переходное состояние) колонка event_time:

  • CODEC(ZSTD(3)): compressed = 8,99 МиБ (ratio 2,33×)
  • CODEC(Delta, ZSTD(3)): compressed = 8,13 МиБ (ratio 2,58×)

Delta+ZSTD компактнее ZSTD-only на 9,5% (8,13 против 8,99 МиБ) — небольшой, но не бесплатный выигрыш именно потому, что колонка почти монотонна внутри части; для колонок без такой структуры (случайные значения, произвольный String) Delta смысла не имеет и может даже немного проиграть.

index_granularity. Разреженный первичный индекс хранит запись не на каждую строку, а на каждые index_granularity строк (гранулу) — чем мельче гранула, тем точнее индекс отсекает лишнее на узком диапазонном фильтре, но тем больше сам индекс и тем выше накладные расходы на часть. Стенд сравнивает index_granularity=128 (мелкая) и index_granularity=8192 (дефолт, крупная) на одном и том же датасете, с фильтром в одни сутки:

SELECT count() FROM demo.ops_gran_fine
WHERE event_time >= '2026-06-15 00:00:00' AND event_time < '2026-06-16 00:00:00'

Результат count() одинаков на обеих таблицах — 2193, гранулярность не влияет на корректность. А вот read_rows (из system.query_log) — нет: мелкая гранула (128) прочитала 1025 строк, крупная (8192) — 65536 строк из того же диапазона. Это в 64 раза меньше данных, реально поднятых с диска, на этом узком фильтре — именно тот случай, где точечный/узкодиапазонный доступ достаточно частый, чтобы имело смысл платить за более мелкую гранулу увеличением индекса.

Память запроса. max_memory_usage — не декларация на бумаге, а реальный лимит, который сервер применяет посреди выполнения запроса. Стенд намеренно провоцирует отказ: SELECT country, groupArray(url) FROM demo.ops_events GROUP BY country с max_memory_usage=1000000 (1 МБ) — groupArray накапливает все url в памяти на каждую группу, и лимит в 1 МБ для этого заведомо мал.

qctxMem := clickhouse.Context(ctx, clickhouse.WithSettings(clickhouse.Settings{"max_memory_usage": 1_000_000}))
heavySQL := fmt.Sprintf("SELECT country, groupArray(url) FROM demo.%s GROUP BY country", table)
err := drainQuery(qctxMem, ch, heavySQL)
// err != nil: код 241, "Memory limit (for query) exceeded: would use 10.52 MiB…, maximum: 976.56 KiB"

На живом прогоне сервер честно отклонил запрос — код ошибки 241, Memory limit (for query) exceeded, с текстом вида «would use 10.52 MiB…, maximum: 976.56 KiB». Лимит здесь — не подсказка планировщику, а жёсткая граница, которая обрывает выполнение запроса посреди работы; на проде это то, что защищает соседние запросы от одного «тяжёлого соседа», а не просто настройка для галочки.

Активные части и codec-сжатие event_time (живой прогон ops-stand)SYSTEM STOP MERGES200×1000 строк600 частейSYSTEM START MERGESфон, t+1с10 частейOPTIMIZE TABLE FINALфорсированно, синхронно3 частиevent_time, 500 000 строк, после OPTIMIZE FINAL:0246810 МиБZSTD(3)8.99 МиБDelta, ZSTD(3)8.13 МиБDelta+ZSTD компактнее на 9.5% (8.13 против 8.99 МиБ)

Продакшн-чеклист

  • Число активных частей мониторится на весь сервер, не только по одной таблицеsystem.asynchronous_metrics.TotalPartsOfMergeTreeTables растёт быстрее ожидаемого, значит фоновый merge scheduler не успевает за темпом вставок; лечится либо укрупнением батчей на стороне вставки, либо явным OPTIMIZE FINAL в окне обслуживания, но не как рутинная замена фона.
  • OPTIMIZE TABLE ... FINAL — плановая операция, не рефлекс. Она синхронна и тяжела (полное слияние всех частей партиции сразу); использовать перед бэкапом или после массовой загрузки, но не гонять на проде по расписанию «на всякий случай».
  • Точечные UPDATE/DELETE по некластерному предикату — исключение, не правило. Стоимость мутации определяется числом затронутых частей/партиций, а не числом изменённых строк; для регулярной чистки/переразметки — партиционирование, REPLACE PARTITION, TTL-выражения.
  • system.mutations.is_done=1 не значит «данные видны следующему запросу немедленно» — если логика зависит от актуальности system.parts сразу после мутации, закладывать короткий поллинг, а не одно чтение.
  • Размер по колонкам — только через system.parts_columns, с проверкой part_type. system.columns — кешируемое представление, может отставать после RESTORE; Compact-формат части (по умолчанию для частей < 10 МиБ) отдаёт размер всей части на любой запрошенный столбец, а не честный вклад колонки — min_bytes_for_wide_part=0, если сравнение по колонкам критично.
  • Восстановление проверяется контрольной суммой, не только count(). Количество строк совпадёт и при повреждении части данных; sum(cityHash64(*))/аналог по всем колонкам ловит это, где count() — нет.
  • Codec выбирается по природе колонки, а не по умолчанию для всех. ZSTD — разумный дефолт; Delta/DoubleDelta перед ним — для почти монотонных числовых/временных рядов, отсортированных внутри части по ORDER BY; для случайных значений добавочного выигрыша не будет.
  • index_granularity — компромисс, не «чем меньше, тем лучше». Мельче гранула — точнее индекс на узких диапазонных фильтрах ценой размера индекса и накладных расходов на часть; крупнее — наоборот; выбирать по реальному профилю фильтров, а не менять глобально «для скорости».
  • max_memory_usage выставлен осознанно на уровне пользователя/профиля, а не оставлен на серверный дефолт — один тяжёлый аналитический запрос без лимита способен вытеснить память у соседних запросов на том же сервере.

Следующая статья серии — про то, что происходит, когда данные становятся слишком велики или слишком холодны для локального диска: тиринг на S3 и запрос внешних данных напрямую, в статье «ClickHouse и S3: тиринг + внешние данные».

Версии в прогоне: ClickHouse 26.6.1.1193, clickhouse-go v2.47.0, github.com/shopspring/decimal v1.4.0.

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

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

Комментарии