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.
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.
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.
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:
0zamiast 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_datezmaterializowana zcreated_at– partycjonowanie po datach bez zbędnych obliczeńLowCardinalitydla sport i device_type – oszczędność 80% miejscaALIASdlapotential_payout– nie przechowujemy, obliczamy przy zapytaniu- Brak
Nullabletam, 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: ClickHouse client: jak zaprzyjaźniłem się z konsolą i HTTP API w projekcie gamblingowym
→ Następny: MergeTree w ClickHouse: jak silnik kroi analitykę na granulki i scala części
— Editorial Team
Brak komentarzy.