AggregatingMergeTree en ClickHouse®: Rollups que Escalan
Este es el quinto post de la serie ClickHouse Deep Dive, donde voy explicando ClickHouse desde cero. Si no estás familiarizado con cómo funciona, puedes repasar los artículos anteriores. En el post de modelado de datos situamos AggregatingMergeTree dentro de la guía de selección de engines, y en el post de materialized views lo introdujimos con el patrón -State/-Merge. Ambos posts se quedaron en la superficie. Este va hasta el fondo.
ClickHouse puede escanear miles de millones de filas en segundos. Pero cuando estás ejecutando el mismo GROUP BY cientos de veces por minuto, hasta ClickHouse necesita ayuda. AggregatingMergeTree es cómo intercambias espacio en disco por velocidad de consulta: pre-computar las agregaciones en tiempo de escritura, almacenar los estados intermedios y leer el resultado final en milisegundos en vez de segundos.
La documentación está repartida entre una docena de fuentes, y ninguna da una visión completa. Este post conecta las piezas: cómo funciona el engine, los tres combinadores como modelo mental, cuándo usar SimpleAggregateFunction vs AggregateFunction, rollups en cascada a múltiples resoluciones, y las trampas de producción que aprendimos por las malas.
Cómo funciona AggregatingMergeTree
Misma capa de almacenamiento que cualquier variante de MergeTree. Mismos parts, mismos merges de fondo, mismo sparse index. El post de modelado de datos lo explicaba: todas las variantes de MergeTree son el mismo engine con una estrategia de merge distinta. Para AggregatingMergeTree, la estrategia es: las filas con la misma clave ORDER BY combinan sus columnas AggregateFunction.
Esa última frase es el engine entero. Todo lo demás se deriva de ahí.
La mejor forma de entender el engine es ver un ejemplo, y uno especialmente útil es la analítica de peticiones API. En este caso, la tabla raw almacena eventos individuales:
CREATE TABLE access_log (
endpoint String,
timestamp DateTime,
latency_ms Float64,
user_id UInt64
) ENGINE = MergeTree()
ORDER BY (endpoint, timestamp);
La tabla destino AggregatingMergeTree almacena rollups por hora. Cada columna que no es clave es un AggregateFunction, que guarda estados intermedios binarios en vez de valores finales. El ORDER BY define qué filas se combinan, así que coincide con el GROUP BY de la materialized view:
CREATE TABLE endpoint_stats_1h (
endpoint LowCardinality(String),
hour DateTime,
request_count AggregateFunction(sum, UInt64),
unique_users AggregateFunction(uniq, UInt64)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (endpoint, hour);
La materialized view lee eventos raw, trunca timestamps y produce estados de agregación con el combinador -State:
CREATE MATERIALIZED VIEW endpoint_stats_1h_mv TO endpoint_stats_1h AS
SELECT
endpoint,
toStartOfHour(timestamp) AS hour,
sumState(toUInt64(1)) AS request_count,
uniqState(user_id) AS unique_users
FROM access_log
GROUP BY endpoint, hour;
El GROUP BY de la MV produce (endpoint, hour), que coincide con el ORDER BY de la tabla destino. Esa alineación es lo que hace que todo funcione: durante los merges de fondo, cada fila que comparte el mismo endpoint y hora combina sus columnas de agregación.
En la parte de consulta, el combinador -Merge convierte los estados almacenados en valores finales. El GROUP BY es obligatorio porque los merges de fondo son asíncronos: pueden quedar filas parciales sin fusionar en el momento de la consulta.
SELECT
endpoint,
hour,
sumMerge(request_count) AS request_count,
uniqMerge(unique_users) AS unique_users
FROM endpoint_stats_1h
WHERE endpoint = '/api/v1/data'
GROUP BY endpoint, hour
ORDER BY hour DESC;
-State al INSERT, -Merge al SELECT. Hay un tercer combinador, -MergeState, que produce otro estado intermedio en vez de un valor final. Eso es lo que hace posibles los rollups en cascada, y lo cubro en esa sección.
ORDER BY: agrupación de merges y rendimiento de consultas
ORDER BY en un AggregatingMergeTree hace doble trabajo. Controla qué filas se fusionan, y es el sparse index que ClickHouse usa para saltar gránulos durante las consultas. Dos funciones, una sola clave. Eso significa dos formas de fallar si lo configuras mal.
Granularidad incorrecta: dos versiones del mismo problema.
- Demasiado granular: si la clave
ORDER BYproduce combinaciones mayoritariamente únicas, el engine no tiene nada que fusionar. He visto tablas donde pasó exactamente esto: la tabla crecía linealmente con los inserts, los tiempos de consulta subían con ella, y se perdía todo el sentido de la pre-agregación. Tu claveORDER BYdebería producir suficientes combinaciones duplicadas para que el engine tenga filas que combinar. - Demasiado grueso: el problema contrario. Si agregas a un nivel demasiado alto, digamos solo
endpointsin dimensión temporal, acabas con una fila por endpoint. Consultas rápidas, tabla minúscula, pero nunca podrás desglosar los resultados por hora o por día. Esa granularidad desaparece. La has agregado, y no hay vuelta atrás sin reprocesar desde los datos raw. Solo puedes consultar a la resolución que almacenaste, lo cual está bien si solo necesitas totales históricos completos.
No coincide con tu patrón de acceso. La clave ORDER BY también es el índice primario. Si tus consultas filtran por hour primero pero tu ORDER BY empieza por endpoint, ClickHouse no puede usar el sparse index de forma eficiente y escanea más gránulos de los necesarios. Mismos datos, mismos merges, consultas más lentas. Tienes tres opciones dependiendo de la situación:
- Reordenar las columnas del
ORDER BYpara que coincidan con tu filtro más común, si eso sigue funcionando para la granularidad de merge. - Añadir un skipping index para patrones de acceso que no se alineen con tu
ORDER BY. Son filtros ligeros (bloom filters, min-max) que permiten a ClickHouse saltar gránulos sin cambiar el orden de clasificación. - Usar un
PRIMARY KEYseparado si tuORDER BYtiene más columnas de las que tu filtro de consulta típico usa, lo cual reduciría la efectividad del sparse index.
La mayoría de la gente no sabe que PRIMARY KEY y ORDER BY pueden ser distintos en ClickHouse. PRIMARY KEY puede ser un prefijo de ORDER BY. Los datos se ordenan en disco según el ORDER BY completo (que controla la granularidad de merge), pero solo las columnas del PRIMARY KEY van al sparse index (que controla el rendimiento de consultas y el uso de RAM). El post de modelado de datos lo cubre en detalle. Para AggregatingMergeTree, esto significa que puedes tener un ORDER BY granular para merges de grano fino, manteniendo un PRIMARY KEY más corto que encaje con cómo realmente consultas la tabla.
El equilibrio entre granularidad, patrón de acceso y caso de uso es la decisión de diseño real. Una clave más gruesa fusiona más agresivamente (tabla más pequeña, scans más rápidos), pero pierdes la capacidad de consultar a granularidad fina. Una agregación granular permite servir cualquier petición, pero si el volumen de datos solicitados aumenta, por ejemplo un usuario pidiendo el histórico completo de una tabla time-series, la consulta se pone cara rápido. No hay respuesta universal. Empieza con la granularidad que tus consultas realmente necesitan, comprueba system.parts para verificar que los merges están reduciendo el número de filas, y monitoriza el rendimiento de las consultas conforme el uso crece.
Columnas no clave y la trampa del any()
Una tabla AggregatingMergeTree tiene tres tipos de columnas: columnas clave del ORDER BY (como endpoint y hour), columnas de agregación (SimpleAggregateFunction o AggregateFunction), y todo lo demás. El engine sabe qué hacer con las dos primeras. Las columnas clave identifican qué filas van juntas. Las columnas de agregación se combinan con su función (sum, max, uniq, etc.).
El matiz está en que si añades una columna plana, por ejemplo name: String, que no está en el GROUP BY, el engine no tiene regla para combinarla. Cuando dos filas se fusionan, hay dos valores distintos de name, y ClickHouse tiene que elegir uno. Elige de forma arbitraria con semántica any().
Esto significa que el valor que ves depende de si ya ha habido un merge o no. Antes del merge, consultas la tabla y ves ambas filas con sus names correctos. Después del merge, se queda un name y el otro desaparece, sin que tú controles cuál. Ojo, porque esto no da error. La consulta devuelve un resultado, los números parecen correctos y la columna name se ha actualizado de forma arbitraria.
La solución es sencilla: cada columna no clave en un AggregatingMergeTree debería ser un tipo de agregación. Si necesitas mantener un valor de tipo string, envuélvelo en una función argMax para que el comportamiento sea explícito.
SimpleAggregateFunction vs AggregateFunction
Este es el framework de decisión que la documentación no te da.
AggregateFunction almacena un estado binario serializado. El estado de sum es un total acumulado. El estado de uniq es un sketch HyperLogLog. El estado de quantile es un t-digest. Son blobs opacos, no los puedes leer con un SELECT normal, y necesitan el combinador -Merge para producir un valor final.
SimpleAggregateFunction almacena el resultado parcial directamente como un valor normal. Sin blob binario. Sin combinador especial en el SELECT. Lees la columna tal cual.
La pega: SimpleAggregateFunction solo funciona para funciones donde el resultado parcial ES el resultado final. Por ejemplo, max, min, sum y any cualifican. Son las mismas que tienes disponibles con SummingMergeTree, y puedes encontrar la lista completa en la documentación oficial de ClickHouse.
uniq no cualifica, porque no puedes calcular conteos únicos tomando el máximo de dos conteos únicos. Lo mismo para quantile, que necesita un t-digest para fusionar resultados parciales. Y argMin/argMax, que necesitan rastrear tanto el valor como su argumento asociado. No puedes reducir ese par a un único resultado parcial.
Las métricas de endpoint me hicieron entender esta distinción. Imagina que estás rollando un access log en estadísticas por minuto:
request_countesSimpleAggregateFunction(sum, UInt64). La suma de sumas es la suma. Simple.max_latencyesSimpleAggregateFunction(max, Float64). El máximo de máximos es el máximo.unique_usersesAggregateFunction(uniq, UInt64). No puedes fusionar conteos únicos sumándolos ni sacando el máximo. El sketch HyperLogLog necesita combinarse con otros sketches para producir un conteo aproximado correcto.p99_latencyesAggregateFunction(quantile(0.99), Float64). El p99 de dos p99s no es el p99. El t-digest necesita fusionarse con otros digests.
CREATE TABLE endpoint_stats_1m (
endpoint LowCardinality(String),
minute DateTime,
request_count SimpleAggregateFunction(sum, UInt64),
max_latency SimpleAggregateFunction(max, Float64),
min_latency SimpleAggregateFunction(min, Float64),
unique_users AggregateFunction(uniq, UInt64),
p99_latency AggregateFunction(quantile(0.99), Float64)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(minute)
ORDER BY (endpoint, minute);
La consulta mezcla ambos patrones. Las columnas SimpleAggregateFunction usan su función de agregación normal (sum, max, min). Las columnas AggregateFunction necesitan el combinador -Merge (uniqMerge, quantileMerge):
SELECT
endpoint,
minute,
sum(request_count) AS request_count,
max(max_latency) AS max_latency,
min(min_latency) AS min_latency,
uniqMerge(unique_users) AS unique_users,
quantileMerge(0.99)(p99_latency) AS p99_latency
FROM endpoint_stats_1m
WHERE endpoint = '/api/v1/data'
GROUP BY endpoint, minute
ORDER BY minute DESC;
Usa SimpleAggregateFunction siempre que puedas. Ocupa menos en disco, las consultas son más rápidas y no necesita el combinador -Merge en el SELECT. Recurre a AggregateFunction cuando la función requiera mantener estado entre resultados parciales.
Rollups en cascada y agregación multi-fuente
Aquí es donde -MergeState justifica su existencia. Si necesitas las mismas métricas a varias resoluciones temporales, cada nivel debería leer del anterior, no de los datos raw. La MV de 1 hora lee de la tabla de 1 minuto. La MV de 1 día lee de la de 1 hora.
La clave está en el SELECT de la MV. Para la tabla por hora leyendo de la tabla de 1 minuto de arriba:
CREATE MATERIALIZED VIEW endpoint_stats_1h_mv TO endpoint_stats_1h AS
SELECT
endpoint,
toStartOfHour(minute) AS hour,
sum(request_count) AS request_count, -- Simple: suma de sumas
max(max_latency) AS max_latency, -- Simple: máximo de máximos
min(min_latency) AS min_latency, -- Simple: mínimo de mínimos
uniqMergeState(unique_users) AS unique_users, -- Full: estado a estado
quantileMergeState(0.99)(p99_latency) AS p99_latency -- Full: estado a estado
FROM endpoint_stats_1m
GROUP BY endpoint, hour;
uniqMergeState toma el sketch HyperLogLog de la tabla de 1 minuto y produce otro sketch para la tabla de 1 hora. No un valor final. Un estado intermedio que puede fusionarse de nuevo a nivel diario. Las columnas SimpleAggregateFunction usan sum(), max(), min() normales porque sus resultados parciales ya son números planos.
No puedes mezclar tipos de función entre niveles. Si la tabla de 1 minuto almacena sumState, la MV de horas debe usar sumMergeState, no avgState. Los estados binarios son específicos de cada función, y el mensaje de error cuando te equivocas no ayuda nada.
El otro patrón que merece la pena conocer: múltiples materialized views escribiendo en el mismo destino. Una MV procesa logs de acceso web, otra procesa eventos móviles, ambas con clave (endpoint, minute) y escribiendo en la misma tabla endpoint_stats_1m. Durante los merges de fondo, el engine combina todos los estados parciales para la misma clave ORDER BY sin importar qué MV los produjo. Apenas documentado, pero funciona. Al engine le da igual de dónde vengan las filas.
Trampas en producción
Basadas en issues de GitHub, posts de la comunidad, y cosas con las que nos hemos tropezado en producción.
No puedes hacer ALTER sobre columnas AggregateFunction. Añadir, eliminar o modificar una columna AggregateFunction en una tabla viva falla o se queda colgado. Los cambios de esquema en estas columnas implican drop-recreate-backfill, igual que los cambios de esquema en MVs. Planifica bien tu esquema desde el principio. Ya lo dije en el post de materialized views, y lo repito: escribe y prueba la query de backfill el mismo día que creas la tabla.
Las consultas sin GROUP BY devuelven filas parciales sin fusionar. Los merges de AggregatingMergeTree son eventuales, igual que en todas las variantes de MergeTree. Entre merges, existen múltiples filas parciales para la misma clave ORDER BY. Sin GROUP BY en tu SELECT, te las devuelve todas. Es el mismo principio que FINAL en ReplacingMergeTree, salvo que aquí lo resuelves con GROUP BY y funciones -Merge en vez de FINAL.
Olvidarte de -Merge en el SELECT devuelve basura binaria. Un SELECT * sobre una tabla AggregatingMergeTree muestra blobs binarios en las columnas AggregateFunction. Sin error, sin aviso. Solo una salida ilegible. Necesitas funciones -Merge para cada columna AggregateFunction. Después de unos meses trabajando con ClickHouse se vuelve obvio, pero si estás empezando, tenlo en cuenta. Las columnas SimpleAggregateFunction no tienen este problema, que es otro motivo para preferirlas cuando puedas.
La serialización de estados AggregateFunction puede cambiar entre versiones de ClickHouse. El formato binario que almacena los estados intermedios (el sketch HyperLogLog para uniq, el t-digest para quantile) no tiene garantía de estabilidad entre versiones mayores. Si actualizas ClickHouse y el formato de serialización cambia, los estados existentes podrían no deserializarse correctamente. En la práctica, prueba tus tablas AggregatingMergeTree después de cada upgrade, y ten tus queries de backfill preparadas.
Hacer backfill con valores raw en vez de valores -State corrompe silenciosamente. Si insertas números planos en una columna AggregateFunction (saltándote el combinador -State), ClickHouse no siempre lo rechaza en el momento del insert. Los datos parecen correctos hasta que el siguiente merge de fondo los combina con un estado de agregación real y produce basura. El post de materialized views lo cubre en detalle. Haz siempre el backfill a través de la MV o usa las funciones -State explícitamente.
Cuándo no usar AggregatingMergeTree
Si tu agregación solo necesita sum y count, SummingMergeTree es más sencillo. Sin columnas AggregateFunction, sin sintaxis -State/-Merge, sin blobs binarios. Solo columnas numéricas que se suman durante los merges. En el post de materialized views dije que es preferible -State/-Merge sobre SummingMergeTree, y lo mantengo para todo lo que pase de lo trivial. Pero si genuinamente solo necesitas sumas y conteos, y tienes la certeza de que no vas a necesitar agregaciones complejas con estados intermedios más adelante, SummingMergeTree te ahorra complejidad real.
Si tu agregación es un simple reordenamiento o un GROUP BY de un solo nivel sin uniq/quantile, considera una projection en su lugar. Las projections almacenan un orden de clasificación alternativo (y opcionalmente una agregación con GROUP BY) dentro de la misma tabla. El optimizador de consultas las elige automáticamente. Sin tabla destino separada, sin MV, sin sintaxis -State/-Merge. Los cambios de esquema son ALTER TABLE ADD PROJECTION / DROP PROJECTION en vez de drop-recreate-backfill. Nosotros usamos projections en tablas raw y MVs con AggregatingMergeTree para todo lo que necesite uniq, quantile o cascading. Esa separación nos ha funcionado bien. Las projections alcanzan sus límites antes de lo que esperarías, y el post de materialized views entra en detalle.
| Escenario | Engine | Por qué |
|---|---|---|
| Solo sum y count | SummingMergeTree | Sintaxis más sencilla, sin estados binarios |
uniq, quantile, argMin/argMax | AggregatingMergeTree | Estas funciones requieren mantener estado de agregación |
| Rollups multi-resolución | AggregatingMergeTree | -MergeState permite el cascading |
| Simple reordenamiento o count | Projection sobre MergeTree | Routing automático, sin tabla separada |
| Última fila por clave | ReplacingMergeTree | Un problema completamente distinto |
Conclusión
Una capa de almacenamiento, siete estrategias de merge. El modelo mental del post de modelado de datos sigue valiendo: elige la estrategia de merge que encaje con la forma de tus datos, y todo lo demás (compresión, sparse indexes, particionado) funciona igual.
AggregatingMergeTree es el que convierte "esta consulta tarda 3 segundos" en "esta consulta tarda 50 milisegundos". Exige más diseño previo que las otras variantes, pero una vez montada la pipeline, se ejecuta sola.
Siguiente en la serie: un pipeline real de velas OHLC construido sobre estos patrones, desde trades raw hasta velas multi-resolución servidas a TradingView.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com