Volver al inicio

ReplacingMergeTree en ClickHouse: Guía completa

El artículo detalla el motor ReplacingMergeTree en ClickHouse: por qué es necesario para combatir duplicados en entregas no confiables, cómo funciona la fusión por ORDER BY, el papel del versionado y el modificador FINAL. Se discuten errores típicos, comparación con CollapsingMergeTree y el patrón de vista materializada para evitar FINAL.

ReplacingMergeTree: Deduplicación de datos en ClickHouse
Advertisement 728x90

ReplacingMergeTree: Cómo Vencer los Duplicados en ClickHouse Sin Dolor

1. Por Qué se Necesita ReplacingMergeTree — El Problema Real de los Duplicados

Imagina que estás desarrollando un casino online. Un jugador hace clic en el botón "Hacer Apuesta" — 1000 rublos al negro. En ese momento, el servidor que procesa la solicitud se cae repentinamente (sobrecalentamiento, fallo de red, quién sabe). El cliente no recibe respuesta y piensa: "La apuesta no se realizó". El jugador hace clic de nuevo. El servidor se recupera y acepta ambas solicitudes. En la base de datos — dos apuestas idénticas. El jugador está furioso: se le descontaron 2000 rublos en lugar de 1000.

Este es un problema clásico de idempotencia (del latín idem — mismo, potens — capaz). Una operación es idempotente si repetirla produce el mismo resultado que hacerla una vez. En el mundo de las bases de datos, necesitamos un mecanismo que determine: "Ya he visto esta apuesta, ignoraré la segunda versión".

En ClickHouse, existe ReplacingMergeTree para esto. Es un motor de tabla que elimina automáticamente duplicados durante la fusión de partes de datos. Pero te advierto de antemano: no es magia — tiene peculiaridades que discutiremos.

Google AdInline article slot

Analogía de la vida real: ReplacingMergeTree es como una secretaria que lleva un registro de reuniones. La gente viene a ti con solicitudes. A veces el mismo cliente trae dos solicitudes idénticas (por ejemplo, perdió un tren y pide un reembolso, luego llama de nuevo con la misma solicitud). La secretaria no tira los duplicados en la puerta — simplemente pone todos los papeles en una carpeta. Una vez al día, revisa la carpeta y conserva solo la solicitud más reciente de cada cliente. Si alguien pregunta "¿cuántas solicitudes de Ivanov?" antes de la clasificación — verán dos. Después — una.

2. Cómo Funciona ReplacingMergeTree — Ladrillo a Ladrillo

Los Duplicados Surgen de la Entrega No Confiable

ClickHouse fue diseñado originalmente para análisis a gran escala, donde una pérdida o duplicado ocasional no es crítico. Pero luego la gente empezó a usarlo para datos críticos — y se quemó. ReplacingMergeTree es la respuesta a ese dolor.

¿Por qué aparecen duplicados?

Google AdInline article slot
  • El cliente envió datos, no recibió confirmación (timeout) y los envió de nuevo.
  • El sistema de colas (Kafka, RabbitMQ) ofrece garantía at-least-once — al menos una entrega, posibles repeticiones.
  • Un error en el proceso ETL (Extract, Transform, Load) — el pipeline se ejecutó dos veces.

Mecanismo: Fusión por Clave ORDER BY

Al crear una tabla con ReplacingMergeTree, debes especificar una clave de ordenamientoORDER BY (columna1, columna2). Esto no es una clave primaria en el sentido clásico (como en PostgreSQL), sino una forma de ordenar físicamente los datos en el disco. ClickHouse almacena datos en partes — fragmentos ordenados por esta clave.

Cuando dos partes se fusionan en una (un proceso de fondo llamado fusión), ReplacingMergeTree escanea las filas con el mismo valor de clave ORDER BY y conserva solo una. ¿Cuál? Por defecto — la última por tiempo de inserción. Pero puedes especificar una columna version numérica, entonces se conserva la fila con el valor máximo de versión.

Analogía con Git: ReplacingMergeTree durante la fusión se comporta como Git cuando resuelves un conflicto: de dos cambios al mismo archivo, se conserva el más reciente (si no especificas una estrategia explícitamente). Solo que aquí el archivo es una fila en la tabla, y la clave es ORDER BY.

Google AdInline article slot

Versionado: Cómo ReplacingMergeTree(version) Cambia las Reglas

Sintaxis: ReplacingMergeTree(columna_version). Si columna_version es un entero (UInt* o DateTime), se conserva la fila con el valor más grande. Esto da control manual: puedes especificar explícitamente qué versión "gana".

