Powrót do strony głównej

Typy danych ClickHouse: pełny przewodnik dla analityki

Szczegółowy przewodnik wszystkich typów danych ClickHouse z praktycznymi przykładami z analityki gier hazardowych. Omówiono Integer (UInt8–UInt256), Float32/64, Decimal dla finansów, String vs FixedString vs LowCardinality, DateTime64 dla zakładów na żywo, UUID, Array do przechowywania historii kursów, Nullable (i dlaczego lepiej go unikać), Enum dla statusów, IPv4/IPv6 do detekcji oszustw. Podano pełny produkcyjny schemat tabeli zakładów z wyjaśnieniem każdego wyboru i typowymi błędami.

ClickHouse: typy danych, które uratują Twój budżet dyskowy
Advertisement 728x90

ClickHouse: pełny przewodnik po typach danych dla analityki zakładów (na czym się sparzyłem)

Jeden bajt, który kosztował mnie 500 GB miejsca na dysku

Kiedy zaczynałem pracę z ClickHouse, używałem String do wszystkiego: user_id, event_time, kwota zakładu. Po miesiącu tabela z 2 miliardami wierszy ważyła 4 terabajty. Kolega spojrzał na schemat i powiedział: "Po co przechowujesz liczbę jako string?" Okazało się, że String dla user_id zajmuje 8 razy więcej miejsca niż UInt64. Przerobiłem – i tabela skurczyła się do 800 GB.

ClickHouse oferuje dziesiątki typów danych. Używanie właściwego typu to nie oszczędność gigabajtów, ale szybkość zapytań (mniej danych do odczytu z dysku) i stabilność (Decimal zamiast Float nie sprawi niespodzianek z zaokrąglaniem).

Poniżej – wszystko, co wyniosłem z rzeczywistych projektów (analityka bukmacherska, wykrywanie fraudów, LTV). Na końcu – gotowy schemat dla platformy zakładów.

Google AdInline article slot

1. Typy całkowite: liczymy użytkowników i zakłady

ClickHouse obsługuje całkowite ze znakiem (Int) i bez znaku (UInt) od 8 do 256 bitów.

Typ Zakres Rozmiar Kiedy używam
UInt8 0..255 1 bajt Statusy (0/1), kody błędów
UInt16 0..65535 2 bajty Numery portów, małe liczniki
UInt32 0..4,2 mld 4 bajty ID krajów, typy zdarzeń
UInt64 0..18 trylionów 8 bajtów user_id, event_id, kwoty w groszach
Int128/256 ogromne 16/32 bajty Hash kryptograficzne, bardzo duże liczniki

Praktyka z produkcji:

user_id UInt64,          -- 8 mld użytkowników nam wystarczy
age UInt8,               -- nikt nie żyje dłużej niż 255 lat
country_code UInt16,     -- na świecie jest 197 krajów, ale UInt16 ładniej
is_fraud UInt8,          -- 0 lub 1, po co więcej?

Częsty błąd: używanie UInt64 do wszystkiego. Jeśli pole przyjmuje tylko wartości 0 lub 1 (flaga), UInt8 jest 8 razy bardziej kompaktowy. Przy miliardzie wierszy to 8 GB vs 1 GB.

Google AdInline article slot

Na czym się sparzyłem: przechowywałem timestamp jako UInt64 (Unix time). Działa, ale traci się możliwość używania funkcji daty toDate(), toHour() itd. Używaj DateTime.

2. Float32/Float64: portfel czy dziura?

odds Float64,            -- kurs, może być 2.5, 1.85, 100.0
probability Float32,     -- procenty 0.1..1.0, dokładność 32 bitów wystarczy

Dlaczego Float są niebezpieczne dla finansów:

SELECT 0.1 + 0.2 AS float_sum;
-- Wynik: 0.30000000000000004 (klasyka IEEE 754)

Wyobraź sobie, że masz 1 mln zakładów po 0.01 centa. Błąd zaokrąglenia zamieni się w prawdziwe pieniądze. Do kwot zakładów i wypłat używaj Decimal.

Google AdInline article slot

Kiedy Float jest OK: kursy (2.15, 1.85), prawdopodobieństwa, procenty, metryki uczenia maszynowego.

3. Decimal(P, S): pieniądze lubią precyzję

bet_amount Decimal(18, 2),   -- do 10^16 złotych, 2 miejsca po przecinku
payout Decimal(20, 2),       -- wypłata może być większa niż zakład
balance Decimal(32, 2)       -- saldo gracza przez cały czas
  • P (precision) – całkowita liczba cyfr (do 38)
  • S (scale) – ile po przecinku

Zasada, którą wypracowałem: dla złotych i dolarów – Decimal(18,2) wystarczy z zapasem (biliony). Dla kryptowalut – Decimal(38,8).

Operacje na Decimal:

