Späť na blog

ClickHouse ReplacingMergeTree: Ilúzia Deduplikácie

|
| clickhouse, databases, performance, analytics, deduplication

ReplacingMergeTree vyzeral ako magia, kym sme nevystopovali, odkial idu duplicity. “Migrovali sme do ClickHouse a vybrali sme ReplacingMergeTree pretože potrebujeme updatovať záznamy. Ale naše dashboardy ukazujú duplicitné počty a čísla sa menia počas celého dňa.” Počul som variácie tejto sťažnosti od viacerých tímov, ktoré prvýkrát adoptovali ClickHouse. Zmätok je pochopiteľný—názov “ReplacingMergeTree” silne naznačuje, že nahrádza duplicitné riadky. Áno, nahrádza. Eventuálne. Len nie vtedy, kedy by ste čakali.

Prvýkrát som narazil na tento problém na analytickom pipeline spracúvajúcom user eventy. Boli sme si istí výberom ReplacingMergeTree pretože eventy mohli byť preprocesované a chceli sme aby neskoršie verzie “nahradili” skoršie. Keď QA nahlásilo, že naše event counts sú o 30% vyššie než očakávané, najprv som obvinil kvalitu upstream dát. Trvalo mi dlhšie než rád priznám, kým som si uvedomil, že ClickHouse verne ukladal každý event, ktorý sme poslali—len sa ešte nedostal k deduplikovaniu.

Testované na: ClickHouse 24.1, ReplacingMergeTree so 100M riadkami

Pochopenie MergeTree Architektúry

Predtým než sa ponoríme do problému deduplikácie, pochopme ako ClickHouse ukladá dáta. Táto architektúra je fundamentálna pre pochopenie prečo sa ReplacingMergeTree správa tak ako sa správa.

ClickHouse používa dizajn inšpirovaný Log-Structured Merge-Tree (LSM-Tree). Keď vložíte dáta, ClickHouse neaktualizuje existujúce súbory na disku. Namiesto toho vytvára nové nemenné “parts”—adresáre obsahujúce zoradené, komprimované column dáta. Každý INSERT vytvára nový part.

Tento dizajn optimalizuje pre zápisy. ClickHouse dokáže spracovať milióny riadkov za sekundu pretože nikdy nepotrebuje read-modify-write existujúce dáta. Kompromis je, že súvisiace dáta môžu byť roztrúsené cez viaceré parts, a čistenie tejto fragmentácie prebieha asynchrónne cez proces zvaný “merging.”

Počas background merges ClickHouse kombinuje viaceré parts do väčších, aplikujúc špecifickú logiku table engine. Pre ReplacingMergeTree táto merge logika zahŕňa ponechanie len riadku s najvyššou version column (alebo posledným insert časom ak nie je version column špecifikovaný). Kľúčové je že táto deduplikácia prebieha len počas merges, nie počas queries.

-- Vytvor tabuľku s ReplacingMergeTree
CREATE TABLE events (
    event_id UInt64,
    user_id UInt64,
    event_type String,
    updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, event_id);

-- Vlož rovnaký event dvakrát
INSERT INTO events VALUES (1, 100, 'click', '2024-01-01 10:00:00');
INSERT INTO events VALUES (1, 100, 'click_updated', '2024-01-01 10:00:01');

-- Query okamžite
SELECT * FROM events WHERE event_id = 1;

-- Vracia OBA riadky!
┌─event_id─┬─user_id─┬─event_type────┬─updated_at──────────┐
1100 │ click         │ 2024-01-01 10:00:00
1100 │ click_updated │ 2024-01-01 10:00:01
└──────────┴─────────┴───────────────┴─────────────────────┘

Toto správanie prekvapuje vývojárov prichádzajúcich z tradičných databáz kde UPDATE alebo UPSERT okamžite reflektuje v následných queries. V ClickHouse vkladáte nový riadok a “nahradenie” starého riadku nastáva kedykoľvek sa background thready ClickHouse rozhodnú zmergovať parts obsahujúce tie riadky.

Merge Timeline

Dovoľte mi ilustrovať presne čo sa deje keď vložíte duplicitné kľúče:

ReplacingMergeTree timeline:

INSERT event_id=1 (v1) → Part_1 vytvorený
INSERT event_id=1 (v2) → Part_2 vytvorený

Parts na disku:
┌─────────┐  ┌─────────┐
│ Part_1  │  │ Part_2  │
│ v1      │  │ v2      │
└─────────┘  └─────────┘

SELECT * → Číta oba parts → Vracia oba riadky

