Powrót do strony głównej

ReplacingMergeTree w ClickHouse: kompletny przewodnik

Artykuł szczegółowo omawia silnik ReplacingMergeTree w ClickHouse: po co jest potrzebny do walki z duplikatami przy zawodnej dostawie, jak działa scalanie według ORDER BY, rola wersjonowania i modyfikatora FINAL. Rozważane są typowe pułapki, porównanie z CollapsingMergeTree i wzorzec widoków zmaterializowanych do ominięcia FINAL.

ReplacingMergeTree: Deduplikacja danych w ClickHouse
Advertisement 728x90

ReplacingMergeTree: Jak pokonać duplikaty w ClickHouse bez bólu

1. Po co ReplacingMergeTree — problem duplikatów w realnym świecie

Wyobraź sobie, że tworzysz kasyno online. Gracz klika przycisk „Postaw zakład” — 1000 rubli na czarne. W tym momencie serwer przetwarzający żądanie nagle pada (przegrzanie, awaria sieci, kto wie). Klient nie otrzymał odpowiedzi i myśli: „Zakład nie poszedł”. Gracz klika ponownie. Serwer się ocknął, przyjął oba żądania. W bazie danych są dwa identyczne zakłady. Gracz wściekły: pobrano 2000 rubli zamiast 1000.

To klasyczny problem idempotentności (z łac. idem — ten sam, potens — zdolny). Operacja jest idempotentna, jeśli jej wielokrotne wykonanie daje ten sam wynik co jednorazowe. W świecie baz danych potrzebujemy mechanizmu, który sam rozwiąże: „Ten zakład już widziałem, drugą wersję zignoruję”.

W ClickHouse służy do tego ReplacingMergeTree. To silnik tabeli (Engine), który automatycznie usuwa duplikaty podczas scalania (merge) fragmentów danych. Ale od razu ostrzegę: nie jest magiczny — ma cechy, o których porozmawiamy.

Google AdInline article slot

Analogia z życia: ReplacingMergeTree to jak sekretarz prowadzący dziennik spotkań. Przychodzą do ciebie ludzie z wnioskami. Czasem ten sam klient przynosi dwa identyczne podania (np. spóźnił się na pociąg i prosi o zwrot biletu, potem dzwoni ponownie z tym samym). Sekretarz nie wyrzuca duplikatów na wejściu — po prostu wkłada wszystkie papiery do teczki. Raz dziennie przegląda teczkę i zostawia tylko ostatnie podanie od każdego klienta. Jeśli ktoś zapyta „ile wniosków od Kowalskiego?” przed przeglądnięciem — zobaczy dwa. Po — jeden.

2. Jak działa ReplacingMergeTree — krok po kroku

Duplikaty powstają z powodu zawodnej dostawy

ClickHouse początkowo projektowano do analityki dużych wolumenów danych, gdzie rzadki brak lub duplikat nie szkodzi. Ale potem ludzie zaczęli go używać do danych krytycznych — i się sparzyli. ReplacingMergeTree jest odpowiedzią na ten ból.

Dlaczego duplikaty w ogóle powstają?

Google AdInline article slot
  • Klient wysłał dane, nie otrzymał potwierdzenia (timeout), wysłał ponownie.
  • System kolejek (Kafka, RabbitMQ) dał gwarancję at-least-once — co najmniej jedna dostawa, możliwe powtórki.
  • Błąd w procesie ETL (Extract, Transform, Load — ekstrakcja, transformacja, ładowanie) — uruchomiono potok dwukrotnie.

Mechanizm: scalanie według klucza ORDER BY

Podczas tworzenia tabeli z ReplacingMergeTree musisz podać klucz sortowaniaORDER BY (kolumna1, kolumna2). To nie klucz główny w klasycznym rozumieniu (jak w PostgreSQL), ale sposób fizycznego uporządkowania danych na dysku. ClickHouse przechowuje dane w sortach — fragmentach posortowanych według tego klucza.

