Zurück zur Startseite

ClickHouse-Datentypen: Vollständige Referenz für Analysen

Detaillierte Referenz aller ClickHouse-Datentypen mit praktischen Beispielen aus der Glücksspielanalyse. Behandelt Integer (UInt8–UInt256), Float32/64, Decimal für Finanzen, String vs FixedString vs LowCardinality, DateTime64 für Live-Wetten, UUID, Array zum Speichern von Quotenverläufen, Nullable (und warum man es vermeiden sollte), Enum für Status, IPv4/IPv6 zur Betrugserkennung. Enthält ein vollständiges Produktionstabellenschema für Wetten mit Erklärung jeder Wahl und typischen Fehlern.

ClickHouse: Datentypen, die Ihr Festplattenbudget retten
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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.

Google AdInline article slot

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: 0 statt 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_date wird aus created_at materialisiert – datumsbasierte Partitionierung ohne zusätzliche Berechnung
  • LowCardinality für Sport und Gerätetyp – spart 80% Speicherplatz
  • ALIAS für potential_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:
Nächste: MergeTree in ClickHouse: Wie die Engine Analysen in Granula schneidet und Teile zusammenführt

— Editorial Team

Advertisement 728x90

Weiterlesen