Background merge (eventuálne):
┌─────────┐  ┌─────────┐     ┌───────────┐
│ Part_1  │ +│ Part_2  │ → │ Part_1_2  │
│ v1      │  │ v2      │     │ len v2   │  ← dedupe tu
└─────────┘  └─────────┘     └───────────┘

Po merge:
SELECT * → Vracia len v2

Timing background merges je nedeterministický. ClickHouse merguje parts na základe rôznych heuristík: počet parts, size ratios, dostupné zdroje a nakonfigurované thresholdy. Part môže byť zmergovaný v priebehu sekúnd od vytvorenia alebo sedieť nezmergovaný hodiny. Počas vysokej ingescie môže merge proces zaostávať, akumulujúc veľa malých parts a veľa duplicitných riadkov.

Táto “eventuálne konzistentná” povaha ReplacingMergeTree ho robí nevhodným pre use cases vyžadujúce okamžitú konzistenciu. Ak queryujete event count hneď po vložení updates, dostanete nafúknuté čísla. Ak queryujete o hodinu neskôr (po prebehnutí merges), možno dostanete presné čísla. Táto nekonzistentnosť je by design, nie bug.

Reálny Dopad

Predstavte si analytický dashboard ukazujúci “aktívnych userov za poslednú hodinu.” Ak váš pipeline preprocesuje eventy (kvôli neskorým príchodom, korekciám alebo retries), každé prepracovanie vytvára nové parts s duplicitnými user eventmi. Váš active user count sa nafúkne duplicate ratiom, ktoré sa mení na základe:

  • Ako často sú eventy preprocesované
  • Ako rýchlo ClickHouse merguje parts
  • Aký je čas dňa (merge speed sa mení so serverovou záťažou)

Videl som dashboardy kde “reálne” číslo bolo 100,000 aktívnych userov, ale zobrazené číslo fluktuovalo medzi 100,000 a 130,000 v závislosti od toho kedy ste sa pozreli. Vysvetľovať stakeholderom že “databáza eventuálne ukáže správne číslo” nie je skvelá konverzácia.

Riešenia

1. FINAL Modifier (Jednoduché ale Pomalé)

FINAL modifier núti ClickHouse aplikovať deduplikačnú logiku v čase query, v podstate vykonávajúc in-memory merge všetkých parts matchujúcich váš query:

-- Vynúť deduplikáciu v čase query
SELECT * FROM events FINAL WHERE event_id = 1;

-- Vracia len poslednú verziu
┌─event_id─┬─user_id─┬─event_type────┬─updated_at──────────┐
1100 │ click_updated │ 2024-01-01 10:00:01
└──────────┴─────────┴───────────────┴─────────────────────┘

-- VAROVANIE: FINAL je POMALÉ
-- Merguje VŠETKY parts v pamäti počas query
-- OK pre malé tabuľky, katastrofa pre veľké

FINAL funguje dobre pre malé result sets alebo low-traffic tabuľky. Ale na tabuľke so 100 miliónmi riadkov a stovkami parts môže FINAL zmeniť 100ms query na 10-sekundový query. Núti ClickHouse čítať všetky potenciálne matchujúce parts, sortovať ich a deduplikovať—všetko synchrónne, všetko v pamäti.

V novších ClickHouse verziách (23.3+) bol FINAL významne optimalizovaný. Setting do_not_merge_across_partitions_select_final môže pomôcť tým, že merguje len v rámci partícií. Ale aj optimalizovaný FINAL je pomalší než alternatívy nižšie.

2. argMax Pattern (Odporúčané)

Najvýkonnejší prístup pre produkčné queries je použitie agregačných funkcií ClickHouse na výber poslednej verzie:

-- Použi argMax pre poslednú verziu
SELECT
    event_id,
    argMax(user_id, updated_at) as user_id,
    argMax(event_type, updated_at) as event_type,
    max(updated_at) as updated_at
FROM events
WHERE event_id = 1
GROUP BY event_id;

-- Toto je paralelizovateľné a omnoho rýchlejšie ako FINAL

Funkcia argMax(column, ordering_column) vracia hodnotu column z riadku ktorý má maximum ordering_column. Grupovaním na primary key a použitím argMax na všetky ostatné columns efektívne dostanete poslednú verziu každého riadku.

Tento prístup je rýchly pretože:

  • GROUP BY môže byť vykonaný paralelne cez shardy a thready
  • Vectorized execution engine ClickHouse spracúva agregácie efektívne
  • Nie je potrebný cross-part merging—každý part sa spracúva nezávisle

Pre tabuľky s veľa columns sa písanie argMax pre každý column stáva únavným. Môžete vytvoriť view na skrytie tejto complexity:

