Volver al inicio

Tipos de datos de ClickHouse: Referencia completa para análisis

Referencia detallada de todos los tipos de datos de ClickHouse con ejemplos prácticos de análisis de juegos de azar. Cubre Integer (UInt8–UInt256), Float32/64, Decimal para finanzas, String vs FixedString vs LowCardinality, DateTime64 para apuestas en vivo, UUID, Array para almacenar historial de cuotas, Nullable (y por qué evitarlo), Enum para estados, IPv4/IPv6 para detección de fraude. Incluye un esquema completo de tabla de producción para apuestas con explicación de cada elección y errores típicos.

ClickHouse: Tipos de datos que salvarán tu presupuesto de disco
Advertisement 728x90

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.

Google AdInline article slot

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.

Google AdInline article slot

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.

Google AdInline article slot

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: 0 en 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_date se materializa desde created_at — particionamiento basado en fecha sin cómputo extra
  • LowCardinality para sport y device_type — ahorra 80% de espacio
  • ALIAS para potential_payout — no se almacena, se calcula en la consulta
  • Sin Nullable donde 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:
Siguiente: MergeTree en ClickHouse: Cómo el motor divide la analítica en gránulos y fusiona partes

— Editorial Team

Advertisement 728x90

Leer después