ClickHouse® DB: Qué Es, Cómo Funciona y Cuándo Falla
Tesla ha metido más de mil billones de filas en ClickHouse. Cloudflare procesa 11 millones de filas por segundo. Netflix empuja unos 5 petabytes de logs diarios.
Son números reales. Estas empresas no tienen razón para inflar cifras.
En Numia tenemos ClickHouse con más de 100.000 millones de filas atendiendo 10 millones de peticiones al día. He dedicado incontables horas a debuggear queries, afinar schemas y descubrir a las malas qué funciona y qué no. Este post es lo que ojalá alguien me hubiera explicado antes de arrancar.
La mayoría de artículos tipo "qué es ClickHouse" no pasan del pitch comercial. Este va más al fondo. Cuando termines, vas a entender POR QUÉ ClickHouse está diseñado así, CUÁNDO tiene sentido usarlo y DÓNDE se rompe.
Por Qué Existe ClickHouse
En 2009 Yandex tenía un problema gordo. Su plataforma de analítica web, Yandex.Metrica, procesaba 200 millones de eventos al día. Para 2016 esa cifra se disparó a 25.000 millones diarios. El cuello de botella no era guardar datos, sino analizarlos en tiempo real sin pre-agregar nada.
Las soluciones de la época no daban la talla a esa escala. Los OLAP cubes exigían pre-agregación, lo que mataba la flexibilidad de queries. Bases de datos por filas como PostgreSQL no podían escanear miles de millones de filas a la velocidad que un dashboard interactivo necesita.
Las cifras que lo reventaron todo: 374 servidores, 20,3 billones de filas, 2 petabytes comprimidos. Los informes tardaban una media de 26 segundos en cargar. Después de migrar a ClickHouse: 0,8 segundos. Mismos informes, 32x más rápido. No por mejor hardware, sino por mejor arquitectura.
Dos decisiones lo hicieron posible:
- Almacenamiento columnar: leer solo los datos que tu query necesita
- Ejecución vectorizada: procesar datos en batches, no fila a fila
Todo el diseño de ClickHouse parte de estas dos ideas.
Almacenamiento Columnar
La diferencia entre almacenamiento por filas y columnar es lo que más pesa en el rendimiento de las queries.
Imagina una tabla sencilla:
CREATE TABLE events (
timestamp DateTime,
user_id UInt64,
event_type String
)
Almacenamiento por filas (PostgreSQL, MySQL):
[timestamp1, user_id1, event_type1], [timestamp2, user_id2, event_type2]...
Cada fila va junta en disco. Para leer una columna tienes que leerlas todas.
Almacenamiento columnar (ClickHouse):
[timestamp1, timestamp2...], [user_id1, user_id2...], [event_type1, event_type2...]
Cada columna vive por separado. Leer una columna significa leer solo esa columna.
Esto importa por cómo funcionan las queries analíticas. "¿Cuántos eventos por día?" solo necesita la columna timestamp. Una base de datos por filas lee el 100% de los datos. Una columnar lee ~33% (una de tres columnas). ¿Tabla con 50 columnas y tu query necesita 3? Por filas se lee todo. Columnar lee el 6%.
La otra cara: traer una fila completa es peor. Por filas se lee un bloque contiguo. Columnar tiene que ir a cada archivo de columna por separado, un acceso por columna. Con 50 columnas son 50 lecturas en vez de 1. Por eso las bases columnares son terribles para workloads tipo SELECT * WHERE id = 123.
Engines y MergeTree
MergeTree es donde ClickHouse hace el trabajo pesado. El nombre es literal: los datos llegan en partes y un proceso en background las va mergeando.
La jerarquía que necesitas entender:
- Tabla: colección de partes
- Parte: trozo de datos inmutable que se crea con cada
INSERT - Gránulo: la unidad más pequeña que ClickHouse lee (por defecto: 8.192 filas)
- Marca: puntero a la ubicación de un gránulo
A diferencia de las bases de datos tradicionales, que indexan cada fila (B-tree), ClickHouse usa un índice disperso: una entrada por gránulo, no una por fila. Cada entrada se llama marca y apunta al inicio de un gránulo en disco.
Con una tabla de 8,87 millones de filas:
- 8,87M / 8.192 = 1.083 gránulos
- El índice primario guarda una marca por gránulo
- Tamaño total del índice: 96,93 KB en memoria
El índice primario entero cabe en RAM. Siempre. Es así por diseño. Al lanzar una query, ClickHouse hace búsqueda binaria sobre las marcas para localizar qué gránulos pueden contener tus datos. Con 1.083 marcas son ~10 pasos. Luego lee como mucho 8.192 filas por gránulo coincidente.
Crear una tabla MergeTree:
CREATE TABLE events (
timestamp DateTime,
user_id UInt64,
event_type LowCardinality(String),
payload String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (event_type, user_id, timestamp);
Dos decisiones clave aquí:
PARTITION BY controla la separación física. Particionar por mes hace que los datos de enero vivan en archivos distintos a los de febrero. Las queries que filtran por mes se saltan particiones enteras.
ORDER BY controla el orden dentro de cada parte.
El flujo de datos va así:
INSERTcrea una parte nueva (inmutable)- Un thread en background mergea partes pequeñas en otras más grandes
- Los merges pueden disparar computación extra en engines especializados
Las variantes más relevantes:
| Motor | Propósito |
|---|---|
MergeTree | Motor base |
ReplacingMergeTree | Deduplica durante el merge |
AggregatingMergeTree | Pre-agrega durante el merge |
Por Qué ORDER BY Importa
El índice disperso solo puede filtrar si los datos están ordenados de forma que las filas relevantes caigan en los mismos gránulos. Eso es justo lo que hace ORDER BY. Controla cómo se ordenan las filas dentro de cada parte, y eso determina cuántos gránulos puede saltarse ClickHouse.
Mira esta query:
SELECT * FROM events
WHERE event_type = 'click' AND user_id = 12345;
Con ORDER BY (event_type, user_id, timestamp):
- La búsqueda binaria localiza los gránulos de
event_type = 'click' - Dentro de esos, localiza los de
user_id = 12345 - Escanea como mucho 8.192 filas
Con ORDER BY (timestamp, event_type, user_id):
event_typeva segundo en el orden- El índice no puede filtrar bien
- Acaba escaneando potencialmente millones de filas
Misma query, mismos datos, distinto ORDER BY. La diferencia puede ser de 100x en tiempo.
La regla de oro: primero patrones de query, luego cardinalidad. Tu ORDER BY debería arrancar con las columnas que más aparecen en tus WHERE. Una columna por la que nunca filtras no aporta nada al índice, da igual su cardinalidad.
Una vez que tengas claras las columnas por las que realmente consultas, ordénalas de menor a mayor cardinalidad. ¿Por qué? Una columna con pocos valores distintos al principio descarta bloques enormes de gránulos de un tirón. Si event_type tiene 10 valores únicos y user_id tiene 1 millón, poner event_type primero elimina ~90% de los gránulos de entrada. Dale la vuelta y user_id no te sirve porque cada gránulo mezcla muchos user_ids distintos.
Por ejemplo, si siempre filtras por tenant_id y timestamp pero rara vez por event_type, tu ORDER BY debería ser (tenant_id, timestamp) aunque event_type tenga menor cardinalidad. Un índice que no casa con tus queries es ordenación tirada a la basura.
La compresión también gana con un buen orden. La documentación muestra 3:1 con un orden pobre y 39:1 con uno óptimo. Mismos datos, 13x mejor compresión solo por cómo ordenas las columnas.
Ejecución Vectorizada
Las bases de datos tradicionales procesan fila por fila:
por cada fila:
aplicar filtro
calcular agregación
ClickHouse procesa en batches:
por cada batch de 65.536 filas:
aplicar filtro al batch completo (SIMD)
calcular agregación del batch (SIMD)
SIMD significa Single Instruction, Multiple Data. Una sola instrucción de CPU procesa varios valores a la vez. Las CPUs modernas soportan AVX2 (4 doubles de golpe) y AVX-512 (8 doubles de golpe).
ClickHouse trae múltiples implementaciones SIMD por operador y elige la adecuada según tu CPU en tiempo de ejecución. Además tiene más de 30 variantes precompiladas de tablas hash para distintos patrones de datos.
En la práctica esto se traduce en agregaciones 10-100x más rápidas que el procesamiento fila por fila. Por eso ClickHouse agrega miles de millones de filas en segundos mientras PostgreSQL tarda minutos con los mismos datos.
Compresión
El almacenamiento columnar tiene otra ventaja: valores parecidos quedan juntos, y los valores parecidos se comprimen de lujo. Timestamps al lado de timestamps logran ratios de 10:1 a 30:1. Con secuencias monótonas, ClickHouse llega a 800:1 con encoding DoubleDelta (más abajo lo explico).
Cada columna en disco se comprime. Cuanto menos ocupe, menos I/O necesita tu query. ClickHouse aplica compresión en dos capas:
- Un códec especializado transforma los datos para que sean más compresibles
- Un algoritmo de compresión general los reduce de verdad
Primero doblas todo con cuidado, luego sellas la bolsa al vacío.
Compresión General
Funcionan con cualquier tipo de dato. Se elige uno según un tradeoff sencillo:
| Códec | Velocidad | Compresión |
|---|---|---|
LZ4 | Descompresión más rápida | Buena (reducción 2-3x) |
ZSTD | Ligeramente más lento | ~30% mejor que LZ4 |
Los dos descomprimen más rápido de lo que la mayoría de SSDs pueden leer, así que en la práctica apenas notas diferencia. LZ4 es el default y una apuesta segura. Usa ZSTD cuando el coste de almacenamiento te importe más que el ahorro marginal de CPU.
Códecs Especializados
No comprimen por sí mismos. Transforman los datos a una forma que la compresión general maneja mucho mejor. Cada uno ataca un patrón concreto:
Delta guarda la diferencia entre valores consecutivos en vez de los valores en sí.
Original: [1000, 1001, 1002, 1003]
Tras Delta: [1000, 1, 1, 1]
Una columna llena de números pequeños como 1, 1, 1 se comprime mucho mejor que 1000, 1001, 1002. Perfecto para timestamps y cualquier columna que crece de forma monótona.
DoubleDelta va un paso más allá y guarda el delta de los deltas.
Original: [1000, 1005, 1010, 1015]
Tras Delta: [1000, 5, 5, 5]
Tras DoubleDelta: [1000, 5, 0, 0, 0]
Cuando el intervalo entre valores es constante (un sensor que reporta cada 5 segundos, por ejemplo), el segundo delta es todo ceros. Puede llegar a 800:1 en series temporales regulares. Perfecto para contadores y secuencias con intervalo fijo.
Gorilla usa encoding XOR, pensada para números de punto flotante. Hace XOR de cada valor con el anterior. Cuando los floats consecutivos se parecen, el resultado es casi todo ceros.
Original: [36.6, 36.7, 36.6, 36.8]
Tras XOR: [36.6, (mayoritariamente ceros), (mayoritariamente ceros), (mayoritariamente ceros)]
Va genial para métricas y mediciones donde los valores no pegan saltos grandes entre filas.
Juntando Las Piezas
Se combina un códec especializado con uno general por columna, eligiendo según el tipo de dato:
CREATE TABLE metrics (
timestamp DateTime CODEC(Delta, ZSTD),
value Float64 CODEC(Gorilla, LZ4),
counter UInt64 CODEC(DoubleDelta, ZSTD)
)
Si no tienes claro cuál elegir, los defaults (LZ4, sin códec especializado) van sobrados. Añade códecs especializados cuando conozcas bien los patrones de tus datos y quieras exprimir más compresión.
Cuándo ClickHouse Brilla
ClickHouse brilla cuando toca escanear mucho dato y agregarlo. Análisis de logs, series temporales, tracking de eventos, dashboards en tiempo real. Cloudflare mete 11M filas/seg por él. PostHog montó todo su producto de analytics encima. Los timestamps se ordenan de forma natural y comprimen bien con encoding Delta, así que las cargas de series temporales son especialmente rápidas.
Los patrones de query que van bien: agregaciones (COUNT, SUM, AVG) sobre millones de filas, filtros por rango temporal tipo WHERE timestamp > now() - INTERVAL 7 DAY, y GROUP BY en columnas de baja cardinalidad como país o tipo de dispositivo.
Algunos números reales de producción:
| Empresa | Escala |
|---|---|
| Tesla | Más de 1 mil billones de filas |
| Netflix | ~5 PB logs/día |
| Cloudflare | 11M filas/seg, 47 Gbps |
| Yandex | 20,3 billones de filas (2014) |
Cuándo ClickHouse Se Rompe
ClickHouse no es una base de datos de propósito general. Me he estampado contra cada una de estas paredes en producción.
No hay transacciones ACID ni bloqueo a nivel de fila. Si necesitas garantías transaccionales, tira de PostgreSQL.
Los point lookups en columnas que no están en el ORDER BY duelen:
-- Lento si user_id no es lo primero en ORDER BY
SELECT * FROM events WHERE user_id = 12345;
El índice disperso no consigue saltarse gránulos bien, así que terminas escaneando todo.
Los updates no van como esperas. ClickHouse no actualiza in place. Puedes recurrir a ReplacingMergeTree, pero el timing del merge no está garantizado. CollapsingMergeTree funciona, aunque exige delete/insert explícito con columnas de signo. Nada de esto es tan limpio como un UPDATE ... WHERE.
Los JOINs en columnas de alta cardinalidad son otro punto débil:
-- Millones de claves de join únicas = dolor
SELECT * FROM events e JOIN users u ON e.user_id = u.id;
El lado derecho del JOIN se carga entero en memoria. Si la tabla es grande, OOM seguro. La salida es denormalizar tus datos o usar diccionarios.
Y por último, los inserts pequeños y frecuentes. Cada INSERT crea una parte en disco. Miles de inserts pequeños generan miles de partes, y eso acumula overhead de merge. Agrupa tus inserts (1.000-100.000 filas por batch).
El Modelo Mental
ClickHouse es rápido porque solo lee las columnas que necesitas, tiene el índice primario entero en RAM (96 KB para 8,87 millones de filas), procesa filas en batches SIMD y comprime todo 10-30x para que haya menos que leer de disco.
Se rompe cuando necesitas transacciones, lookups rápidos en columnas no indexadas, updates frecuentes, JOINs con millones de claves únicas o inserts de filas sueltas a alto volumen.
Ese es el tradeoff. ClickHouse sacrifica flexibilidad de escritura y garantías transaccionales a cambio de velocidad de lectura en queries analíticas. Si eso encaja con tu workload, nada se le acerca. Si no, vas a pelearte con el sistema a cada paso.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com