CREATE VIEW events_deduped AS
SELECT
    event_id,
    argMax(user_id, updated_at) as user_id,
    argMax(event_type, updated_at) as event_type,
    argMax(payload, updated_at) as payload,
    max(updated_at) as updated_at
FROM events
GROUP BY event_id;

-- Queries používajú view transparentne
SELECT * FROM events_deduped WHERE user_id = 100;

3. Subquery s Row Number

Pre komplexnejšiu deduplikačnú logiku alebo keď potrebujete prístup ku všetkým columns bez ich vymenovania:

-- Window function prístup
SELECT * FROM (
    SELECT *,
        row_number() OVER (PARTITION BY event_id ORDER BY updated_at DESC) as rn
    FROM events
)
WHERE rn = 1;

Tento prístup je flexibilnejší ale typicky pomalší než argMax pretože window funkcie majú viac overheadu. Použite ho keď potrebujete features ako “daj druhú najnovšiu verziu” alebo komplexnú ranking logiku.

4. Force Merge (Pre Testovanie/Malé Tabuľky)

Môžete explicitne spustiť merge ktorý deduplikuje dáta:

-- Force merge všetkých parts (používaj opatrne!)
OPTIMIZE TABLE events FINAL;

-- Alebo merge špecifickú partíciu
OPTIMIZE TABLE events PARTITION '2024-01-01' FINAL;

-- VAROVANIE: Toto je resource-intenzívne
-- Nepúšťaj na produkcii počas peak hodín

OPTIMIZE TABLE FINAL vynúti synchrónnu deduplikáciu. Kým query beží, ClickHouse zmerguje všetky parts, deduplikuje riadky a prepíše dáta na disk. Je to užitočné pre:

  • Testovanie aby ste overili že deduplikácia funguje správne
  • Batch processing kde vložíte, optimalizujete, potom queryujete
  • Maintenance okná kde chcete znížiť part count

Nikdy nepúšťajte OPTIMIZE TABLE FINAL na veľkých tabuľkách počas produkčných hodín. Je resource-intenzívne, vytvára I/O pressure a môže spomaliť konkurentné queries. Niektoré tímy plánujú nočné alebo týždenné OPTIMIZE behy počas low-traffic periód.

5. Materialized View s AggregatingMergeTree

Pre real-time deduplikačné požiadavky, pre-agregujte pomocou materialized view:

-- Pre-agreguj aby si sa vyhol FINAL
CREATE MATERIALIZED VIEW events_latest
ENGINE = AggregatingMergeTree()
ORDER BY event_id
AS SELECT
    event_id,
    argMaxState(user_id, updated_at) as user_id,
    argMaxState(event_type, updated_at) as event_type,
    maxState(updated_at) as updated_at
FROM events
GROUP BY event_id;

-- Query view (vždy vracia posledné)
SELECT
    event_id,
    argMaxMerge(user_id) as user_id,
    argMaxMerge(event_type) as event_type,
    maxMerge(updated_at) as updated_at
FROM events_latest
GROUP BY event_id;

Tento prístup pushuje deduplikáciu do ingestion pipeline. Každý insert do events automaticky updatuje events_latest s agregovaným stavom. Materialized view tiež používa MergeTree, takže má parts ktoré sa mergujú—ale merge logika AggregatingMergeTree kombinuje aggregate states správne, takže vždy dostanete presné výsledky aj pred úplným merge.

Kompromis je storage a write amplification. Udržiavate dve kópie dát a každý insert spôsobuje dodatočnú prácu na update materialized view.

Výber Správneho MergeTree Engine

ClickHouse ponúka niekoľko MergeTree variantov, každý s rôznou deduplikačnou sémantikou:

| Engine | Deduplikačné Správanie | Najlepšie Pre |
|--------|----------------------|----------|
| MergeTree | Žiadne (drží všetky riadky) | Append-only dáta |
| ReplacingMergeTree | Drží posledný podľa version column | Občasné updates, eventual consistency OK |
| CollapsingMergeTree | Riadky so sign=-1 rušia riadky so sign=1 | Rýchle deletes, changelogy |
| VersionedCollapsingMergeTree | Ako Collapsing ale zvláda out-of-order inserts | Distribuované systémy s reorderingom |
| AggregatingMergeTree | Kombinuje aggregate function states | Pre-agregované rollups, real-time metriky |
| SummingMergeTree | Sčítava numerické columns | Countery, running totals |

Ak je real-time deduplikácia kritická, zvážte či je ReplacingMergeTree správna voľba. Pre event processing kde potrebujete okamžitú konzistenciu, AggregatingMergeTree s argMaxState často funguje lepšie. Pre naozaj mutable dáta s častými updates a deletes možno potrebujete CollapsingMergeTree napriek jeho komplexite.

