Вставки в ClickHouse сначала замедляются, потом падают с Too many parts, а ALTER TABLE ... UPDATE одной строки в таблице на 100 млн строк занимает секунду и читает почти гигабайт. Причина одна: данные лежат в неизменяемых part’ах. Каждый INSERT создаёт новый part, фоновые слияния укрупняют их, а любая «правка на месте» на деле создаёт новую версию part’а. Что такое part, гранула и засечка, показано в статье про первичный ключ MergeTree; здесь разобрано, что происходит с part’ами после записи. Это та же идея долга за запись, что в LSM-деревьях, но в PostgreSQL долг гасит VACUUM внутри файла, а в ClickHouse его гасят слияния, переписывая данные целиком.
Пороги вставки и мутация как копия part'а
parts_to_delay_insert и parts_to_throw_insert работают по числу активных part'ов в партиции. Мутация не правит файл на месте: она создаёт новый part, перезаписывает затронутый столбец и связывает остальные файлы жёсткими ссылками.Сто тысяч строк двумя способами
docker run -d --name dba-clickhouse-merges -e CLICKHOUSE_SKIP_USER_SETUP=1 clickhouse/clickhouse-server:25.3
docker exec -i dba-clickhouse-merges clickhouse-client -q "SELECT version()"
вывод
25.3.14.14
Две одинаковые таблицы получают по 100 000 строк: в small — 10 000 вставок по 10 строк, в big — 10 вставок по 10 000. Слияния включены, настройки по умолчанию:
CH="docker exec -i dba-clickhouse-merges clickhouse-client"
$CH -mn -q "CREATE TABLE small (id UInt64, v UInt32) ENGINE = MergeTree ORDER BY id;
CREATE TABLE big (id UInt64, v UInt32) ENGINE = MergeTree ORDER BY id;"
for i in $(seq 0 9999); do echo "INSERT INTO small SELECT $i*10+number, rand32() FROM numbers(10);"; done | $CH -mn
for i in $(seq 0 9); do echo "INSERT INTO big SELECT $i*10000+number, rand32() FROM numbers(10000);"; done | $CH -mn
Журнал system.part_log считает новые part’ы и слияния; строки в слиянии — это строки, записанные заново:
SYSTEM FLUSH LOGS;
SELECT table, countIf(event_type = 'NewPart') AS inserts, sumIf(rows, event_type = 'NewPart') AS inserted_rows,
round(sumIf(size_in_bytes, event_type = 'NewPart') / 1048576, 1) AS new_mib,
countIf(event_type = 'MergeParts') AS merges, sumIf(rows, event_type = 'MergeParts') AS merged_rows,
round(sumIf(size_in_bytes, event_type = 'MergeParts') / 1048576) AS merged_mib
FROM system.part_log WHERE table IN ('small', 'big') GROUP BY table ORDER BY table;
SELECT table, count() AS parts, max(level) AS max_level FROM system.parts WHERE table IN ('small', 'big') AND active GROUP BY table ORDER BY table;
вывод
big 10 100000 0.8 2 160000 1
small 10000 100000 3.8 6881 222451720 1705
big 1 2
small 4 6003
Те же 100 000 строк: big переписал 160 000 строк двумя слияниями, small — 222 451 720 строк 6 881 слиянием. Слияния записали заново в 2 225 раз больше строк, чем вставлено, против 1,6 раза у big; по объёму это 1 705 МиБ при 3,8 МиБ новых part’ов. Уровень 6003 у большого part’а (all_1_9997_6003) означает цепочку из 6 003 слияний: большой part раз за разом пересобирался вместе с новой крошкой. Ошибки при этом нет: слияния успевают, а опрос count() по system.parts раз в две секунды показал не больше 8 активных part’ов. Мелкие вставки сами таблицу не ломают, они превращают запись в многократную перезапись; ломает её отставание слияний.
Слияния отстали: задержка вставки и ошибка
Пороги лежат в настройках таблицы:
SELECT name, value FROM system.merge_tree_settings
WHERE name IN ('parts_to_delay_insert', 'parts_to_throw_insert', 'max_delay_to_insert', 'max_parts_in_total', 'max_avg_part_size_for_too_many_parts', 'min_bytes_for_wide_part', 'old_parts_lifetime')
ORDER BY name;
вывод
max_avg_part_size_for_too_many_parts 1073741824
max_delay_to_insert 1
max_parts_in_total 100000
min_bytes_for_wide_part 10485760
old_parts_lifetime 480
parts_to_delay_insert 1000
parts_to_throw_insert 3000
Чтобы отставание получилось надёжно, слияния таблицы останавливаются, и вставки идут по одной строке:
$CH -mn -q "CREATE TABLE nomerge (id UInt64, v UInt32) ENGINE = MergeTree ORDER BY id; SYSTEM STOP MERGES nomerge;"
for i in $(seq 0 3199); do echo "INSERT INTO nomerge SETTINGS log_comment = 'nm' SELECT $i, rand32() FROM numbers(1);"; done | $CH -mn
Клиент остановился на 3 001-й вставке (-mn прекращает работу при первой ошибке) и целиком проработал 16 минут 48 секунд, хотя строк вставлено 3 000:
вывод
Code: 252. DB::Exception: Received from localhost:9000. DB::Exception: Too many parts (3000 with average size of 318.00 B) in table 'default.nomerge (c935eb70-fa14-46b4-bb72-3372ed64e754)'. Merges are processing significantly slower than inserts. (TOO_MANY_PARTS)
Время каждой вставки лежит в system.query_log; номер вставки берётся из текста запроса:
WITH toUInt32(extract(query, 'SELECT (\\d+),')) AS n
SELECT multiIf(n < 1000, '0-999', n < 1500, '1000-1499', n < 2000, '1500-1999', n < 2500, '2000-2499', n < 3000, '2500-2999', '3000') AS bucket,
count() AS inserts, round(median(query_duration_ms)) AS median_ms, max(query_duration_ms) AS max_ms
FROM system.query_log WHERE log_comment = 'nm' AND type IN ('QueryFinish', 'ExceptionWhileProcessing')
GROUP BY bucket ORDER BY min(n);
вывод
0-999 1000 0 2
1000-1499 500 126 251
1500-1999 500 376 501
2000-2499 500 626 752
2500-2999 500 876 1001
3000 1 1 1
До 1 000 part’ов вставка занимает миллисекунды. С 1 000-го она засыпает: в system.events DelayedInserts = 2 000 и DelayedInsertsMilliseconds = 1 000 100 (больше тысячи секунд сна), а медиана растёт 126 → 376 → 626 → 876 мс. На 3 001-й вставке — ошибка. Порог — число активных part’ов в одной партиции (parts_to_delay_insert = 1000, parts_to_throw_insert = 3000), а не в таблице. Код MergeTreeData::delayInsertOrThrowIfNeeded v25.3.14.14-lts: ошибку с текстом из вывода бросает строка 4993, а условие !parts_are_large_enough_in_average в той же ветке (строки 4988–4997) отключает порог, если средний part больше max_avg_part_size_for_too_many_parts (1 073 741 824 байта в выводе выше). Задержка считается там же (цитата, строки 5026–5036):
const UInt64 max_delay_milliseconds = ((*settings)[MergeTreeSetting::max_delay_to_insert] > 0 ? (*settings)[MergeTreeSetting::max_delay_to_insert] * 1000 : 1000);
if (allowed_parts_over_threshold == 0 || parts_over_threshold > allowed_parts_over_threshold)
{
delay_milliseconds = max_delay_milliseconds;
}
else
{
double delay_factor = static_cast<double>(parts_over_threshold) / allowed_parts_over_threshold;
const UInt64 min_delay_milliseconds = (*settings)[MergeTreeSetting::min_delay_to_insert_ms];
delay_milliseconds = std::max(min_delay_milliseconds, static_cast<UInt64>(max_delay_milliseconds * delay_factor));
}
Задержка — доля max_delay_to_insert (1 секунда) по числу part’ов сверх порога относительно 2 000 (3 000 − 1 000). Для вставки при 1 250 part’ах это (1 250 − 1 000 + 1) / 2 000 × 1 000 мс ≈ 125 мс; в выводе медиана блока 1000–1499 — 126 мс. Слияния в этом опыте остановлены намеренно; если они просто не успевают за вставками, пороги срабатывают так же.
Мутации на 100 миллионах строк
$CH -mn -q "CREATE TABLE ev (id UInt64, user_id UInt32, status UInt8) ENGINE = MergeTree ORDER BY id;
INSERT INTO ev SELECT number, rand32(1), rand32(2) % 5 FROM numbers(100000000);
OPTIMIZE TABLE ev FINAL;
SELECT name, rows, formatReadableSize(bytes_on_disk) FROM system.parts WHERE table = 'ev' AND active;"
вывод
all_1_90_3 100000000 832.64 MiB
Файлы part’а: номер inode, число жёстких ссылок, размер, имя (ls -li):
docker exec dba-clickhouse-merges sh -c "cd /var/lib/clickhouse/data/default/ev/all_1_90_3 && ls -li *.bin | awk '{print \$1, \$3, \$6, \$10}'"
вывод
291671586 1 400466909 id.bin
291671590 1 70803307 status.bin
291671588 1 401727307 user_id.bin
ALTER UPDATE одной строки. mutations_sync = 2 ждёт завершения на всех репликах:
ALTER TABLE ev UPDATE status = 9 WHERE id = 12345 SETTINGS mutations_sync = 2;
SELECT mutation_id, command, parts_to_do, is_done FROM system.mutations WHERE table = 'ev';
вывод
mutation_91.txt (UPDATE status = 9 WHERE id = 12345) 0 1
Тот же ls -li по новому part’у all_1_90_3_91 (суффикс _91 — номер мутации):
вывод
291671586 2 400466909 id.bin
291673657 1 70803302 status.bin
291671588 2 401727307 user_id.bin
id.bin и user_id.bin — те же inode с двумя ссылками: мутация не копировала их, а сделала жёсткие ссылки. status.bin — новый файл. Журнал part’ов:
SELECT part_name, duration_ms, rows, read_rows, read_bytes FROM system.part_log WHERE table = 'ev' AND event_type = 'MutatePart' ORDER BY event_time_microseconds;
вывод
all_1_90_3_91 1040 100000000 100000000 900000000
Изменена одна строка, но прочитаны 100 млн строк id и status: 900 000 000 байт = 100 000 000 × (8 + 1). Столбец status заново записан целиком, 70 803 302 байта, за 1 040 мс; user_id не читался. Код разводит два случая: если мутация задевает все столбцы или part компактный, part пишется заново (MutateAllPartColumnsTask), иначе (MutateSomePartColumnsTask) нетронутые файлы связываются жёсткими ссылками.
if (MutationHelpers::haveMutationsOfDynamicColumns(ctx->source_part, ctx->commands_for_part)
|| !isWidePart(ctx->source_part)
|| !isFullPartStorage(ctx->source_part->getDataPartStorage())
|| (ctx->interpreter && ctx->interpreter->isAffectingAllColumns()))
{
MutateTask.cpp, v25.3.14.14-lts: isWidePart и isAffectingAllColumns выбирают переписывание всего. В ветке «части» каждый файл, который мутация не пересчитывает, превращается в ссылку:
else
{
ctx->new_data_part->getDataPartStorage().createHardLinkFrom(
ctx->source_part->getDataPartStorage(), it->name(), destination);
hardlinked_files.insert(it->name());
}
ALTER DELETE той же одной строки (WHERE id = 777) трогает все столбцы, и ls -li нового part’а all_1_90_3_92 показывает по одной ссылке у каждого файла и три новых inode: 291675017, 291675021, 291675019. В system.part_log строка all_1_90_3_92 2640 99999999 100000000 1300000000: 2 640 мс и 13 байт на строку (8 + 4 + 1) прочитано.
Лёгкий DELETE не переписывает данные, а ставит маску:
DELETE FROM ev WHERE id = 778;
SELECT mutation_id, command FROM system.mutations WHERE table = 'ev' AND mutation_id = 'mutation_93.txt';
SELECT count() FROM ev;
SELECT sum(rows) FROM system.parts WHERE table = 'ev' AND active;
вывод
mutation_93.txt (UPDATE _row_exists = 0 WHERE id = 778)
99999998
99999999
DELETE в 25.3 — мутация, которая пишет скрытый столбец _row_exists. count() видит 99 999 998 строк, в part’е физически остаётся 99 999 999. В all_1_90_3_93 три прежних файла остались ссылками (по две на inode), а новых файлов два: _row_exists.bin — 445 568 байт и _row_exists.cmrk2 — 14 559. Мутация прочитала id и маску (899 999 991 байт, 829 мс) против 2 640 мс у ALTER DELETE. Строка исчезает физически только при слиянии: OPTIMIZE TABLE ev FINAL собрал all_1_90_4_93 за 2 348 мс, прочитав 1 399 999 986 байт всех столбцов, и rows part’а стал 99 999 998. Счётчики показывают, какой путь выбрали мутации:
SELECT event, value FROM system.events WHERE event IN ('MutationAllPartColumns', 'MutationSomePartColumns') ORDER BY event;
вывод
MutationAllPartColumns 1
MutationSomePartColumns 2
Одна мутация переписала все столбцы (ALTER DELETE), две обошлись ссылками (ALTER UPDATE и лёгкий DELETE). Лёгкого UPDATE с patch part’ами в 25.3 нет: UPDATE ev SET status = 1 WHERE id = 5 даёт Syntax error: failed at position 1 (UPDATE); в текущей документации он описан, но это уже другая версия.
Размер каталога таблицы (du -shL, жёсткие ссылки считаются один раз) при двух мутациях на других строках: 901M до, 968M после ALTER UPDATE ... WHERE id = 6 (новый status.bin, 67 МБ), 1.8G после ALTER DELETE WHERE id = 779 (вторая копия всех столбцов). Старая версия part’а лежит рядом, пока не пройдёт old_parts_lifetime (480 секунд из вывода выше). Part меньше min_bytes_for_wide_part (10 485 760 байт там же) хранит все столбцы в одном data.bin; для такого part’а ветка !isWidePart из цитаты выше выбирает переписывание всего part’а.
ReplacingMergeTree: версия вместо UPDATE
Если строки нужно менять часто, UPDATE заменяют вставкой новой версии строки:
CREATE TABLE rep (id UInt32, status UInt8, ver UInt32) ENGINE = ReplacingMergeTree(ver) ORDER BY id;
SYSTEM STOP MERGES rep;
INSERT INTO rep VALUES (1, 0, 1), (2, 0, 1);
INSERT INTO rep VALUES (1, 5, 2);
SELECT count(), countIf(status = 5) FROM rep;
SELECT count(), countIf(status = 5) FROM rep FINAL;
SYSTEM START MERGES rep;
OPTIMIZE TABLE rep FINAL;
SELECT count() FROM rep;
вывод
3 1
2 1
2
Пока слияния не прошли, обычный SELECT видит обе версии ключа 1 (3 строки); FINAL сворачивает дубликаты при чтении (2 строки), после слияния их нет и без него. Цена FINAL — работа при каждом чтении, цена UPDATE — переписывание столбцов при записи.
Что из этого следует
Пакетируй вставки. 10 000 вставок по 10 строк дали в 2 225 раз больше записанных строк слияниями, чем вставлено, а 10 вставок по 10 000 — в 1,6 раза.
Пороги считаются по партиции. Задержка и ошибка зависят от числа part’ов в самой «толстой» партиции, а не во всей таблице.
Мутация — это операция над столбцом, а не над строкой. Одна строка в 100 млн строк стоила 1 040 мс и 900 МБ чтения; на ALTER DELETE ушло в 2,5 раза больше времени и 1,3 ГБ. Мутацию на горячей таблице ставят в очередь, как миграцию: следи за system.mutations (parts_to_do, latest_fail_reason) и за запасом диска, потому что новый part лежит рядом со старым.
Для частых правок меняй схему. Версии строки (ReplacingMergeTree + FINAL) переносят цену на чтение; лёгкий DELETE в 25.3 дёшев по записи (маска в 445 568 байт), но места не освобождает до слияния.
Чек-лист
- Считай
inserts / merges / merged_rowsвsystem.part_logпо таблице: если слияния переписывают в сотни раз больше, чем вставлено, увеличь размер пакета, а не порогиparts_to_*. - Следи за числом активных part’ов в партиции (
system.parts,active): задержка вставок начинается с 1 000, ошибка — с 3 000. - Перед
ALTER UPDATE/DELETEоцени, сколько столбцов он перепишет (isAffectingAllColumns, компактные part’ы) и хватит ли диска на вторую копию до истеченияold_parts_lifetime. - Для лёгкого
DELETEпомни, чтоcount()уменьшился, аrowsв part’е — нет, пока не прошло слияние.
Контейнер удаляется командой docker rm -f -v dba-clickhouse-merges.