La Dura Verdad Sobre Self-Hosting ClickHouse® Infra A Escala
Introducción
ReplicatedMergeTree me fundió la cabeza.
En Numia, servimos APIs de blockchain en tiempo real a escala cercana al petabyte. ClickHouse gestiona esta carga de trabajo de maravilla. Almacenamiento columnar, ejecución vectorizada, ratios de compresión que hacen los costes de almacenamiento casi irrelevantes. Para consultas analíticas en tiempo real sobre miles de millones de filas, nada se le acerca.
Pero esto es lo que la documentación no te prepara: la configuración distribuida es donde ClickHouse deja de ser una base de datos y se convierte en un problema de sistemas distribuidos que tú eres responsable de resolver si no quieres pagar por la versión cloud.
Pasamos 12 meses construyendo herramientas para gestionar clústeres de ClickHouse. Una CLI que abstraía la complejidad. Skills de IA que permitían a nuestros agentes crear tablas, ejecutar migraciones, configurar pipelines. Nuestra tasa de errores bajó. Y entonces lo borramos todo. No lo refactorizamos. No lo simplificamos. Lo borramos. Dos mil líneas de código, eliminadas.
Este artículo trata sobre por qué. Trata sobre el problema arquitectónico que ninguna cantidad de herramientas podía solucionar, y cómo finalmente construimos algo que hizo desaparecer la complejidad.
Si alguna vez te has preguntado por qué tu clúster de ClickHouse parece sostenerse con cinta adhesiva y optimismo, estás en el lugar correcto. Si alguna vez has depurado una cola de replicación a las 3 de la madrugada mientras un cliente se quejaba, puede que esta historia te resulte familiar. Y si estás evaluando ClickHouse e intentando entender en qué te estás metiendo, considera esto tu guía de campo sobre la configuración distribuida de la que nadie te advierte.
Déjame enseñarte exactamente qué salió mal.
El Problema De Las N*X+1 Tablas
Cada tabla lógica en un clúster distribuido de ClickHouse se convierte en N*X+1 tablas físicas. Una tabla en tu modelo mental, un montón en la realidad. Así es como sucede.
Un clúster distribuido reparte el procesamiento y el almacenamiento entre máquinas llamadas shards. Quieres una tabla llamada events, pero no puedes simplemente crear events. Necesitas una tabla _local en cada shard que almacene los datos de verdad:
-- La tabla local en cada shard (el esquema se define aquí)
CREATE TABLE events_local ON CLUSTER '{cluster}' (
chain_id UInt32,
block_number UInt64,
tx_hash String,
timestamp DateTime,
event_type LowCardinality(String),
payload String
) ENGINE = ReplicatedMergeTree(
'events_local',
'{replica}'
)
PARTITION BY toYYYYMM(timestamp)
ORDER BY (chain_id, block_number, tx_hash);
Con tres shards, son tres tablas físicas: events_local en el shard 1, shard 2 y shard 3. Pero los shards solos no te dan tolerancia a fallos. Si uno cae, los datos que contiene dejan de estar disponibles. Así que añades réplicas, copias de cada shard que pueden servir lecturas y tomar el relevo si el primario falla. Con tres shards y dos réplicas cada uno, ahora tienes seis tablas físicas:
events_localen shard 1, réplica 1events_localen shard 1, réplica 2events_localen shard 2, réplica 1events_localen shard 2, réplica 2events_localen shard 3, réplica 1events_localen shard 3, réplica 2
Seis tablas, pero aún no puedes consultarlas directamente sin saber qué shard tiene qué datos. Ahí es donde entra la tabla Distributed: una fachada que lee de todas las tablas locales para que tú no tengas que hacerlo.
-- La tabla Distributed (solo lógica de enrutamiento)
CREATE TABLE events AS events_local
ENGINE = Distributed(
'{cluster}',
default,
events_local,
sipHash64(chain_id, tx_hash)
);
Ese sipHash64(chain_id, tx_hash) es tu clave de sharding. Determina qué tabla local recibe cada fila según los valores de las columnas que elijas. Elige columnas con distribución de valores desbalanceada y algunos shards almacenarán más datos que otros, hundiendo el rendimiento.
Ahora las consultas llegan a la tabla Distributed, que paraleliza entre shards y agrega resultados. Las inserciones también pasan por ella, enrutadas al shard correcto. Pero es una entidad más que mantener. Si los esquemas divergen entre las tablas locales y la Distributed, las consultas fallan silenciosamente o devuelven resultados incorrectos.
Así que aquí estamos: siete entidades físicas para una tabla lógica. N shards x X réplicas + 1 fachada Distributed. Escala eso a decenas de tablas y entenderás por qué los clústeres de ClickHouse resultan frágiles. Querías una base de datos; lo que tienes es un sistema distribuido que gestionar. Y cada hora dedicada a esa complejidad es una hora no dedicada a tu producto.
El Infierno De Los ALTER Y Los Desastres Del DROP
El problema de las N*X+1 empeora en el momento en que necesitas cambiar algo. Imagina que necesitas añadir una columna. Con una base de datos normal, ejecutarías un ALTER y seguirías con tu vida. Con ReplicatedMergeTree, necesitas dos operaciones como mínimo. Si tienes mala suerte, te toca arreglar un desastre.
-- Paso 1: ALTER de las tablas locales en todos los shards
ALTER TABLE events_local ON CLUSTER '{cluster}'
ADD COLUMN new_field String DEFAULT '';
-- Paso 2: ALTER de la tabla Distributed por separado
ALTER TABLE events
ADD COLUMN new_field String DEFAULT '';
¿Por qué por separado? Porque la tabla Distributed no participa en la cláusula ON CLUSTER. No es una tabla replicada. Es una capa de enrutamiento que resulta que tiene su propia definición de esquema que tiene que coincidir con las tablas locales.
Se supone que la cláusula ON CLUSTER facilita esto. Ejecutas el comando una vez y ClickHouse lo propaga a todos los shards. En la práctica, tiene varios modos de fallo:
- Aplicación parcial: el ALTER tiene éxito en algunos shards pero falla en otros. Ahora tu clúster tiene un esquema inconsistente.
- Fallos por timeout: clústeres grandes o redes lentas hacen que la operación expire, dejándote en la incertidumbre de qué ha pasado realmente.
- Contención de ZooKeeper: bajo carga, ZooKeeper puede convertirse en un cuello de botella, haciendo que los ALTERs se encolen o fallen.
Cuando ON CLUSTER falla a mitad de camino, estás en un escenario de recuperación. Necesitas averiguar qué shards tienen el nuevo esquema y cuáles no. Luego tienes que aplicar manualmente el ALTER en los shards que fallaron y esperar que nada más haya cambiado mientras tanto.
Las operaciones DROP tienen el mismo problema de coordinación, pero las consecuencias son distintas. Una tabla Distributed y sus tablas locales subyacentes deben eliminarse juntas. Olvidar cualquiera de las dos deja tu clúster en un estado inconsistente.
-- Olvidar la tabla local
DROP TABLE events ON CLUSTER '{cluster}';
-- Resultado: las consultas fallan inmediatamente con "Table doesn't exist" y el espacio sigue ocupado en disco
-- Olvidar la tabla Distributed
DROP TABLE events_local ON CLUSTER '{cluster}';
-- Resultado: la tabla Distributed sigue existiendo pero las consultas fallan con "Table events_local doesn't exist" en cada shard
TRUNCATE en tablas grandes es su propia aventura. ReplicatedMergeTree almacena datos en partes, y truncar una tabla con miles de partes puede llevar horas. Durante esa ventana, la tabla está en un estado intermedio extraño. Aprendimos a ser creativos: a veces es más rápido crear una tabla nueva, intercambiar los nombres y eliminar la antigua que truncar in situ.
Las reingestiones son lo peor. Corregir un bug en el pipeline, rellenar un campo nuevo, corregir datos erróneos: estás eliminando terabytes y reinsertándolos. La cola de replicación se atasca mientras cada réplica sincroniza eliminaciones y luego inserciones. A nuestra escala, una reingestión completa tardaba días. El clúster entero se ralentizaba y las consultas expiraban. ¿Y si algo fallaba a mitad de camino? Datos parciales entre réplicas sin una recuperación limpia. A empezar de cero.
Cada operación se convirtió en una ceremonia de múltiples pasos con planes de rollback. Documentamos la secuencia exacta, las consultas de verificación a ejecutar entre cada paso y los procedimientos de recuperación para fallos parciales. Esa documentación era más larga que la mayoría de especificaciones de funcionalidades.
ZooKeeper: El Impuesto Oculto
ReplicatedMergeTree no solo añade complejidad a tu clúster de ClickHouse. Añade un componente completamente separado que necesitas ejecutar, monitorizar y depurar.
ZooKeeper es la capa de coordinación. Es lo que hace que la parte "Replicated" de ReplicatedMergeTree funcione. Cada tabla replicada se registra en ZooKeeper. Cada réplica rastrea su estado allí. La cola de replicación vive allí. Las elecciones de líder suceden allí.
Esto es lo que ZooKeeper realmente hace por tu clúster de ClickHouse:
Seguimiento de partes: cuando una réplica ingesta datos, crea una parte. ZooKeeper rastrea qué réplicas tienen qué partes, para que las otras sepan lo que necesitan descargar.
Cola de replicación: cuando la réplica A tiene una parte que la réplica B no tiene, ZooKeeper encola una tarea de descarga. La réplica B extrae la parte de A. La cola rastrea progreso, reintentos y fallos.
Liderazgo: para operaciones que necesitan coordinación (como los merges), ZooKeeper gestiona la elección de líder. Solo una réplica fusiona a la vez para evitar conflictos.
Metadatos: esquemas de tablas, información de particiones, topología del clúster. Todo almacenado en ZooKeeper.
Este es un diseño elegante de sistemas distribuidos. También es otro componente que ejecutar y un punto único de fallo.
ZooKeeper necesita su propia configuración de alta disponibilidad. Necesitas al menos tres nodos de ZooKeeper para quórum. Tienen que estar en máquinas separadas para tolerancia a fallos. Necesitan monitorización y procedimientos de backup. Necesitan planificación de capacidad a medida que tu clúster de ClickHouse crece.
Y tienen sus propios modos de fallo:
Expiración de sesión: si un shard de ClickHouse pierde conexión con ZooKeeper durante demasiado tiempo, su sesión expira. El shard cree que sigue siendo parte del clúster, pero ZooKeeper no está de acuerdo. La recuperación implica reiniciar servicios y esperar que la cola de replicación se ponga al día.
Acumulación de cola: bajo carga de escritura intensa, la cola de replicación puede crecer más rápido de lo que las réplicas pueden procesarla. Las partes se acumulan. Las réplicas se quedan atrás. Los resultados de las consultas se vuelven inconsistentes según qué réplica consultes. Las tareas en segundo plano se apilan.
Escenarios de split brain: las particiones de red entre nodos de ZooKeeper pueden causar problemas de quórum. Los shards de ClickHouse no pueden escribir porque no pueden coordinarse. Tu clúster está técnicamente activo pero funcionalmente caído.
He pasado más horas depurando problemas de ZooKeeper que depurando consultas reales de ClickHouse. Ya no estás construyendo un producto. Estás operando un sistema distribuido. La base de datos te dio las primitivas; tú construiste la capa de coordinación.
A las 3 de la madrugada, cuando un cliente se queja de datos que faltan, lo último que quieres oír es "la cola de replicación está atascada." Pero eso es lo que significa ejecutar ReplicatedMergeTree a escala. Te conviertes en un experto en ZooKeeper lo quieras o no.
El Intento Con Herramientas
No aceptamos esta complejidad callados. Construimos herramientas para gestionarla. La primera capa fue una CLI. Abstraía el patrón de creación dual de tablas. Ejecutabas un solo comando y generaba tanto la tabla _local como la Distributed. Gestionaba las cláusulas ON CLUSTER. Manejaba la configuración de la clave de sharding. Verificaba que todos los shards recibieran el esquema antes de reportar éxito.
# Versión simplificada de cómo se veía la capa de abstracción
class ClickHouseTableManager:
def create_table(self, name: str, schema: Schema) -> Result:
# Paso 1: Crear tabla local en el clúster
local_result = self.execute_on_cluster(
f"CREATE TABLE {name}_local {schema.to_ddl()} "
f"ENGINE = ReplicatedMergeTree(...)"
)
if not local_result.all_succeeded():
return self.rollback_partial_create(name, local_result)
# Paso 2: Verificar que todos los shards tienen la tabla
verification = self.verify_table_exists_all_shards(f"{name}_local")
if not verification.passed():
return self.rollback_and_report(name, verification)
# Paso 3: Crear tabla distributed
dist_result = self.execute(
f"CREATE TABLE {name} AS {name}_local "
f"ENGINE = Distributed(...)"
)
if not dist_result.succeeded():
return self.rollback_distributed_failure(name, dist_result)
# Paso 4: Verificación final
return self.verify_full_setup(name)
La segunda capa fueron skills de ClickHouse para nuestros agentes de IA. Queríamos que Claude pudiera crear tablas, ejecutar migraciones, configurar pipelines en tiempo real y probar consultas. Los skills envolvían la CLI, añadiendo comprensión de lenguaje natural sobre la abstracción.
Ayudó y nuestra tasa de errores bajó. Los nuevos miembros del equipo podían trabajar con ClickHouse sin entender el patrón N+1. El camino feliz era genuinamente feliz. Pero los sistemas distribuidos tienen muchos caminos infelices.
El primer caso límite fueron los fallos parciales de ALTER. El comando ON CLUSTER expiraba en un shard pero tenía éxito en otros. La CLI lo detectaba, pero ¿cuál es la recuperación? No puedes simplemente reintentar en el shard que falló porque quizá sí tuvo éxito y fue el acknowledge lo que se perdió. Necesitas comprobar el esquema en cada shard, compararlos y decidir qué hacer.
El segundo caso límite fue el DROP en cascada incorrecto. Una vista materializada referenciaba una tabla que estábamos eliminando. El DROP tuvo éxito pero la vista materializada quedó rota. La CLI no rastreaba dependencias de vistas. Lo añadimos. Luego encontramos vistas que referenciaban vistas.
El tercer caso límite fue la deriva de réplicas. Tras una partición de red, dos réplicas tenían datos divergentes. ZooKeeper las mostraba como sanas. Las consultas devolvían resultados diferentes según qué réplica consultaras. La CLI no tenía forma de detectar esto porque desde su perspectiva, todo estaba bien.
Cada bug que corregíamos revelaba otro que no habíamos considerado. Las herramientas se complicaban más. Añadimos lógica de reintentos con backoff exponencial. Añadimos utilidades de comparación de esquemas. Añadimos health checks que consultaban cada réplica y comparaban resultados. Añadimos procedimientos de rollback que podían deshacer operaciones parciales. Añadimos logging tan detallado que generaba gigabytes al día.
La CLI creció hasta miles de líneas. Los skills de IA se volvieron frágiles porque dependían de que la CLI se comportara de forma predecible, y la CLI intentaba manejar comportamiento impredecible de sistemas distribuidos.
Estábamos construyendo un framework de sistemas distribuidos sobre una base de datos que se suponía que debía manejar la distribución por nosotros.
La Revelación Arquitectónica
La revelación llegó durante otra sesión de depuración más. Un ALTER fallido había dejado dos shards con esquemas diferentes. La CLI lo detectó pero no podía resolverlo automáticamente porque teníamos escrituras concurrentes. Necesitábamos parar las escrituras, arreglar el esquema, verificar la consistencia y reanudar. Era la tercera vez ese mes.
El problema no eran nuestras herramientas. Era la arquitectura. ReplicatedMergeTree requiere que gestiones la distribución en la capa de aplicación. La base de datos te da primitivas: partes, colas de replicación, coordinación con ZooKeeper. Tú eres responsable de ensamblar esas primitivas en un sistema distribuido funcional. Tú gestionas el sharding, la consistencia y la recuperación ante fallos.
Ninguna cantidad de herramientas puede arreglar un desajuste arquitectónico. Puedes suavizar las aristas, automatizar el camino feliz, manejar algunos casos de fallo. Pero no puedes cambiar el hecho de que estás operando un sistema distribuido que resulta que usa ClickHouse para almacenamiento.
La solución era arquitectónica: desacoplar almacenamiento y cómputo. En lugar de que cada réplica mantuviera su propia copia de los datos, todos los nodos de cómputo leen del mismo backend de almacenamiento. Sin réplicas locales, sin colas de replicación, sin coordinación de ZooKeeper para la consistencia de datos. Una definición de tabla, un ALTER, una fuente de verdad.
Así que lo construimos.
MergeTree Cloud-Native
MergeTree cloud-native cambia cómo funciona ClickHouse. En lugar de distribuir datos entre nodos que mantienen cada uno su propia copia, centraliza los datos en almacenamiento de objetos y convierte los nodos de cómputo en stateless.
-- Una tabla. Eso es todo. Sin sufijo _local. Sin fachada Distributed.
CREATE TABLE events (
chain_id UInt32,
block_number UInt64,
tx_hash String,
timestamp DateTime,
event_type LowCardinality(String),
payload String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (chain_id, block_number, tx_hash);
Misma tabla lógica, pero ahora realmente es una tabla. No siete.
Cada nodo de cómputo se conecta al mismo backend de almacenamiento compatible con S3. Cuando llega una consulta, el nodo lee las partes relevantes directamente del almacenamiento, las procesa y devuelve resultados. Sin almacenamiento local de datos más allá del caché. Sin sincronización entre nodos. Solo leer y computar.
Con ReplicatedMergeTree, los shards se coordinaban porque cada uno contenía copias diferentes de los datos. ZooKeeper rastreaba quién tenía qué y las colas de replicación aseguraban consistencia eventual. Con MergeTree cloud-native, no hay nada que coordinar. Cada nodo ve los mismos datos porque todos leen del mismo sitio.
Los ALTER se convierten en una sola sentencia, sin ON CLUSTER. Sin operaciones duales. El esquema vive en un solo lugar.
ALTER TABLE events ADD COLUMN new_field String DEFAULT '';
Añadir capacidad pasa de días a minutos. Con ReplicatedMergeTree, añadir un shard significaba copiar más de 25 TB de datos. Con MergeTree cloud-native, el nuevo nodo se conecta al almacenamiento compartido y empieza a servir consultas inmediatamente.
Construimos esto contra almacenamiento compatible con S3 permitiendo que el cómputo escale independientemente del almacenamiento. Tardamos meses en validarlo a escala de producción, pero lo conseguimos.
La Migración Y La Eliminación
La migración fue anticlimática. Clúster nuevo junto al antiguo, pipelines de datos apuntando a ambos, validación de resultados idénticos, cambio.
Lo que vino después fue más satisfactorio. Toda la capa de abstracción —creación dual de tablas, ALTERs duales, enrutamiento de shards, recuperación de fallos parciales— fue eliminada. Dos mil líneas de código que existían solo porque la arquitectura lo requería.
# Antes: 2.000 líneas gestionando complejidad distribuida
def create_table(self, name, schema):
local_result = self.execute_on_cluster(...)
if not local_result.all_succeeded():
return self.rollback_partial_create(name, local_result)
# ... decenas de líneas más manejando casos límite
# Después: ~200 líneas
def create_table(self, name, schema):
return self.execute(f"CREATE TABLE {name} ... ENGINE = MergeTree()")
Los skills de IA que construimos para Claude ahora funcionan de verdad. Añadir una columna antes significaba orquestar un proceso de múltiples pasos con verificación y rollback. Ahora es una sentencia.
Conclusión
Esto no iba de ahorrar dinero, aunque lo hicimos. Iba de eliminar una categoría entera de problemas.
Cada hora depurando problemas de replicación, cada caso límite en la CLI, cada fallo parcial de ALTER: ese tiempo se acumula. La nueva arquitectura no elimina problemas. Elimina los problemas que vienen de gestionar la distribución en la capa de aplicación. Lo que queda es modelado de datos, optimización de consultas y lógica de negocio. Esos son problemas que merece la pena tener.
Podríamos haber seguido con ReplicatedMergeTree. Funcionaba. Pero "funcionar" no es el listón cuando construyes a largo plazo. Queríamos algo donde la superficie operacional coincidiera con el modelo lógico. Una tabla debería significar una tabla.
Lo estamos abriendo: ObsessionDB.
La complejidad no era el precio de la escala. Era el coste de una arquitectura que te hacía responsable de problemas que la base de datos debería haber resuelto.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.