ClickHouse: Referencia completa de tipos de datos para análisis de apuestas (lo que me costó caro)
Un byte que me costó 500 GB de espacio en disco
Cuando empecé a trabajar con ClickHouse, usaba String para todo: user_id, event_time, monto de la apuesta. Un mes después, una tabla con 2 mil millones de filas pesaba 4 terabytes. Un colega miró el esquema y dijo: "¿Por qué almacenas un número como cadena?" Resulta que String para user_id ocupa 8 veces más espacio que UInt64. Lo cambié, y la tabla se redujo a 800 GB.
ClickHouse ofrece decenas de tipos de datos. Usar el tipo correcto no es solo cuestión de ahorrar gigabytes, sino de velocidad de consulta (menos datos que leer del disco) y estabilidad (Decimal en lugar de Float no te sorprenderá con errores de redondeo).
A continuación, todo lo que he aprendido de proyectos reales (análisis de apuestas, detección de fraude, LTV). Al final, un esquema listo para una plataforma de apuestas.
1. Tipos enteros: contando usuarios y apuestas
ClickHouse admite enteros con signo (Int) y sin signo (UInt) de 8 a 256 bits.
| Tipo | Rango | Tamaño | Cuándo lo uso |
|---|---|---|---|
UInt8 |
0..255 | 1 byte | Estados (0/1), códigos de error |
UInt16 |
0..65535 | 2 bytes | Números de puerto, contadores pequeños |
UInt32 |
0..4.2 mil millones | 4 bytes | IDs de país, tipos de evento |
UInt64 |
0..18 quintillones | 8 bytes | user_id, event_id, montos en céntimos |
Int128/256 |
enorme | 16/32 bytes | Hashes criptográficos, contadores muy grandes |
Práctica en producción:
user_id UInt64, -- 8 mil millones de usuarios nos basta
age UInt8, -- nadie vive más de 255 años
country_code UInt16, -- 197 países en el mundo, pero UInt16 es más cómodo
is_fraud UInt8, -- 0 o 1, ¿para qué más?
Error común: usar UInt64 para todo. Si un campo solo toma valores 0 o 1 (una bandera), UInt8 es 8 veces más compacto. En mil millones de filas, eso son 8 GB frente a 1 GB.
Lo que me costó caro: almacené timestamp como UInt64 (tiempo Unix). Funciona, pero pierdes la capacidad de usar funciones de fecha como toDate(), toHour(), etc. Usa DateTime.
2. Float32/Float64: ¿billetera o agujero?
odds Float64, -- las cuotas pueden ser 2.5, 1.85, 100.0
probability Float32, -- porcentajes 0.1..1.0, precisión de 32 bits es suficiente
Por qué Float es peligroso para finanzas:
SELECT 0.1 + 0.2 AS float_sum;
-- Resultado: 0.30000000000000004 (clásico IEEE 754)
Imagina que tienes 1 millón de apuestas de 0.01 céntimos cada una. El error de redondeo se convierte en dinero real. Para montos de apuesta y pagos, usa Decimal.
Cuándo Float está bien: cuotas (2.15, 1.85), probabilidades, porcentajes, métricas de machine learning.
3. Decimal(P, S): el dinero ama la precisión
bet_amount Decimal(18, 2), -- hasta 10^16 rublos, 2 decimales
payout Decimal(20, 2), -- el pago puede ser mayor que la apuesta
balance Decimal(32, 2) -- saldo de por vida del jugador
P(precisión) — número total de dígitos (hasta 38)S(escala) — dígitos después del punto decimal
Regla que deduje: para rublos y dólares — Decimal(18,2) es suficiente con margen (billones). Para cripto — Decimal(38,8).
Operaciones con Decimal:
SELECT
bet_amount * odds AS potential_payout, -- Decimal * Float64 → Decimal
bet_amount + 0.01 AS rounded_up -- funciona, pero ten cuidado
FROM bets;
Lo que me costó caro: ClickHouse no maneja Decimal * Decimal con diferentes escalas — promociona al mayor. Teníamos céntimos en el cuarto decimal que nunca se redondeaban. Solución: convertir explícitamente con toDecimal32().
4. String vs FixedString vs LowCardinality(String)
String — Para todo lo largo
session_id String, -- UUID sin guiones, longitud variable
user_agent String, -- cadenas largas, valores únicos
raw_json String -- logs JSON
FixedString(N) — Para longitud fija (rara vez necesario)
country_code FixedString(2), -- 'RU', 'US', 'DE' exactamente 2 bytes
md5_hash FixedString(32) -- siempre 32 caracteres
Casi nunca lo uso: si insertas una cadena más corta, ClickHouse la rellena con bytes nulos, lo que causa sorpresas en las comparaciones.
LowCardinality(String) — Magia para valores repetidos
sport LowCardinality(String), -- 'fútbol', 'baloncesto', 'tenis' (repetido)
device LowCardinality(String), -- 'ios', 'android', 'web' (10-20 únicos)
outcome LowCardinality(String) -- 'win', 'loss', 'void'
Cómo funciona: ClickHouse construye un diccionario de valores únicos y almacena solo índices. Para una columna con 10 valores únicos, el ahorro es de 100x.
Momento real: en una tabla de apuestas, el campo sport se repetía miles de millones de veces. Después de reemplazar String por LowCardinality(String), el tamaño de la columna pasó de 40 GB a 400 MB.
Cuándo no usarlo: si el número de valores únicos supera los 10,000 (por ejemplo, user_agent). El diccionario se hincha y el rendimiento se degrada.
5. DateTime vs DateTime64 vs Date: el tiempo es dinero
| Tipo | Precisión | Tamaño | Cuándo usarlo |
|---|---|---|---|
Date |
día | 2 bytes | Particionamiento, informes diarios |
Date32 |
día (hasta 2106) | 4 bytes | si se necesita año > 2149 |
DateTime |
segundo | 4 bytes | la mayoría de eventos |
DateTime64(3) |
milisegundo | 8 bytes | apuestas en vivo, orden de eventos |
DateTime64(6) |
microsegundo | 8 bytes | logs, métricas |
Lo que uso en proyectos de producción:
event_time DateTime64(3), -- milisegundos para análisis en vivo
registration_date Date, -- particionamiento diario
last_update DateTime -- precisión de segundo es suficiente
Error común: almacenar la hora como timestamp Unix (UInt64). Pierdes todas las funciones de fecha/hora:
-- Esto no funciona:
SELECT toHour(event_time_uint) ... -- error
-- Necesitas:
SELECT toHour(toDateTime(event_time_uint)) ... -- conversión extra
Lo que me costó caro: usé DateTime para apuestas en vivo. Cuando importaba, 10 eventos en el mismo segundo eran indistinguibles. Cambié a DateTime64(3) — y se restauró el orden.
6. UUID: cuando el estándar importa más que la velocidad
session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()
UUID ocupa 16 bytes (como dos UInt64). La comparación es más lenta que con números.
Cuándo aún lo uso: necesidad de generar IDs en el cliente sin acceso a la base de datos, integración con sistemas externos, sistemas distribuidos sin un generador único.
Alternativa: UInt128 como dos números de 64 bits, pero entonces pierdes funciones como toUUID().
7. Array(T): almacenar listas sin normalización
tags Array(String), -- ['fútbol', 'en vivo', 'previa']
coeff_history Array(Float64), -- [1.5, 1.8, 2.1] cambios de cuotas
bet_bundle Array(UInt64) -- IDs de apuestas en una acumulada
Dónde realmente ayudó: almacenamos el historial de cambios de cuotas para un solo evento en un array. En una base de datos relacional, necesitarías una tabla separada. ClickHouse funciona muy bien con arrayMap, arrayFilter, arrayJoin.
Consulta real: encontrar eventos donde las cuotas bajaron más del 30%:
SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);
Limitación: los arrays anidados (Array(Array(String))) apenas son compatibles. Desnormaliza a una estructura plana.
8. Nullable(T): malo de evitar
bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)
Nullable añade una bandera extra por valor (una máscara de bits). Esto significa:
- Byte extra por fila
- Agregaciones más lentas (SUM, AVG deben comprobar NULL)
- No funciona con algunos motores (por ejemplo, para claves ORDER BY)
Mi postura: en ClickHouse, evito NULL. En su lugar:
- Números:
0en lugar de NULL - Cadenas:
''(cadena vacía) - Fechas:
'1970-01-01'
Excepción: cuando 0 es un valor legítimo. Por ejemplo, un bono de 0 rublos no es lo mismo que no haber otorgado bono. Entonces usa Nullable.
9. Enum8/Enum16: para listas de valores finitos
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)
Ventajas: se almacena como 1 byte (Enum8) o 2 bytes (Enum16), comparaciones rápidas, salida legible.
Internamente: ClickHouse almacena números, pero SELECT muestra cadenas.
INSERT INTO bets (outcome) VALUES ('win'); -- como cadena
INSERT INTO bets (outcome) VALUES (1); -- o como número
Error común: intentar ALTER TABLE ... MODIFY COLUMN para añadir un nuevo valor a un Enum. ClickHouse no permite cambiar un Enum sin recrear la tabla. Todos los valores posibles deben planificarse con antelación.
Si tienes dudas, usa LowCardinality(String). Sacrifica un byte por flexibilidad.
10. IPv4/IPv6: detectando multicuentas
ip_address IPv4,
client_ip IPv6 -- los operadores móviles usan IPv6
Se almacena como binario (4 o 16 bytes), operaciones de subred rápidas.
Caso de uso real: encontrar usuarios desde la misma 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; -- posible bot
Funciones que salvan el día: IPv4NumToString(), IPv4CIDRToRange(), isIPv4String().
Esquema completo para una plataforma de apuestas (probado en producción)
CREATE TABLE betting.bets_full
(
-- Identificadores
bet_id UInt64 DEFAULT generateUUIDv4() (materializado) ???
-- no, UUID por separado
bet_uuid UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_id UInt64,
session_id String, -- no UUID, viene de logs nginx
-- Marcas de tiempo
created_at DateTime64(3), -- milisegundos para en vivo
updated_at DateTime,
bet_date Date DEFAULT toDate(created_at), -- columna materializada
-- Campos monetarios (¡solo Decimal!)
bet_amount Decimal(18, 2),
odds Float64, -- cuotas — Float está bien
potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
real_payout Decimal(20, 2),
-- Categorías con repeticiones
sport LowCardinality(String),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
device_type LowCardinality(String),
-- Listas (historial de cambios)
odds_history Array(Float64), -- cambios de cuotas en el tiempo
cashout_attempts Array(DateTime64(3)), -- intentos de retiro
-- Detección de fraude
ip_address IPv4,
fingerprint FixedString(32), -- hash del navegador
-- Nullable solo donde realmente se necesita
refund_amount Nullable(Decimal(18, 2)), -- NULL si no hay reembolso
cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;
Por qué este esquema sobrevivió en producción:
bet_datese materializa desdecreated_at— particionamiento basado en fecha sin cómputo extraLowCardinalitypara sport y device_type — ahorra 80% de espacioALIASparapotential_payout— no se almacena, se calcula en la consulta- Sin
Nullabledonde 0 o cadena vacía son suficientes
Lo que sigue
Elegir los tipos correctos es la base. En próximos artículos, cubriremos la construcción de agregaciones, funciones de ventana y vistas materializadas sobre estos datos.
La tabla de este artículo ha estado en nuestro clúster de producción durante un año, con 3 billones de registros. Pesa 12 TB (con compresión ZSTD). Si todo fuera String, serían 40 TB. Elige tus tipos sabiamente.
← Anterior: Cliente de ClickHouse: Cómo me hice amigo de la consola y la API HTTP en un proyecto de apuestas
→ Siguiente: MergeTree en ClickHouse: Cómo el motor divide la analítica en gránulos y fusiona partes
— Editorial Team
Aún no hay comentarios.