ClickHouse: úplný přehled datových typů pro analýzu sázek (na čem jsem se spálil)
Jeden bajt, který mě stál 500 GB diskového prostoru
Když jsem začínal s ClickHouse, používal jsem String na všechno: user_id, event_time, částka sázky. Po měsíci tabulka s 2 miliardami řádků vážila 4 terabajty. Kolega se podíval na schéma a řekl: "Proč ukládáš číslo jako řetězec?" Ukázalo se, že String pro user_id zabírá 8krát více místa než UInt64. Předělal jsem to – a tabulka se zmenšila na 800 GB.
ClickHouse nabízí desítky datových typů. Používat správný typ není jen o úspoře gigabajtů, ale o rychlosti dotazů (méně dat ke čtení z disku) a stabilitě (Decimal místo Float nepřináší překvapení se zaokrouhlováním).
Níže je vše, co jsem vytěžil z reálných projektů (sázková analytika, detekce podvodů, LTV). Na konci je hotové schéma pro sázkovou platformu.
1. Celočíselné typy: počítáme uživatele a sázky
ClickHouse podporuje znaménkové (Int) a bezznaménkové (UInt) celé typy od 8 do 256 bitů.
| Typ | Rozsah | Velikost | Kdy používám |
|---|---|---|---|
UInt8 |
0..255 | 1 bajt | Stavy (0/1), kódy chyb |
UInt16 |
0..65535 | 2 bajty | Čísla portů, malé počítadla |
UInt32 |
0..4,2 miliardy | 4 bajty | ID zemí, typy událostí |
UInt64 |
0..18 kvintilionů | 8 bajtů | user_id, event_id, částky v haléřích |
Int128/256 |
obrovské | 16/32 bajtů | Kryptografické hashe, velmi velká počítadla |
Praxe z produkce:
user_id UInt64, -- 8 miliard uživatelů nám stačí
age UInt8, -- nikdo nežije déle než 255 let
country_code UInt16, -- na světě je 197 zemí, ale UInt16 je hezčí
is_fraud UInt8, -- 0 nebo 1, proč víc?
Častá chyba: používat UInt64 na všechno. Pokud pole nabývá pouze hodnot 0 nebo 1 (příznak), UInt8 je 8krát kompaktnější. Na miliardě řádků je to 8 GB proti 1 GB.
Na čem jsem se spálil: ukládal jsem timestamp jako UInt64 (Unix time). Funguje to, ale ztrácí se možnost používat funkce pro práci s daty toDate(), toHour() atd. Používejte DateTime.
2. Float32/Float64: peněženka nebo díra?
odds Float64, -- kurz, může být 2.5, 1.85, 100.0
probability Float32, -- procenta 0.1..1.0, přesnost 32 bitů stačí
Proč jsou Float nebezpečné pro finance:
SELECT 0.1 + 0.2 AS float_sum;
-- Výsledek: 0.30000000000000004 (klasika IEEE 754)
Představte si, že máte 1 milion sázek po 0.01 centu. Chyba zaokrouhlení se promění v reálné peníze. Pro částky sázek a výplat používejte Decimal.
Kdy je Float v pořádku: kurzy (2.15, 1.85), pravděpodobnosti, procenta, metriky strojového učení.
3. Decimal(P, S): peníze milují přesnost
bet_amount Decimal(18, 2), -- až 10^16 korun, 2 desetinná místa
payout Decimal(20, 2), -- výplata může být větší než sázka
balance Decimal(32, 2) -- zůstatek hráče za celou dobu
P(precision) – celkový počet číslic (až 38)S(scale) – kolik za desetinnou čárkou
Pravidlo, které jsem odvodil: pro koruny a dolary – Decimal(18,2) stačí s rezervou (biliony). Pro kryptoměny – Decimal(38,8).
Operace s Decimal:
SELECT
bet_amount * odds AS potential_payout, -- Decimal * Float64 → Decimal
bet_amount + 0.01 AS rounded_up -- funguje, ale buďte opatrní
FROM bets;
Na čem jsem se spálil: ClickHouse neumí Decimal * Decimal s různým měřítkem – převádí na větší. Měli jsme haléře na 4. desetinném místě, které se nikdy nezaokrouhlovaly. Oprava: explicitně převádějte pomocí toDecimal32().
4. String vs FixedString vs LowCardinality(String)
String – pro všechno dlouhé
session_id String, -- UUID bez pomlček, různá délka
user_agent String, -- dlouhé řetězce, unikátní hodnoty
raw_json String -- logy v JSON
FixedString(N) – pro pevnou délku (zřídka potřeba)
country_code FixedString(2), -- 'CZ', 'US', 'DE' přesně 2 bajty
md5_hash FixedString(32) -- vždy 32 znaků
Téměř nikdy nepoužívám: pokud vložíte kratší řetězec, ClickHouse jej doplní nulami, což přináší překvapení při porovnávání.
LowCardinality(String) – magie pro opakující se hodnoty
sport LowCardinality(String), -- 'fotbal', 'basketbal', 'tenis' (opakují se)
device LowCardinality(String), -- 'ios', 'android', 'web' (10-20 unikátních)
outcome LowCardinality(String) -- 'win', 'loss', 'void'
Jak to funguje: ClickHouse vytvoří slovník unikátních hodnot a ukládá pouze indexy. Pro sloupec s 10 unikátními hodnotami je úspora 100x.
Zkušenost z praxe: v tabulce sázek se pole sport opakovalo miliardkrát. Po nahrazení String za LowCardinality(String) se velikost sloupce zmenšila ze 40 GB na 400 MB.
Kdy nepoužívat: pokud je počet unikátních hodnot > 10000 (např. user_agent). Slovník se nafoukne a začnou zpomalení.
5. DateTime vs DateTime64 vs Date: čas jsou peníze
| Typ | Přesnost | Velikost | Kdy použít |
|---|---|---|---|
Date |
den | 2 bajty | partitionování, denní reporty |
Date32 |
den (do roku 2106) | 4 bajty | pokud potřebujete rok > 2149 |
DateTime |
sekunda | 4 bajty | většina událostí |
DateTime64(3) |
milisekunda | 8 bajtů | live sázky, pořadí událostí |
DateTime64(6) |
mikrosekunda | 8 bajtů | logy, metriky |
Co používám v produkčních projektech:
event_time DateTime64(3), -- milisekundy pro live analytiku
registration_date Date, -- partitionování podle dnů
last_update DateTime -- sekundová přesnost stačí
Častá chyba: ukládat čas jako Unix timestamp (UInt64). Ztrácíte všechny funkce pro datum/čas:
-- Takhle ne:
SELECT toHour(event_time_uint) ... -- chyba
-- Musíte takto:
SELECT toHour(toDateTime(event_time_uint)) ... -- zbytečný převod
Na čem jsem se spálil: použil jsem DateTime pro live sázky. Přišel čas – 10 událostí v jedné sekundě nelze rozlišit. Přešel jsem na DateTime64(3) – a pořádek byl obnoven.
6. UUID: když je standard důležitější než rychlost
session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()
UUID zabírá 16 bajtů (jako dvě UInt64). Porovnávání je pomalejší než u čísel.
Kdy ho přesto používám: potřeba generovat ID na klientovi bez dotazu na DB, integrace s externími systémy, distribuované systémy bez jednotného generátoru.
Alternativa: UInt128 jako dvě 64bitová čísla, ale pak ztrácíte funkce jako toUUID().
7. Array(T): ukládání seznamů bez normalizace
tags Array(String), -- ['fotbal', 'live', 'předzápas']
coeff_history Array(Float64), -- [1.5, 1.8, 2.1] změna kurzu
bet_bundle Array(UInt64) -- ID sázek v expresu
Kde se to skutečně hodilo: ukládáme historii změny kurzu na jednu událost v poli. V relační databázi bychom museli vytvořit samostatnou tabulku. ClickHouse skvěle pracuje s arrayMap, arrayFilter, arrayJoin.
Dotaz z praxe: najít události, kde kurz klesl o více než 30 %:
SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);
Omezení: vnořená pole (Array(Array(String))) jsou téměř nepodporovaná. Denormalizujte na plochou strukturu.
8. Nullable(T): zlo, kterému se lze vyhnout
bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)
Nullable přidává jeden dodatečný příznak ke každé hodnotě (bitová maska). To znamená:
- Dodatečný bajt na řádek
- Pomalejší agregace (SUM, AVG musí kontrolovat NULL)
- Nefunguje s některými enginy (např. pro ORDER BY klíče)
Můj postoj: v ClickHouse se NULL vyhýbám. Místo toho:
- Čísla:
0místo NULL - Řetězce:
''(prázdný řetězec) - Data:
'1970-01-01'
Výjimka: když 0 je legitimní hodnota. Například bonus 0 korun není totéž jako bonus neudělený. Pak použijte Nullable.
9. Enum8/Enum16: pro konečné seznamy hodnot
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)
Výhody: ukládá se jako 1 bajt (Enum8) nebo 2 bajty (Enum16), rychlá porovnání, čitelný výstup.
Interní fungování: ClickHouse ukládá čísla, ale při SELECT vypisuje řetězce.
INSERT INTO bets (outcome) VALUES ('win'); -- řetězcem
INSERT INTO bets (outcome) VALUES (1); -- nebo číslem
Častá chyba: pokusit se ALTER TABLE ... MODIFY COLUMN přidat novou hodnotu do Enum. ClickHouse neumožňuje změnit Enum bez přetvoření tabulky. Všechny možné hodnoty je třeba předvídat předem.
Pokud si nejste jisti – použijte LowCardinality(String). Obětujete bajt kvůli flexibilitě.
10. IPv4/IPv6: detekujeme multiaccounting
ip_address IPv4,
client_ip IPv6 -- mobilní operátoři používají IPv6
Ukládá se binárně (4 nebo 16 bajtů), rychlé operace se sítěmi.
Reálný use-case: hledání uživatelů z jedné 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; -- potenciální bot
Funkce, které zachraňují: IPv4NumToString(), IPv4CIDRToRange(), isIPv4String().
Kompletní schéma pro sázkovou platformu (funguje v produkci)
CREATE TABLE betting.bets_full
(
-- Identifikátory
bet_id UInt64 DEFAULT generateUUIDv4() (materialized) ???
-- ne, UUID zvlášť
bet_uuid UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_id UInt64,
session_id String, -- ne UUID, přichází z nginx logů
-- Časová razítka
created_at DateTime64(3), -- milisekundy pro live
updated_at DateTime,
bet_date Date DEFAULT toDate(created_at), -- materializovaný sloupec
-- Peněžní pole (pouze Decimal!)
bet_amount Decimal(18, 2),
odds Float64, -- kurz – Float je v pořádku
potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
real_payout Decimal(20, 2),
-- Kategorie s opakováním
sport LowCardinality(String),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
device_type LowCardinality(String),
-- Seznamy (historie změn)
odds_history Array(Float64), -- změna kurzu v čase
cashout_attempts Array(DateTime64(3)), -- pokusy o cashout
-- Fraud detection
ip_address IPv4,
fingerprint FixedString(32), -- hash prohlížeče
-- Nullable jen tam, kde je to opravdu potřeba
refund_amount Nullable(Decimal(18, 2)), -- NULL pokud nedošlo k refundaci
cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;
Proč toto schéma přežilo v produkci:
bet_dateje materializován zcreated_at– partitionování podle dat bez zbytečných výpočtůLowCardinalitypro sport a device_type – úspora 80 % místaALIASpropotential_payout– neukládáme, počítáme při dotazu- Žádné
Nullabletam, kde lze vystačit s 0 nebo prázdným řetězcem
Co dál
Správný výběr typů je základ. V následujících článcích rozebereme, jak na těchto datech stavět agregace, okenní funkce a materializované pohledy.
Tabulka z článku je v našem produkčním clusteru už rok, 3 biliony záznamů. Váží 12 TB (s kompresí ZSTD). Kdybychom vše předělali na String – bylo by to 40 TB. Vybírejte typy s rozumem.
← Předchozí: ClickHouse client: jak jsem se skamarádil s konzolí a HTTP API v gaming projektu
→ Další: MergeTree v ClickHouse: jak engine řeže analytiku na granule a slučuje části
— Editorial Team
Zatím žádné komentáře.