16 min de lecturaIngeniería

Por qué usamos ClickHouse para clics y no Postgres

Los datos de clics son append-only, de muchas escrituras y con forma de consulta analítica.

Marius Voß
DevRel · edge infra

Postgres es excelente. Lo usamos intensamente, y confiamos en él. Almacena cada enlace, cada espacio de trabajo, 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 de la redirección - 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, timestamp) devuelve agrupaciones de 90 días por espacio de trabajo 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 espacio de trabajo, 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 espacio de trabajo. 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 espacio de trabajo 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 medida que el volumen de clics crece en órdenes de magnitud.

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 espacio de trabajo X en los últimos 30 días", el motor solo lee las columnas country_code, workspace_id, y timestamp. Nunca toca user_agent o referer. 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 device - se comprimen a casi nada bajo los codecs LZ4 y ZSTD de ClickHouse. Las columnas de baja cardinalidad como estas son donde los codecs columnares ganan más frente al almacenamiento por filas de Postgres. 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, timestamp). Una consulta para los clics del espacio de trabajo 123 en los últimos 7 días lee solo las partes de datos donde aparece workspace_id = 123, 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 o más nodos de ClickHouse con coordinación equivalente a ZooKeeper (ClickHouse Keeper), replicando las partes a medida que se escriben. Eso todavía no lo ejecutamos: producción es un único host, con un servidor de ClickHouse sobre MergeTree simple. La replicación es el siguiente paso planificado cuando un servidor deje de ser suficiente.

El schema que enviamos

