Elido
15 min de lecturaIngeniería

Por qué usamos ClickHouse para analítica de clics (y no Postgres)

La carga de trabajo es de 100M+ eventos/mes, intensiva en escritura, con forma de consulta analítica. Por qué gana lo columnar, el schema que enviamos, y con qué nos topamos a los 90 días de Postgres

Marius Voß
DevRel · edge infra
Postgres icon at left with a 90-day stress curve flattening into a wall, with arrows redirecting click-event traffic to a ClickHouse cluster on the right serving aggregate queries in sub-100ms

Postgres es excelente. Lo usamos intensamente, y confiamos en él. Almacena cada enlace, cada workspace, cada registro de facturación, cada usuario. Pero alrededor de la marca de 90 días en un acortador de URL en crecimiento, te topas con un muro si también estás almacenando eventos de clics crudos allí. El muro no es dramático - Postgres no se cae, simplemente se vuelve más lento de la manera específica y frustrante en que las consultas agregadas sobre tablas grandes append-only se vuelven más lentas. Los index scans dejan de ayudar. Autovacuum no puede mantener el ritmo. Un dashboard que cargaba en 800ms ahora carga en 4 segundos. Añades más índices. Empeora.

Este post explica por qué movimos los eventos de clics a ClickHouse, cómo se ve el schema, cómo funciona el camino de ingesta, y dónde están las aristas afiladas. Es un compañero al post de latencia de redirección y cubre el tier de datos que se sienta downstream del redirect - la parte que convierte eventos crudos de tipo fire-and-forget en los dashboards en la página de soluciones de analítica.

TL;DR#

  • Los eventos de clics son append-only y con forma de consulta agregada. El almacenamiento columnar gana en ambos puntos.
  • La sobrecarga de Postgres por fila por evento, el bloat de índices, y la contención de vacuum se acumulan después de ~30M eventos en una tabla compartida.
  • ClickHouse con ORDER BY (workspace_id, link_id, created_at) devuelve agrupaciones de 90 días por workspace en menos de 100ms sin índices adicionales.
  • Los metadatos de enlaces permanecen en Postgres (mutables, transaccionales); los eventos de clics permanecen en ClickHouse (append-only, analíticos). La división es limpia porque los dos no necesitan superponerse en tiempo de escritura.

Por qué Postgres lucha con esta carga de trabajo#

La forma de la carga de trabajo es el problema. Un evento de clic es:

  • Escrito una vez y nunca actualizado.
  • Escrito a tasas altas y en ráfagas - 50:1 ratio de escritura-a-lectura es una estimación aproximada, y en un día de lanzamiento de campaña el ratio se dispara aún más.
  • Consultado en agregado: total de clics por enlace, desglose geo para un workspace, división de dispositivo a través de los 100 enlaces principales de una campaña. Casi nadie consulta una sola fila de clic directamente.

Postgres está construido para un conjunto diferente de suposiciones. Cada fila lleva un encabezado de tupla, campos de ID de transacción (xmin, xmax), y metadatos de visibilidad. Para cargas transaccionales donde actualizas filas en su lugar y necesitas MVCC para manejar lecturas y escrituras concurrentes, esa sobrecarga es el precio que pagas por corrección. Para datos analíticos append-only que nunca tocas de nuevo, es puro desperdicio.

El problema de los índices es peor. Los índices B-tree de Postgres funcionan bien para búsquedas puntuales selectivas - encontrar la fila con link_id = 'xyz'. Ayudan menos cuando estás ejecutando GROUP BY link_id, country a través de 10 millones de filas para un workspace. El índice estrecha el scan, pero la consulta aún tiene que extraer datos de fila descomprimidos de páginas heap, deserializarlos, agregarlos. A 30M filas la consulta es lenta. A 100M filas es dolorosa.

Autovacuum empeora esto en la práctica. Las inserciones de alto rendimiento generan tuplas muertas (versiones de fila MVCC que ya no son visibles para las transacciones activas pero que aún no han sido limpiadas). Autovacuum las reclama, pero compite con consultas en vivo por I/O. Verás picos periódicos de latencia en tus dashboards de Postgres que corresponden a autovacuum corriendo en la tabla de eventos. Los docs de compresión y arquitectura de chunks de TimescaleDB vale la pena leerlos para los detalles de cómo una extensión de Postgres intenta trabajar alrededor de esto - el problema es lo suficientemente real como para que exista una capa dedicada de series de tiempo encima de Postgres para abordarlo.

