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

*Segunda entrega de la serie **[ClickHouse desde cero a pro](/search?tag=clickhouse-desde-cero)**. Tiempo de lectura estimado: 11 minutos.*

En la [entrega anterior](/post/clickhouse-desde-cero-i-instalacion-y-primeros-pasos) 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

```sql
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

```sql
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

```sql
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

```sql
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.

```sql
CREATE TABLE events (
  ts DateTime64(3, 'UTC'),
  ...
) ENGINE = MergeTree() ORDER BY ts;
```

### Tipos compuestos

```sql
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](/post/clickhouse-desde-cero-iii-consultas-analiticas-en-profundidad).

### 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:

```sql
-- 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:

```sql
SELECT * FROM system.merges;
```

Y las *parts* activas de una tabla:

```sql
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.

```sql
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:

```sql
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](/post/clickhouse-desde-cero-iv-materialized-views-projections-y-ttl).

Casi siempre se particiona por mes:

```sql
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*.

```sql
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`:

```sql
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](/post/clickhouse-desde-cero-iv-materialized-views-projections-y-ttl).

## Un ejemplo completo

Diseñamos una tabla de CDRs (registros de llamadas), que es un caso típico donde ClickHouse brilla:

```sql
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](/post/clickhouse-desde-cero-iv-materialized-views-projections-y-ttl).
- `LowCardinality` en `country` y `disposition` (ANSWERED, BUSY, NO ANSWER, FAILED).

## Por dónde seguir

- **[III: consultas analíticas en profundidad](/post/clickhouse-desde-cero-iii-consultas-analiticas-en-profundidad)** — ahora que tienes tablas bien diseñadas, toca exprimir el lenguaje.
- **[IV: materialized views, projections y TTL](/post/clickhouse-desde-cero-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](/post/clickhouse-desde-cero-v-produccion-replicacion-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](/post/clickhouse-desde-cero-i-instalacion-y-primeros-pasos).
