Zpět na domů

Typy dat ClickHouse: úplný průvodce pro analytiku

Podrobný průvodce všemi typy dat ClickHouse s praktickými příklady z gambling analytiky. Rozebírají se Integer (UInt8–UInt256), Float32/64, Decimal pro finance, String vs FixedString vs LowCardinality, DateTime64 pro live sázky, UUID, Array pro ukládání historie kurzů, Nullable (a proč se mu raději vyhnout), Enum pro stavy, IPv4/IPv6 pro fraud detekci. Uvedeno kompletní produkční schéma tabulky sázek s vysvětlením každé volby a typickými chybami.

ClickHouse: typy dat, které zachrání váš diskový rozpočet
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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.

Google AdInline article slot

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: 0 mí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_date je materializován z created_at – partitionování podle dat bez zbytečných výpočtů
  • LowCardinality pro sport a device_type – úspora 80 % místa
  • ALIAS pro potential_payout – neukládáme, počítáme při dotazu
  • Žádné Nullable tam, 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í:
Další: MergeTree v ClickHouse: jak engine řeže analytiku na granule a slučuje části

— Editorial Team

Advertisement 728x90

Číst dál