Corrimos clics en Postgres a través de los primeros 90 días del proyecto. Un workspace con ~4M eventos de clics tenía un dashboard que tomaba 3.2 segundos para renderizar un rollup de 7 días por país. Añadir un índice parcial en (workspace_id, created_at) lo bajó a 1.4 segundos. Lo suficientemente bueno en ese momento; claramente no un camino que escale a 100M+ eventos por mes.

Por qué ClickHouse encaja#

ClickHouse es un motor de base de datos columnar construido explícitamente para cargas analíticas - consultado el 2026-05-12. Columnar significa que los datos para cada columna se almacenan contiguamente en disco en lugar de intercalados fila por fila. Para una consulta como "suma el conteo de clics por país para el workspace X en los últimos 30 días", el motor solo lee las columnas country, workspace_id, y created_at. Nunca toca user_agent_hash o referrer_host. La reducción de I/O en tablas anchas con muchas columnas es sustancial.

El ratio de compresión es la segunda propiedad. Los datos columnares se comprimen significativamente mejor que los datos por fila porque los valores repetidos en la misma columna - el mismo workspace_id apareciendo miles de veces, el mismo conjunto de 60 y tantos códigos de país, un puñado de valores os - se comprimen a casi nada bajo los codecs LZ4 y ZSTD de ClickHouse. Nuestros eventos de clics se comprimen a aproximadamente 12-15% de su tamaño crudo en disco. Los mismos datos en Postgres se sientan en 35-45% de tamaño crudo con la compresión toast por defecto. Eso no es solo una victoria de almacenamiento - datos en disco más pequeños significan que más de ellos caben en la caché de páginas del SO, lo que significa menos lecturas de disco por consulta.

La tercera propiedad es la familia de motores MergeTree - consultado el 2026-05-12. Las tablas MergeTree ordenan y agrupan datos por la clave ORDER BY de la tabla. Las consultas que filtran o agrupan en las columnas líderes de esa clave solo leen las partes de datos que se superponen con el rango del filtro. Este es el equivalente de ClickHouse a un índice clustered, excepto que es obligatorio (lo defines en el tiempo de creación de la tabla) y aplica a cada consulta, no solo a columnas indexadas.

Usamos ORDER BY (workspace_id, link_id, created_at). Una consulta para los clics del workspace abc123 en los últimos 7 días lee solo las partes de datos donde aparece workspace_id = 'abc123', luego dentro de esas partes lee solo la rebanada de rango de tiempo. En 90 días de datos de clics, esto típicamente toca el 3-8% del total de partes de datos. El índice primario sparse que ClickHouse mantiene sobre las columnas ORDER BY hace que este targeting sea eficiente sin requerir un B-tree denso para cada patrón de consulta. ReplicatedMergeTree añade HA - dos nodos de ClickHouse con coordinación equivalente a ZooKeeper (ClickHouse Keeper), replicando las partes a medida que se escriben.

El schema que enviamos#