Gdy dwa sorty scalają się w jeden (proces tła merging), ReplacingMergeTree przegląda wiersze z tą samą wartością klucza ORDER BY i zostawia tylko jeden. Który? Domyślnie — ostatni według czasu wstawienia. Ale można podać kolumnę numeryczną version (wersja), wtedy zostanie wiersz z maksymalną wartością version.

Analogia z Gitem: ReplacingMergeTree podczas merge zachowuje się jak Git, gdy rozwiązujesz konflikt: z dwóch zmian tego samego pliku zostawiana jest ostatnia (jeśli nie określisz jawnie strategii). Tylko tutaj plik to wiersz w tabeli, a klucz to ORDER BY.

Google AdInline article slot

Wersjonowanie: jak ReplacingMergeTree(version) zmienia zasady

Składnia: ReplacingMergeTree(version_column). Jeśli version_column jest liczbą całkowitą (UInt* lub DateTime), to zostanie wiersz z największą wartością. Daje to ręczne sterowanie: możesz jawnie wskazać, która wersja „zwycięży”.

Przykład: wysyłamy zakłady z updated_at = now(). Przy ponownym wysłaniu updated_at będzie nieco większe. Merge zostawi nowszy. Jeśli nie podasz version, ClickHouse wybiera ostatni z przychodzących — to może nie być najnowszy według logiki biznesowej, a po prostu ostatni fizyczny wstawiony. Różnica jest ważna.

3. CREATE TABLE z ReplacingMergeTree — analiza szczegółowa

-- Tworzymy tabelę dla zakładów z deduplikacją
CREATE TABLE bets_dedup
(
    user_id    UInt64,           -- ID gracza (do kogo należy zakład)
    bet_id     String,           -- Unikalny ID zakładu (generowany po stronie klienta)
    amount     Decimal(10,2),    -- Kwota w rublach
    created_at DateTime,         -- Czas utworzenia zakładu
    updated_at DateTime          -- Czas ostatniej aktualizacji (dla wersji)
)
ENGINE = ReplacingMergeTree(updated_at)   -- Silnik z wersją według updated_at
ORDER BY (user_id, bet_id)                -- Klucz deduplikacji: (user_id, bet_id)

Co tu się dzieje wiersz po wierszu:

  • ENGINE = ReplacingMergeTree(updated_at) — określamy, że to ReplacingMergeTree, a kolumna updated_at będzie używana jako wersja. Podczas merge z dwóch wierszy z tym samym ORDER BY zostanie ten, który ma większe updated_at (nowszy). Jeśli updated_at są równe — zostanie ostatni fizycznie wstawiony (ale lepiej na tym nie polegać).

  • ORDER BY (user_id, bet_id) — najważniejszy parametr! To ten zestaw kolumn określa, co jest duplikatem. Dwa wiersze są uznawane za duplikaty, jeśli mają zgodne wartości wszystkich kolumn z ORDER BY. Tutaj: zakład od użytkownika user_id z identyfikatorem bet_id jest unikalny. Jeśli przyjdą dwa wiersze z user_id=123, bet_id='abc-456' — scalą się w jeden.

Dlaczego ORDER BY, a nie PRIMARY KEY? W ClickHouse PRIMARY KEY nie musi być unikalny. To wskazówka dla indeksu, a ORDER BY to fizyczny porządek na dysku. ReplacingMergeTree opiera się właśnie na ORDER BY, nawet jeśli PRIMARY KEY jest krótszy. Jeśli nie podasz PRIMARY KEY, jest on zgodny z ORDER BY.

Co będzie, jeśli ORDER BY jest zbyt szeroki? Na przykład, jeśli dodasz tam amount. Wtedy dwa zakłady z różnymi kwotami (nawet identyczne pod względem user_id, bet_id) nie będą uznane za duplikaty — oba pozostaną. Deduplikacja nie zadziała. Pułapka nr 1 (wrócimy do niej na końcu).

4. Dlaczego SELECT może zwrócić duplikaty przed merge — i jak z tym żyć

Główny niuans: ReplacingMergeTree usuwa duplikaty tylko w momencie merge fragmentów danych. To proces tła, który nie zachodzi natychmiast. Między wstawieniem duplikatów a ich fizycznym usunięciem może minąć od kilku sekund do kilku godzin (zależy od ustawień i obciążenia).