SELECT 
    bet_amount * odds AS potential_payout,  -- Decimal * Float64 → Decimal
    bet_amount + 0.01 AS rounded_up         -- działa, ale bądź ostrożny
FROM bets;

Na czym się sparzyłem: ClickHouse nie umie Decimal * Decimal z różną skalą – sprowadza do większej. Mieliśmy grosze na 4 miejscu, które nigdy się nie zaokrąglały. Fix: jawnie konwertuj przez toDecimal32().

4. String vs FixedString vs LowCardinality(String)

String – do wszystkiego, co długie

session_id String,           -- UUID bez myślników, różna długość
user_agent String,           -- długie stringi, unikalne wartości
raw_json String              -- logi w JSON

FixedString(N) – dla stałej długości (rzadko potrzebne)

country_code FixedString(2), -- 'PL', 'US', 'DE' dokładnie 2 bajty
md5_hash FixedString(32)     -- zawsze 32 znaki

Prawie nigdy nie używam: jeśli wstawisz krótszy string, ClickHouse dopełni go zerami, a to niespodzianki przy porównaniach.

LowCardinality(String) – magia dla powtarzających się wartości

sport LowCardinality(String),      -- 'football', 'basketball', 'tennis' (powtarzają się)
device LowCardinality(String),     -- 'ios', 'android', 'web' (10-20 unikalnych)
outcome LowCardinality(String)     -- 'win', 'loss', 'void'

Jak to działa: ClickHouse buduje słownik unikalnych wartości i przechowuje tylko indeksy. Dla kolumny z 10 unikalnymi wartościami oszczędność – 100x.

Przykład z praktyki: w tabeli zakładów pole sport powtarzało się miliardy razy. Po zamianie String na LowCardinality(String) rozmiar kolumny zmniejszył się z 40 GB do 400 MB.

Kiedy nie używać: jeśli liczba unikalnych wartości > 10000 (np. user_agent). Słownik rośnie i zaczynają się spowolnienia.

5. DateTime vs DateTime64 vs Date: czas to pieniądz

Typ Dokładność Rozmiar Kiedy używać
Date dzień 2 bajty partycjonowanie, raporty dzienne
Date32 dzień (do 2106 roku) 4 bajty jeśli potrzebny rok > 2149
DateTime sekunda 4 bajty większość zdarzeń
DateTime64(3) milisekunda 8 bajtów zakłady na żywo, kolejność zdarzeń
DateTime64(6) mikrosekunda 8 bajtów logi, metryki

Czego używam w projektach produkcyjnych:

event_time DateTime64(3),      -- milisekundy do analityki na żywo
registration_date Date,        -- partycjonowanie po dniach
last_update DateTime           -- dokładność sekundowa wystarczy

Częsty błąd: przechowywanie czasu jako Unix timestamp (UInt64). Traci się wszystkie funkcje daty/czasu:

-- Tak nie można:
SELECT toHour(event_time_uint) ... -- błąd

-- Trzeba tak:
SELECT toHour(toDateTime(event_time_uint)) ... -- dodatkowa konwersja

Na czym się sparzyłem: używałem DateTime dla zakładów na żywo. Przyszedł czas – 10 zdarzeń w jednej sekundzie nie do odróżnienia. Przeszedłem na DateTime64(3) – i porządek wrócił.

6. UUID: kiedy standard ważniejszy niż szybkość

session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()

UUID zajmuje 16 bajtów (jak dwa UInt64). Porównanie wolniejsze niż z liczbami.

Kiedy i tak używam: trzeba generować ID po stronie klienta bez odwoływania się do bazy, integracja z systemami zewnętrznymi, systemy rozproszone bez jednego generatora.

Alternatywa: UInt128 jako dwie 64-bitowe liczby, ale wtedy traci się funkcje typu toUUID().

7. Array(T): przechowywanie list bez normalizacji

tags Array(String),                     -- ['football', 'live', 'prematch']
coeff_history Array(Float64),           -- [1.5, 1.8, 2.1] zmiana kursu
bet_bundle Array(UInt64)                -- ID zakładów w kuponie

Gdzie się przydało: przechowujemy historię zmiany kursu dla jednego zdarzenia w tablicy. W relacyjnej bazie trzeba by zrobić osobną tabelę. ClickHouse świetnie działa z arrayMap, arrayFilter, arrayJoin.

Zapytanie z życia: znajdź zdarzenia, gdzie kurs spadł o więcej niż 30%:

SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);

Ograniczenie: zagnieżdżone tablice (Array(Array(String))) prawie nie są obsługiwane. Denormalizuj do płaskiej struktury.

8. Nullable(T): zło, którego można uniknąć

bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)

Nullable dodaje jeden dodatkowy znacznik do każdej wartości (maska bitowa). To oznacza:

  • Dodatkowy bajt na wiersz
  • Wolniejsze agregacje (SUM, AVG muszą sprawdzać NULL)
  • Nie działa z niektórymi silnikami (np. dla kluczy ORDER BY)

