Backfill y Reingesta Automáticos para ClickHouse® DB
Matamos una reingesta de 20 millones de filas cuando iba por un tercio. Y no de forma limpia. Le mandamos un kill en seco a mitad de ejecución, como haría un portátil al cerrarse o un runner de CI que se queda sin tiempo. Luego lanzamos un solo comando para reanudarla. Terminó el resto en 25 segundos y dejó exactamente 20.000.000 de filas en la tabla destino. Ni una perdida. Ni una duplicada.
Esa recuperación es lo importante, así que vuelvo a ella. Primero, el problema que resuelve.
Si alguna vez has tenido que mover una tabla grande en ClickHouse®, conoces la película. Cambiaste una sort key, o añadiste una columna, o arreglaste una materialized view, y ahora hay que reescribir unos cuantos miles de millones de filas para que cuadren. El reflejo es un único INSERT ... SELECT gigante. En cualquier caso real, ese reflejo tumba el clúster.
La trampa no es el INSERT. Es el bucle.
Una sola sentencia reescribiendo miles de millones de filas pelea con tu tráfico de lectura por memoria, lastra los merges en segundo plano, llega a un límite de memoria y muere. Muere al 80%, sin checkpoint, así que vuelves a empezar desde cero. Reintentas, y como el insert no es idempotente, la mitad de las filas están ahora duplicadas. Y en todo momento no tienes ni idea de por dónde iba. Estás en otra terminal lanzando SELECT ... FROM system.processes, entrecerrando los ojos.
Así que haces lo razonable. Lo trocean. Partes el rango, iteras, insertas una porción cada vez.
Aquí es donde te pilla. El bucle de chunks es el trabajo de verdad. Para hacerlo bien tienes que elegir límites que mantengan cada porción por debajo de un presupuesto de memoria, lo que implica leer la distribución de los datos, lo que implica lidiar con el sesgo cuando un solo valor se lleva media tabla. Tienes que hacer cada chunk idempotente para que un reintento no duplique. Tienes que persistir el progreso para que una caída no te cueste la ejecución entera. Tienes que hacer polling para ver cuándo termina, cazar fallos y mostrar el progreso en algún sitio. Eso es un pequeño sistema. La mayoría de equipos lo escriben una vez, con prisa, un poco mal, y luego lo mantienen para siempre.
Ese bucle nos lo sabemos de memoria. En Numia corríamos petabytes de datos on-chain sobre ClickHouse, y la reingesta era parte de la semana, sin más. Un cambio de esquema, una materialized view arreglada, una cadena que había que volver a traer desde un bloque temprano, y unos cuantos miles de millones de filas tenían que moverse para cuadrar. Así que escribimos el bucle de chunks. Luego lo volvimos a escribir para la siguiente tabla. Lo metimos en un script, le pegamos el resume después de que una ejecución muriera de madrugada y nos costara el progreso, y de ahí en adelante fuimos parcheándolo. Nunca fue el trabajo interesante. Solo estaba delante de todo el trabajo interesante.
Nos cansamos de escribir ese sistema. Así que el plugin de backfill de CHKit lo escribe por ti, y el mismo plan puede ejecutarse desde tu máquina o entregarse a nuestra plataforma para correr como un job gestionado. Es open source, MIT, y funciona contra ClickHouse Cloud, ObsessionDB o un clúster de ClickHouse autoalojado.
Apúntalo a una tabla de ClickHouse y te da un plan de backfill
Empiezas por un plan. Le das una tabla y un presupuesto de tamaño por chunk:
chkit backfill plan --target analytics.events --max-chunk-bytes 2G
CHKit lee las particiones y las sort keys de la tabla y calcula los límites de los chunks por su cuenta. Las particiones son el primer corte. Cualquier partición que pase del presupuesto se vuelve a cortar por la sort key, y decide cómo según los datos: una clave numérica se divide en rangos por cuantiles, una columna temporal en buckets de tiempo, y una clave sesgada donde un valor domina se parte por la siguiente columna de la sort key, para que ese valor caliente no se convierta en un chunk enorme. Tú no calculas nada de esto. Recibes un plan inmutable, una lista de chunks, cada uno más o menos del tamaño que pediste.
¿Aguanta esto con datos reales? Lo apuntamos a wikicold, una tabla de 4.230 millones de filas en nuestro clúster de benchmark, con un presupuesto de 2 GiB. Planificó 89 chunks en 28 particiones en unos 69 segundos. Los chunks salieron parejos: 1,7 GiB de media, 2,18 GiB el mayor, todos cerca del objetivo. Nada ajustado a mano.
El plan es solo contexto, todavía no se ha ejecutado nada. Es a propósito, porque el plan es lo que consumen los dos caminos de ejecución.
Seguro de interrumpir, seguro de reintentar
Ahora lo ejecutas:
chkit backfill run --plan-id <id>
Aquí es donde el bucle que no escribiste se gana el sueldo. Pasan tres cosas sin que las pidas.
Cada chunk es idempotente. Cada uno lleva un token de dedup determinista construido a partir del plan y del id del chunk, así que ClickHouse descarta un bloque duplicado si el mismo chunk aterriza dos veces. Reintenta un chunk, vuelve a lanzar el plan entero, ejecútalo dos veces mientras duermes: el recuento de filas no se mueve. El problema de los duplicados no se resuelve porque tú vayas con cuidado. No puede pasar.
Los chunks corren como queries asíncronas con IDs deterministas, y su progreso se lee de vuelta desde system.query_log. No hay nada que vigilar en una terminal. La ejecución acota su propia concurrencia y hace polling de cada chunk hasta que termina.
Y una ejecución es reanudable. El progreso hace checkpoint a disco, y el resume hace algo un poco paranoico antes de continuar: primero se reconcilia con el servidor. Comprueba qué chunks terminaron de verdad, incluidos los que se completaron en el clúster pero nunca llegaron al checkpoint local, y ejecuta solo lo que queda de verdad. --replay-failed vuelve a lanzar los que dieron error.
Nada de eso es un flag que tengas que recordar. La idempotencia, los checkpoints, el paso de reconciliación: son los valores por defecto, porque la única promesa que una reingesta tiene que cumplir es que puedes irte y volver a ella. Lo construimos para la versión de ti a las 4 de la mañana mirando una ejecución a medias, no para la que lee la documentación una tarde tranquila.
Volvamos al número del principio. Planificamos 20 millones de filas en 15 chunks, matamos el proceso después de que terminaran 5, y la tabla destino se quedó con unos 10,8 millones de filas, un estado parcial. Entonces:
chkit backfill resume --plan-id <id>
25,5 segundos después, la tabla destino tenía exactamente 20.000.000 de filas. Volvimos a lanzar el plan entero encima, los 15 chunks otra vez, y el recuento se quedó en 20.000.000. El resume terminó el trabajo. La idempotencia hizo que repetirlo no cambiara nada.
Un algoritmo, dos sitios donde ejecutarlo
Todo lo de hasta ahora corre desde tu portátil, contra cualquier clúster de ClickHouse al que lo apuntes. Ese es el plugin que usará la mayoría, @chkit/plugin-backfill, y no tiene ni idea de que ObsessionDB existe.
Hay un segundo plugin, @chkit/plugin-obsessiondb, también open source, que importa el mismo planificador. La diferencia es un verbo:
chkit backfill submit --target analytics.events
En vez de correr los chunks desde tu máquina, submit entrega el plan al sistema de jobs de ObsessionDB. Los chunks corren en el servidor, en nuestra infraestructura, y recibes un enlace al job en la consola. Lo miras ahí en lugar de tener una terminal abierta. Mismo plan, mismo SQL de los chunks, mismos tokens de dedup. Lo único que cambia es dónde corre el trabajo, y quién tiene que vigilarlo, que ahora no es nadie.
Cuando lo probamos contra un servicio del clúster de benchmark, submit construyó el plan, mandó cuatro tareas al backend, y las cuatro corrieron hasta el final en el servidor, seguidas por el enlace de la consola. El recuento de filas se mantuvo. Misma idempotencia, porque es el mismo SQL.
Aquí está ese job en la consola. Progreso, filas leídas y escritas, throughput, y una fila por tarea con su propia duración y sus bytes. Una tarea que falla se reintenta desde aquí, no desde una terminal que te dejaste abierta. Vive al lado de tus servicios, tu monitorización y tus alertas, el mismo sitio donde ya vigilas la base de datos.
Podríamos haber escrito un backfill de cloud aparte, afinado para nuestra propia infraestructura. A propósito no lo hicimos. El camino gestionado importa el mismo planificador que usa el open source, corre el mismo SQL y estampa los mismos tokens de dedup. En cuanto esos dos divergen, cómo se comporta en tu portátil deja de predecir cómo se comporta en el nuestro, y ese es justo el momento en que la gente deja de fiarse de una herramienta. Las tripas compartidas y aburridas eran el objetivo.
Aquí va el reparto honesto. El código es open source y gratis; la capacidad es para clientes. Enviar un job necesita un servicio de ObsessionDB al que enviarlo. Correr en local no necesita nada de nosotros. El camino gestionado está para los backfills que son demasiado grandes o demasiado largos como para vigilarlos desde una terminal.
Cuándo tirarías de esto
Reingestar después de un cambio de esquema es el caso obvio, y es el que hemos estado describiendo. Otros cuantos comparten la forma. Recomputar una materialized view después de cambiar su definición, donde el backfill corre el insert a través del propio SELECT de la vista. Volver a traer un dataset desde cero. Rellenar una ventana de datos derivados después de arreglar el bug que los calculó mal la primera vez. Cualquier copia masiva repetible donde, si no, estarías manteniendo ese bucle hecho a mano.
La reingesta es el primer job. La apuesta es la plataforma.
El camino gestionado no es una copia hosteada de la CLI. Es la primera pieza de cara al cliente de un sistema de jobs que llevamos construyendo dentro de ObsessionDB, lo que mueve grandes cantidades de datos por nuestros propios clústeres.
La reingesta es el primer tipo de job que abrimos, pero la forma es general. Un recompute de materialized view es el mismo problema. También lo es una importación masiva desde un bucket, traer un origen desde cero, o rellenar una ventana después de un arreglo. Todos son una gran cantidad de datos que tiene que moverse, con seguridad, bajo demanda o en un horario, sin una persona vigilando una terminal.
La apuesta es sencilla. Las operaciones de datos fáciles y repetibles son lo que separa una base de datos gestionada de una más barata. El rendimiento y el precio te meten por la puerta. Pero si un equipo puede recomputar un modelo, rellenar un arreglo y reingestar un origen varias veces al día sin tocar infraestructura ni escribir a mano un bucle de chunks, eso cuesta mucho de soltar.
backfill submit es el paso uno. Tu reingesta corre en nuestra infraestructura, y la miras en la consola. Lo que viene después son el resto de tipos de job, y una forma de crearlos y seguirlos sin tocar la CLI para nada.
La regla que me quedaría
Si un job es puntual y cabe en una sentada: córrelo en local. Si es grande, largo, tiene que sobrevivir a que cierres el portátil, o quieres que quede seguido y sea repetible: envíalo. Local para los trabajos rápidos. Submit para los que no quieres sostener en las manos.
Pruébalo
El plugin de backfill está en GitHub y en npm:
bun add -d @chkit/plugin-backfill
Es MIT, y funciona contra ClickHouse Cloud, ObsessionDB o un clúster de ClickHouse autoalojado (el soporte de un solo nodo está en camino). Es beta: los comandos del core son estables, puede haber pequeños cambios que rompan cosas antes de la 1.0, y el camino de submit es nuevo, lo lanzamos esta semana. Corremos backfill en producción para la reingesta de clientes, que es de donde salieron la mayoría de estos detalles en primer lugar.
Para el camino gestionado, añade @chkit/plugin-obsessiondb y levanta un servicio gratis en console.obsessiondb.com.
La referencia completa de comandos vive en la documentación de backfill. Y si no has visto el lado de esquema de CHKit, el post sobre schema-as-code cuenta cómo las propias tablas se convierten en código.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com