CREATE TABLE clicks
(
    workspace_id    LowCardinality(String),
    link_id         String,
    click_id        UUID,
    created_at      DateTime64(3, 'UTC'),
    country         LowCardinality(FixedString(2)),
    device          LowCardinality(String),
    os              LowCardinality(String),
    browser         LowCardinality(String),
    referrer_host   String,
    utm_source      LowCardinality(String),
    utm_medium      LowCardinality(String),
    utm_campaign    String,
    utm_term        String,
    utm_content     String,
    ip_truncated    String,
    user_agent_hash FixedString(64)
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/clicks', '{replica}')
PARTITION BY toYYYYMM(created_at)
ORDER BY (workspace_id, link_id, created_at)
SETTINGS index_granularity = 8192;

Algunas decisiones del schema vale la pena destacar.

LowCardinality(String) en workspace_id, country, device, os, browser, utm_source, utm_medium - estas columnas tienen cardinalidad acotada (cientos de IDs de workspace distintos, 250 y tantos códigos de país, un puñado de tipos de dispositivo). LowCardinality activa la codificación de diccionario, que almacena los valores como referencias enteras a un diccionario de strings únicos. La velocidad de consulta en estas columnas mejora materialmente; la compresión mejora aún más. Aprendimos esto de los docs de ClickHouse sobre el tipo de dato low-cardinality - la regla general es que cualquier columna string con menos de 10,000 valores distintos es candidata.

PARTITION BY toYYYYMM(created_at) crea una partición de datos separada por mes calendario. Las particiones antiguas pueden ser eliminadas (o movidas a almacenamiento más lento) con una sola sentencia ALTER TABLE clicks DROP PARTITION '202501', que es instantánea - sin scan completo de tabla. Para la aplicación de retención de datos GDPR, esto es prácticamente importante: eliminar los clics históricos de un workspace es una sola operación por partición mensual en lugar de un DELETE WHERE workspace_id = ? distribuido.

ip_truncated almacena la dirección IP con el último octeto en cero (203.0.113.0 no 203.0.113.42). La IP completa nunca se almacena. La forma truncada es suficiente para la búsqueda geo - que hacemos en el momento de ingesta, antes del almacenamiento - y no constituye datos personales bajo la definición del Artículo 4 del GDPR cuando se combina con nuestras prácticas de minimización de datos. Ver solutions/analytics para la arquitectura de privacidad completa.

El camino de ingesta#

Horizontal flow diagram: Edge POPs fire click events to Redpanda topic clicks.v1 (shown partitioned), the click-ingester Go service batches them in 1000-row chunks every 250ms, then INSERTs into ClickHouse

El handler de redirect en cada POP de edge es fire-and-forget. Añade un evento de clic a un topic de Redpanda (clicks.v1) y emite el 302. No espera reconocimiento de ClickHouse. Si Redpanda no está disponible en ese momento, el clic se descarta - el redirect no. La jerarquía es deliberada: un clic perdido es recuperable (podemos reconstruir conteos aproximados desde logs de acceso); un redirect fallido es visible para el usuario.

Los consumer groups de Redpanda - consultado el 2026-05-12 - proveen la garantía de entrega entre el edge y el ingester. El servicio click-ingester corre como un consumer group contra el topic clicks.v1. Las particiones se asignan automáticamente; si una instancia de ingester falla, sus particiones se rebalancean a las instancias supervivientes dentro del timeout de heartbeat del grupo.

El ingester acumula eventos en memoria y los flushea en batches: 1,000 filas o 250ms, lo que venga primero. Esto es importante. El motor MergeTree de ClickHouse está optimizado para inserciones bulk - escribe cada inserción como una nueva parte de datos, y muchas inserciones pequeñas generan muchas partes pequeñas, lo que dispara merging de fondo agresivo y ralentiza las consultas durante la carga de merge. Los docs de ClickHouse recomiendan inserciones de al menos 1,000 filas para mantener manejable el número de partes. Flusheamos a 1,000 filas / 250ms y no hemos visto problemas de conteo de partes en operación normal.

La contrapresión propaga a través de Redpanda. Si ClickHouse está lento o reiniciándose, el lag del consumer del ingester aumenta, lo cual monitoreamos como un indicador adelantado de problemas de frescura de datos. La configuración de retención de Redpanda (14 días) nos da una ventana de replay significativa si ClickHouse necesita ser reconstruido desde cero.

La arquitectura se describe en más detalle en /docs/architecture/click-ingester.

Postgres vs ClickHouse: la división#

Dos bases de datos para un servicio es un costo que necesita justificación. La justificación aquí es que las dos cargas de trabajo son genuinamente incompatibles a escala, y los datos no se superponen de una manera que requiera coordinación síncrona.

Postgres es dueño de los metadatos de enlaces: las tablas links, workspaces, users, billing, rules. Estas son mutables (los enlaces se archivan, las URLs de destino cambian, los registros de facturación se actualizan), transaccionales (crear un enlace y acuñar su slug debe ser atómico), y pesadas en lectura relativo a escrituras. Los índices B-tree y MVCC son exactamente lo que quieres.

ClickHouse es dueño de los eventos de clics: la tabla clicks. Estos son append-only (un clic, una vez escrito, nunca se actualiza), pesados en escritura, y consultados en patrones agregados que el almacenamiento columnar maneja mejor. No hay joins cross-database en tiempo de escritura - el ingester inserta eventos de clics con workspace_id y link_id como campos string desnormalizados. En tiempo de consulta, la API de analítica puede buscar metadatos de enlaces desde Postgres y conteos de clics desde ClickHouse por separado, luego unir en código de aplicación. Eso es una ronda de red extra; es un trade-off mucho mejor que poner la carga de consultas analíticas en Postgres.

La documentación de Postgres distribuido de Citus describe la alternativa: extensiones de Postgres distribuido diseñadas para manejar cargas analíticas de series de tiempo. Evaluamos este camino y concluimos que el rendimiento de consultas y el ratio de compresión de ClickHouse están lo suficientemente adelantados para cargas puramente analíticas como para que la sobrecarga operacional de dos bases de datos valga la pena. Si tu carga es mixta - algo analítico, mucha mutación a nivel de fila - el enfoque de Citus tiene más sentido.

Patrones de consulta que golpeamos por hora#

El dashboard se refresca para cada workspace en aproximadamente un ciclo de 60 segundos. Hay tres consultas pesadas:

Rollup de clics por enlace, últimas 24 horas. La parte superior de cada lista de enlaces muestra un sparkline. Esto es un GROUP BY link_id, toHour(created_at) sobre las últimas 24 horas para el workspace.

Desglose por país para una campaña. Un equipo de marketing verificando el rendimiento de una campaña quiere GROUP BY country, COUNT(*) filtrado por utm_campaign sobre un rango de fechas.

División de dispositivo a través de los top-100 enlaces de un workspace. La pestaña de analítica muestra un desglose de dispositivo/OS. Esto es GROUP BY device, os, COUNT(*) para los enlaces de mayor tráfico del workspace sobre los últimos 30 días.

Los tres patrones son servidos bien por el clustering ORDER BY (workspace_id, link_id, created_at). El filtro de workspace poda el scan a la rebanada correcta de datos inmediatamente; las columnas link_id y created_at lo estrechan más para los primeros dos patrones. La consulta de división de dispositivo escanea más columnas pero aún se beneficia del clustering a nivel de workspace.

Una consulta representativa para los top-100 enlaces por conteo de clics sobre las últimas 24 horas:

SELECT
    link_id,
    count() AS clicks_24h
FROM clicks
WHERE
    workspace_id = 'ws_abc123'
    AND created_at >= now() - INTERVAL 1 DAY
GROUP BY link_id
ORDER BY clicks_24h DESC
LIMIT 100

En un workspace con 12 millones de eventos de clics abarcando 90 días, esta consulta devuelve en aproximadamente 45-80ms dependiendo del número de partes de datos en la partición relevante. La misma consulta contra una tabla de Postgres de tamaño equivalente corre 2-4 segundos con la mejor configuración de índices que probamos.

No usamos vistas materializadas de ClickHouse para estos patrones. El clustering ORDER BY y el partition pruning son suficientes, y las vistas materializadas añaden complejidad de migración de schema. Si la latencia de consulta alguna vez se vuelve un problema en volúmenes mayores de eventos, las vistas materializadas son la siguiente palanca - no indexación adicional.

Modos de fallo que hemos golpeado#

Tres incidentes de producción valen la pena documentar.

Hipo de ClickHouse Keeper. ClickHouse Keeper es la capa de coordinación equivalente a ZooKeeper para ReplicatedMergeTree. En una ocasión, un reinicio de nodo Keeper durante un merge de fondo causó que la tabla replicada pausara la aceptación de inserciones por aproximadamente 90 segundos mientras se elegía el nuevo líder. El ingester reintentó con backoff exponencial (2s, 4s, 8s, tope 60s) - esta es lógica estándar de reintentos en Go con manejo de context.DeadlineExceeded. Durante la ventana de caída, alrededor de 1,800 eventos de clics se acumularon en el buffer en memoria del ingester y fueron flusheados exitosamente una vez que Keeper se recuperó. El único efecto visible para el usuario fue una brecha de ~2 minutos en los datos del dashboard en vivo. La solución fue asegurar que los nodos Keeper estén distribuidos a través de hosts físicos separados con circuitos de poder separados en la red de Hetzner.

Desbalance de particiones en Redpanda. El topic clicks.v1 usa 12 particiones. Después de añadir una segunda instancia de ingester, la asignación de particiones por el balanceador por defecto de Redpanda asignó 9 particiones a una instancia y 3 a la otra - usó la estrategia de total-particiones-por-consumer y los conteos de instancia resultaron producir un desbalance 3:1. Esto no era un riesgo de pérdida de datos, pero la instancia sobre-asignada estaba haciendo batching lentamente bajo la carga, aumentando la latencia de procesamiento de clics a aproximadamente 800ms. La solución fue un rebalanceo manual de particiones usando la API de admin de Redpanda y cambiar al balanceador rack-aware para usar asignación round-robin.

ALTER TABLE en una tabla grande es async. Añadimos una columna browser_version en enero. El ALTER TABLE ... ADD COLUMN de ClickHouse reconoce inmediatamente y aplica el cambio de schema asíncronamente sobre merges de partes de fondo. Durante aproximadamente 20 minutos después del alter, las consultas que referenciaban la nueva columna devolvían NULL para filas en partes no mutadas. Nada se rompió - estábamos añadiendo una columna, no cambiando una existente - pero una consulta que asumiera browser_version IS NOT NULL habría devuelto resultados incorrectos. La lección: trata ALTER TABLE en tablas grandes de ClickHouse como eventualmente consistente, no síncrona.

Lo que no hacemos en ClickHouse#

Dos cosas que surgen en conversaciones y que hemos evitado deliberadamente.

Joins por evento a tablas de Postgres. Algunas consultas de analítica naturalmente quieren unir eventos de clic a metadatos de enlace - "muéstrame todos los clics para enlaces etiquetados 'campaign-q2', con sus URLs de destino". Hacer esto como una consulta federada (clicks de ClickHouse unido a links de Postgres vía una función de tabla JDBC de ClickHouse) es posible pero lento. En su lugar, o bien desnormalizamos campos relevantes en el evento de clic en tiempo de ingesta (como utm_campaign, que se deriva de la plantilla UTM del enlace) o resolvemos el join en código de aplicación: dos consultas separadas, fusionadas en la capa de API de analítica. Esto mantiene a ambas bases de datos operando dentro de sus envolturas de rendimiento.

Actualizaciones por fila. ClickHouse soporta ALTER TABLE UPDATE para mutaciones a nivel de fila, pero es una operación async e intensiva en recursos que trabaja contra las suposiciones del motor. Si te encuentras necesitando actualizar un evento de clic después del hecho - digamos, para marcarlo retroactivamente como un clic de bot - has construido la arquitectura mal. El patrón correcto es almacenar una tabla click_quality separada en ClickHouse que mapea click_id a un score de calidad, computado por el servicio url-scanner asíncronamente, y hacer JOIN en tiempo de consulta. Las lecturas son baratas; las actualizaciones retroactivas son caras y deberían ser raras.


Para el panorama de infraestructura más amplio - cómo el redirect del edge interactúa con Redpanda, cómo funciona la deduplicación de clics, y cómo el servicio click-ingester encaja en la topología de servicios - ver /docs/architecture/click-ingester. La historia del lado edge (publicación fire-and-forget en Redpanda, presupuesto de latencia) está en el post p95 de redirect. Para lo que los datos habilitan downstream - desgloses geo, divisiones de dispositivo, dashboards de atribución UTM - la página solutions/analytics cubre la superficie de producto. Si estás construyendo campañas de enlaces contra la API y te importa qué patrones de consulta expone el SDK, solutions/developers es el punto de entrada relevante.

Para equipos evaluando Elido como infraestructura de enlaces, la elección de arquitectura aquí - ClickHouse para clics, Postgres para todo lo demás - es también lo que nos permite ofrecer analítica sin muestreo. El post de smart links explicados cubre cómo las decisiones de enrutamiento que generan estos eventos de clics funcionan en el edge.


Marius Voß es DevRel e edge infra en Elido. Ayudó a diseñar la pipeline de click-ingester y ha pasado más tiempo del que le gustaría debuggeando el timing de elección de ClickHouse Keeper.

Prueba Elido

Pega una URL, obtén un enlace corto

Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.

Gratis, sin registro · 2 por día

Prueba Elido

Acortador de URL alojado en la UE: dominios personalizados, análisis profundo y API abierta. Plan gratuito - sin tarjeta de crédito.

Etiquetas
clickhouse click analytics
clickhouse for analytics
click event storage
clickhouse vs postgres
columnar database
analytics architecture

Seguir leyendo