Co to oznacza w praktyce?

Wstawmy dwa duplikaty:

-- Pierwsze wstawienie
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now());

-- Po 5 sekundach — drugie (serwer nie otrzymał potwierdzenia i wysłał ponownie)
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now() + interval 5 second);

Teraz wykonajmy zwykły SELECT * FROM bets_dedup WHERE user_id = 123. Co zobaczymy? Dwa wiersze. Ponieważ merge jeszcze nie nastąpił. Dane leżą w różnych fragmentach (parts). Każdy fragment jest posortowany według ORDER BY wewnątrz siebie, ale duplikaty mogą być w różnych fragmentach.

Jak uzyskać gwarantowany jeden wiersz? Użyć FINAL:

SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

FINAL zmusza ClickHouse do na bieżąco wykonania scalenia wszystkich fragmentów dla tego zapytania, stosując logikę ReplacingMergeTree. Otrzymasz jeden wiersz — z maksymalnym updated_at (lub ostatnim według czasu wstawienia, jeśli bez wersji).

Dlaczego FINAL jest wolny? ClickHouse czyta wszystkie fragmenty tabeli, sortuje je w pamięci według klucza ORDER BY, usuwa duplikaty i dopiero potem zwraca wynik. Na dużych tabelach (miliardy wierszy) może to zająć sekundy lub minuty. Optymalizator nie może efektywnie używać indeksów — trzeba przebrać wiele danych.

Rada: Nie używaj FINAL w czasie rzeczywistym na dużych tabelach. Używaj go do:

  • Punktowych zapytań pojedynczego user_id (indeks i tak pomoże).
  • Zadań tła, gdzie czas nie jest krytyczny (nocne raporty).
  • Małych tabel (do milionów wierszy).

Dla obciążeń produkcyjnych istnieje lepszy wzorzec — widok zmaterializowany bez FINAL.

5. Wydajność FINAL — kiedy akceptowalna, kiedy nie

Kiedy FINAL jest OK:

  • Tabela jest mała (do 10–20 mln wierszy na serwer).
  • Zapytujesz jednego użytkownika po indeksie (WHERE user_id = konkretny).
  • Masz agregację tła raz na godzinę i 10 sekund oczekiwania nie szkodzi.
  • Eksport danych raz dziennie do raportu.

Kiedy FINAL jest zabójcą:

  • Tabela >100 mln wierszy.
  • Zapytanie bez filtrowania (SELECT * FROM table FINAL) — ClickHouse przeczyta wszystko.
  • Scenariusz wysokiego obciążenia OLTP (dziesiątki zapytań na sekundę z FINAL).
  • Częste aktualizacje tych samych kluczy — fragmentów jest dużo, FINAL czyta je wszystkie.

Analogia: SELECT ... FINAL jak ręczne przeglądanie wszystkich papierów w archiwum, aby znaleźć ostatnią wersję dokumentu, zamiast zajrzeć do specjalnego „dziennika aktualnych wersji”. Działa, ale nie dla każdego zapytania klienta.

Jak sprawdzić, czy zapytanie używa FINAL?

W ClickHouse jest polecenie EXPLAIN:

EXPLAIN SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

Szukaj w planie ReadFromMergeTree z flagą final. Jeśli widzisz — zapytanie uczciwie przechodzi przez fragmenty.

6. Wzorzec: agregacja tła bez FINAL przez widok zmaterializowany

To mój ulubiony sposób na ominięcie FINAL. Pomysł: niech ReplacingMergeTree żyje własnym życiem, duplikaty stopniowo znikają w tle. A do odczytu tworzymy widok zmaterializowany (Materialized View), który okresowo przebudowuje się i zawiera już „czyste” dane bez duplikatów.

Jak to wygląda:

