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.
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í?
- 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.
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 sloupecupdated_atbude použit jako verze. Při merge ze dvou řádků se stejnýmORDER BYzůstane ten s většímupdated_at(novější). Pokud jsouupdated_atstejné — 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 suser_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í hodnotuamountz řádku s největšímupdated_at. Pokud máme duplikáty s různýmiupdated_at(a různýmiamount— 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ýINSERTdobets_rawokamžitě (téměř) spustí přepočet vbets_cleanpřesbets_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_cleanmůže obsahovat dočasné duplikáty, dokud sebets_rawnesloučí. Pro dokonalou čistotu je třeba buď použítFINALpři čtení zbets_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:
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é).Deduplikace na úrovni INSERT — engine
ReplicatedReplacingMergeTrees ZooKeeper. To je jiná úroveň: duplikáty se odříznou hned při vložení, ale za cenu zpoždění a složitosti.Alternativy:
VersionedCollapsingMergeTree— hybrid, který umí verzování i schlupování zároveň.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í: Konfigurace ClickHouse: jak jsem nastavoval prod a nestřelil se do nohy
→ Další: SummingMergeTree a AggregatingMergeTree: inkrementální agregace bez bolesti
— Editorial Team
Zatím žádné komentáře.