Ejemplo: enviamos apuestas con updated_at = now(). En un reenvío, updated_at será ligeramente mayor. La fusión conservará la más reciente. Si no especificas version, ClickHouse elige la última que llegó — que puede no ser la más reciente según la lógica de negocio, solo la última inserción física. La diferencia importa.

3. CREATE TABLE con ReplacingMergeTree — Desglosándolo

-- Crear una tabla para apuestas con deduplicación
CREATE TABLE bets_dedup
(
    user_id    UInt64,           -- ID del jugador (quién posee la apuesta)
    bet_id     String,           -- ID único de apuesta (generado en el cliente)
    amount     Decimal(10,2),    -- Monto en rublos
    created_at DateTime,         -- Hora de creación de la apuesta
    updated_at DateTime          -- Hora de última actualización (para versión)
)
ENGINE = ReplacingMergeTree(updated_at)   -- Motor con versión por updated_at
ORDER BY (user_id, bet_id)                -- Clave de deduplicación: (user_id, bet_id)

Qué sucede línea por línea:

  • ENGINE = ReplacingMergeTree(updated_at) — especifica que esto es ReplacingMergeTree, y la columna updated_at se usará como versión. Durante la fusión, de dos filas con el mismo ORDER BY, se conserva la que tiene el updated_at más grande (más reciente). Si updated_at es igual — se conserva la última insertada físicamente (pero es mejor no confiar en eso).

  • ORDER BY (user_id, bet_id) — ¡el parámetro más importante! Este conjunto de columnas define qué se considera un duplicado. Dos filas se consideran duplicadas si tienen los mismos valores para todas las columnas en ORDER BY. Aquí: una apuesta del usuario user_id con bet_id — es única. Si llegan dos filas con user_id=123, bet_id='abc-456' — se fusionarán en una.

¿Por qué ORDER BY y no PRIMARY KEY? En ClickHouse, PRIMARY KEY no tiene por qué ser única. Es una sugerencia para el índice, mientras que ORDER BY es el orden físico en disco. ReplacingMergeTree se basa en ORDER BY, incluso si PRIMARY KEY es más corto. Si no especificas PRIMARY KEY, coincide con ORDER BY.

¿Qué pasa si ORDER BY es demasiado amplio? Por ejemplo, incluir amount. Entonces dos apuestas con montos diferentes (incluso idénticas en user_id, bet_id) no se considerarán duplicadas — ambas permanecerán. La deduplicación no funcionará. Escollo #1 (volveremos a ello al final).

4. Por Qué SELECT Puede Devolver Duplicados Antes de la Fusión — y Cómo Vivir con Ello

El principal matiz: ReplacingMergeTree elimina duplicados solo durante la fusión de partes de datos. Este es un proceso de fondo que no ocurre instantáneamente. Entre la inserción de duplicados y su eliminación física, pueden pasar desde unos segundos hasta varias horas (dependiendo de la configuración y la carga).

¿Qué significa esto en la práctica?

Insertemos dos duplicados:

-- Primera inserción
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now());

-- Después de 5 segundos — segunda (el servidor no recibió confirmación y envió de nuevo)
INSERT INTO bets_dedup VALUES (123, 'bet-001', 1000, now(), now() + interval 5 second);

Ahora ejecuta un SELECT * FROM bets_dedup WHERE user_id = 123 normal. ¿Qué veremos? Dos filas. Porque la fusión aún no ha ocurrido. Los datos están en diferentes partes. Cada parte está ordenada internamente por ORDER BY, pero los duplicados pueden estar en partes diferentes.

¿Cómo obtener una fila garantizada? Usa FINAL:

SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

FINAL obliga a ClickHouse a fusionar sobre la marcha todas las partes para esta consulta, aplicando la lógica de ReplacingMergeTree. Obtendrás una fila — con el updated_at máximo (o la última por tiempo de inserción si no hay versión).

¿Por qué FINAL es lento? ClickHouse lee todas las partes de la tabla, las ordena en memoria por la clave ORDER BY, elimina duplicados, y solo entonces devuelve el resultado. En tablas grandes (miles de millones de filas), esto puede tomar segundos o minutos. El optimizador no puede usar índices de manera eficiente — tiene que escanear muchos datos.

Consejo: No uses FINAL en tiempo real en tablas grandes. Úsalo para:

  • Consultas puntuales para un solo user_id (el índice aún ayuda).
  • Tareas de fondo donde el tiempo no es crítico (informes nocturnos).
  • Tablas pequeñas (hasta millones de filas).