-- 1. Tabela główna — brudna, z duplikatami
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. Tabela docelowa — czysta, bez duplikatów
CREATE TABLE bets_clean
(
    user_id UInt64,
    bet_id String,
    amount Decimal(10,2),
    created_at DateTime,
    updated_at DateTime
)
ENGINE = MergeTree()                    -- Zwykły MergeTree bez deduplikacji
ORDER BY (user_id, bet_id);

-- 3. Widok zmaterializowany — przenosi dane przy wstawianiu
CREATE MATERIALIZED VIEW bets_mv TO bets_clean AS
SELECT
    user_id,
    argMax(amount, updated_at) AS amount,      -- bierzemy amount z wiersza z maksymalnym 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;   -- Grupujemy według klucza deduplikacji

Omówienie kluczowych punktów:

  • argMax(amount, updated_at) — funkcja agregująca, która zwraca wartość amount z wiersza o największym updated_at. Jeśli mamy duplikaty z różnymi updated_at (i różnymi amount — np. kwota zakładu się zmieniła), to zostanie najnowsza kwota. To odpowiednik ręcznego zarządzania wersjami.

  • GROUP BY user_id, bet_id — tutaj jawnie mówimy: „uznaj za duplikat kombinację użytkownik+identyfikator zakładu”. Teraz nie trzeba czekać na merge — każdy INSERT do bets_raw natychmiast (prawie) wyzwala przeliczenie w bets_clean przez bets_mv.

  • Ważne ograniczenie: Widok zmaterializowany w ClickHouse przetwarza dane porcjami — każde wstawienie osobno. Jeśli w jednym wstawieniu są dwa duplikaty (user_id, bet_id) — w ramach porcji zostaną scalone. Jeśli duplikaty przyszły w różnych wstawieniach — bets_clean może zawierać tymczasowe duplikaty, dopóki bets_raw się nie scali. Dla idealnej czystości trzeba albo użyć FINAL przy odczycie z bets_raw, albo okresowo wykonać OPTIMIZE TABLE bets_raw (wymuszone scalenie).

Analogia: To jakbyś miał brudnopis (bets_raw), gdzie składasz wszystkie poprawki, i sekretarza, który co 5 minut przepisuje czystopis (bets_clean) bez błędów. Czytelnicy patrzą tylko na czystopis — szybko i bez duplikatów.

7. ReplacingMergeTree(version) z monotonicznie rosnącym version — semantyka aktualizacji

Zwykły ReplacingMergeTree po prostu zostawia „ostatni przychodzący” wiersz. To źle, jeśli stare dane mogą przyjść później niż nowe (np. z powodu opóźnienia sieci). Rozwiązanie: użyj kolumny version, która monotonicznie rośnie (np. timestamp lub ID sekwencji).

Przykład: tabela balansu gracza z historią doładowań

CREATE TABLE player_balance
(
    user_id        UInt64,
    transaction_id String,        -- Unikalny ID transakcji (UUID)
    amount         Int64,         -- Zmiana balansu (może być ujemna)
    balance_after  Int64,         -- Balans po transakcji
    event_time     DateTime,      -- Czas zdarzenia na kliencie
    ingestion_time DateTime       -- Czas wstawienia do ClickHouse (wersja)
)
ENGINE = ReplacingMergeTree(ingestion_time)   -- Wersja = czas wstawienia
ORDER BY (user_id, transaction_id);

Teraz nawet jeśli transakcja tx-001 przyszła dwukrotnie, ale z różnymi ingestion_time, zostanie ta wstawiona później (z większym ingestion_time). Chroni to przed „opóźnionymi duplikatami” — gdy pierwsze wstawienie było o 12:00, drugie o 12:05 (powtórka), ale z powodu awarii sieci drugie przyszło na serwer wcześniej niż pierwsze. Bez wersji zostałby wcześniejszy (według czasu wstawienia) — a to mogło być nieprawidłowe.

Co znaczy „monotonicznie rosnący”? Przy każdym nowym wstawieniu wartość ingestion_time powinna być większa lub równa poprzednim. Używaj now() (bieżący czas na serwerze ClickHouse) lub atomowego licznika (np. z ZooKeeper). Nie można polegać na czasie z klienta — zegary mogą skakać.

8. Pełny przykład: deduplikacja doładowań balansu według transaction_id

Złóżmy wszystko razem. Mamy mikrousługę, która przyjmuje doładowania balansu od systemu płatności. System płatności wysyła webhooki (wywołania HTTP) — czasem duplikuje.

-- Krok 1: Tworzymy tabelę dla surowych zdarzeń
CREATE TABLE balance_events
(
    user_id        UInt64,
    transaction_id String,        -- Unikalny ID z systemu płatności
    amount         Int64,         -- +1000 rub
    event_time     DateTime,      -- Czas obciążenia pieniędzy u użytkownika
    inserted_at    DateTime DEFAULT now()  -- Automatycznie ustawiane przy wstawieniu
)
ENGINE = ReplacingMergeTree(inserted_at)
ORDER BY (user_id, transaction_id);   -- Deduplikacja według pary (użytkownik, transakcja)

-- Krok 2: Wstawiamy dane (załóżmy, że przyszedł duplikat)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Po minucie przylatuje duplikat (inserted_at ustawi się automatycznie jako now() + 60 sek)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Krok 3: Odczyt bez FINAL — zobaczymy 2 wiersze (ale tylko jeśli jeszcze się nie scaliły)
SELECT * FROM balance_events WHERE user_id = 1;
-- Wynik: dwa wiersze z tymi samymi user_id, transaction_id, amount

-- Krok 4: Odczyt z FINAL — widzimy jeden wiersz (z maksymalnym inserted_at)
SELECT * FROM balance_events FINAL WHERE user_id = 1;
-- Wynik: jeden wiersz

Dlaczego samo transaction_id w ORDER BY nie wystarczy? Ponieważ dwóch różnych użytkowników może mieć ten sam transaction_id (np. każdy system płatności ma swój licznik). Dodajemy user_id — gwarantujemy unikalność w obrębie użytkownika. Jeśli system generuje globalne UUID (550e8400-e29b-41d4-a716-446655440000) — można ORDER BY transaction_id, jeden UUID wystarczy.

9. Porównanie z CollapsingMergeTree

CollapsingMergeTree — inny silnik do walki ze zmianami. Przechowuje pary „plus” i „minus” i scala je podczas merge.

Kluczowe różnice:

Cecha ReplacingMergeTree CollapsingMergeTree
Mechanizm Zostawia jeden wiersz z duplikatów Scala pary (+1 i -1)
Do czego służy Deduplikacja wstawień Aktualizacja agregatów (np. koszyk produktów)
Czy potrzebna wersja Opcjonalnie (kolumna version) Wymagana flaga Sign (+1/-1)
Czy można przechowywać historię Tak, wszystkie wersje do merge Nie, pary są niszczone
FINAL do odczytu Tak, bez niego widać duplikaty Tak, bez niego widać niescalone pary

Kiedy wybrać ReplacingMergeTree:

  • Potrzebujesz po prostu usunąć duplikaty wierszy.
  • Masz naturalny klucz do deduplikacji (ID transakcji).
  • Dane zmieniają się rzadko (głównie wstawienia).

Kiedy wybrać CollapsingMergeTree:

  • Często aktualizujesz zagregowaną metrykę (np. „liczba produktów w koszyku”).
  • Potrzebujesz przechowywać tylko wynik, a nie historię zmian.

Przykład dla CollapsingMergeTree:

CREATE TABLE cart_items
(
    user_id UInt64,
    product_id UInt64,
    quantity Int16,
    sign Int8  -- +1 (dodaj), -1 (usuń)
) ENGINE = CollapsingMergeTree(sign)
ORDER BY (user_id, product_id);

Z ReplacingMergeTree po prostu nadpisywałbyś wiersz z quantity nową wersją — ale wtedy straciłbyś historię zmian. CollapsingMergeTree pozwala obliczyć wynik (SUM(quantity * sign)) nawet bez FINAL.

10. Typowe pułapki — i jak ich unikać

Pułapka nr 1: ORDER BY nie obejmuje wszystkich unikalnych pól

