ClickHouse desde cero (II): tipos de datos y MergeTree
Segunda entrega de la serie ClickHouse desde cero a pro. Tiempo de lectura estimado: 11 minutos.
En la entrega anterior levantamos un ClickHouse local, creamos una tabla con MergeTree y metimos un millón de filas. En este post vamos al grano: qué tipos de datos existen, por qué los strings de baja cardinalidad son especiales, y cómo funciona realmente el motor MergeTree por dentro.
Si entiendes estas dos cosas (tipos y MergeTree), ya estás por encima del 80% de la gente que usa ClickHouse a diario.
El sistema de tipos
ClickHouse tiene un sistema de tipos estático y fuerte, con más granularidad que PostgreSQL. Elegir el tipo correcto no es cosmético: impacta en el tamaño en disco, en la velocidad de lectura y en la memoria usada durante las consultas.
Enteros
Int8 -- -128 a 127
Int16 -- -32.768 a 32.767
Int32 -- ~ ±2.100 millones
Int64 -- ~ ±9 trillones
Int128, Int256
UInt8, UInt16, UInt32, UInt64, UInt128, UInt256 -- sin signo
Usa el más pequeño que te quepa. Un UInt8 ocupa 1 byte por fila. Un UInt64, 8. En una tabla de 10.000 millones de filas, esa diferencia son 70 GB. ClickHouse comprime, sí, pero un tipo pequeño comprime aún mejor.
Decimales y floats
Float32, Float64 -- para métricas donde no importa la precisión exacta
Decimal(P, S) -- para dinero, siempre
Decimal32(S), Decimal64(S), Decimal128(S)
Decimal(18, 4) significa hasta 18 dígitos en total, 4 después de la coma. Cualquier cosa que sea dinero o que pase por una API pública debería ser Decimal, no Float.
Strings
String -- variable, UTF-8, sin límite
FixedString(N) -- N bytes exactos (útil para hashes, IPs binarias)
LowCardinality(String) -- string diccionarizado
LowCardinality merece una sección aparte. Es, probablemente, la optimización más rentable y peor conocida de ClickHouse.
Fechas y tiempos
Date -- día, 2 bytes, hasta 2149
Date32 -- día, 4 bytes, hasta 2299
DateTime -- segundos desde epoch, 4 bytes
DateTime64(precision, timezone) -- hasta nanosegundos, 8 bytes
Una lección aprendida a golpes: usa siempre DateTime o DateTime64 con timezone explícita si tu aplicación cruza husos horarios. Ahorra bugs raros.
CREATE TABLE events (
ts DateTime64(3, 'UTC'),
...
) ENGINE = MergeTree() ORDER BY ts;
Tipos compuestos
Array(T) -- array homogéneo: Array(String), Array(UInt32)
Tuple(T1, T2, ...) -- tupla heterogénea
Map(K, V) -- mapa clave-valor
Nested(...) -- sub-tabla embebida
Nullable(T) -- cualquier tipo con soporte de NULL
JSON -- tipo JSON dinámico (desde 24.x)
Los arrays son de primera clase en ClickHouse. No es un añadido como en PostgreSQL: son idiomáticos. Verás muchísimos arrays en la entrega III.
Nullable: úsalo con cuidado
Nullable(T) añade una columna extra interna (bitmap de nulos) y desactiva varias optimizaciones. Si puedes, representa la ausencia con un valor centinela:
-- Evita esto si puedes
customer_id Nullable(UInt64)
-- Prefiere esto
customer_id UInt64 DEFAULT 0
Obviamente no siempre es posible, pero la regla general es: no hagas Nullable por reflejo.
LowCardinality: la optimización que nadie te cuenta
Un String en ClickHouse se almacena tal cual: cada fila guarda sus bytes en disco. Para 200 millones de filas con un campo country = 'ES', eso son 200 millones de copias de 'ES'.
LowCardinality(String) cambia eso: guarda un diccionario de valores únicos y en cada fila pone solo un índice (normalmente un UInt8 o UInt16). Es básicamente un enum construido automáticamente.
Beneficios:
- La columna en disco ocupa muy poco (bytes por fila en vez de cadenas).
- Los
GROUP BYyWHEREsobre esa columna son mucho más rápidos porque se operan sobre índices numéricos. - No tienes que declarar los valores posibles por adelantado, como harías con un
Enum.
Cuándo usarlo:
- Países, idiomas, dispositivos, navegadores, estados, tipos...
- Cualquier string con menos de ~10.000 valores distintos.
Cuándo no usarlo:
- UUIDs, rutas únicas, emails, cualquier cosa con alta cardinalidad. El diccionario crece sin control y pierde la ventaja.
Regla práctica: si al hacer SELECT uniqExact(columna) FROM tabla sale menos de unos pocos miles, LowCardinality es tu amigo.
El motor MergeTree
ClickHouse tiene docenas de table engines. El que vas a usar casi siempre es MergeTree o alguno de sus derivados (ReplacingMergeTree, SummingMergeTree, AggregatingMergeTree, ReplicatedMergeTree).
Entender cómo funciona internamente es clave para diseñar tablas rápidas.
Parts
Cuando haces un INSERT, ClickHouse escribe los datos en una carpeta nueva en disco llamada part. Un part es inmutable: nunca se modifica después de crearse. Cada columna dentro del part es un fichero separado (.bin + .mrk para el índice de marks).
Cada INSERT produce uno o más parts. Esto explica por qué insertar fila a fila es un desastre: generas miles de parts pequeñas que el motor luego tiene que fusionar.
Merges
En background, ClickHouse va mergeando parts pequeñas en otras más grandes. De ahí el nombre del motor. Un merge lee varios parts ordenados, los combina, y escribe una part nueva. Cuando acaba, borra las antiguas.
Puedes ver los merges en curso:
SELECT * FROM system.merges;
Y las parts activas de una tabla:
SELECT
partition,
name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'pageviews' AND active
ORDER BY modification_time DESC;
ORDER BY: la clave de ordenación
La cláusula ORDER BY en la definición de la tabla determina cómo se ordenan los datos dentro de cada part. Esa ordenación es la que hace rápidas las consultas con filtros.
CREATE TABLE calls (
ts DateTime,
country LowCardinality(String),
customer UInt64,
duration UInt16
)
ENGINE = MergeTree()
ORDER BY (country, ts);
Si la mayoría de consultas filtran por país primero y luego por rango de tiempo, esta ORDER BY es ideal: los datos de un mismo país quedan contiguos en disco y el índice esparcido (sparse primary index) permite saltar directamente al rango.
Si invertías las consultas (filtras por ts casi siempre, rara vez por country), la mejor elección sería ORDER BY (ts, country).
Regla práctica: los campos más selectivos y más usados en WHERE van primero.
PRIMARY KEY vs ORDER BY
En ClickHouse, PRIMARY KEY no significa unicidad. Significa "el prefijo de la clave de ordenación que se indexa". Por defecto, PRIMARY KEY = ORDER BY.
Puedes separarlas si quieres ordenar por muchos campos pero indexar solo algunos:
ORDER BY (country, city, ts, customer_id)
PRIMARY KEY (country, city)
Esto reduce el tamaño del índice en memoria.
PARTITION BY
PARTITION BY divide físicamente la tabla en particiones independientes. No es lo mismo que en PostgreSQL: aquí no mejora las lecturas por sí solo (la clave de ordenación ya lo hace). Lo que hace es:
- Permitir operaciones en bloque por partición (
DROP PARTITION,DETACH PARTITION). - Separar datos que rara vez se consultan juntos.
- Facilitar la expiración por TTL, que veremos en la entrega IV.
Casi siempre se particiona por mes:
ENGINE = MergeTree()
PARTITION BY toYYYYMM(ts)
ORDER BY (country, ts);
No te pases particionando. Una partición por día sobre tres años son más de 1.000 particiones: demasiadas, afecta al rendimiento. Mes es el estándar para la mayoría de casos. Diaria solo si retienes pocos meses.
Variantes útiles de MergeTree
ReplacingMergeTree
Desduplica filas con la misma ORDER BY durante los merges. No es instantáneo: la desduplicación ocurre cuando el motor fusiona las parts.
CREATE TABLE users (
id UInt64,
email String,
updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;
Útil para tablas de "última versión conocida". Pero ojo: para consultar solo la última versión, necesitas FINAL:
SELECT * FROM users FINAL WHERE id = 42;
FINAL tiene coste. Para tablas grandes existe SELECT ... FROM users WHERE id = 42 ORDER BY updated_at DESC LIMIT 1 BY id.
SummingMergeTree
Suma automáticamente las columnas numéricas cuando fusiona filas con la misma ORDER BY. Útil para pre-agregaciones simples.
AggregatingMergeTree
La más potente de las tres: permite usar cualquier función de agregación, no solo suma. Es la base de los materialized views complejos que veremos en la entrega IV.
Un ejemplo completo
Diseñamos una tabla de CDRs (registros de llamadas), que es un caso típico donde ClickHouse brilla:
CREATE TABLE cdrs (
ts DateTime64(3, 'UTC'),
customer_id UInt32,
callee String,
caller String,
country LowCardinality(String),
duration_ms UInt32,
cost_eur Decimal(12, 6),
disposition LowCardinality(String),
sip_code UInt16
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(ts)
ORDER BY (customer_id, ts)
TTL toDateTime(ts) + INTERVAL 36 MONTH;
Decisiones aquí:
customer_idprimero enORDER BYporque casi todas las consultas filtran por cliente.tssegundo para que los rangos temporales de un cliente sean contiguos.- Partición mensual.
TTLde 36 meses: después, los datos se borran solos. Veremos TTL en detalle en la entrega IV.LowCardinalityencountryydisposition(ANSWERED, BUSY, NO ANSWER, FAILED).
Por dónde seguir
- III: consultas analíticas en profundidad — ahora que tienes tablas bien diseñadas, toca exprimir el lenguaje.
- IV: materialized views, projections y TTL — pre-agregaciones automáticas y gestión del ciclo de vida del dato.
- V: producción, replicación y clusters — lo que necesitas saber cuando dejas de hacer pruebas y esto se convierte en un servicio crítico.
Y si llegas tarde a la serie, empieza por la entrega I: instalación y primeros pasos.