Kedy Použiť ReplacingMergeTree

✅ Dobré use cases:
- Event sourcing kde chcete históriu + posledné
- Dáta ktoré sa zriedka menia
- Background batch processing s forced merges
- Keď aproximatívne counts sú akceptovateľné počas lagu
- CDC pipelines kde eventual consistency je OK

❌ Zlé use cases:
- Real-time deduplikácia requirements
- Často updatované záznamy
- Keď presné counts záležia okamžite
- OLTP-style upserts
- User-facing dashboardy bez query-time deduplikácie

Lepšie alternatívy:
- CollapsingMergeTree (pre rýchle deletes)
- VersionedCollapsingMergeTree (s verziami)
- AggregatingMergeTree + argMax (pre posledný stav)
- Externá deduplikácia pred insertom

Monitoring Merge Zdravia

Zdravé merge operácie sú esenciálne pre eventuálnu deduplikáciu ReplacingMergeTree. Monitorujte tieto metriky:

-- Skontroluj unmerged parts per tabuľka
SELECT
    table,
    count() as parts,
    sum(rows) as total_rows,
    formatReadableSize(sum(bytes_on_disk)) as size
FROM system.parts
WHERE active AND database = 'default'
GROUP BY table
ORDER BY parts DESC;

-- Odhadni duplicate ratio
SELECT
    count() as total_rows,
    uniq(event_id) as unique_events,
    1 - (unique_events / total_rows) as duplicate_ratio
FROM events;

-- Skontroluj merge aktivitu
SELECT
    table,
    event_type,
    event_time,
    duration_ms,
    rows_read,
    rows_written
FROM system.part_log
WHERE event_type = 'MergeParts'
    AND event_time > now() - INTERVAL 1 HOUR
ORDER BY event_time DESC
LIMIT 20;

Nastavte alerty pre:

  • Part count prekračujúci thresholdy (napr. >300 parts na partíciu)
  • Duplicate ratio prekračujúce akceptovateľné úrovne
  • Merge queue s backlogom (veľa parts čakajúcich na merge)
# Príklad Prometheus alertu
- alert: ClickHouseHighPartCount
  expr: |
    clickhouse_parts_count{active="true"} > 300
  for: 30m
  labels:
    severity: warning
  annotations:
    summary: "ClickHouse tabuľka {{ $labels.table }} má príliš veľa parts"

- alert: ClickHouseHighDuplicateRatio
  expr: |
    (1 - clickhouse_unique_rows / clickhouse_total_rows) > 0.2
  for: 1h
  labels:
    severity: warning
  annotations:
    summary: "Vysoké duplicate ratio v {{ $labels.table }}"

Checklist Najlepších Praktík

## ReplacingMergeTree Použitie

### Dizajn
- [ ] Pochop že merge je async, nie real-time
- [ ] Naplánuj query stratégiu (FINAL vs argMax)
- [ ] Zváž AggregatingMergeTree pre real-time
- [ ] Vyber vhodný version column

### Queries
- [ ] Použi argMax pattern pre production queries
- [ ] Vyhni sa FINAL na veľkých tabuľkách
- [ ] Vytvor views na skrytie deduplikačnej logiky
- [ ] Monitoruj part count a merge lag

### Operations
- [ ] Plánuj OPTIMIZE počas off-peak hodín
- [ ] Monitoruj duplicate ratios
- [ ] Nastav alerty pre vysoké part counts
- [ ] Dimenzuj merge_tree settings primerane

Záver

ReplacingMergeTree je mocný nástroj, ale jeho eventual consistency model zaskočí veľa vývojárov. Názov naznačuje okamžitú náhradu; realita je background deduplikácia ktorá nastane “keď sa merge thread dostane k tomu.”

Pochopenie tohto správania vedie k lepším design rozhodnutiam:

  1. Deduplikácia prebieha počas merge, nie query—plánujte zodpovedajúco
  2. Použite argMax pattern namiesto FINAL pre produkčné queries
  3. FINAL je pomalé—používajte len pre malé result sets alebo testovanie
  4. Zvážte AggregatingMergeTree ak potrebujete real-time presné counts
  5. Monitorujte merge zdravie—ak merges zaostávajú, duplikáty sa akumulujú

Skontrolujte svoje duplicate ratio rýchlym query. Ak vidíte vyššie čísla než očakávané, teraz viete prečo—a ako to opraviť.


Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "ClickHouse ReplacingMergeTree: Ilúzia Deduplikácie". https://www.michal-drozd.com/sk/blog/clickhouse-replacingmergetree-deduplikacia/ (Publikované 13. novembra 2025).