CREATE TABLE IF NOT EXISTS click_events
(
    timestamp       DateTime64(3, 'UTC'),
    link_id         UInt64,
    workspace_id    UInt64,
    domain_id       UInt64,
    slug            LowCardinality(String),
    host            LowCardinality(String),
    user_agent      String,
    referer         String,
    ip              String,
    country_code    LowCardinality(FixedString(2)) DEFAULT '',
    device          LowCardinality(String) DEFAULT '',
    browser         LowCardinality(String) DEFAULT '',
    destination     String DEFAULT '',
    retention_days  UInt16 DEFAULT 730,
    INDEX idx_workspace workspace_id TYPE bloom_filter GRANULARITY 4,
    INDEX idx_link      link_id      TYPE bloom_filter GRANULARITY 4
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (workspace_id, link_id, timestamp)
TTL toDateTime(timestamp) + toIntervalDay(retention_days);

Esa es la tabla base. Migraciones posteriores añaden región, ciudad y coordenadas aproximadas, un hash de visitante diario, y flags de bot, scanner y sospecha con ALTER TABLE ... ADD COLUMN.

Algunas decisiones del schema vale la pena destacar.

LowCardinality(String) en slug, host, country_code, device, browser - estas columnas tienen valores acotados o muy repetitivos (unos cientos de hostnames personalizados, 250 y tantos códigos de país, un puñado de tipos de dispositivo). Los IDs son UInt64 simples, que se comprimen bien por sí solos. 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(timestamp) crea una partición de datos separada por mes calendario. Particiones antiguas enteras pueden ser eliminadas (o movidas a almacenamiento más lento) con una sola sentencia ALTER TABLE click_events DROP PARTITION '202501', que es instantánea - sin scan completo de tabla. Una partición contiene los clics de todos los espacios de trabajo de ese mes, sin embargo, así que la retención por plan no puede ser un drop de partición. En su lugar, cada fila se marca en la ingesta con la ventana de su espacio de trabajo en retention_days (90 días en Free, 365 en Pro, 730 en Business), y la cláusula TTL elimina las filas caducadas a medida que ClickHouse fusiona partes.

ip almacena la dirección truncada: el edge pone a cero el último octeto de una dirección IPv4 (203.0.113.0 no 203.0.113.42) y conserva los primeros 48 bits de una IPv6, antes de que el evento de clic salga del proceso de redirección. La búsqueda geo ocurre en el edge antes del truncado, así que el país, la región, la ciudad y las coordenadas aproximadas se almacenan con el clic, junto con el user agent completo. Una IP truncada más ciudad y user agent sigue debiendo tratarse como dato personal. La página de soluciones de analítica enlazada arriba cubre la arquitectura de privacidad completa.

El camino de ingesta

Diagrama de flujo horizontal: el servicio de redirección del edge envía eventos de clic a un topic de Redpanda con particiones, el servicio Go click-ingester los agrupa en lotes de hasta 1000 filas y luego los inserta con INSERT en ClickHouse

El handler de redirección en el servicio edge es fire-and-forget. Añade un evento de clic a un topic de Redpanda (clicks en producción) y emite el 302. No espera reconocimiento de ClickHouse. Si Redpanda no está disponible en ese momento, el clic se descarta - la redirección no. La jerarquía es deliberada: un clic perdido es recuperable (podemos reconstruir conteos aproximados desde logs de acceso); una redirección fallida 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. Producción ejecuta hoy un ingester; el consumer group es lo que permitirá que una segunda instancia asuma particiones y haga failover dentro del timeout de heartbeat del grupo, cuando añadamos una.

El ingester acumula eventos en memoria y los flushea en batches: 1,000 filas o 5 segundos, 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 / 5 segundos y no hemos visto problemas de conteo de partes en operación normal.

La contrapresión propaga a través de Redpanda. El ingester confirma el offset de un registro solo después de que su batch se escribe en ClickHouse, así que si ClickHouse está lento o reiniciándose, los batches fallidos se reintentan y el lag del consumer crece, lo cual es el indicador adelantado de problemas de frescura de datos. Si el propio ingester se reinicia, los registros no confirmados se vuelven a leer desde Redpanda, dentro de la ventana de retención del topic.

La arquitectura se describe en más detalle en /docs/guides/clickhouse-export.

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 click_events. 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 numéricos 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 espacio de trabajo 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(timestamp) sobre las últimas 24 horas para el espacio de trabajo.

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

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

Los tres patrones son servidos bien por el clustering ORDER BY (workspace_id, link_id, timestamp). El filtro de espacio de trabajo poda el scan a la rebanada correcta de datos inmediatamente; las columnas link_id y timestamp 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 espacio de trabajo.

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 click_events
WHERE
    workspace_id = 123
    AND timestamp >= now() - INTERVAL 1 DAY
GROUP BY link_id
ORDER BY clicks_24h DESC
LIMIT 100

Esta consulta lee solo las particiones del rango de fechas y solo las columnas que nombra, así que su coste crece con la rebanada que pides y no con toda la tabla. La consulta equivalente en Postgres tiene que recorrer filas, que es de donde vinieron los 1.4 segundos de arriba.

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 para los que planificamos

Producción ejecuta un servidor de ClickHouse, un broker de Redpanda y un ingester en un único host, así que los modos de fallo que vale la pena documentar son los que esa configuración tiene hoy y los que llegarán con la ampliación que tenemos planificada.

Reinicios de ClickHouse. Con un solo servidor, un reinicio o una actualización de ClickHouse pausa las inserciones. El ingester mantiene el batch fallido en memoria, lo reintenta y confirma los offsets de Redpanda solo después de una inserción correcta, así que los clics esperan en lugar de desaparecer. El efecto visible es una brecha en los datos del dashboard en vivo hasta que se drena el backlog. Una brecha que conocemos: el buffer en memoria no tiene un límite superior mientras ClickHouse está caído, lo cual está en nuestro backlog. La replicación con ReplicatedMergeTree y ClickHouse Keeper es la solución planificada para esta pausa, y trae la suya: una elección de líder de Keeper también detiene las inserciones en tablas replicadas durante un breve tiempo, así que los nodos Keeper necesitan estar en hosts separados.

Balance de particiones del ingester. Hoy un ingester es dueño de todas las particiones del topic clicks. Cuando añadamos una segunda instancia, la estrategia de asignación del consumer group decide cuán uniformemente se reparten las particiones, y un reparto desigual se manifiesta como una instancia haciendo batching lentamente mientras la otra está ociosa. Lo que hay que vigilar es el lag del consumer por instancia, no solo el total del grupo.

ALTER TABLE en una tabla grande es async. Hemos añadido columnas a click_events varias veces (region, city, lat y lng, visitor_hash, los flags de scanner). El ALTER TABLE ... ADD COLUMN de ClickHouse reconoce inmediatamente y no reescribe las partes existentes; las filas en partes más antiguas devuelven el valor por defecto de la columna (una cadena vacía o cero) hasta que los merges lo materializan. Nada se rompe, pero una consulta que trate un city vacío como "sin ciudad" contará mal las filas antiguas. La lección: trata ALTER TABLE en tablas grandes de ClickHouse como eventualmente consistente, y elige valores por defecto que puedas distinguir de los valores reales.

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 (click_events 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 destination, la URL a la que se envió realmente al visitante) 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. Esos flags los decidimos antes de la inserción: el edge puntúa los clics sospechosos y clasifica los scanners de enlaces y las redes anonimizadoras antes de emitir el evento, así que is_suspicious, is_scanner y anonymizer_type se escriben con la fila. Si un score tuviera que calcularse más tarde, el patrón sería una tabla separada unida 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 la redirección 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/guides/clickhouse-export. La historia del lado edge (publicación fire-and-forget en Redpanda, presupuesto de latencia) está en el post p95 de redirección. 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 leyendo logs de merge de ClickHouse.

Prueba Elido

Pega una URL, obtén un enlace corto

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

Sin registro. Crea una cuenta gratis y obtén 50 enlaces al mes.

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