Para cargas de producción, hay un patrón mejor — una vista materializada sin FINAL.

5. Rendimiento de FINAL — Cuándo es Aceptable, Cuándo No

Cuándo FINAL está bien:

  • La tabla es pequeña (hasta 10–20 millones de filas por servidor).
  • Consultas a un solo usuario por índice (WHERE user_id = específico).
  • Tienes una agregación de fondo una vez por hora, y 10 segundos de espera están bien.
  • Exportación de datos una vez al día para un informe.

Cuándo FINAL es un asesino:

  • Tabla >100 millones de filas.
  • Consulta sin filtrado (SELECT * FROM table FINAL) — ClickHouse leerá todo.
  • Escenario OLTP de alta carga (decenas de consultas por segundo con FINAL).
  • Actualizaciones frecuentes a las mismas claves — se acumulan muchas partes, FINAL las lee todas.

Analogía: SELECT ... FINAL es como revisar manualmente todos los papeles en un archivo para encontrar la última versión de un documento, en lugar de mirar un "registro de versiones actuales" especial. Funciona, pero no para cada solicitud de cliente.

¿Cómo verificar si una consulta usa FINAL?

ClickHouse tiene el comando EXPLAIN:

EXPLAIN SELECT * FROM bets_dedup FINAL WHERE user_id = 123;

Busca ReadFromMergeTree con la bandera final. Si la ves — la consulta recorre honestamente las partes.

6. Patrón: Agregación en Segundo Plano Sin FINAL Mediante Vista Materializada

Esta es mi forma favorita de evitar FINAL. La idea: deja que ReplacingMergeTree viva su vida, los duplicados se colapsan gradualmente en segundo plano. Para la lectura, creamos una vista materializada que se reconstruye periódicamente y contiene datos ya "limpios" sin duplicados.

Cómo se ve:

