Powrót do strony głównej

MergeTree w ClickHouse: granulki, części i indeks rzadki

Głęboki przewodnik techniczny po silniku MergeTree w ClickHouse. Wyjaśniona wewnętrzna struktura: part (część), granule (8192 wierszy), mark (znacznik w .mrk), pliki fizyczne .bin i .mrk. Omówiony proces tła scalania części, dlaczego ORDER BY określa fizyczny porządek i indeks rzadki, a PRIMARY KEY tylko prefiks. Pokazano, jak partycjonowanie (toYYYYMM) odcina całe katalogi, jak czytać EXPLAIN indexes=1, polecenia SHOW/DROP/DETACH/ATTACH PARTITION i kiedy naprawdę potrzebny jest OPTIMIZE TABLE. Przykłady na tabeli stawek z poprawnym i niepoprawnym ORDER BY.

MergeTree: jak ClickHouse przechowuje dane na dysku i przyspiesza zapytania
Advertisement 728x90

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.

Google AdInline article slot

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ę.

Google AdInline article slot

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.

Google AdInline article slot

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:

  1. Zapytanie chce kolumnę amount dla user_id=123
  2. Indeks rzadki mówi: ten user_id może być w granulkach #45, #46, #47
  3. ClickHouse otwiera user_id.mrk, bierze przesunięcie dla granulki #45
  4. Idzie do user_id.bin pod to przesunięcie, czyta 8192 wartości
  5. Znajduje wiersze z odpowiednim user_id, zapamiętuje numery wierszy
  6. Po numerach wierszy oblicza pozycje w amount.mrk i czyta tylko potrzebne bajty z amount.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:
Następny: Ładowanie danych do ClickHouse: jak przestałem wstawiać pojedyncze wiersze i przyspieszyłem przyjmowanie 500 razy

— Editorial Team

Advertisement 728x90

Czytaj dalej