-- ŹLE: używamy tylko user_id
CREATE TABLE bets_bad ENGINE = ReplacingMergeTree ORDER BY user_id;

-- Wstawiliśmy dwa zakłady tego samego użytkownika z różnymi bet_id
INSERT INTO bets_bad VALUES (1, 'bet_001', 100);
INSERT INTO bets_bad VALUES (1, 'bet_002', 200);

-- Podczas merge SCALĄ SIĘ w jeden wiersz — ponieważ ORDER BY (user_id) jest taki sam!
-- Straciliśmy zakład bet_002.

Poprawnie: Dołącz do ORDER BY wszystkie kolumny, które czynią wiersz unikalnym — zazwyczaj jest to identyfikator zastępczy (transaction_id) lub kombinacja (user_id, bet_id).

Pułapka nr 2: Naiwna nadzieja na natychmiastową deduplikację

Nowicjusze piszą INSERT z duplikatem i od razu SELECT bez FINAL — widzą duplikaty. Rozczarowują się ClickHouse. Pamiętaj: deduplikacja jest asynchroniczna. Jeśli potrzebujesz natychmiastowej spójności — użyj FINAL lub wzorca z widokiem zmaterializowanym.

Pułapka nr 3: Użycie wersji, która nie jest monotoniczna

-- ŹLE: wersja to czas z klienta
CREATE TABLE events ENGINE = ReplacingMergeTree(client_time) ORDER BY (id);

-- U klienta zegar się spóźnia, wysyła starą wersję po nowej
-- Podczas merge zostanie nieprawidłowy (stary) wiersz

Rozwiązanie: Użyj now() po stronie ClickHouse lub sprzętowego licznika.

Pułapka nr 4: Optymizm co do FINAL na dużych danych

Miałem przypadek: programista włączył FINAL we wszystkich raportach dla tabeli z 2 miliardami wierszy. Zapytania zaczęły przekraczać limit czasu po 300 sekundach. Trzeba było przepisać na agregację z GROUP BY i argMax.

Złota zasada: Jeśli czytasz więcej niż 10% tabeli przez FINAL — robisz coś źle. Użyj widoków zmaterializowanych lub przemyśl architekturę.

Pułapka nr 5: ReplacingMergeTree bez ORDER BY

ClickHouse nie pozwoli utworzyć tabeli bez ORDER BY. Ale można podać ORDER BY tuple() (pusta krotka). Wtedy wszystkie wiersze w tabeli są uznawane za duplikaty — zostanie jeden jedyny wiersz po pierwszym merge. Prawie nigdy nie jest potrzebne.

Co dalej — linki do powiązanych artykułów

Teraz, gdy już rozumiesz ReplacingMergeTree, kolejne tematy do zgłębienia:

  1. Jak optymalizować scalenia — ustawienia merge_with_ttl_timeout, number_of_free_entries_in_pool_to_lower_max_size_of_merge (brzmi strasznie, ale przydatne).

  2. Deduplikacja na poziomie INSERT — silnik ReplicatedReplacingMergeTree z ZooKeeper. To inny poziom: duplikaty są odcinane od razu przy wstawianiu, ale kosztem opóźnień i złożoności.

  3. Alternatywy: VersionedCollapsingMergeTree — hybryda, która umie wersjonowanie i scalanie jednocześnie.

  4. Widoki zmaterializowane w szczegółach — jak budować wielopoziomowe agregacje, aby całkowicie obejść się bez FINAL.

I na koniec: ReplacingMergeTree to potężne narzędzie, ale nie chodzi o „usuń duplikaty natychmiast”. Chodzi o „dane w końcu staną się czyste, a ty na razie pracuj z tym”. Jeśli potrzebujesz ścisłej unikalności (jak PRIMARY KEY w PostgreSQL) — ClickHouse nie jest najlepszym wyborem. Ale dla 99% zadań analitycznych z powtarzającymi się wstawieniami — to ratunek.


Poprzedni:
Następny: SummingMergeTree i AggregatingMergeTree: inkrementalna agregacja bez bólu

— Editorial Team

Advertisement 728x90

Czytaj dalej