-- 1. Tabla base — sucia, con duplicados
CREATE TABLE bets_raw
(
    user_id UInt64,
    bet_id String,
    amount Decimal(10,2),
    created_at DateTime,
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (user_id, bet_id);

-- 2. Tabla destino — limpia, sin duplicados
CREATE TABLE bets_clean
(
    user_id UInt64,
    bet_id String,
    amount Decimal(10,2),
    created_at DateTime,
    updated_at DateTime
)
ENGINE = MergeTree()                    -- MergeTree normal sin deduplicación
ORDER BY (user_id, bet_id);

-- 3. Vista materializada — transfiere datos al insertar
CREATE MATERIALIZED VIEW bets_mv TO bets_clean AS
SELECT
    user_id,
    argMax(amount, updated_at) AS amount,      -- toma amount de la fila con max updated_at
    argMax(created_at, updated_at) AS created_at,
    max(updated_at) AS updated_at
FROM bets_raw
GROUP BY user_id, bet_id;   -- Agrupa por clave de deduplicación

Puntos clave explicados:

  • argMax(amount, updated_at) — una función agregada que devuelve el valor de amount de la fila con el updated_at más grande. Si tenemos duplicados con diferentes updated_at (y diferentes amount — por ejemplo, el monto de la apuesta cambió), se conserva el monto más reciente. Esto es análogo al control de versiones manual.

  • GROUP BY user_id, bet_id — aquí decimos explícitamente: "considera la combinación usuario+ID de apuesta como un duplicado". Ahora no hay necesidad de esperar la fusión — cada INSERT en bets_raw desencadena inmediatamente (casi) un recálculo en bets_clean a través de bets_mv.

  • Limitación importante: Las vistas materializadas en ClickHouse procesan datos en lotes — cada inserción por separado. Si una sola inserción contiene dos duplicados (user_id, bet_id) — se colapsarán dentro del lote. Si los duplicados llegan en inserciones diferentes — bets_clean puede contener duplicados temporales hasta que bets_raw se fusione. Para una limpieza perfecta, necesitas usar FINAL al leer de bets_raw, o ejecutar periódicamente OPTIMIZE TABLE bets_raw (fusión forzada).

Analogía: Es como tener un borrador (bets_raw) donde pones todas las correcciones, y una secretaria que cada 5 minutos reescribe una copia limpia (bets_clean) sin errores. Los lectores solo miran la copia limpia — rápido y sin duplicados.

7. ReplacingMergeTree(version) con Versión Monótonamente Creciente — Semántica de Actualización

Un ReplacingMergeTree normal simplemente conserva la fila "que llegó última". Esto es malo si los datos antiguos pueden llegar después de los nuevos (por ejemplo, debido a retrasos de red). Solución: usa una columna version que aumente monótonamente (por ejemplo, timestamp o ID de secuencia).

Ejemplo: tabla de saldo de jugador con historial de depósitos

CREATE TABLE player_balance
(
    user_id        UInt64,
    transaction_id String,        -- ID de transacción único (UUID)
    amount         Int64,         -- Cambio de saldo (puede ser negativo)
    balance_after  Int64,         -- Saldo después de la transacción
    event_time     DateTime,      -- Hora del evento en el cliente
    ingestion_time DateTime       -- Hora de inserción en ClickHouse (versión)
)
ENGINE = ReplacingMergeTree(ingestion_time)   -- Versión = hora de inserción
ORDER BY (user_id, transaction_id);

Ahora, incluso si la transacción tx-001 llega dos veces, pero con diferentes ingestion_time, se conserva la insertada más tarde (con ingestion_time mayor). Esto protege contra "duplicados tardíos" — cuando la primera inserción fue a las 12:00, la segunda a las 12:05 (repetición), pero debido a un fallo de red la segunda llegó al servidor antes que la primera. Sin versión, se conservaría la anterior (por tiempo de inserción) — que podría ser la incorrecta.

¿Qué significa "monótonamente creciente"? Con cada nueva inserción, el valor de ingestion_time debe ser mayor o igual que los anteriores. Usa now() (hora actual en el servidor ClickHouse) o un contador atómico (por ejemplo, de ZooKeeper). No confíes en la hora del cliente — los relojes pueden saltar.

8. Ejemplo Completo: Deduplicación de Recargas de Saldo por transaction_id

Pongamos todo junto. Tenemos un microservicio que acepta recargas de saldo de un sistema de pago. El sistema de pago envía webhooks (llamadas HTTP) — a veces duplicados.

-- Paso 1: Crear una tabla para eventos en bruto
CREATE TABLE balance_events
(
    user_id        UInt64,
    transaction_id String,        -- ID único del sistema de pago
    amount         Int64,         -- +1000 rublos
    event_time     DateTime,      -- Hora del descuento del dinero al usuario
    inserted_at    DateTime DEFAULT now()  -- Se establece automáticamente al insertar
)
ENGINE = ReplacingMergeTree(inserted_at)
ORDER BY (user_id, transaction_id);   -- Deduplicación por par (usuario, transacción)

-- Paso 2: Insertar datos (supongamos que llega un duplicado)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Después de un minuto, llega un duplicado (inserted_at se establecerá automáticamente como now() + 60 seg)
INSERT INTO balance_events (user_id, transaction_id, amount, event_time) 
VALUES (1, 'pay_001', 1000, '2025-06-01 10:00:00');

-- Paso 3: Leer sin FINAL — veremos 2 filas (pero solo si aún no se han fusionado)
SELECT * FROM balance_events WHERE user_id = 1;
-- Resultado: dos filas con mismo user_id, transaction_id, amount

-- Paso 4: Leer con FINAL — vemos una fila (con el max inserted_at)
SELECT * FROM balance_events FINAL WHERE user_id = 1;
-- Resultado: una fila

¿Por qué no basta con transaction_id en ORDER BY? Porque dos usuarios diferentes podrían tener el mismo transaction_id (por ejemplo, cada sistema de pago tiene su propio contador). Agregar user_id garantiza unicidad dentro de un usuario. Si el sistema genera UUID globales (550e8400-e29b-41d4-a716-446655440000) — puedes usar solo ORDER BY transaction_id, un UUID es suficiente.

9. Comparación con CollapsingMergeTree

CollapsingMergeTree es otro motor para manejar cambios. Almacena pares "más" y "menos" y los colapsa durante la fusión.

Diferencias clave:

Característica ReplacingMergeTree CollapsingMergeTree
Mecanismo Conserva una fila de los duplicados Colapsa pares (+1 y -1)
Propósito Deduplicación de inserciones Actualización de agregados (ej. carrito de compras)
Versión necesaria Opcional (columna version) Obligatorio indicador Sign (+1/-1)
¿Se puede almacenar historial? Sí, todas las versiones hasta la fusión No, los pares se destruyen
FINAL para lectura Sí, sin él los duplicados son visibles Sí, sin él los pares no colapsados son visibles

Cuándo elegir ReplacingMergeTree:

  • Solo necesitas eliminar filas duplicadas.
  • Tienes una clave natural para la deduplicación (ID de transacción).
  • Los datos cambian raramente (principalmente inserciones).

Cuándo elegir CollapsingMergeTree:

  • Actualizas frecuentemente una métrica agregada (ej. "número de artículos en el carrito").
  • Solo necesitas almacenar el resultado, no el historial de cambios.

Ejemplo para CollapsingMergeTree:

CREATE TABLE cart_items
(
    user_id UInt64,
    product_id UInt64,
    quantity Int16,
    sign Int8  -- +1 (añadir), -1 (eliminar)
) ENGINE = CollapsingMergeTree(sign)
ORDER BY (user_id, product_id);

Con ReplacingMergeTree, simplemente sobrescribirías la fila con una nueva versión de quantity — pero entonces perderías el historial de cambios. CollapsingMergeTree permite calcular el total (SUM(quantity * sign)) incluso sin FINAL.

10. Escollos Comunes — y Cómo Evitarlos

Escollo #1: ORDER BY No Incluye Todos los Campos Únicos

-- MALO: usando solo user_id
CREATE TABLE bets_bad ENGINE = ReplacingMergeTree ORDER BY user_id;

-- Se insertaron dos apuestas para el mismo usuario con diferentes bet_id
INSERT INTO bets_bad VALUES (1, 'bet_001', 100);
INSERT INTO bets_bad VALUES (1, 'bet_002', 200);

-- Durante la fusión SE FUSIONARÁN en una fila — porque ORDER BY (user_id) es el mismo.
-- Se perdió bet_002.

Correcto: Incluye en ORDER BY todas las columnas que hacen única una fila — generalmente un ID sustituto (transaction_id) o una combinación (user_id, bet_id).

Escollo #2: Esperanza Ingenua de Deduplicación Instantánea

Los novatos escriben INSERT con un duplicado e inmediatamente hacen SELECT sin FINAL — ven duplicados. Se decepcionan de ClickHouse. Recuerda: la deduplicación es asíncrona. Si necesitas consistencia instantánea — usa FINAL o el patrón de vista materializada.

Escollo #3: Usar una Versión Que No es Monótona

-- MALO: la versión es la hora del cliente
CREATE TABLE events ENGINE = ReplacingMergeTree(client_time) ORDER BY (id);

-- El reloj del cliente está atrasado, envían una versión antigua después de una nueva
-- Durante la fusión, se conservará la fila incorrecta (la antigua)

Solución: Usa now() del lado de ClickHouse o un contador de hardware.

Escollo #4: Optimismo Acerca de FINAL en Datos Grandes

Tuve un caso: un desarrollador habilitó FINAL en todos los informes en una tabla de 2 mil millones de filas. Las consultas empezaron a agotar el tiempo de espera después de 300 segundos. Tuve que reescribir a agregación con GROUP BY y argMax.

Regla de Oro: Si estás leyendo más del 10% de una tabla mediante FINAL — estás haciendo algo mal. Usa vistas materializadas o replantea la arquitectura.

Escollo #5: ReplacingMergeTree Sin ORDER BY

ClickHouse no te permitirá crear una tabla sin ORDER BY. Pero puedes especificar ORDER BY tuple() (tupla vacía). Entonces todas las filas de la tabla se consideran duplicadas — solo quedará una fila después de la primera fusión. Casi nunca es necesario.

Qué Sigue — Enlaces a Artículos Relacionados

Ahora que dominas ReplacingMergeTree, aquí tienes los siguientes temas para explorar:

  1. Cómo optimizar fusiones — ajustes como merge_with_ttl_timeout, number_of_free_entries_in_pool_to_lower_max_size_of_merge (suena aterrador pero útil).

  2. Deduplicación a nivel de INSERT — el motor ReplicatedReplacingMergeTree con ZooKeeper. Este es otro nivel: los duplicados se eliminan inmediatamente al insertar, pero a costa de retrasos y complejidad.

  3. Alternativa: VersionedCollapsingMergeTree — un híbrido que soporta versionado y colapso simultáneamente.

  4. Vistas materializadas en detalle — cómo construir agregaciones multinivel para evitar FINAL por completo.

Y finalmente: ReplacingMergeTree es una herramienta poderosa, pero no se trata de "eliminar duplicados inmediatamente". Se trata de "los datos eventualmente estarán limpios, y mientras tanto trabajas con ellos". Si necesitas unicidad estricta (como PRIMARY KEY en PostgreSQL) — ClickHouse no es la mejor opción. Pero para el 99% de las tareas analíticas con inserciones repetidas — es un salvavidas.


Anterior:
Siguiente: SummingMergeTree y AggregatingMergeTree: Agregación Incremental sin Dolor

— Editorial Team

Advertisement 728x90

Leer después