ClickHouse: Vollständige Datentyp-Referenz für Wettanalysen (Woran ich mich verbrannt habe)
Ein Byte, das mich 500 GB Festplattenspeicher gekostet hat
Als ich anfing, mit ClickHouse zu arbeiten, habe ich für alles String verwendet: user_id, event_time, Einsatzbetrag. Einen Monat später wog eine Tabelle mit 2 Milliarden Zeilen 4 Terabyte. Ein Kollege sah sich das Schema an und sagte: "Warum speicherst du eine Zahl als String?" Es stellte sich heraus, dass String für user_id 8-mal mehr Speicherplatz benötigt als UInt64. Ich änderte es – und die Tabelle schrumpfte auf 800 GB.
ClickHouse bietet Dutzende von Datentypen. Den richtigen Typ zu verwenden, geht nicht um Gigabyte-Einsparung – es geht um Abfragegeschwindigkeit (weniger Daten, die von der Festplatte gelesen werden müssen) und Stabilität (Decimal statt Float überrascht dich nicht mit Rundungsfehlern).
Unten findest du alles, was ich aus echten Projekten gelernt habe (Wettanalysen, Betrugserkennung, LTV). Am Ende – ein fertiges Schema für eine Wettplattform.
1. Ganzzahltypen: Benutzer und Wetten zählen
ClickHouse unterstützt signed (Int) und unsigned (UInt) Ganzzahlen von 8 bis 256 Bit.
| Typ | Bereich | Größe | Wann ich es verwende |
|---|---|---|---|
UInt8 |
0..255 | 1 Byte | Status (0/1), Fehlercodes |
UInt16 |
0..65535 | 2 Bytes | Portnummern, kleine Zähler |
UInt32 |
0..4,2 Milliarden | 4 Bytes | Länder-IDs, Ereignistypen |
UInt64 |
0..18 Trillionen | 8 Bytes | user_id, event_id, Beträge in Cent |
Int128/256 |
riesig | 16/32 Bytes | Kryptografische Hashes, sehr große Zähler |
Produktionspraxis:
user_id UInt64, -- 8 Milliarden Benutzer reichen uns
age UInt8, -- niemand wird älter als 255 Jahre
country_code UInt16, -- 197 Länder auf der Welt, aber UInt16 ist schöner
is_fraud UInt8, -- 0 oder 1, warum mehr?
Häufiger Fehler: UInt64 für alles verwenden. Wenn ein Feld nur die Werte 0 oder 1 annimmt (ein Flag), ist UInt8 8-mal kompakter. Bei einer Milliarde Zeilen sind das 8 GB vs. 1 GB.
Woran ich mich verbrannt habe: Ich habe timestamp als UInt64 (Unix-Zeit) gespeichert. Es funktioniert, aber du verlierst die Fähigkeit, Datumsfunktionen wie toDate(), toHour() usw. zu verwenden. Verwende DateTime.
2. Float32/Float64: Geldbörse oder Loch?
odds Float64, -- Quoten können 2,5, 1,85, 100,0 sein
probability Float32, -- Prozentsätze 0,1..1,0, 32-Bit-Genauigkeit reicht
Warum Float für Finanzen gefährlich ist:
SELECT 0.1 + 0.2 AS float_sum;
-- Ergebnis: 0.30000000000000004 (klassisches IEEE 754)
Stell dir vor, du hast 1 Million Wetten zu je 0,01 Cent. Der Rundungsfehler wird zu echtem Geld. Für Einsatzbeträge und Auszahlungen verwende Decimal.
Wann Float in Ordnung ist: Quoten (2,15, 1,85), Wahrscheinlichkeiten, Prozentsätze, Metriken für maschinelles Lernen.
3. Decimal(P, S): Geld liebt Genauigkeit
bet_amount Decimal(18, 2), -- bis zu 10^16 Rubel, 2 Dezimalstellen
payout Decimal(20, 2), -- Auszahlung kann größer sein als Einsatz
balance Decimal(32, 2) -- Lebenszeitguthaben des Spielers
P(precision) – Gesamtzahl der Ziffern (bis zu 38)S(scale) – Nachkommastellen
Regel, die ich abgeleitet habe: Für Rubel und Dollar – Decimal(18,2) reicht mit Reserve (Billionen). Für Krypto – Decimal(38,8).
Operationen mit Decimal:
SELECT
bet_amount * odds AS potential_payout, -- Decimal * Float64 → Decimal
bet_amount + 0.01 AS rounded_up -- funktioniert, aber Vorsicht
FROM bets;
Woran ich mich verbrannt habe: ClickHouse behandelt Decimal * Decimal mit unterschiedlichen Skalen nicht gut – es erweitert auf die größere. Wir hatten Cent-Beträge in der 4. Dezimalstelle, die nie gerundet wurden. Lösung: explizit mit toDecimal32() casten.
4. String vs FixedString vs LowCardinality(String)
String – Für alles Lange
session_id String, -- UUID ohne Bindestriche, variable Länge
user_agent String, -- lange Zeichenketten, eindeutige Werte
raw_json String -- JSON-Logs
FixedString(N) – Für feste Länge (selten benötigt)
country_code FixedString(2), -- 'RU', 'US', 'DE' genau 2 Bytes
md5_hash FixedString(32) -- immer 32 Zeichen
Ich verwende es fast nie: Wenn du eine kürzere Zeichenkette einfügst, füllt ClickHouse sie mit Nullbytes auf, was zu Überraschungen bei Vergleichen führt.
LowCardinality(String) – Magie für wiederholte Werte
sport LowCardinality(String), -- 'Fußball', 'Basketball', 'Tennis' (wiederholt)
device LowCardinality(String), -- 'ios', 'android', 'web' (10-20 eindeutige)
outcome LowCardinality(String) -- 'Sieg', 'Niederlage', 'ungültig'
Wie es funktioniert: ClickHouse erstellt ein Wörterbuch der eindeutigen Werte und speichert nur Indizes. Für eine Spalte mit 10 eindeutigen Werten beträgt die Ersparnis das 100-fache.
Moment aus der Praxis: In einer Wetten-Tabelle wiederholte sich das Feld sport milliardenfach. Nach dem Ersetzen von String durch LowCardinality(String) sank die Spaltengröße von 40 GB auf 400 MB.
Wann nicht verwenden: Wenn die Anzahl der eindeutigen Werte 10.000 übersteigt (z.B. user_agent). Das Wörterbuch bläht auf und die Leistung verschlechtert sich.
5. DateTime vs DateTime64 vs Date: Zeit ist Geld
| Typ | Genauigkeit | Größe | Wann verwenden |
|---|---|---|---|
Date |
Tag | 2 Bytes | Partitionierung, tägliche Berichte |
Date32 |
Tag (bis 2106) | 4 Bytes | wenn Jahr > 2149 benötigt |
DateTime |
Sekunde | 4 Bytes | die meisten Ereignisse |
DateTime64(3) |
Millisekunde | 8 Bytes | Live-Wetten, Ereignisreihenfolge |
DateTime64(6) |
Mikrosekunde | 8 Bytes | Logs, Metriken |
Was ich in Produktionsprojekten verwende:
event_time DateTime64(3), -- Millisekunden für Live-Analysen
registration_date Date, -- tägliche Partitionierung
last_update DateTime -- Sekundengenauigkeit reicht
Häufiger Fehler: Zeit als Unix-Timestamp (UInt64) speichern. Du verlierst alle Datums-/Zeitfunktionen:
-- Das funktioniert nicht:
SELECT toHour(event_time_uint) ... -- Fehler
-- Du brauchst:
SELECT toHour(toDateTime(event_time_uint)) ... -- zusätzliche Konvertierung
Woran ich mich verbrannt habe: Ich habe DateTime für Live-Wetten verwendet. Als es darauf ankam, waren 10 Ereignisse in derselben Sekunde nicht unterscheidbar. Wechsel zu DateTime64(3) – und die Ordnung war wiederhergestellt.
6. UUID: Wenn Standard wichtiger ist als Geschwindigkeit
session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()
UUID belegt 16 Bytes (wie zwei UInt64). Der Vergleich ist langsamer als bei Zahlen.
Wann ich es trotzdem verwende: IDs müssen clientseitig ohne DB-Zugriff generiert werden, Integration mit externen Systemen, verteilte Systeme ohne einen einzelnen Generator.
Alternative: UInt128 als zwei 64-Bit-Zahlen, aber dann verlierst du Funktionen wie toUUID().
7. Array(T): Listen ohne Normalisierung speichern
tags Array(String), -- ['Fußball', 'live', 'prematch']
coeff_history Array(Float64), -- [1.5, 1.8, 2.1] Quotenänderungen
bet_bundle Array(UInt64) -- Wett-IDs in einer Kombiwette
Wo es tatsächlich geholfen hat: Wir speichern den Verlauf der Quotenänderungen für ein einzelnes Ereignis in einem Array. In einer relationalen DB bräuchtest du eine separate Tabelle. ClickHouse arbeitet hervorragend mit arrayMap, arrayFilter, arrayJoin.
Echte Abfrage: Finde Ereignisse, bei denen die Quote um mehr als 30% gefallen ist:
SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);
Einschränkung: Verschachtelte Arrays (Array(Array(String))) werden kaum unterstützt. Denormalisiere zu einer flachen Struktur.
8. Nullable(T): Das Übel, das es zu vermeiden gilt
bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)
Nullable fügt ein zusätzliches Flag pro Wert hinzu (eine Bitmaske). Das bedeutet:
- Ein zusätzliches Byte pro Zeile
- Langsamere Aggregationen (SUM, AVG müssen auf NULL prüfen)
- Funktioniert nicht mit einigen Engines (z.B. für ORDER BY-Schlüssel)
Meine Haltung: In ClickHouse vermeide ich NULL. Stattdessen:
- Zahlen:
0statt NULL - Zeichenketten:
''(leere Zeichenkette) - Daten:
'1970-01-01'
Ausnahme: wenn 0 ein legitimer Wert ist. Zum Beispiel ist ein Bonus von 0 Rubel nicht dasselbe wie kein Bonus gewährt. Dann verwende Nullable.
9. Enum8/Enum16: Für endliche Wertelisten
outcome Enum8('Sieg' = 1, 'Niederlage' = 2, 'ungültig' = 3),
bet_type Enum8('Einzelwette' = 1, 'Kombiwette' = 2, 'Systemwette' = 3),
event_status Enum8('geplant' = 1, 'live' = 2, 'beendet' = 3, 'storniert' = 4)
Vorteile: Gespeichert als 1 Byte (Enum8) oder 2 Bytes (Enum16), schnelle Vergleiche, lesbare Ausgabe.
Interna: ClickHouse speichert Zahlen, aber SELECT gibt Zeichenketten aus.
INSERT INTO bets (outcome) VALUES ('Sieg'); -- als Zeichenkette
INSERT INTO bets (outcome) VALUES (1); -- oder als Zahl
Häufiger Fehler: Versuchen, mit ALTER TABLE ... MODIFY COLUMN einen neuen Wert zu einem Enum hinzuzufügen. ClickHouse erlaubt es nicht, ein Enum zu ändern, ohne die Tabelle neu zu erstellen. Alle möglichen Werte müssen im Voraus geplant werden.
Im Zweifelsfall verwende LowCardinality(String). Opfere ein Byte für Flexibilität.
10. IPv4/IPv6: Erkennung von Mehrfachkonten
ip_address IPv4,
client_ip IPv6 -- Mobilfunkanbieter verwenden IPv6
Gespeichert als Binär (4 oder 16 Bytes), schnelle Subnetzoperationen.
Echter Anwendungsfall: Finde Benutzer von derselben 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; -- potenzieller Bot
Funktionen, die den Tag retten: IPv4NumToString(), IPv4CIDRToRange(), isIPv4String().
Vollständiges Schema für eine Wettplattform (produktionserprobt)
CREATE TABLE betting.bets_full
(
-- Identifikatoren
bet_id UInt64 DEFAULT generateUUIDv4() (materialisiert) ???
-- nein, UUID separat
bet_uuid UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_id UInt64,
session_id String, -- nicht UUID, kommt aus nginx-Logs
-- Zeitstempel
created_at DateTime64(3), -- Millisekunden für Live
updated_at DateTime,
bet_date Date DEFAULT toDate(created_at), -- materialisierte Spalte
-- Geldbeträge (nur Decimal!)
bet_amount Decimal(18, 2),
odds Float64, -- Quoten – Float ist in Ordnung
potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
real_payout Decimal(20, 2),
-- Kategorien mit Wiederholungen
sport LowCardinality(String),
bet_type Enum8('Einzelwette' = 1, 'Kombiwette' = 2, 'Systemwette' = 3),
outcome Enum8('Sieg' = 1, 'Niederlage' = 2, 'ungültig' = 3),
device_type LowCardinality(String),
-- Listen (Änderungshistorie)
odds_history Array(Float64), -- Quotenänderungen im Zeitverlauf
cashout_attempts Array(DateTime64(3)), -- Auszahlungsversuche
-- Betrugserkennung
ip_address IPv4,
fingerprint FixedString(32), -- Browser-Hash
-- Nullable nur wo wirklich nötig
refund_amount Nullable(Decimal(18, 2)), -- NULL, wenn keine Rückerstattung
cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;
Warum dieses Schema in der Produktion überlebt hat:
bet_datewird auscreated_atmaterialisiert – datumsbasierte Partitionierung ohne zusätzliche BerechnungLowCardinalityfür Sport und Gerätetyp – spart 80% SpeicherplatzALIASfürpotential_payout– nicht gespeichert, wird bei Abfrage berechnet- Kein
Nullable, wo 0 oder leere Zeichenkette ausreichen
Was kommt als Nächstes
Die Wahl der richtigen Typen ist die Grundlage. In den kommenden Artikeln werden wir den Aufbau von Aggregationen, Fensterfunktionen und materialisierten Ansichten auf diesen Daten behandeln.
Die Tabelle aus diesem Artikel läuft seit einem Jahr in unserem Produktionscluster mit 3 Billionen Datensätzen. Sie wiegt 12 TB (mit ZSTD-Kompression). Wenn alles String wäre, wären es 40 TB. Wähle deine Typen mit Bedacht.
← Vorherige: ClickHouse-Client: Wie ich mich mit der Konsole und der HTTP-API in einem Glücksspielprojekt anfreundete
→ Nächste: MergeTree in ClickHouse: Wie die Engine Analysen in Granula schneidet und Teile zusammenführt
— Editorial Team
Noch keine Kommentare.