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.
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ą?
- 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 sortowania — ORDER 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.
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 kolumnaupdated_atbędzie używana jako wersja. Podczas merge z dwóch wierszy z tym samymORDER BYzostanie ten, który ma większeupdated_at(nowszy). Jeśliupdated_atsą 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 zuser_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śćamountz wiersza o największymupdated_at. Jeśli mamy duplikaty z różnymiupdated_at(i różnymiamount— 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żdyINSERTdobets_rawnatychmiast (prawie) wyzwala przeliczenie wbets_cleanprzezbets_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_cleanmoże zawierać tymczasowe duplikaty, dopókibets_rawsię nie scali. Dla idealnej czystości trzeba albo użyćFINALprzy odczycie zbets_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:
Jak optymalizować scalenia — ustawienia
merge_with_ttl_timeout,number_of_free_entries_in_pool_to_lower_max_size_of_merge(brzmi strasznie, ale przydatne).Deduplikacja na poziomie INSERT — silnik
ReplicatedReplacingMergeTreez ZooKeeper. To inny poziom: duplikaty są odcinane od razu przy wstawianiu, ale kosztem opóźnień i złożoności.Alternatywy:
VersionedCollapsingMergeTree— hybryda, która umie wersjonowanie i scalanie jednocześnie.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: Konfiguracja ClickHouse: jak skonfigurowałem prod i nie postrzeliłem się w stopę
→ Następny: SummingMergeTree i AggregatingMergeTree: inkrementalna agregacja bez bólu
— Editorial Team
Brak komentarzy.