MergeTree w ClickHouse: jak silnik kroi analitykę na granulki i scala części
Siedem lat bólu, trzy stracone produkcje i jedna tabliczka architektoniczna
Kiedy pierwszy raz usłyszałem o MergeTree, pomyślałem: "Kolejny silnik z modną nazwą". Potem na produkcji tabela z 500 mln zakładów zaczęła spowalniać zapytania, które wcześniej latały. Patrzyliśmy w EXPLAIN i widzieliśmy Read 250000 granules. Wtedy jeszcze nie wiedziałem, co to granula.
Okazało się, że stworzyłem tabelę z nieprawidłowym ORDER BY. Każde zapytanie skanowało 80% wszystkich danych, mimo że filtrowało tylko po jednej kolumnie.
MergeTree to nie tylko silnik. To architektura, która określa, jak twoje dane leżą na dysku, jak są kompresowane i co najważniejsze – jak ClickHouse decyduje, które kawałki czytać, a które pominąć. Zrozumienie wnętrzności uratowało mi trzy projekty. Poniżej – mapa, po której chodzę od pięciu lat.
1. Część, granula, marker: matrioszka na dysku
ClickHouse nie przechowuje tabeli w jednym pliku. Tnie dane na parts (części), wewnątrz każdej części na granules (granulki), a orientuje się za pomocą marks (markerów).
Struktura dyskowa tabeli bets:
/var/lib/clickhouse/data/betting/bets/
├── 202401_1_1_0/ # part nr 1 (styczeń 2024)
│ ├── user_id.bin # kolumna user_id (dane binarne)
│ ├── user_id.mrk # markery dla user_id
│ ├── created_at.bin
│ ├── created_at.mrk
│ ├── amount.bin
│ ├── amount.mrk
│ └── ...
├── 202401_2_2_0/ # part nr 2
└── 202402_3_3_0/ # part nr 3 (luty)
Part (część) – minimalna jednostka, którą zarządza MergeTree. Każda część jest tworzona przy wstawianiu, a potem w tle scalana z sąsiednimi.
Granule (granula) – blok danych o rozmiarze index_granularity wierszy (domyślnie 8192). ClickHouse czyta dane całymi granulkami. Nie można odczytać jednego wiersza – tylko całą granulkę.
Mark (marker) – wskaźnik na pozycję granulki w pliku .bin. W .mrk znajduje się przesunięcie: gdzie zaczyna się granula na dysku i jaki ma offset.
Dlaczego to ważne: gdy wykonujesz SELECT amount FROM bets WHERE user_id = 123, ClickHouse na podstawie indeksu rzadkiego (sparse index) określa, w których granulkach może znajdować się ten user_id, i czyta tylko je. Pozostałych granulek nawet nie otwiera.
2. Scalanie części: dlaczego ClickHouse nie psuje się od miliona małych wstawień
Każdy INSERT tworzy nową część na dysku. Jeśli wstawiasz po 100 rekordów 10 000 razy – będziesz miał 10 000 części. To katastrofa: zapytanie będzie musiało otworzyć 10 000 plików.
Jak ClickHouse ratuje sytuację:
Proces w tle merge scala małe części w większe. Na przykład:
- 10 części po 1 GB → 1 część po 10 GB
Parametry, które zmieniam na produkcji:
<merge_tree>
<min_rows_for_wide_part>100000</min_rows_for_wide_part>
<max_bytes_for_merge>100000000000</max_bytes_for_merge> <!-- 100 GB -->
<merge_with_ttl_timeout>3600</merge_with_ttl_timeout>
</merge_tree>
Lifehack: jeśli robisz duże wstawienie (1M+ wierszy), część nie będzie scalana z innymi, dopóki nie pojawi się sąsiednia. ClickHouse przechowuje części w kolejności rosnącej klucza, więc INSERT ... ORDER BY pomaga.
Na czym się sparzyłem: strumieniowaliśmy zakłady przez Kafka po 10-50 rekordów na sekundę. Po tygodniu uzbierało się 300 000 części. Zapytania zaczęły zwalniać, bo każde zapytanie otwierało wszystkie pliki. Rozwiązanie: podnieśliśmy min_rows_for_wide_part do 500k i zwiększyliśmy max_insert_block_size do 1M. Strumień danych musieliśmy buforować w Kafka, ale części skurczyły się do 500.
3. PRIMARY KEY vs ORDER BY: najczęstszy błąd początkujących
W MySQL PRIMARY KEY to unikalny identyfikator. W ClickHouse – nie do końca.
-- Widzę to cały czas
CREATE TABLE bets (
user_id UInt64,
created_at DateTime,
amount Decimal(18,2)
) ENGINE = MergeTree()
PRIMARY KEY (user_id) -- ← błąd
ORDER BY (user_id); -- ← to też błąd
Prawda:
- ORDER BY określa fizyczną kolejność wierszy na dysku. Obowiązkowy.
- PRIMARY KEY – to samo co ORDER BY, jeśli nie jest określony. Ale może być PREFIKSEM ORDER BY.
Poprawnie:
ORDER BY (created_at, user_id) -- najpierw czas, potem użytkownik
PRIMARY KEY (created_at) -- indeks tylko po czasie
Co się dzieje: ClickHouse buduje indeks rzadki (sparse index) na podstawie ORDER BY. PRIMARY KEY mówi tylko, którą część ORDER BY użyć do filtrowania.
Prawdziwy przykład z naszej produkcji:
-- Niepoprawnie (wolno)
ORDER BY (user_id, created_at)
-- Zapytanie: szukanie zakładów z ostatniej godziny. Indeks nie pomaga, skanujemy wszystko.
-- Poprawnie (szybko)
ORDER BY (created_at, user_id)
-- Zapytanie: skaczemy do odpowiedniej daty po indeksie, potem wewnątrz filtrujemy po user_id
4. Indeks rzadki: jak 8192 wierszy zamienia się w jeden wpis w indeksie
ClickHouse NIE buduje indeksu dla każdego wiersza. Bierze granulkę (8192 wierszy) i zapisuje w indeksie:
- minimalną wartość ORDER BY w tej granulce
- maksymalną wartość ORDER BY
To wszystko. Nie B-tree, nie hash table, tylko prosta tablica par min-max.
Jak przyspiesza się zapytanie:
-- Szukamy zakładów z 5 minut
SELECT * FROM bets WHERE created_at BETWEEN '2024-03-15 14:00:00' AND '2024-03-15 14:05:00';
-- Indeks (rzadki) sprawdza każdą granulkę:
-- Granulka 1: min='2024-03-15 13:00:00' max='2024-03-15 14:00:00' → NIE pasuje (max < 14:05?)
-- Granulka 2: min='2024-03-15 14:00:00' max='2024-03-15 15:00:00' → PASUJE (min <= 14:05)
-- Granulka 3: min='2024-03-15 15:00:00' max='2024-03-15 16:00:00' → NIE PASUJE (min > 14:05)
Dlaczego to szybkie: indeks zajmuje (liczba granulek) * 16 bajtów. Dla 1 mld wierszy to ~1.9 mln granulek → 30 MB indeksu. Cały indeks mieści się w pamięci.
5. Partycjonowanie: skaczemy do odpowiedniego miesiąca
PARTITION BY to reguła, według której ClickHouse umieszcza części w różnych katalogach na dysku.
PARTITION BY toYYYYMM(created_at) -- partycje po miesiącach
Na dysku:
/var/lib/clickhouse/data/betting/bets/
├── 202401/ # styczeń 2024
├── 202402/ # luty 2024
└── 202403/ # marzec 2024
Jak to przyspiesza zapytanie:
SELECT * FROM bets WHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';
-- ClickHouse od razu idzie do folderu 202402/, pozostałych partycji nawet nie otwiera
Kiedy partycjonowanie nie pomaga:
- Małe partycje (po dniach przy 100 mln wierszy dziennie → 365 partycji, każda po 300 MB → dużo plików)
- Filtr nie po kluczu partycjonowania
Mój wybór: toYYYYMM() dla 10–100 mln wierszy miesięcznie, toYYYYMMDD() jeśli 1+ mld dziennie (ale wtedy potrzebny klaster).
6. Format .bin i .mrk: jak dane leżą na dysku
Kiedyś zajrzałem do katalogu tabeli i zobaczyłem:
$ ls -la /var/lib/clickhouse/data/betting/bets/202401_1_1_0/
-rw-r----- 1 clickhouse clickhouse 1.2G user_id.bin
-rw-r----- 1 clickhouse clickhouse 12M user_id.mrk
-rw-r----- 1 clickhouse clickhouse 900M created_at.bin
-rw-r----- 1 clickhouse clickhouse 12M created_at.mrk
-rw-r----- 1 clickhouse clickhouse 2.1G amount.bin
-rw-r----- 1 clickhouse clickhouse 12M amount.mrk
- .bin – rzeczywiste dane kolumny, skompresowane LZ4 (lub ZSTD, jeśli skonfigurowano)
- .mrk – markery: pozycja każdej granulki w .bin
Jak to jest czytane:
- Zapytanie chce kolumnę
amountdlauser_id=123 - Indeks rzadki mówi: ten user_id może być w granulkach #45, #46, #47
- ClickHouse otwiera
user_id.mrk, bierze przesunięcie dla granulki #45 - Idzie do
user_id.binpod to przesunięcie, czyta 8192 wartości - Znajduje wiersze z odpowiednim user_id, zapamiętuje numery wierszy
- Po numerach wierszy oblicza pozycje w
amount.mrki czyta tylko potrzebne bajty zamount.bin
Wniosek: fizycznie dane są czytane tylko dla potrzebnych kolumn i tylko dla potrzebnych granulek. Cała reszta to metadane.
7. Prawidłowy ORDER BY na przykładzie tabeli zakładów
Zły ORDER BY (tak robiłem):
CREATE TABLE betting.bets_wrong
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at); -- indeksowanie najpierw po użytkowniku
Problem: 90% zapytań w naszym projekcie to "pokaż zakłady z ostatniej godziny" (filtr po czasie). Indeks nie pomaga, bo user_id zmienia się szybciej niż czas. ClickHouse skanuje wszystkie partycje.
Prawidłowy ORDER BY:
CREATE TABLE betting.bets_correct
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2),
sport LowCardinality(String),
outcome Enum8('win'=1,'loss'=2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id); -- indeksowanie najpierw po czasie
Teraz:
- Zapytanie po zakresie dat: natychmiast skacze do odpowiednich granulek
- Wewnątrz daty można filtrować po user_id
- Dodatkowo można dodać
SECONDARY INDEX(ale to osobna historia)
8. EXPLAIN indexes = 1: sprawdzamy, ile granulek naprawdę jest czytanych
Najbardziej przydatne narzędzie do debugowania:
EXPLAIN indexes = 1
SELECT user_id, sum(amount)
FROM betting.bets
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'
AND user_id = 100500
GROUP BY user_id;
Wynik:
Expression (Projection)
Aggregating
Expression
ReadFromMergeTree (betting.bets)
Indexes:
Partition key: partition_idx (1/3 partitions, 1 read)
Primary key: created_at (42/500 granules, 42 read)
MinMax: created_at (0 skipped, 1 read)
Co widzimy: z 500 granulek w partycji przeczytano tylko 42. Stopień filtracji – 8%. Bez prawidłowego ORDER BY byłoby 500 z 500.
9. Polecenia do zarządzania partycjami
Wyświetlenie wszystkich partycji:
SELECT
partition,
name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'bets' AND active = 1;
Usunięcie starej partycji (szybciej niż DELETE):
ALTER TABLE betting.bets DROP PARTITION '202401';
Czyści dysk natychmiast. DELETE FROM usuwa wiersz po wierszu, potem scala – różnica w godzinach.
Odłączenie partycji (bez usuwania danych):
ALTER TABLE betting.bets DETACH PARTITION '202402';
-- dane przeniesione do detached/
Przywrócenie:
ALTER TABLE betting.bets ATTACH PARTITION '202402';
Kopiowanie partycji do innej tabeli (życiowy przypadek):
ALTER TABLE betting.bets_archive REPLACE PARTITION '202401' FROM betting.bets;
10. OPTIMIZE TABLE – kiedy nie trzeba (a kiedy jednak trzeba)
OPTIMIZE TABLE wymusza ręczne scalanie części.
Zła wiadomość: większość artykułów radzi uruchamiać go okresowo. Dobra wiadomość: w 99% przypadków nie trzeba. ClickHouse sam scala w tle.
Kiedy naprawdę użyłem OPTIMIZE:
- Po wgraniu dużego bloku danych historycznych (100 mln wierszy jednym wstawieniem) – aby pozostałe partycje nie czekały na scalanie zgodnie z harmonogramem
- Przed wykonaniem kopii zapasowej, aby zmniejszyć liczbę plików w tabeli
- Testowanie – aby zobaczyć rzeczywisty rozmiar po kompresji
Jak robić bezpiecznie:
OPTIMIZE TABLE betting.bets PARTITION '202403' FINAL;
FINAL scala wszystkie części w jedną dla tej partycji. Bez FINAL – tylko części, które są już gotowe.
Moja rada: nie ruszaj OPTIMIZE w automatycznych skryptach. Scalenia w tle są dobrze skonfigurowane. Jeśli części się nie scalają – sprawdź max_bytes_to_merge i wolne miejsce na dysku.
Co dalej
MergeTree to serce ClickHouse. Teraz wiesz, jak bije. Następny artykuł – o zaawansowanym indeksowaniu: indeksy skokowe, kolumny zmaterializowane i projekcje.
← Poprzedni: ClickHouse: pełny przewodnik po typach danych dla analityki zakładów (na czym się sparzyłem)
→ Następny: Ładowanie danych do ClickHouse: jak przestałem wstawiać pojedyncze wiersze i przyspieszyłem przyjmowanie 500 razy
— Editorial Team
Brak komentarzy.