Справочник DBA: базы данных изнутри читается по порядку

Статья 6.7 · раздел 6 «VACUUM, bloat и обслуживание» · 46 из 109

ClickHouse: слияния part'ов, «too many parts» и мутации — почему UPDATE переписывает столбец целиком

опубликовано
читать
≈ 10 мин
автор
среда и версия
ClickHouse 25.3.14.14 в контейнере clickhouse/clickhouse-server:25.3; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Вставки в ClickHouse сначала замедляются, потом падают с Too many parts, а ALTER TABLE ... UPDATE одной строки в таблице на 100 млн строк занимает секунду и читает почти гигабайт. Причина одна: данные лежат в неизменяемых part’ах. Каждый INSERT создаёт новый part, фоновые слияния укрупняют их, а любая «правка на месте» на деле создаёт новую версию part’а. Что такое part, гранула и засечка, показано в статье про первичный ключ MergeTree; здесь разобрано, что происходит с part’ами после записи. Это та же идея долга за запись, что в LSM-деревьях, но в PostgreSQL долг гасит VACUUM внутри файла, а в ClickHouse его гасят слияния, переписывая данные целиком.

DBA / 01

Пороги вставки и мутация как копия part'а

Пороги вставки в ClickHouse и мутация как копия part'аСверху шкала активных part'ов в партиции при остановленных слияниях: до 1 000 вставка занимает миллисекунды, с 1 000 по 2 999 задержка растёт линейно, медиана от 126 до 876 мс, на 3 000 вставка падает с ошибкой Too many parts. При включённых слияниях 10 000 вставок по 10 строк дали не больше 8 активных part'ов. Снизу ALTER UPDATE одной строки превращает part all_1_90_3 в all_1_90_3_91: id.bin и user_id.bin остаются теми же inode, status.bin записан заново, 70 803 302 байта; мутация заняла 1 040 мс и прочитала 900 000 000 байт.слияния остановлены: активные part'ы в партиции01 0003 000вставка 0 мсзадержка растёт: медиана 126 → 876 мсToo many partsслияния включены, 10 000 вставок по 10 строк: не больше 8 part'овall_1_90_3 · 100 000 000 строкall_1_90_3_91 · UPDATE одной строкиid.bininode 291671586status.bininode 291671590user_id.bininode 291671588id.binссылка на тот же inodestatus.binновый файл, 70 803 302 Бuser_id.binссылка на тот же inode1 040 мс, прочитано 900 000 000 Бради одной строки status.bin переписан целиком
Пороги 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 байт), но места не освобождает до слияния.

Чек-лист

  1. Считай inserts / merges / merged_rows в system.part_log по таблице: если слияния переписывают в сотни раз больше, чем вставлено, увеличь размер пакета, а не пороги parts_to_*.
  2. Следи за числом активных part’ов в партиции (system.parts, active): задержка вставок начинается с 1 000, ошибка — с 3 000.
  3. Перед ALTER UPDATE/DELETE оцени, сколько столбцов он перепишет (isAffectingAllColumns, компактные part’ы) и хватит ли диска на вторую копию до истечения old_parts_lifetime.
  4. Для лёгкого DELETE помни, что count() уменьшился, а rows в part’е — нет, пока не прошло слияние.

Контейнер удаляется командой docker rm -f -v dba-clickhouse-merges.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru