Zpět na domů

ReplacingMergeTree v ClickHouse: úplný průvodce

Článek podrobně rozebírá engine ReplacingMergeTree v ClickHouse: k čemu slouží pro boj s duplicitami při nespolehlivém doručení, jak funguje slučování podle ORDER BY, role verzování a modifikátoru FINAL. Zkoumají se typické nástrahy, srovnání s CollapsingMergeTree a vzor materializovaných pohledů pro obejití FINAL.

ReplacingMergeTree: Deduplikace dat v ClickHouse
Advertisement 728x90

ReplacingMergeTree: Jak porazit duplikáty v ClickHouse bez bolesti

1. Proč je ReplacingMergeTree potřeba — problém duplikátů v reálném světě

Představ si, že vyvíjíš online kasino. Hráč klikne na tlačítko „Vsadit“ — 1000 Kč na černou. V tu chvíli server zpracovávající požadavek náhle spadne (přehřátí, výpadek sítě, kdo ví). Klient nedostal odpověď a myslí si: „Sázka neprošla.“ Hráč klikne znovu. Server se vzpamatoval, přijal oba požadavky. V databázi jsou dvě stejné sázky. Hráč zuří: strhli 2000 Kč místo 1000.

Toto je klasický problém idempotence (z lat. idem — stejný, potens — schopný). Operace je idempotentní, pokud její opakované provedení dává stejný výsledek jako jednorázové. Ve světě databází potřebujeme mechanismus, který si sám poradí: „Tuhle sázku už jsem viděl, druhou verzi ignoruji.“

V ClickHouse k tomu slouží ReplacingMergeTree. Je to engine tabulky, který automaticky odstraňuje duplikáty při slučování (merge) kousků dat. Ale hned varuji: není kouzelný — má zvláštnosti, o kterých si povíme.

Google AdInline article slot

Analogii ze života: ReplacingMergeTree je jako sekretářka, která vede deník schůzek. Přicházejí k tobě lidé s žádostmi. Někdy tentýž klient přinese dvě stejné žádosti (např. zmeškal vlak a žádá o vrácení jízdenky, pak zavolá znovu se stejným). Sekretářka duplikáty na vstupu nevyhazuje — prostě všechny papíry složí do složky. Jednou denně složku probere a nechá jen poslední žádost od každého klienta. Pokud se někdo zeptá „kolik žádostí od Nováka?“ před probráním — uvidí dvě. Po probrání — jednu.

2. Jak ReplacingMergeTree funguje — po cihličkách

Duplikáty vznikají kvůli nespolehlivému doručení

ClickHouse byl původně navržen pro analýzu velkých objemů dat, kde občasné vynechání nebo duplikát nevadí. Pak ho ale lidé začali používat pro kritická data — a spálili se. ReplacingMergeTree je odpovědí na tuto bolest.

Proč duplikáty vůbec vznikají?

Google AdInline article slot
  • Klient odeslal data, neobdržel potvrzení (timeout), odeslal znovu.
  • Systém front (Kafka, RabbitMQ) poskytl záruku at-least-once — minimálně jedno doručení, možné opakování.
  • Chyba v ETL procesu (Extract, Transform, Load) — spustili pipeline dvakrát.

Mechanismus: merge podle ORDER BY klíče

Při vytváření tabulky s ReplacingMergeTree musíš uvést klíč řazeníORDER BY (sloupec1, sloupec2). Není to primární klíč v klasickém smyslu (jako v PostgreSQL), ale způsob fyzického uspořádání dat na disku. ClickHouse ukládá data do sortů — kousků seřazených podle tohoto klíče.

Když se dva sorty sloučí do jednoho (proces na pozadí merging), ReplacingMergeTree projde řádky se stejnou hodnotou ORDER BY klíče a ponechá jen jeden. Který? Ve výchozím nastavení — poslední podle času vložení. Ale lze zadat číselný sloupec version (verze), pak zůstane řádek s maximální hodnotou version.

Analogii s Gitem: ReplacingMergeTree se při merge chová jako Git, když řešíš konflikt: ze dvou změn stejného souboru se ponechá poslední (pokud explicitně neurčíš strategii). Jenže tady je souborem řádek v tabulce a klíčem ORDER BY.

Google AdInline article slot

Verzování: jak ReplacingMergeTree(version) mění pravidla

Syntaxe: ReplacingMergeTree(version_column). Pokud je version_column celé číslo (UInt* nebo DateTime), zůstane řádek s největší hodnotou. To umožňuje ruční správu: můžeš explicitně určit, která verze „zvítězí“.

Příklad: odesíláme sázky s updated_at = now(). Při opakovaném odeslání bude updated_at o něco větší. Merge ponechá novější. Pokud neuvedeš version, ClickHouse vybere poslední z příchozích — to nemusí být nejnovější podle business logiky, ale jen poslední fyzické vložení. Rozdíl je důležitý.

3. CREATE TABLE s ReplacingMergeTree — rozebíráme na atomy

-- Vytvoříme tabulku pro sázky s deduplikací
CREATE TABLE bets_dedup
(
    user_id    UInt64,           -- ID hráče (komu sázka patří)
    bet_id     String,           -- Unikátní ID sázky (generuje se na klientovi)
    amount     Decimal(10,2),    -- Částka v Kč
    created_at DateTime,         -- Čas vytvoření sázky
    updated_at DateTime          -- Čas poslední aktualizace (pro verzi)
)
ENGINE = ReplacingMergeTree(updated_at)   -- Engine s verzí podle updated_at
ORDER BY (user_id, bet_id)                -- Klíč deduplikace: (user_id, bet_id)

Co se zde děje po řádcích:

  • ENGINE = ReplacingMergeTree(updated_at) — určujeme, že jde o ReplacingMergeTree, a sloupec updated_at bude použit jako verze. Při merge ze dvou řádků se stejným ORDER BY zůstane ten s větším updated_at (novější). Pokud jsou updated_at stejné — zůstane poslední fyzicky vložený (ale na to se raději nespoléhej).

  • ORDER BY (user_id, bet_id) — nejdůležitější parametr! Právě tato sada sloupců určuje, co se považuje za duplikát. Dva řádky jsou považovány za duplikáty, pokud mají shodné hodnoty všech sloupců z ORDER BY. Zde: sázka od uživatele user_id s identifikátorem bet_id je unikátní. Pokud přijdou dva řádky s user_id=123, bet_id='abc-456' — sloučí se do jednoho.

Proč ORDER BY, a ne PRIMARY KEY? V ClickHouse PRIMARY KEY nemusí být unikátní. Je to nápověda pro index, zatímco ORDER BY je fyzické pořadí na disku. ReplacingMergeTree se opírá právě o ORDER BY, i když je PRIMARY KEY kratší. Pokud PRIMARY KEY neuvedeš, shoduje se s ORDER BY.

Co se stane, když je ORDER BY příliš široký? Například do něj zahrneš amount. Pak dvě sázky s různými částkami (i stejné podle user_id, bet_id) nebudou považovány za duplikáty — obě zůstanou. Deduplikace nebude fungovat. Past č. 1 (vrátíme se k ní na konci).

4. Proč SELECT může vrátit duplikáty před mergem — a jak s tím žít

Hlavní nuance: ReplacingMergeTree odstraňuje duplikáty pouze při merge kousků dat. To je proces na pozadí, který neprobíhá okamžitě. Mezi vložením duplikátů a jejich fyzickým odstraněním může uplynout několik sekund až několik hodin (závisí na nastavení a zátěži).

Co to znamená v praxi?

Vložíme dva duplikáty:

-- První vložení
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now());

-- Po 5 sekundách — druhé (server neobdržel potvrzení a odeslal znovu)
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now() + interval 5 second);

Nyní provedeme obyčejný SELECT * FROM bets_dedup WHERE user_id = 123. Co uvidíme? Dva řádky. Protože merge ještě neproběhl. Data leží v různých kouscích (parts). Každý kousek je uvnitř seřazen podle ORDER BY, ale duplikáty mohou být v různých kouscích.

Jak získat garantovaně jeden řádek? Použít FINAL:

SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

FINAL donutí ClickHouse za běhu provést sloučení všech kousků pro tento výběr s aplikací logiky ReplacingMergeTree. Získáš jeden řádek — s maximální updated_at (nebo poslední podle času vložení, pokud bez verze).

Proč je FINAL pomalý? ClickHouse přečte všechny kousky tabulky, seřadí je v paměti podle ORDER BY klíče, odstraní duplikáty a teprve potom vrátí výsledek. U velkých tabulek (miliardy řádků) to může trvat sekundy nebo minuty. Optimalizátor nemůže efektivně využít indexy — musí projít mnoho dat.

Rada: Nepoužívej FINAL v reálném čase na velkých tabulkách. Použij ho pro:

  • Bodové dotazy na jednoho user_id (index stejně pomůže).
  • Úlohy na pozadí, kde čas není kritický (noční reporty).
  • Malé tabulky (do milionů řádků).

Pro produkční zátěž existuje lepší vzor — materializovaný pohled bez FINAL.

5. Výkon FINAL — kdy je přijatelný, kdy ne

Kdy je FINAL v pohodě:

  • Tabulka je malá (do 10–20 mil. řádků na server).
  • Dotazuješ se na jednoho uživatele podle indexu (WHERE user_id = konkrétní).
  • Máš agregaci na pozadí jednou za hodinu a 10 sekund čekání nevadí.
  • Export dat jednou denně do reportu.

Kdy je FINAL zabiják:

  • Tabulka >100 mil. řádků.
  • Dotaz bez filtrování (SELECT * FROM table FINAL) — ClickHouse přečte vše.
  • Vytížený OLTP-like scénář (desítky dotazů za sekundu s FINAL).
  • Časté aktualizace stejných klíčů — kousků je mnoho, FINAL je čte všechny.

Analogii: SELECT ... FINAL je jako ruční probírání všech papírů v archivu, abys našel poslední verzi dokumentu, místo nahlédnutí do speciálního „deníku aktuálních verzí“. Funguje, ale ne pro každý požadavek klienta.

Jak zkontrolovat, zda dotaz používá FINAL?

V ClickHouse je příkaz EXPLAIN:

EXPLAIN SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

Hledej v plánu ReadFromMergeTree s příznakem final. Pokud vidíš — dotaz poctivě prochází kousky.

6. Vzor: agregace na pozadí bez FINAL pomocí materializovaného pohledu

Toto je můj oblíbený způsob, jak obejít FINAL. Myšlenka: nech ReplacingMergeTree žít svým životem, duplikáty se postupně na pozadí schlupují. Pro čtení vytvoříme materializovaný pohled (Materialized View), který se periodicky přestavuje a obsahuje již „čistá“ data bez duplikátů.

Jak to vypadá:

-- 1. Hlavní tabulka — špinavá, s duplikáty
CREATE TABLE bets_raw
(
    user_id UInt64,
    bet_id String,
    amount Decimal(10,2),
    created_at DateTime,
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, bet_id);

-- 2. Cílová tabulka — čistá, bez duplikátů
CREATE TABLE bets_clean
(
    user_id UInt64,
    bet_id String,
    amount Decimal(10,2),
    created_at DateTime,
    updated_at DateTime
)
ENGINE = MergeTree()                    -- Obyčejný MergeTree bez deduplikace
ORDER BY (user_id, bet_id);

-- 3. Materializovaný pohled — přenáší data při vložení
CREATE MATERIALIZED VIEW bets_mv TO bets_clean AS
SELECT
    user_id,
    argMax(amount, updated_at) AS amount,      -- vezmi amount z řádku s maximálním updated_at
    argMax(created_at, updated_at) AS created_at,
    max(updated_at) AS updated_at
FROM bets_raw
GROUP BY user_id, bet_id;   -- Seskupíme podle klíče deduplikace

Rozebereme klíčové momenty:

  • argMax(amount, updated_at) — agregační funkce, která vrací hodnotu amount z řádku s největším updated_at. Pokud máme duplikáty s různými updated_at (a různými amount — např. se změnila částka sázky), zůstane nejnovější částka. To je obdoba ručního řízení verzí.

  • GROUP BY user_id, bet_id — zde explicitně říkáme: „považuj za duplikát kombinaci uživatel+identifikátor sázky“. Nyní není třeba čekat na merge — každý INSERT do bets_raw okamžitě (téměř) spustí přepočet v bets_clean přes bets_mv.

  • Důležité omezení: Materializovaný pohled v ClickHouse zpracovává data po dávkách — každé vložení zvlášť. Pokud jsou v jednom vložení dva duplikáty (user_id, bet_id) — v rámci dávky se schlupí. Pokud duplikáty přišly různými vloženími — bets_clean může obsahovat dočasné duplikáty, dokud se bets_raw nesloučí. Pro dokonalou čistotu je třeba buď použít FINAL při čtení z bets_raw, nebo provádět periodický OPTIMIZE TABLE bets_raw (vynucený merge).

Analogii: Je to jako kdybys měl koncept (bets_raw), kam ukládáš všechny škrtance, a sekretářku, která každých 5 minut přepisuje čistopis (bets_clean) bez chyb. Čtenáři se dívají jen do čistopisu — rychle a bez duplikátů.

7. ReplacingMergeTree(version) s monotónně rostoucí version — update sémantika

Obyčejný ReplacingMergeTree prostě ponechá „poslední příchozí“ řádek. To je špatné, pokud stará data mohou přijít později než nová (např. kvůli zpoždění v síti). Řešení: použít sloupec version, který monotónně roste (např. timestamp nebo sekvenční ID).

Příklad: tabulka zůstatku hráče s historií dobíjení

CREATE TABLE player_balance
(
    user_id        UInt64,
    transaction_id String,        -- Unikátní ID transakce (UUID)
    amount         Int64,         -- Změna zůstatku (může být záporná)
    balance_after  Int64,         -- Zůstatek po transakci
    event_time     DateTime,      -- Čas události na klientovi
    ingestion_time DateTime       -- Čas vložení do ClickHouse (verze)
)
ENGINE = ReplacingMergeTree(ingestion_time)   -- Verze = čas vložení
ORDER BY (user_id, transaction_id);

Nyní, i když transakce tx-001 přišla dvakrát, ale s různými ingestion_time, zůstane ta, která byla vložena později (s větším ingestion_time). To chrání před „opožděnými duplikáty“ — když první vložení bylo ve 12:00, druhé ve 12:05 (opakování), ale kvůli výpadku sítě přišlo druhé na server dříve než první. Bez verze by zůstalo dřívější (podle času vložení) — a to mohlo být nesprávné.

Co znamená „monotónně rostoucí“? Při každém novém vložení musí být hodnota ingestion_time větší nebo rovna předchozím. Použij now() (aktuální čas na serveru ClickHouse) nebo atomický čítač (např. z ZooKeeper). Nelze spoléhat na čas z klienta — hodiny mohou skákat.

8. Úplný příklad: deduplikace dobíjení zůstatku podle transaction_id

Dáme vše dohromady. Máme mikroslužbu, která přijímá dobíjení zůstatku od platebního systému. Platební systém posílá webhooky (HTTP volání) — občas duplikuje.

-- Krok 1: Vytvoříme tabulku pro syrové události
CREATE TABLE balance_events
(
    user_id        UInt64,
    transaction_id String,        -- Unikátní ID od platebního systému
    amount         Int64,         -- +1000 Kč
    event_time     DateTime,      -- Čas stržení peněz u uživatele
    inserted_at    DateTime DEFAULT now()  -- Automaticky se nastaví při vložení
)
ENGINE = ReplacingMergeTree(inserted_at)
ORDER BY (user_id, transaction_id);   -- Deduplikace podle dvojice (uživatel, transakce)

-- Krok 2: Vložíme data (předpokládáme, že přišel duplikát)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Za minutu přijde duplikát (inserted_at se nastaví automaticky jako now() + 60 s)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Krok 3: Čteme bez FINAL — uvidíme 2 řádky (ale jen pokud se ještě nesloučily)
SELECT * FROM balance_events WHERE user_id = 1;
-- Výsledek: dva řádky se stejnými user_id, transaction_id, amount

-- Krok 4: Čteme s FINAL — vidíme jeden řádek (s maximálním inserted_at)
SELECT * FROM balance_events FINAL WHERE user_id = 1;
-- Výsledek: jeden řádek

Proč transaction_id v ORDER BY nestačí? Protože dva různí uživatelé mohou mít stejné transaction_id (např. každý platební systém má svůj čítač). Přidáme user_id — garantujeme jedinečnost v rámci uživatele. Pokud systém generuje globální UUID (550e8400-e29b-41d4-a716-446655440000) — stačí ORDER BY transaction_id (jedno UUID stačí).

9. Srovnání s CollapsingMergeTree

CollapsingMergeTree je jiný engine pro boj se změnami. Ukládá dvojice „plus“ a „mínus“ a schlupuje je při merge.

Klíčové rozdíly:

Vlastnost ReplacingMergeTree CollapsingMergeTree
Mechanismus Ponechá jeden řádek z duplikátů Schlupuje dvojice (+1 a -1)
K čemu slouží Deduplikace vložení Aktualizace agregátů (např. košík zboží)
Je potřeba verze Volitelně (sloupec version) Povinný příznak Sign (+1/-1)
Lze uchovávat historii Ano, všechny verze do merge Ne, dvojice se ničí
FINAL pro čtení Ano, bez něj jsou vidět duplikáty Ano, bez něj jsou vidět neschlupené dvojice

Kdy zvolit ReplacingMergeTree:

  • Potřebuješ jen odstranit duplicitní řádky.
  • Máš přirozený klíč pro deduplikaci (ID transakce).
  • Data se mění zřídka (hlavně vložení).

Kdy zvolit CollapsingMergeTree:

  • Často aktualizuješ agregovanou metriku (např. „počet položek v košíku“).
  • Potřebuješ uchovávat pouze výsledek, ne historii změn.

Příklad pro CollapsingMergeTree:

CREATE TABLE cart_items
(
    user_id UInt64,
    product_id UInt64,
    quantity Int16,
    sign Int8  -- +1 (přidat), -1 (odebrat)
) ENGINE = CollapsingMergeTree(sign)
ORDER BY (user_id, product_id);

S ReplacingMergeTree bys prostě přepsal řádek s quantity novou verzí — ale pak bys ztratil historii změn. CollapsingMergeTree umožňuje spočítat výsledek (SUM(quantity * sign)) i bez FINAL.

10. Typické pasti — a jak se jim vyhnout

Past č. 1: ORDER BY nezahrnuje všechna unikátní pole

-- ŠPATNĚ: používáme pouze user_id
CREATE TABLE bets_bad ENGINE = ReplacingMergeTree ORDER BY user_id;

-- Vložili jsme dvě sázky jednoho uživatele s různými bet_id
INSERT INTO bets_bad VALUES (1, 'bet_001', 100);
INSERT INTO bets_bad VALUES (1, 'bet_002', 200);

-- Při merge se SLOUČÍ do jednoho řádku — protože ORDER BY (user_id) je stejný!
-- Ztratili jsme sázku bet_002.

Správně: Zahrň do ORDER BY všechny sloupce, které dělají řádek jedinečným — obvykle surogátní ID (transaction_id) nebo kombinace (user_id, bet_id).

Past č. 2: Naivní naděje na okamžitou deduplikaci

Začátečníci napíší INSERT s duplikátem a hned SELECT bez FINAL — vidí duplikáty. Zklamou se v ClickHouse. Pamatuj: deduplikace je asynchronní. Pokud potřebuješ okamžitou konzistenci — použij FINAL nebo vzor s materializovaným pohledem.

Past č. 3: Použití verze, která není monotónní

-- ŠPATNĚ: verze je čas z klienta
CREATE TABLE events ENGINE = ReplacingMergeTree(client_time) ORDER BY (id);

-- Klientovi se zpozdily hodiny, posílá starou verzi po nové
-- Při merge zůstane nesprávný (starý) řádek

Řešení: Použij now() na straně ClickHouse nebo hardwarový čítač.

Past č. 4: Optimismus ohledně FINAL na velkých datech

Měl jsem případ: vývojář zapnul FINAL ve všech reportech na tabulce se 2 miliardami řádků. Dotazy začaly padat na timeout po 300 sekundách. Museli jsme přepsat na agregaci s GROUP BY a argMax.

Zlaté pravidlo: Pokud čteš více než 10 % tabulky přes FINAL — děláš něco špatně. Použij materializované pohledy nebo přehodnoť architekturu.

Past č. 5: ReplacingMergeTree bez ORDER BY

ClickHouse nedovolí vytvořit tabulku bez ORDER BY. Ale lze uvést ORDER BY tuple() (prázdná n-tice). Pak jsou všechny řádky v tabulce považovány za duplikáty — po prvním merge zůstane jediný řádek. Téměř nikdy to není potřeba.

Co dál — odkazy na související články

Teď, když jsi pochopil ReplacingMergeTree, další témata ke studiu:

  1. Jak optimalizovat slučování — nastavení merge_with_ttl_timeout, number_of_free_entries_in_pool_to_lower_max_size_of_merge (zní to děsivě, ale je to užitečné).

  2. Deduplikace na úrovni INSERT — engine ReplicatedReplacingMergeTree s ZooKeeper. To je jiná úroveň: duplikáty se odříznou hned při vložení, ale za cenu zpoždění a složitosti.

  3. Alternativy: VersionedCollapsingMergeTree — hybrid, který umí verzování i schlupování zároveň.

  4. Materializované pohledy do detailu — jak stavět víceúrovňové agregace, aby ses zcela obešel bez FINAL.

A na závěr: ReplacingMergeTree je mocný nástroj, ale není o „odstranit duplikáty okamžitě“. Je o „data se nakonec vyčistí, a ty mezitím pracuj s tímto“. Pokud potřebuješ striktní jedinečnost (jako PRIMARY KEY v PostgreSQL) — ClickHouse není nejlepší volba. Ale pro 99 % analytických úloh s opakovanými vloženími je to záchrana.


Předchozí:
Další: SummingMergeTree a AggregatingMergeTree: inkrementální agregace bez bolesti

— Editorial Team

Advertisement 728x90

Číst dál