Javier Valencia Javier Valencia
ClickHouse desde cero (II): tipos de datos y MergeTree

ClickHouse desde cero (II): tipos de datos y MergeTree

Javier Valencia · · 5 min de lectura · 40 visitas · Desarrollo
bases-de-datos tutorial clickhouse analytics clickhouse-desde-cero

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 BY y WHERE sobre 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_id primero en ORDER BY porque casi todas las consultas filtran por cliente.
  • ts segundo para que los rangos temporales de un cliente sean contiguos.
  • Partición mensual.
  • TTL de 36 meses: después, los datos se borran solos. Veremos TTL en detalle en la entrega IV.
  • LowCardinality en country y disposition (ANSWERED, BUSY, NO ANSWER, FAILED).

Por dónde seguir

Y si llegas tarde a la serie, empieza por la entrega I: instalación y primeros pasos.