Moje stanowisko: w ClickHouse unikam NULL. Zamiast tego:

  • Liczby: 0 zamiast NULL
  • Stringi: '' (pusty string)
  • Daty: '1970-01-01'

Wyjątek: gdy 0 jest prawidłową wartością. Na przykład bonus 0 zł to nie to samo, co brak przyznania bonusu. Wtedy używaj Nullable.

9. Enum8/Enum16: dla skończonych list wartości

outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
event_status Enum8('scheduled' = 1, 'live' = 2, 'finished' = 3, 'cancelled' = 4)

Zalety: przechowywane jako 1 bajt (Enum8) lub 2 bajty (Enum16), szybkie porównania, czytelny wynik.

Wnętrzności: ClickHouse przechowuje liczby, ale przy SELECT wyświetla stringi.

INSERT INTO bets (outcome) VALUES ('win');  -- jako string
INSERT INTO bets (outcome) VALUES (1);      -- lub jako liczba

Częsty błąd: próba ALTER TABLE ... MODIFY COLUMN dodania nowej wartości do Enum. ClickHouse nie pozwala zmienić Enum bez odtworzenia tabeli. Wszystkie możliwe wartości trzeba przewidzieć z góry.

Jeśli masz wątpliwości – użyj LowCardinality(String). Poświęcisz bajt dla elastyczności.

10. IPv4/IPv6: wykrywamy multiaccounting

ip_address IPv4,
client_ip IPv6     -- operatorzy komórkowi używają IPv6

Przechowywane jako binarne (4 lub 16 bajtów), szybkie operacje na podsieciach.

Rzeczywisty use-case: znajdź użytkowników z jednego IP:

SELECT user_id, count() AS bets
FROM bets
WHERE ip_address = IPv4StringToNum('192.168.1.1')
  AND created_at >= today() - 7
GROUP BY user_id
HAVING bets > 50;  -- potencjalny bot

Funkcje, które ratują: IPv4NumToString(), IPv4CIDRToRange(), isIPv4String().

Pełny schemat dla platformy zakładów (działa w produkcji)

CREATE TABLE betting.bets_full
(
    -- Identyfikatory
    bet_id          UInt64 DEFAULT generateUUIDv4() (materialized) ???
    -- nie, UUID osobno
    bet_uuid        UUID DEFAULT generateUUIDv4(),
    user_id         UInt64,
    event_id        UInt64,
    session_id      String,                          -- nie UUID, pochodzi z logów nginx
    
    -- Znaczniki czasu
    created_at      DateTime64(3),                   -- milisekundy dla na żywo
    updated_at      DateTime,
    bet_date        Date DEFAULT toDate(created_at), -- kolumna zmaterializowana
    
    -- Pola pieniężne (tylko Decimal!)
    bet_amount      Decimal(18, 2),
    odds            Float64,                         -- kurs – Float jest OK
    potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
    real_payout     Decimal(20, 2),
    
    -- Kategorie z powtórzeniami
    sport           LowCardinality(String),
    bet_type        Enum8('single' = 1, 'express' = 2, 'system' = 3),
    outcome         Enum8('win' = 1, 'loss' = 2, 'void' = 3),
    device_type     LowCardinality(String),
    
    -- Listy (historia zmian)
    odds_history    Array(Float64),                  -- zmiana kursu w czasie
    cashout_attempts Array(DateTime64(3)),           -- próby wypłaty
    
    -- Wykrywanie fraudów
    ip_address      IPv4,
    fingerprint     FixedString(32),                 -- hash przeglądarki
    
    -- Nullable tylko tam, gdzie naprawdę potrzebne
    refund_amount   Nullable(Decimal(18, 2)),       -- NULL jeśli nie było zwrotu
    cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;

Dlaczego ten schemat przetrwał w produkcji:

  • bet_date zmaterializowana z created_at – partycjonowanie po datach bez zbędnych obliczeń
  • LowCardinality dla sport i device_type – oszczędność 80% miejsca
  • ALIAS dla potential_payout – nie przechowujemy, obliczamy przy zapytaniu
  • Brak Nullable tam, gdzie można użyć 0 lub pustego stringa

Co dalej

Wybór odpowiednich typów to podstawa. W kolejnych artykułach omówimy, jak na tych danych budować agregacje, funkcje okienne i widoki zmaterializowane.

Tabela z artykułu leży w naszym klastrze produkcyjnym od roku, 3 biliony rekordów. Waży 12 TB (z kompresją ZSTD). Gdyby przerobić wszystko na String – byłoby 40 TB. Wybieraj typy z głową.


Poprzedni:
Następny: MergeTree w ClickHouse: jak silnik kroi analitykę na granulki i scala części

— Editorial Team

Advertisement 728x90

Czytaj dalej