La Dura Verdad Sobre Self-Hosting ClickHouse® Infra
Introducción
ReplicatedMergeTree me reventó el cerebro.
En Numia servimos APIs de blockchain en tiempo real a escala cercana al petabyte. ClickHouse se come esa carga de trabajo sin despeinarse. Almacenamiento columnar, ejecución vectorizada, ratios de compresión que hacen el coste de almacenamiento casi irrelevante. Para queries analíticas en tiempo real sobre miles de millones de filas, nada se le acerca.
Pero hay algo para lo que la documentación no te prepara: el setup distribuido es donde ClickHouse deja de ser una base de datos y pasa a ser un problema de sistemas distribuidos que te toca resolver a ti si no quieres pagar la versión cloud.
Pasamos 12 meses montando herramientas para gestionar clústeres de ClickHouse. Una CLI que abstraía la complejidad. Skills de IA para que nuestros agentes pudieran crear tablas, lanzar migraciones y configurar pipelines. La tasa de errores bajó. Y entonces lo borramos todo. No lo refactorizamos. No lo simplificamos. Lo borramos. Dos mil líneas de código, a la basura.
Este post trata de por qué. Del problema arquitectónico que ninguna herramienta podía solucionar, y de cómo al final construimos algo que hizo desaparecer la complejidad.
Si alguna vez te has preguntado por qué tu clúster de ClickHouse parece sostenerse con celo y buenas intenciones, estás en el sitio correcto. Si alguna vez has debuggeado una cola de replicación a las 3 de la mañana con un cliente quejándose, esta historia te va a sonar. Y si estás evaluando ClickHouse intentando entender dónde te metes, toma esto como tu guía de campo sobre el setup distribuido del que nadie te avisa.
Te voy a enseñar 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 cabeza, un montón en la realidad. Así pasa.
Un clúster distribuido reparte procesamiento y almacenamiento entre máquinas llamadas shards. Quieres una tabla events, pero no puedes simplemente crearla. Necesitas una tabla _local en cada shard que guarde 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 ya tienes tres tablas físicas: events_local en shard 1, shard 2 y shard 3. Pero los shards solos no te dan tolerancia a fallos. Si uno se cae, los datos que tiene 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, van 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, y aún no puedes consultarlas directamente sin saber qué shard tiene qué. Ahí entra la tabla Distributed: una fachada que lee de todas las locales para que no tengas que hacerlo tú.
-- 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. Decide qué tabla local recibe cada fila según los valores de las columnas que elijas. Si escoges columnas con distribución desbalanceada, unos shards acumulan más datos que otros y el rendimiento se hunde.
Ahora las queries van a la tabla Distributed, que paraleliza entre shards y agrega resultados. Los inserts también pasan por ella, enrutados al shard correcto. Pero es una entidad más que mantener. Si los schemas divergen entre las tablas locales y la Distributed, las queries fallan en silencio o devuelven resultados incorrectos.
Resultado: 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 se sienten frágiles. Querías una base de datos; lo que tienes es un sistema distribuido que gestionar. Y cada hora que le dedicas a esa complejidad es una hora que no estás dedicando a tu producto.
El Infierno De Los ALTER Y Los Desastres Del DROP
El problema de las N*X+1 empeora en cuanto necesitas cambiar algo. Imagina que tienes que añadir una columna. Con una base de datos normal, lanzas un ALTER y sigues con tu vida. Con ReplicatedMergeTree necesitas mínimo dos operaciones. Y si hay mala suerte, te toca limpiar 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 schema, y esa definición tiene que coincidir con las tablas locales.
En teoría, ON CLUSTER te facilita la vida. Lanzas 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 funciona en algunos shards pero falla en otros. Tu clúster queda con schema inconsistente.
- Fallos por timeout: clústeres grandes o redes lentas hacen que la operación expire, y te quedas sin saber qué pasó realmente.
- Contención de ZooKeeper: bajo carga, ZooKeeper se convierte en cuello de botella y los ALTERs se encolan o fallan.
Cuando ON CLUSTER falla a medio camino, estás en modo recuperación. Toca averiguar qué shards tienen el nuevo schema y cuáles no. Después aplicar manualmente el ALTER en los que fallaron y cruzar los dedos para que nada haya cambiado mientras tanto.
Las operaciones DROP tienen el mismo problema de coordinación, pero las consecuencias cambian. Una tabla Distributed y sus tablas locales hay que eliminarlas juntas. Si te olvidas de cualquiera de las dos, el clúster queda en 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 aventura propia. ReplicatedMergeTree guarda datos en partes, y truncar una tabla con miles de partes puede tardar horas. Durante ese rato, la tabla queda en un estado intermedio raro. Aprendimos a buscar atajos: a veces es más rápido crear tabla nueva, intercambiar nombres y borrar la vieja que truncar in situ.
Las reingestiones son lo peor. Corregir un bug de pipeline, rellenar un campo nuevo, arreglar datos corruptos: estás borrando terabytes y volviéndolos a meter. La cola de replicación se atasca mientras cada réplica sincroniza borrados y luego inserciones. A nuestra escala, una reingestión completa tardaba días. El clúster entero se arrastraba y las queries expiraban. ¿Y si algo petaba a la mitad? Datos parciales entre réplicas sin forma limpia de recuperar. Vuelta a empezar.
Cada operación acabó siendo una ceremonia de múltiples pasos con planes de rollback. Documentamos la secuencia exacta, las queries de verificación entre cada paso y los procedimientos de recuperación ante fallos parciales. Esa documentación era más larga que la mayoría de specs de funcionalidades.
ZooKeeper: El Impuesto Oculto
ReplicatedMergeTree no solo añade complejidad a tu clúster de ClickHouse. Te mete un componente completamente separado que hay que ejecutar, monitorizar y debuggear.
ZooKeeper es la capa de coordinación. Lo que hace que la parte "Replicated" de ReplicatedMergeTree funcione. Cada tabla replicada se registra en ZooKeeper. Cada réplica guarda su estado ahí. La cola de replicación vive ahí. Las elecciones de líder pasan ahí.
Esto es lo que ZooKeeper hace realmente por tu clúster de ClickHouse:
Tracking de partes: cuando una réplica ingesta datos, crea una parte. ZooKeeper lleva la cuenta de qué réplicas tienen qué partes, para que las demás sepan lo que les falta.
Cola de replicación: cuando la réplica A tiene una parte que la B no, ZooKeeper encola una tarea de descarga. La réplica B tira de la parte desde A. La cola rastrea progreso, reintentos y fallos.
Liderazgo: para operaciones que requieren coordinación (como los merges), ZooKeeper se encarga de la elección de líder. Solo una réplica mergea a la vez para evitar conflictos.
Metadatos: schemas de tablas, información de particiones, topología del clúster. Todo en ZooKeeper.
Es un diseño elegante de sistemas distribuidos. También es otro componente que mantener y un punto único de fallo.
ZooKeeper necesita su propio setup de alta disponibilidad. Mínimo tres nodos para quórum. En máquinas separadas para tolerancia a fallos. Con monitorización y procedimientos de backup. Con planificación de capacidad a medida que tu clúster de ClickHouse crece.
Y vienen con sus propios modos de fallo:
Expiración de sesión: si un shard de ClickHouse pierde conexión con ZooKeeper durante demasiado rato, la sesión expira. El shard cree que sigue en el clúster, pero ZooKeeper opina diferente. Recuperarse implica reiniciar servicios y rezar para que la cola de replicación se ponga al día.
Acumulación de cola: bajo escritura intensa, la cola de replicación crece más rápido de lo que las réplicas pueden procesarla. Las partes se amontonan. Las réplicas se quedan atrás. Los resultados de las queries empiezan a ser inconsistentes según qué réplica consultes. Las tareas de background se apilan.
Split brain: las particiones de red entre nodos de ZooKeeper causan problemas de quórum. Los shards de ClickHouse no pueden escribir porque no se pueden coordinar. Tu clúster está técnicamente arriba pero funcionalmente muerto.
He pasado más horas debuggeando problemas de ZooKeeper que queries reales de ClickHouse. Ya no estás construyendo un producto. Estás operando un sistema distribuido. La base de datos te dio las primitivas; la capa de coordinación la montaste tú.
A las 3 de la mañana, con un cliente quejándose de datos que no aparecen, lo último que quieres oír es "la cola de replicación está atascada". Pero eso es lo que significa correr ReplicatedMergeTree a escala. Te acabas convirtiendo en experto de ZooKeeper, quieras o no.
El Intento Con Herramientas
No nos quedamos de brazos cruzados con esa complejidad. Montamos herramientas para gestionarla. La primera capa fue una CLI. Abstraía el patrón de creación dual de tablas. Lanzabas un solo comando y generaba tanto la tabla _local como la Distributed. Gestionaba las cláusulas ON CLUSTER. Configuraba la clave de sharding. Verificaba que todos los shards recibieran el schema antes de cantar é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, lanzar migraciones, configurar pipelines en tiempo real y probar queries. Los skills envolvían la CLI, añadiendo comprensión de lenguaje natural encima de la abstracción.
Ayudó, y la tasa de errores bajó. La gente nueva del equipo podía trabajar con ClickHouse sin entender el patrón N+1. El happy path era genuinamente feliz. Pero los sistemas distribuidos tienen muchos caminos que no lo son.
El primer edge case 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 ¿cómo te recuperas? No puedes simplemente reintentar en el shard que falló porque quizá sí funcionó y lo que se perdió fue el acknowledge. Toca revisar el schema en cada shard, comparar y decidir qué hacer.
El segundo edge case fue el DROP en cascada incorrecto. Una vista materializada referenciaba una tabla que estábamos borrando. El DROP funcionó pero la vista materializada quedó rota. La CLI no controlaba dependencias de vistas. Lo añadimos. Después encontramos vistas que referenciaban vistas.
El tercero fue la deriva de réplicas. Tras una partición de red, dos réplicas tenían datos divergentes. ZooKeeper las marcaba como sanas. Las queries devolvían resultados distintos según qué réplica consultaras. La CLI no tenía forma de pillar esto porque desde su punto de vista, todo iba bien.
Cada bug que arreglábamos destapaba otro que no habíamos visto. Las herramientas se fueron complicando. Metimos lógica de reintentos con backoff exponencial. Utilidades de comparación de schemas. Health checks que consultaban cada réplica y comparaban resultados. Procedimientos de rollback para deshacer operaciones parciales. Logging tan detallado que generaba gigas 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 comportase de forma predecible, y la CLI intentaba manejar el comportamiento impredecible de un sistema distribuido.
Estábamos montando un framework de sistemas distribuidos encima de una base de datos que se suponía que tenía que gestionar la distribución por nosotros.
La Revelación Arquitectónica
El momento de claridad llegó durante otra sesión de debug. Un ALTER fallido había dejado dos shards con schemas distintos. La CLI lo detectó pero no podía resolverlo automáticamente porque teníamos escrituras concurrentes. Tocaba parar escrituras, arreglar el schema, verificar consistencia y reanudar. Era la tercera vez ese mes.
El problema no eran nuestras herramientas. Era la arquitectura. ReplicatedMergeTree te obliga a gestionar 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 el responsable de ensamblar esas primitivas en un sistema distribuido funcional. Tú gestionas sharding, consistencia y recuperación ante fallos.
Da igual cuántas herramientas construyas: no puedes arreglar un desajuste de arquitectura. Puedes limar las aristas, automatizar el happy path, cubrir algunos fallos. Pero no cambias el hecho de que estás operando un sistema distribuido que resulta que usa ClickHouse como almacenamiento.
La solución era arquitectónica: desacoplar almacenamiento y cómputo. En vez de que cada réplica guardase 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 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 vez de repartir datos entre nodos que mantienen cada uno su copia, centraliza todo en object storage 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 de verdad es una tabla. No siete.
Todos los nodos de cómputo se conectan al mismo backend de almacenamiento compatible con S3. Cuando llega una query, el nodo lee las partes relevantes directamente del storage, las procesa y devuelve resultados. Sin datos locales más allá del caché. Sin sincronización entre nodos. Solo leer y computar.
Con ReplicatedMergeTree, los shards se coordinaban porque cada uno tenía copias distintas de los datos. ZooKeeper controlaba quién tenía qué y las colas de replicación garantizaban consistencia eventual. Con MergeTree cloud-native no hay nada que coordinar. Todos los nodos ven los mismos datos porque leen del mismo sitio.
Los ALTER pasan a ser una sola sentencia, sin ON CLUSTER. Sin operaciones duales. El schema vive en un único lugar.
ALTER TABLE events ADD COLUMN new_field String DEFAULT '';
Escalar 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 queries al instante.
Lo montamos contra almacenamiento compatible con S3, permitiendo escalar cómputo independientemente del storage. Tardamos meses en validarlo a escala de producción, pero lo sacamos adelante.
La Migración Y La Eliminación
La migración fue anticlimática. Clúster nuevo al lado del viejo, pipelines de datos apuntando a ambos, validación de que los resultados eran idénticos, y cambio.
Lo que vino después fue lo más satisfactorio. Borramos toda la capa de abstracción: creación dual de tablas, ALTERs duales, enrutamiento de shards, recuperación de fallos parciales. Dos mil líneas de código que existían solo porque la arquitectura las exigí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 montamos para Claude ahora funcionan de verdad. Antes, añadir una columna significaba orquestar un proceso de varios pasos con verificación y rollback. Ahora es una sentencia.
Conclusión
No iba de ahorrar dinero, aunque también. Iba de eliminar una categoría entera de problemas.
Cada hora debuggeando replicación, cada edge case en la CLI, cada ALTER parcialmente fallido: ese tiempo se acumula. La nueva arquitectura no elimina problemas. Elimina los que vienen de gestionar la distribución en la capa de aplicación. Lo que queda es modelado de datos, optimización de queries y lógica de negocio. Esos sí son problemas que merece la pena tener.
Podríamos haber seguido con ReplicatedMergeTree. Funcionaba. Pero "funcionar" no es suficiente cuando construyes a largo plazo. Queríamos que la superficie operacional coincidiera con el modelo lógico. Una tabla debería ser una tabla.
Lo estamos abriendo: ObsessionDB.
La complejidad no era el precio de escalar. 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í.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com