ClickHouse: OLAP Columnar para Analytics en Tiempo Real sobre 10 Mil Millones de Filas
Almacenamiento Columnar y Rendimiento de Queries Analíticas
En una base de datos orientada a filas como PostgreSQL, los datos se almacenan fila por fila. Cuando corrés SELECT country, COUNT(*) FROM events WHERE event = 'click' GROUP BY country, PostgreSQL lee cada fila del disco, incluyendo columnas que no se necesita. En una tabla con 50 columnas, se está leyendo 50x más datos de lo necesario.
ClickHouse almacena datos columna por columna. Esa misma query solo lee las columnas event y country. En una tabla con 10 mil millones de filas y 50 columnas, eso es ~98% menos I/O. Pero mejora — cada columna se comprime independientemente. La columna event puede tener solo 20 valores distintos en 10 mil millones de filas, que se comprime de 80GB (strings crudos) a ~500MB con LZ4 + dictionary encoding.
MergeTree: El Motor Que Lo Hace Funcionar
Cada tabla de ClickHouse está respaldada por un motor de tabla. MergeTree es el motor principal para workloads analíticos:
CREATE TABLE events (
tenant_id UInt32,
event_date Date,
event_time DateTime64(3),
event_type LowCardinality(String),
user_id UInt64,
session_id String,
page_url String,
country LowCardinality(FixedString(2)),
device_type LowCardinality(String),
duration_ms UInt32,
properties Map(String, String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_type, user_id)
TTL event_date + INTERVAL 13 MONTH
SETTINGS index_granularity = 8192;
Decisiones clave de diseño:
PARTITION BY: Particiones mensuales por fecha de evento. Las particiones son la unidad de gestión de datos — es posible eliminar, mover o desprender meses enteros eficientemente.
ORDER BY: Esta es la clave primaria y la decisión más importante. ClickHouse almacena datos ordenados por esta clave y construye un índice sparse (una entrada por cada index_granularity filas) para data skipping. Las queries que filtran por las columnas líderes de la clave ORDER BY saltean bloques enteros de datos sin leerlos.
tenant_id va primero porque cada query en un sistema multi-tenant filtra por tenant. Después event_date para queries de rango temporal, event_type para filtrado por categoría, y user_id para drilldowns a nivel de usuario.
Vistas Materializadas: Pre-Agregación en Tiempo de Ingesta
Las vistas materializadas en ClickHouse no son lo que podrías esperar de PostgreSQL — se actualizan incrementalmente al momento del insert, no se refrescan periódicamente:
CREATE MATERIALIZED VIEW events_daily_mv
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_type, country)
AS SELECT
tenant_id,
event_date,
event_type,
country,
count() AS event_count,
uniqState(user_id) AS unique_users,
sumState(duration_ms) AS total_duration_ms
FROM events
GROUP BY tenant_id, event_date, event_type, country;
Cuando hacés INSERT INTO events, ClickHouse automáticamente transforma las nuevas filas a través del SELECT de la vista materializada e inserta los resultados agregados en la tabla events_daily_mv.
SELECT
event_date,
event_type,
sum(event_count) AS events,
uniqMerge(unique_users) AS users,
sumMerge(total_duration_ms) / sum(event_count) AS avg_duration_ms
FROM events_daily_mv
WHERE tenant_id = 42
AND event_date BETWEEN '2026-01-01' AND '2026-01-31'
GROUP BY event_date, event_type
ORDER BY event_date;
Esta query escanea la tabla pre-agregada (~10.000 filas para un mes) en vez de la tabla de eventos crudos (~500 millones de filas para el mismo tenant y período). Tiempo de respuesta: 15ms vs 1.800ms.
Un setup típico incluye cuatro vistas materializadas sobre la tabla de eventos. Overhead total de almacenamiento para las cuatro tablas MV: ~3% de la tabla de eventos crudos. La mejora de rendimiento de queries es 50-200x para queries de dashboard.
Optimización de Queries en 10B Filas
Las técnicas de optimización reales que importan a escala:
1. Filtrar por columnas del ORDER BY
-- Rápido: tenant_id y event_date son las primeras dos columnas del ORDER BY
SELECT event_type, count()
FROM events
WHERE tenant_id = 42 AND event_date = '2026-03-15'
GROUP BY event_type;
-- Escanea: ~2M filas de 10B (0.02%)
-- Lento: country no está en la clave ORDER BY
SELECT country, count()
FROM events
WHERE country = 'US'
GROUP BY country;
-- Escanea: las 10B filas todas
2. Funciones aproximadas para analytics a gran escala
-- Conteo de únicos exacto: 4.2 segundos en 10B filas
SELECT uniqExact(user_id) FROM events WHERE tenant_id = 42;
-- Conteo de únicos aproximado (2% error): 0.3 segundos
SELECT uniq(user_id) FROM events WHERE tenant_id = 42;
Para dashboards donde +-2% de precisión está bien, uniq() (HyperLogLog) es 14x más rápido que uniqExact(). Los stakeholders de negocio nunca notaron la diferencia.
ClickHouse vs DuckDB vs Druid: Análisis Comparativo
Evaluando workloads de producción en los tres, cada uno brilla en diferentes áreas:
| Criterio | ClickHouse | DuckDB | Druid | |----------|-----------|--------|-------| | Escala de datos | 100GB - PB | 1GB - 100GB | 100GB - PB | | Usuarios concurrentes | Docenas a cientos | Un solo usuario | Cientos a miles | | Ingesta | Batch (alto throughput) | Batch/archivo | Streaming tiempo real | | Latencia de query (10B filas) | 200ms - 5s | N/A (solo local) | 100ms - 2s | | Complejidad operacional | Media | Ninguna (embebido) | Alta | | Soporte SQL | SQL analítico completo | SQL completo + extensiones | SQL limitado | | Mejor para | API backend de analytics | Data science, análisis local | Dashboards tiempo real |
DuckDB es notable por lo que hace — queries analíticos en archivos locales con cero infraestructura. Es excelente para prototipar queries antes de deployar a ClickHouse y para análisis ad-hoc en exports CSV/Parquet. Pero no es un servidor. No es posible apuntar 50 usuarios de dashboard a una instancia de DuckDB.
Druid está construido a propósito para analytics en tiempo real con queries sub-segundo en dimensiones de muy alta cardinalidad. Maneja queries concurrentes mejor que ClickHouse a escala extrema. Pero la complejidad operacional de Druid es significativamente mayor — tiene seis tipos de nodos, cada uno necesitando capacity planning separado. ClickHouse tiene un tipo de nodo.
La Arquitectura de Producción
Kafka ──► Buffer Table ──► Events Table ──► Vistas Materializadas
│
┌───────┼───────┐
│ │ │
Queries Queries Queries
Dashboard API Ad-hoc
Cluster ClickHouse: 3 nodos, cada uno con 32 cores, 128GB RAM, 4TB NVMe SSD. ReplicatedMergeTree con 2 réplicas para tolerancia a fallos.
CREATE TABLE events_buffer AS events
ENGINE = Buffer(currentDatabase(), events, 16, 10, 100, 10000, 100000, 1000000, 10000000);
La Buffer table acumula inserts en memoria y flushea a la tabla events subyacente en batches. Esto convierte los inserts por mensaje del consumidor de Kafka en batch inserts eficientes de 10.000-100.000 filas.
Tasa de ingesta: ~120.000 eventos/segundo sostenidos. Carga de queries: ~200 queries/segundo entre todos los usuarios de dashboard. Latencia P99 para queries de dashboard (pegando a vistas materializadas): 180ms. P99 para queries ad-hoc en eventos crudos (un mes completo, un solo tenant): 3,2 segundos.
Todo corre en tres servidores que cuestan alrededor de $2.400/mes. El setup equivalente en PostgreSQL (que ni siquiera podía servir las queries a tiempo) estaba corriendo en un r6g.8xlarge ($2.800/mes) y seguía tirando timeout.
ClickHouse no es magia. Son trade-offs de ingeniería hechos explícitos: entregás transacciones, updates a nivel de fila, y garantías ACID. A cambio, obteners rendimiento de queries analíticos que es genuinamente 100-1000x más rápido que bases de datos de propósito general. Para workloads de analytics, ese trade-off es obvio una vez que viste los números.