CollapsingMergeTree: Estado Actual desde Flujos de Eventos
En el post anterior cubrimos ReplacingMergeTree, donde insertas una fila nueva y los merges de fondo eliminan el estado anterior. Escrituras sencillas, lecturas caras con FINAL o argMax en cada query. Este post es parte de la serie ClickHouse Deep Dive y cubre la alternativa.
Porque hay otra forma, con CollapsingMergeTree. En vez de pedirle a ClickHouse que determine qué fila es la más reciente durante los merges, lo defines tú en la ingesta: cancelas el valor antiguo e insertas una fila nueva. Los merges siguen ocurriendo, pero el proceso se simplifica con esa información extra. No sale gratis: requiere que la pipeline de ingesta conozca el estado anterior. A cambio, la complejidad de lectura se reduce.
El problema es el mismo: calcular el estado actual en un engine de solo append. Pero el enfoque es el opuesto. Y la elección se reduce a dos puntos clave: dónde prefieres que viva la complejidad de la pipeline (ingesta vs consultas), y si los patrones específicos que permite este engine compensan el coste operativo.
El modelo mental: contabilidad de partida doble
CollapsingMergeTree funciona como un libro de contabilidad. Cada INSERT es una entrada. Una fila de estado (Sign = +1) es un abono. Una fila cancelada (Sign = -1) es un cargo. Durante los merges, los cargos cancelan los abonos. Solo el estado neto sobrevive.
Para gestionar esta lógica, la columna Sign es el único concepto específico del engine:
- Tipo:
Int8(tiene que soportar valores negativos, así que no valeUInt8) - Valores: exactamente
1o-1, nada más es válido. - ClickHouse no valida los valores de Sign en el
INSERT. Un0o un2corrompen silenciosamente la lógica de merge. Puedes activar la validación conSET add_implicit_sign_column_constraint_for_collapsing_engine = 1
La tabla se puede definir de la siguiente forma:
CREATE TABLE user_activity (
user_id UInt64,
event_time DateTime,
page_views Int32,
duration Int32,
Sign Int8
) ENGINE = CollapsingMergeTree(Sign)
ORDER BY user_id;
Salvo la columna Sign, todo lo demás es MergeTree estándar. Fíjate en los tipos enteros con signo en page_views y duration para gestionar correctamente las cancelaciones de filas.
Cómo funciona: INSERT, UPDATE, DELETE
Las tres operaciones son INSERTs; no hay UPDATE, no hay DELETE. Solo filas con valores de Sign.
Para crear, insertas una única fila de estado:
INSERT INTO user_activity VALUES (123, now(), 5, 146, 1);
Para actualizar, hacen falta dos INSERTs. Una fila de cancelación que duplica todos los campos del estado anterior, y una fila de estado nueva:
INSERT INTO user_activity VALUES
(123, now(), 5, 146, -1), -- cancel old state
(123, now(), 6, 185, 1); -- new state
Este es el requisito crítico: tu aplicación tiene que conocer el estado previo para cancelarlo. La fila de cancelación debe replicar exactamente todos los valores de las columnas de la clave de ordenación. Este es el coste operativo fundamental de CollapsingMergeTree, y la razón por la que la mayoría se decanta primero por ReplacingMergeTree.
Para eliminar, insertas solo una fila de cancelación:
INSERT INTO user_activity VALUES (123, now(), 6, 185, -1);
Como en cualquier otro engine MergeTree, los merges hacen el trabajo duro. El collapsing solo lo ejecutan ellos, nunca en el INSERT. Entre inserts, tanto el estado antiguo como la fila cancelada coexisten y son visibles para cualquier query. Y recuerda que los merges solo actúan dentro de la misma partición; no cruzan filas entre ubicaciones físicas distintas.
La mejor optimización para esta tabla es insertar siempre la cancelación y el nuevo estado en el mismo INSERT. Mismo INSERT significa la misma part, así que colapsan en el siguiente merge. INSERTs separados crean parts separadas, y esas solo colapsan cuando ClickHouse las fusiona entre sí. El timing es menos predecible.
Patrones de consulta que siempre funcionan
Aquí es donde compensa. Esta es la razón para elegir CollapsingMergeTree sobre ReplacingMergeTree.
Entre merges, un SELECT * devuelve todo: estado antiguo, fila de cancelación, estado nuevo. Tres filas para una única entidad lógica.
Pero un patrón de query sencillo lo resuelve:
SELECT
user_id,
sum(page_views * Sign) AS page_views,
sum(duration * Sign) AS duration
FROM user_activity
GROUP BY user_id
HAVING sum(Sign) > 0;
Para otras operaciones, también hacen falta algunos cambios en las queries:
| Qué quieres | Qué escribes |
|---|---|
| Count | sum(Sign) en vez de count(*) |
| Sum | sum(column * Sign) en vez de sum(column) |
| Average | sum(column * Sign) / sum(Sign) |
| Filtrar eliminados | HAVING sum(Sign) > 0 |
Este patrón usa GROUP BY estándar con todas las optimizaciones de ClickHouse: ejecución en paralelo, PREWHERE e index skipping. Sin lógica especial de merge en tiempo de query.
FINAL también funciona (SELECT * FROM user_activity FINAL), pero tiene el mismo coste que FINAL en ReplacingMergeTree: merge-on-read, desactiva PREWHERE por defecto. Multi-threaded, pero añade latencia. Todo el sentido de CollapsingMergeTree es que no lo necesitas.
ReplacingMergeTree vs CollapsingMergeTree
En general, el tradeoff principal es dónde vive la complejidad. Con CollapsingMergeTree, la ingesta hace el trabajo duro. Con ReplacingMergeTree, las queries son mucho más complejas.
La siguiente tabla recoge las diferencias principales.
| Aspecto | ReplacingMergeTree | CollapsingMergeTree |
|---|---|---|
| Patrón de actualización | Solo insertar versión nueva | Cancelar antigua + insertar nueva |
| Conocimiento del estado | No necesita conocer el estado previo | Debe conocer el estado previo |
| Volumen de escritura | 1 fila por actualización | 2 filas por actualización |
| Corrección de queries | Requiere FINAL o argMax | sum(col * Sign), siempre correcto |
| Compatibilidad con MVs | Solo agregaciones tipo uniq() | Soporte completo para sum/count |
| Cambios en sorting key | No se pueden cambiar columnas ORDER BY | Cancelar clave antigua, insertar nueva |
| Facilidad de uso | Sencillo | Más difícil de acertar |
En la práctica, solo hay dos ventajas que pueden justificar el coste de mantener una pipeline de ingesta con estado. Vamos a repasarlas.
La ventaja de la sorting key
Con ReplacingMergeTree, las columnas del ORDER BY son inmutables. Son la clave de deduplicación. Si las cambias, el engine trata la fila como una entidad completamente nueva. Para "actualizar" una columna de la sorting key, necesitas mutations (caras) o un delete + re-insert manual.
CollapsingMergeTree lo gestiona directamente. Cancelas la fila antigua con los valores de sorting key antiguos, insertas una fila nueva con los valores nuevos. Ambas son INSERTs normales.
Supongamos que un usuario cambia su region, que es parte del ORDER BY:
-- Cancel old state
INSERT INTO users VALUES ('user_123', 'US-East', 100, -1);
-- New state with a different region
INSERT INTO users VALUES ('user_123', 'EU-West', 100, 1);
Durante el merge, la fila cancelada colapsa la entrada antigua de US-East. La entrada de EU-West persiste. ReplacingMergeTree no puede hacer esto sin mutations.
La ventaja de las materialized views
Una MV con SummingMergeTree sobre CollapsingMergeTree gestiona los conteos y las sumas sin problema:
CREATE MATERIALIZED VIEW active_users
ENGINE = SummingMergeTree()
ORDER BY (region)
AS SELECT
region,
sum(Sign) AS user_count
FROM users
GROUP BY region;
Cuando se añade un usuario (Sign=1), user_count se incrementa. Cuando se cancela (Sign=-1), se decrementa. La tabla destino SummingMergeTree suma estos valores durante el merge. Siempre correcto.
Las MVs de ReplacingMergeTree no pueden hacer esto. Ven cada INSERT como una fila nueva. Una materialized view que cuente filas contaría cada versión de cada fila. Solo las funciones tipo uniq(), que deduplican de forma natural, producen resultados correctos.
Si estás construyendo dashboards en tiempo real que necesitan conteos acumulados o totales de ingresos, esto puede ser el factor decisivo.
Guía de decisión
Por lo general, CollapsingMergeTree encaja cuando:
- Tu pipeline tiene acceso al estado anterior (CDC con imágenes before/after)
- Necesitas conteos y sumas compatibles con MVs
- Las columnas de la sorting key pueden cambiar
- Quieres evitar por completo el coste de
FINAL - Alto volumen de eliminaciones (soporte nativo vs limpieza con
is_deleted)
Por el contrario, ReplacingMergeTree es mejor opción cuando:
- Tu pipeline solo conoce el estado actual (sin before/after)
- La sencillez importa más que el rendimiento de consultas
- Baja frecuencia de actualizaciones, con lo que la amplificación 2x de escritura no se justifica
- No necesitas MVs con count/sum
La pregunta no es qué engine es mejor. Es cómo es tu pipeline. Si puedes acceder al estado anterior sin añadir complejidad, CollapsingMergeTree es la opción natural. Si tu fuente solo emite el estado actual, construir filas de cancelación implica consultar ClickHouse en cada escritura. Eso normalmente no compensa.
En el caso de estudio de estado actual, elegimos ReplacingMergeTree porque el exchange no emitía imágenes before/after, solo emitía el estado actual. Montar una pipeline con CollapsingMergeTree habría significado buscar la fila antigua en cada escritura, lo que añadía latencia y complejidad que no necesitábamos.
VersionedCollapsingMergeTree: el engine para producción
CollapsingMergeTree a secas tiene un problema. El algoritmo de collapsing depende de que las filas de estado aparezcan antes que sus correspondientes filas de cancelación en orden de clasificación. Cuando los datos llegan desordenados por cualquier motivo (las causas varían: distintos threads ingiriendo, un sistema distribuido en el productor, problemas de red), el emparejamiento se rompe. Filas que deberían colapsar se quedan sin colapsar indefinidamente.
Esto no es una preocupación teórica, es lo que ocurre en casi cualquier sistema en producción.
VersionedCollapsingMergeTree lo soluciona con una columna Version:
CREATE TABLE order_events (
order_id UInt64,
status LowCardinality(String),
amount Decimal64(2),
updated_at DateTime64(3),
Sign Int8,
Version UInt64
) ENGINE = VersionedCollapsingMergeTree(Sign, Version)
ORDER BY (status, order_id);
El mecanismo de emparejamiento es distinto:
CollapsingMergeTreeempareja filas por posición adyacente en el orden de clasificación. Misma sorting key, Sign opuesto, una junto a otra en el stream. El orden importa.VersionedCollapsingMergeTreeempareja filas por primary key + version + sign opuesto. Una fila de cancelación con Version=3 solo se empareja con una fila de estado con Version=3. Pueden llegar en cualquier orden.
El patrón de INSERT solo añade la nueva columna:
-- Initial state (Version 1)
INSERT INTO order_events VALUES (42, 'pending', 99.99, now(), 1, 1);
-- Update: cancel Version 1, insert Version 2
INSERT INTO order_events VALUES
(42, 'pending', 99.99, now(), -1, 1), -- cancel (same Version)
(42, 'shipped', 99.99, now(), 1, 2); -- new state (higher Version)
La fila de cancelación usa la misma Version que la fila que se cancela. La fila de estado nueva usa una Version superior. Así es como el engine sabe qué cancelación corresponde a qué estado.
La columna de version tiene ciertos requisitos: el valor debe aumentar con el tiempo. Timestamps, enteros auto-incrementales o el LSN (log sequence number) de la base de datos origen, todos valen. UUIDs aleatorios no. Si la columna Version no está en la primary key, ClickHouse la añade implícitamente como último campo.
Los patrones de consulta son idénticos a CollapsingMergeTree. sum(col * Sign) con HAVING sum(Sign) > 0. La columna Version la usa el engine internamente para el emparejamiento, pero no suele aparecer en las queries analíticas.
La regla principal: si estás considerando CollapsingMergeTree, usa VersionedCollapsingMergeTree. El Collapsing sin versión solo funciona para ingesta mono-hilo desde una fuente estrictamente ordenada. En la práctica, eso rara vez se da, y si luego añades más threads, toca migración.
Patrones de producción
Dos patrones que aparecen constantemente en producción.
CDC desde Postgres
Las herramientas de CDC como Debezium emiten imágenes before/after desde el WAL de Postgres. Cada UPDATE produce tanto el estado antiguo como el nuevo. Esto mapea directamente a CollapsingMergeTree:
UPDATEs- Estado antiguo = fila de cancelación (Sign = -1)
- Estado nuevo = fila de estado (Sign = 1)
DELETEs = solo fila de cancelaciónINSERTs = solo fila de estado
CREATE TABLE replicated_users (
id UInt64,
name String,
email String,
region LowCardinality(String),
balance Decimal64(2),
updated_at DateTime64(3),
Sign Int8,
Version UInt64 -- source DB's transaction LSN
) ENGINE = VersionedCollapsingMergeTree(Sign, Version)
PARTITION BY toYYYYMM(updated_at)
ORDER BY (region, id)
SETTINGS do_not_merge_across_partitions_select_final = 1;
El LSN de la base de datos origen es la columna Version natural. Sin lookup necesario: la pipeline ya tiene ambos estados.
Dashboards en tiempo real con MVs de SummingMergeTree
CollapsingMergeTree combina bien con materialized views de SummingMergeTree. Cada fila cancelada resta, cada fila de estado suma. La MV se mantiene correcta sin FINAL, sin argMax, sin ninguna deduplicación en tiempo de query.
Usando la misma tabla user_activity, una MV con SummingMergeTree puede rastrear el total de page views y el conteo de usuarios activos por día:
CREATE MATERIALIZED VIEW daily_activity
ENGINE = SummingMergeTree()
ORDER BY (toDate(event_time))
AS SELECT
toDate(event_time) AS date,
sum(page_views * Sign) AS total_page_views,
sum(Sign) AS active_users
FROM user_activity
GROUP BY date;
Cuando se actualiza la actividad de un usuario, la fila de cancelación decrementa los page views antiguos y el conteo de usuarios activos. La fila de estado nueva vuelve a sumar los valores actualizados. La query del dashboard es un GROUP BY normal:
SELECT
date,
sum(active_users) AS active_users,
sum(total_page_views) AS total_page_views
FROM daily_activity
GROUP BY date;
Correcto en todo momento, independientemente del estado de merge.
Errores comunes
Todos estos aparecen en producción. Algunos son obvios. Otros son lo bastante silenciosos como para pasar desapercibidos durante meses.
Olvidarse de las filas de cancelación. Obvio, pero letal. Si tu app inserta un estado nuevo sin cancelar el anterior, ambos persisten. sum(page_views * Sign) cuenta doble. Sin error, sin aviso. Se acumula con el tiempo. Te das cuenta cuando los números no cuadran, y para entonces tienes un montón de agregaciones corruptas.
-- Forgot the cancel row:
INSERT INTO user_activity VALUES (123, now(), 6, 185, 1);
-- Now two state rows exist:
-- (123, ..., 5, 146, 1) old state, never cancelled
-- (123, ..., 6, 185, 1) new state
-- sum(page_views * Sign) = 5 + 6 = 11 (should be 6)
La fila de cancelación no coincide con la sorting key. La fila de cancelación debe replicar exactamente todos los valores de las columnas ORDER BY del estado original. Sorting keys distintas significan filas lógicas distintas. No colapsan nunca.
-- State: ORDER BY (user_id, event_date)
INSERT INTO events VALUES (123, '2024-01-01', 100, 1);
-- Wrong cancel (different event_date):
INSERT INTO events VALUES (123, '2024-01-02', 100, -1);
-- These NEVER collapse
Cancelación entre particiones. Fila de estado en la partición de enero, fila de cancelación en la de febrero. No van a colapsar nunca. Viven en parts distintas del almacenamiento, y ClickHouse no las fusiona entre sí. Diseña el particionado para que todos los cambios de estado de la misma entidad caigan en la misma partición.
Columnas UInt con multiplicación por Sign. Por eso usé Int32 en vez de UInt32 en la definición de tabla anterior. sum(UInt32_column * Sign) en una fila de cancelación produce un overflow de entero sin signo: un número enorme en vez de uno negativo. Usa tipos con signo para cualquier columna que se multiplique por Sign.
Usar count(*) o sum(column) directamente. Entre merges, devuelven resultados inflados. Siempre sum(Sign) para conteos, sum(column * Sign) para sumas. No hay atajo.
Esperar cancelación instantánea. El collapsing ocurre durante los merges de fondo, no en el INSERT. Entre inserts, ambas filas existen. OPTIMIZE TABLE ... FINAL fuerza un merge pero es caro. Diseña tus queries para que funcionen independientemente del estado de merge, y esto deja de ser un problema.
Todos estos errores son evitables. El coste es disciplina: tu pipeline de escritura tiene que ser meticulosa con las filas de cancelación, los valores de sorting key y la alineación de particiones. Ese es el contrato de CollapsingMergeTree. Corrección en tiempo de escritura, libertad en tiempo de consulta.
Para cerrar
ReplacingMergeTree y CollapsingMergeTree resuelven el mismo problema. Replacing es más sencillo de escribir, más caro de consultar. Collapsing es más difícil de escribir, trivial de consultar. Las características de tu pipeline determinan la elección: si tienes acceso al estado anterior, si necesitas agregaciones compatibles con MVs, y si las sorting keys cambian.
Si tu herramienta de CDC proporciona imágenes before-and-after, el mapeo es directo. Si tu fuente solo conoce el estado actual, quédate con ReplacingMergeTree y acepta el coste de FINAL.
Siguiente en la serie: projections y cómo cambian la ecuación de rendimiento para ambos engines.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com