Zpět na domů

MergeTree v ClickHouse: granule, části a sparse index

Hluboká technická příručka k enginu MergeTree v ClickHouse. Vysvětluje vnitřní strukturu: part (část), granule (8192 řádků), mark (značka v .mrk), fyzické soubory .bin a .mrk. Rozebírá se proces merge částí na pozadí, proč ORDER BY určuje fyzické pořadí a sparse index, a PRIMARY KEY pouze prefix. Ukazuje se, jak partitionování (toYYYYMM) odřezává celé adresáře, jak číst EXPLAIN indexes=1, příkazy SHOW/DROP/DETACH/ATTACH PARTITION a kdy je skutečně potřeba OPTIMIZE TABLE. Příklady na tabulce sazeb se správným a nesprávným ORDER BY.

MergeTree: jak ClickHouse ukládá data na disk a zrychluje dotazy
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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ů.

Google AdInline article slot

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:

  1. Dotaz chce sloupec amount pro user_id=123
  2. Sparse index říká: tento user_id může být v granulích #45, #46, #47
  3. ClickHouse otevře user_id.mrk, vezme offset pro granuli #45
  4. Jde do user_id.bin na tento offset, přečte 8192 hodnot
  5. Najde řádky s požadovaným user_id, zapamatuje si čísla řádků
  6. Podle čísel řádků vypočítá pozice v amount.mrk a přečte jen potřebné bajty z amount.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í:
Další: Načítání dat do ClickHouse: jak jsem přestal vkládat po jednom řádku a zrychlil příjem 500krát

— Editorial Team

Advertisement 728x90

Číst dál