MergeTree v ClickHouse: jak engine řeže analytiku na granule a slučuje části
Sedm let bolesti, tři ztracené produkce a jedna architektonická tabulka
Když jsem poprvé slyšel o MergeTree, říkal jsem si: "Další engine s módním názvem." Potom v produkci tabulka s 500 miliony sázek začala zpomalovat dotazy, které dříve létaly. Dívali jsme se do EXPLAIN a viděli Read 250000 granules. Tehdy jsem ještě nevěděl, co je granule.
Ukázalo se, že jsem vytvořil tabulku s nesprávným ORDER BY. Každý dotaz skenoval 80 % všech dat, přestože filtroval jen podle jednoho sloupce.
MergeTree není jen engine. Je to architektura, která určuje, jak vaše data leží na disku, jak se komprimují a hlavně – jak ClickHouse rozhoduje, které kusy číst a které přeskočit. Pochopení vnitřností mi zachránilo tři projekty. Níže je mapa, po které chodím už pět let.
1. Část, granule, marker: matrjoška na disku
ClickHouse neukládá tabulku jako jeden soubor. Data řeže na parts (části), uvnitř každé části na granules (granule) a orientuje se podle marks (markerů).
Disková struktura tabulky bets:
/var/lib/clickhouse/data/betting/bets/
├── 202401_1_1_0/ # part č.1 (leden 2024)
│ ├── user_id.bin # sloupec user_id (binární data)
│ ├── user_id.mrk # markery pro user_id
│ ├── created_at.bin
│ ├── created_at.mrk
│ ├── amount.bin
│ ├── amount.mrk
│ └── ...
├── 202401_2_2_0/ # part č.2
└── 202402_3_3_0/ # part č.3 (únor)
Part (část) – minimální jednotka, kterou MergeTree spravuje. Každá část se vytvoří při vložení a poté se na pozadí slučuje se sousedními.
Granule (granule) – blok dat o index_granularity řádcích (výchozí 8192). ClickHouse čte data po celých granulích. Nelze přečíst jeden řádek – pouze celou granuli.
Mark (marker) – ukazatel na pozici granule v .bin souboru. V .mrk je uložen offset: kde granule na disku začíná a jaký má offset.
Proč je to důležité: když provedete SELECT amount FROM bets WHERE user_id = 123, ClickHouse podle sparse indexu určí, ve kterých granulích se může tento user_id nacházet, a přečte jen je. Ostatní granule ani neotevře.
2. Merge částí: proč se ClickHouse nerozbije z milionu malých vložení
Každý INSERT vytvoří novou část na disku. Pokud vkládáte po 100 záznamech 10 000krát – budete mít 10 000 částí. To je katastrofa: dotaz by musel otevřít 10 000 souborů.
Jak ClickHouse zachraňuje situaci:
Proces na pozadí merge slučuje malé části do velkých. Například:
- 10 částí po 1 GB → 1 část po 10 GB
Parametry, které v produkci upravuji:
<merge_tree>
<min_rows_for_wide_part>100000</min_rows_for_wide_part>
<max_bytes_for_merge>100000000000</max_bytes_for_merge> <!-- 100 GB -->
<merge_with_ttl_timeout>3600</merge_with_ttl_timeout>
</merge_tree>
Lifehack: pokud provádíte velké vložení (1M+ řádků), část se nebude slučovat s ostatními, dokud se neobjeví sousední. ClickHouse ukládá části v rostoucím pořadí klíče, takže INSERT ... ORDER BY pomáhá.
Na čem jsem se spálil: streamovali jsme sázky přes Kafka po 10–50 záznamech za sekundu. Po týdnu se nashromáždilo 300 000 částí. Dotazy začaly zpomalovat, protože každý dotaz otevíral všechny soubory. Řešení: zvýšili jsme min_rows_for_wide_part na 500k a max_insert_block_size na 1M. Tok dat jsme museli bufferovat v Kafka, ale části se scvrkly na 500.
3. PRIMARY KEY vs ORDER BY: nejčastější chyba začátečníků
V MySQL je PRIMARY KEY unikátní identifikátor. V ClickHouse – ne tak úplně.
-- Vidím to neustále
CREATE TABLE bets (
user_id UInt64,
created_at DateTime,
amount Decimal(18,2)
) ENGINE = MergeTree()
PRIMARY KEY (user_id) -- ← chyba
ORDER BY (user_id); -- ← a toto je také chyba
Pravda:
- ORDER BY určuje fyzické pořadí řádků na disku. Povinný.
- PRIMARY KEY je totéž co ORDER BY, pokud není uveden. Může však být PŘEDPONOU ORDER BY.
Správně:
ORDER BY (created_at, user_id) -- nejprve čas, potom uživatel
PRIMARY KEY (created_at) -- index pouze podle času
Co se děje: ClickHouse staví sparse index (řídký index) na základě ORDER BY. PRIMARY KEY pouze říká, kterou část ORDER BY použít pro filtrování.
Reálný příklad z naší produkce:
-- Špatně (pomalu)
ORDER BY (user_id, created_at)
-- Dotaz: hledání sázek za poslední hodinu. Index nepomáhá, skenujeme vše.
-- Správně (rychle)
ORDER BY (created_at, user_id)
-- Dotaz: skočíme na požadované datum podle indexu, poté uvnitř filtrujeme podle user_id
4. Sparse index: jak se 8192 řádků promění v jeden záznam v indexu
ClickHouse NESTAVÍ index pro každý řádek. Vezme granuli (8192 řádků) a zapíše do indexu:
- minimální hodnotu ORDER BY v této granuli
- maximální hodnotu ORDER BY
To je vše. Žádný B-strom, žádná hash tabulka, jen jednoduché pole min-max párů.
Jak se dotaz zrychluje:
-- Hledáme sázky za 5 minut
SELECT * FROM bets WHERE created_at BETWEEN '2024-03-15 14:00:00' AND '2024-03-15 14:05:00';
-- Index (sparse) kontroluje každou granuli:
-- Granule 1: min='2024-03-15 13:00:00' max='2024-03-15 14:00:00' → NEVYHOVUJE (max < 14:05?)
-- Granule 2: min='2024-03-15 14:00:00' max='2024-03-15 15:00:00' → VYHOVUJE (min <= 14:05)
-- Granule 3: min='2024-03-15 15:00:00' max='2024-03-15 16:00:00' → NEVYHOVUJE (min > 14:05)
Proč je to rychlé: index zabírá (počet granulí) * 16 bajtů. Pro 1 miliardu řádků je to ~1,9 milionu granulí → 30 MB indexu. Celý index se vejde do paměti.
5. Partitionování: skáčeme k požadovanému měsíci
PARTITION BY je pravidlo, podle kterého ClickHouse ukládá části do různých adresářů na disku.
PARTITION BY toYYYYMM(created_at) -- partitiony po měsících
Na disku:
/var/lib/clickhouse/data/betting/bets/
├── 202401/ # leden 2024
├── 202402/ # únor 2024
└── 202403/ # březen 2024
Jak to zrychluje dotaz:
SELECT * FROM bets WHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';
-- ClickHouse jde rovnou do složky 202402/, ostatní partitiony ani neotevře
Kdy partitionování nepomáhá:
- Malé partitiony (po dnech při 100 milionech řádků denně → 365 partitionů, každá po 300 MB → mnoho souborů)
- Filtr není podle klíče partitionování
Můj výběr: toYYYYMM() pro 10–100 milionů řádků měsíčně, toYYYYMMDD() pokud 1+ miliarda denně (ale pak je potřeba cluster).
6. Formát .bin a .mrk: jak data leží na disku
Kdysi jsem vlezl do adresáře tabulky a uviděl:
$ ls -la /var/lib/clickhouse/data/betting/bets/202401_1_1_0/
-rw-r----- 1 clickhouse clickhouse 1.2G user_id.bin
-rw-r----- 1 clickhouse clickhouse 12M user_id.mrk
-rw-r----- 1 clickhouse clickhouse 900M created_at.bin
-rw-r----- 1 clickhouse clickhouse 12M created_at.mrk
-rw-r----- 1 clickhouse clickhouse 2.1G amount.bin
-rw-r----- 1 clickhouse clickhouse 12M amount.mrk
- .bin – skutečná data sloupce, komprimovaná LZ4 (nebo ZSTD, pokud je nastaveno)
- .mrk – markery: pozice každé granule v .bin
Jak se to čte:
- Dotaz chce sloupec
amountprouser_id=123 - Sparse index říká: tento user_id může být v granulích #45, #46, #47
- ClickHouse otevře
user_id.mrk, vezme offset pro granuli #45 - Jde do
user_id.binna tento offset, přečte 8192 hodnot - Najde řádky s požadovaným user_id, zapamatuje si čísla řádků
- Podle čísel řádků vypočítá pozice v
amount.mrka přečte jen potřebné bajty zamount.bin
Závěr: fyzicky se data čtou jen pro potřebné sloupce a jen pro potřebné granule. Vše ostatní jsou metadata.
7. Správný ORDER BY na příkladu tabulky sázek
Špatný ORDER BY (dělal jsem to tak):
CREATE TABLE betting.bets_wrong
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at); -- indexace nejprve podle uživatele
Problém: 90 % dotazů v našem projektu je "ukaž sázky za poslední hodinu" (filtr podle času). Index nepomáhá, protože user_id se mění rychleji než čas. ClickHouse skenuje všechny partitiony.
Správný ORDER BY:
CREATE TABLE betting.bets_correct
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2),
sport LowCardinality(String),
outcome Enum8('win'=1,'loss'=2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id); -- indexace nejprve podle času
Nyní:
- Dotaz podle rozsahu dat: okamžitě skočí na potřebné granule
- Uvnitř data lze filtrovat podle user_id
- Dodatečně lze přidat
SECONDARY INDEX(ale to je samostatný příběh)
8. EXPLAIN indexes = 1: podíváme se, kolik granulí se skutečně čte
Nejužitečnější nástroj pro ladění:
EXPLAIN indexes = 1
SELECT user_id, sum(amount)
FROM betting.bets
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'
AND user_id = 100500
GROUP BY user_id;
Výstup:
Expression (Projection)
Aggregating
Expression
ReadFromMergeTree (betting.bets)
Indexes:
Partition key: partition_idx (1/3 partitions, 1 read)
Primary key: created_at (42/500 granules, 42 read)
MinMax: created_at (0 skipped, 1 read)
Co vidíme: z 500 granulí v partitioně bylo přečteno pouze 42. Stupeň filtrace – 8 %. Bez správného ORDER BY by to bylo 500 z 500.
9. Příkazy pro správu partitionů
Zobrazit všechny partitiony:
SELECT
partition,
name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'bets' AND active = 1;
Smazat starou partition (rychlejší než DELETE):
ALTER TABLE betting.bets DROP PARTITION '202401';
Vyčistí disk okamžitě. DELETE FROM maže po řádcích, poté merge – rozdíl v hodinách.
Odpojit partition (bez smazání dat):
ALTER TABLE betting.bets DETACH PARTITION '202402';
-- data se přesunou do detached/
Vrátit zpět:
ALTER TABLE betting.bets ATTACH PARTITION '202402';
Zkopírovat partition do jiné tabulky (živý případ):
ALTER TABLE betting.bets_archive REPLACE PARTITION '202401' FROM betting.bets;
10. OPTIMIZE TABLE – kdy není potřeba (a kdy je najednou potřeba)
OPTIMIZE TABLE vynucuje ruční merge částí.
Špatná zpráva: většina článků radí spouštět jej pravidelně. Dobrá zpráva: v 99 % případů to není třeba. ClickHouse slučuje na pozadí sám.
Kdy jsem OPTIMIZE skutečně použil:
- Po nahrání velkého bloku historických dat (100 milionů řádků jedním vložením) – aby ostatní partitiony nečekaly na merge podle plánu
- Před vytvořením zálohy, aby se snížil počet souborů v tabulce
- Testování – abych viděl skutečnou velikost po kompresi
Jak to dělat bezpečně:
OPTIMIZE TABLE betting.bets PARTITION '202403' FINAL;
FINAL sloučí všechny části do jedné pro tuto partition. Bez FINAL – pouze části, které jsou již připraveny.
Moje rada: nesahejte na OPTIMIZE v automatických skriptech. Background merge jsou dobře nastavené. Pokud se části neslučují, podívejte se na max_bytes_to_merge a volné místo na disku.
Co dál
MergeTree je srdce ClickHouse. Nyní víte, jak bije. Příští článek – o pokročilé indexaci: skokové indexy, materializované sloupce a projekce.
← Předchozí: ClickHouse: úplný přehled datových typů pro analýzu sázek (na čem jsem se spálil)
→ Další: Načítání dat do ClickHouse: jak jsem přestal vkládat po jednom řádku a zrychlil příjem 500krát
— Editorial Team
Zatím žádné komentáře.