Elido
13 min de lecturaIngeniería

Ingestión de clics de tipo 'disparar y olvidar' con Redpanda

Cómo los POP del borde emiten eventos de clics sin bloquear el redireccionamiento, cómo el worker click-ingester realiza lotes hacia ClickHouse y qué sacrificamos a cambio de la ganancia en latencia

Marius Voß
DevRel · edge infra
Diagrama del pipeline de cinco pasos que muestra un flujo de solicitud de redireccionamiento a través de edge-redirect hacia el tópico de Redpanda, el worker click-ingester y ClickHouse, con la respuesta 301 derivándose antes de la llamada al productor

La ruta de redireccionamiento de un acortador de URLs tiene exactamente una tarea: resolver un slug a un destino y devolver un 301 en milisegundos de un solo dígito. Todo lo demás es contabilidad. Analíticas de clics, atribución, enriquecimiento geográfico, puntuación de fraude, fan-out de webhooks — nada de esto puede estar en la ruta de la solicitud. El presupuesto de latencia no lo permite.

Este es el truco de ingeniería que permite que el pipeline de analíticas coexista con el pilar de p95 de redireccionamiento < 15ms: el borde dispara un evento de clic a Redpanda y se olvida de él. Un worker independiente — click-ingester — lo recoge más tarde, lo enriquece y lo escribe en ClickHouse en lotes. El proceso de redireccionamiento nunca se bloquea. El pipeline de analíticas nunca toca la ruta crítica (hot path). El compromiso es la durabilidad, y es un compromiso menor de lo que parece a primera vista.

Lo que realmente significa "disparar y olvidar" aquí#

El controlador edge-redirect, tras seleccionar la URL de destino de la caché de dos niveles, hace tres cosas antes de que salga el encabezado Location:

  1. Construye un struct click.Event en memoria a partir de la solicitud (slug, ID del workspace, user agent, referer, IP, geo del mmdb GeoLite2-City local, análisis de dispositivo/navegador, indicadores de sospecha).
  2. Llama a producer.Emit(ctx, event) en el productor de Kafka franz-go.
  3. Escribe HTTP/1.1 301 y el encabezado Location en el búfer de respuesta.

La llamada al productor devuelve inmediatamente. No espera un ack de ningún bróker de Redpanda. La biblioteca franz-go almacena el registro en el búfer del proceso y lo despacha en una goroutine en segundo plano; el callback de producción se invoca más tarde, en un grupo de workers que no es propietario de la goroutine de la solicitud. Si la producción falla, el callback registra el error y el evento se descarta. El redireccionamiento ya ha sido servido.

func (p *Producer) Emit(ctx context.Context, e Event) {
    if p == nil {
        return
    }
    b, err := json.Marshal(e)
    if err != nil {
        p.log.Warn("click marshal", zap.Error(err))
        return
    }
    rec := &kgo.Record{Topic: p.topic, Value: b}
    p.client.Produce(ctx, rec, func(_ *kgo.Record, err error) {
        if err != nil && p.log != nil {
            p.log.Warn("click produce", zap.Error(err))
        }
    })
}

Esa es la interfaz completa. Sin cola de reintentos dentro del proceso del borde, sin espera de ack síncrona, sin spool en disco. El contrato con el resto del sistema es simple: emisión por mejor esfuerzo (best-effort), registrar fallos, nunca bloquear.

Una guarda de receptor nulo permite que el desarrollo local funcione sin un bróker de Kafka. Sin ella, cada contribuidor necesitaría un contenedor de Redpanda en ejecución solo para probar la ruta de redireccionamiento contra los controladores de fasthttp.

Por qué no elegimos una escritura síncrona#

La alternativa obvia es escribir cada clic directamente en ClickHouse desde el borde. Lo consideramos. Lo rechazamos por tres razones que se agravan.

Latencia. El viaje de ida y vuelta de un INSERT de ClickHouse desde el POP de Frankfurt a un clúster de ClickHouse en la misma región se sitúa en 3-6ms p50 en una red tranquila, y 12-20ms p95 bajo carga. Ese es todo el presupuesto de redireccionamiento. Añadirlo a la ruta de respuesta empujaría el p95 más allá del SLO de 15ms antes de que fallara cualquier otra cosa. El post sobre la estrategia de caché explica lo ajustado que es el presupuesto en la práctica.

Contrapresión (Backpressure). ClickHouse es feliz ingiriendo lotes de 1000-10000 filas por INSERT. Es infeliz ingiriendo filas individuales en bucles cerrados — el motor MergeTree escribe un archivo de parte por inserción y un proceso en segundo plano fusiona las partes. Un patrón de escritura directa desde una flota de borde multiregión crearía millones de partes diminutas y la cola de fusión nunca se pondría al día. La documentación de ClickHouse es explícita: insertar en lotes de al menos 1000 filas, no más de una vez por segundo.

Aislamiento de fallos. Un reinicio del clúster de ClickHouse, un parpadeo de la red o una consulta lenta que bloquee una réplica se propagaría directamente en fallos de redireccionamiento. El proceso del borde empezaría a agotar el tiempo de espera (empeorando el p95) o empezaría a descartar clics (empeorando la calidad de los datos). Colocar un bus de mensajes entre ambos permite que cada lado falle de forma independiente — el borde sigue redireccionando incluso cuando ClickHouse está degradado, y ClickHouse sigue ingiriendo incluso cuando un POP está fuera de línea.

Redpanda absorbe estas tres presiones. Es compatible con el protocolo Kafka, por lo que franz-go se comunica con él de forma transparente. Tiene una huella de binario único sin JVM. Almacena en el búfer en disco, por lo que una interrupción de ClickHouse de varias horas no pierde eventos siempre que se mantenga la ventana de retención del tópico.

El worker click-ingester#

click-ingester es un servicio de Go que se ejecuta como un grupo de consumidores en el tópico de eventos de clics. Una réplica por región, tres regiones, sin sharding por slug o workspace — el grupo de consumidores se reequilibra si una réplica se reinicia y Redpanda asigna las particiones. El trabajo del consumidor es pequeño:

  • Realizar encuestas (poll) para obtener datos del tópico.
  • Decodificar el JSON de cada registro en un Event tipado.
  • Empujar el evento al búfer en memoria de un escritor.
  • A veces: disparar webhooks, reenviar a Klaviyo / Mixpanel / GA4 MP, publicar en el flujo de clics en vivo de la aplicación.

El escritor agrupa por cantidad o por tiempo, lo que ocurra primero. Valores predeterminados: 1000 eventos por lote, intervalo de vaciado (flush) de 5 segundos. Un lote se construye en una llamada PrepareBatch de INSERT INTO click_events contra ClickHouse y se confirma como una anexión en el lado del servidor. En caso de éxito, el escritor marca los desplazamientos (offsets) de los registros de Kafka subyacentes como confirmados; en caso de fallo, no se confirma nada y el consumidor vuelve a obtener los datos desde el último desplazamiento exitoso en su próxima encuesta.

El contrato de desplazamiento después del vaciado es la garantía de durabilidad. El consumidor nunca le dice a Redpanda "he procesado este registro" hasta que el registro ha aterrizado en ClickHouse como parte de un lote exitoso. Un fallo entre el consumo y el vaciado significa que el grupo de consumidores se reequilibra, el nuevo propietario vuelve a realizar la encuesta desde el último desplazamiento confirmado y los eventos se vuelven a procesar. El reprocesamiento es seguro porque la tabla click_events es de tipo ReplacingMergeTree con clave en un ID de evento sintético — las inserciones duplicadas se colapsan al fusionarse.

Los mensajes erróneos no se reintentan. Un fallo de decodificación de JSON se marca como confirmado inmediatamente para que el consumidor no se quede atascado en un registro venenoso (poison record). Esta es una fuente de pérdida de datos pequeña pero real; la tasa se sitúa en eventos individuales por día en toda la flota, y los eventos afectados aparecen en el contador de Prometheus decode_error_total del consumidor.

El compromiso de durabilidad en cifras#

Disparar y olvidar renuncia a algunos eventos. La pregunta es cuántos y si eso importa para el caso de uso.

Medimos la tasa de pérdida en producción durante una ventana de 90 días. La cifra es de aproximadamente el 0.04% de los eventos emitidos — unos cuatro clics perdidos por cada diez mil. El desglose:

  • Reinicio del proceso del borde con búfer en curso. franz-go almacena en el búfer hasta unos pocos cientos de milisegundos de registros antes de vaciarlos en un bróker. Un SIGTERM durante un despliegue puede descartar lo que esté en el búfer. El script de despliegue emite un apagado limpio que drena el búfer con un tiempo de espera de 2 segundos, lo que captura la mayoría de los casos, pero no todos.
  • Indisponibilidad del bróker de Redpanda más allá de la ventana de reintento del productor. franz-go reintenta los fallos de producción, pero el presupuesto de reintentos es limitado. Si el clúster de Redpanda de una región no está saludable durante más de unos 30 segundos, el búfer se desborda y los nuevos registros se descartan en el borde del productor.
  • Partición de red entre el POP del borde y el clúster regional de Redpanda. El mismo efecto que el anterior. El productor registra advertencias y descarta eventos hasta que vuelve la conectividad.

Para la carga de trabajo de un acortador de URLs, una pérdida del 0.04% es aceptable. Los clics son una señal estadística, no transacciones financieras. Las analíticas de cohortes, la atribución de conversiones y la distribución geográfica se agregan bien a través de una muestra con esa tasa de pérdida. Los casos de uso que no lo tolerarían — industrias reguladas con requisitos de auditoría, recuentos de clics vinculados a la facturación — no son lo que sirve directamente la capa de redireccionamiento.

Para los workspaces que necesitan una mayor durabilidad, ofrecemos un modo de registro de auditoría independiente que escribe cada clic de forma síncrona en Postgres además de la ruta de disparar y olvidar. La escritura síncrona añade 3-5ms p95 al redireccionamiento, es opcional y está desactivada por defecto. La guía de exportación de ClickHouse documenta la forma del registro de auditoría para los equipos de cumplimiento que necesiten conciliar recuentos.

Estrategia de repetición cuando ClickHouse está caído#

El productor es de tipo disparar y olvidar, pero el lado del consumidor tiene una historia de repetición (replay) real.

Cuando ClickHouse no está disponible, las llamadas de vaciado del escritor fallan. El consumidor continúa realizando encuestas — el bucle de encuesta de franz-go es independiente del bucle de vaciado del escritor — pero los desplazamientos no se confirman porque el vaciado no tuvo éxito. La retención de Redpanda está configurada en 72 horas, que es la interrupción máxima tolerable antes de que los eventos empiecen a caducar.

Durante una interrupción real (hemos tenido tres de duración significativa en 18 meses), la secuencia de recuperación es:

  1. ClickHouse vuelve a estar en línea.
  2. El siguiente intento de vaciado tiene éxito y confirma los desplazamientos.
  3. El consumidor se pone al día drenando el backlog a la tasa de lote configurada. Con un lote de 1000 eventos y un vaciado de 5 segundos, el consumidor puede drenar unos 200 eventos por segundo por réplica; tres réplicas significan aproximadamente 36k eventos por minuto.
  4. El tablero de Grafana para la tabla click_events muestra la curva de puesta al día — la tasa de inserción de filas permanece elevada hasta que se despeja el backlog.

La retención de 72 horas está dimensionada para absorber una reconstrucción de ClickHouse de varios días sin pérdida de datos. Nunca hemos usado más de 4 horas de ella en producción. El disco en los brókeres de Redpanda es el coste, y es pequeño en relación con la pérdida de datos analíticos.

También es posible una repetición desde el archivo (replay-from-archive). Redpanda tiene almacenamiento por niveles que envía segmentos cerrados a almacenamiento de objetos compatible con S3. Lo tenemos configurado pero no lo hemos necesitado — la repetición en caliente (hot replay) cubre cada incidente que hemos visto.

Qué más hace el consumidor#

La ingestión de clics no son solo escrituras en ClickHouse. El consumidor es el punto central de distribución (fan-out) para cada sistema de bajada que se interese por los clics.

  • Despachador de webhooks. Los webhooks configurados por el cliente se disparan desde el consumidor, no desde el borde. El consumidor encola un trabajo de webhook por cada clic que coincida con un filtro configurado. Los reintentos, la firma y la entrega ocurren en webhook-dispatcher.
  • Reenvío de eventos en el lado del servidor. Klaviyo, Mixpanel, GA4 Measurement Protocol, Meta CAPI. El consumidor mantiene una caché de configuración por workspace y dispara el POST correspondiente para cada clic que el workspace tenga conectado. Los reenvíos son por mejor esfuerzo con un pequeño reintento en memoria; los fallos persistentes aterrizan en una tabla de mensajes no entregados (dead-letter table).
  • Flujo de clics en vivo. La vista de la aplicación "ver caer una campaña en vivo" se suscribe a un canal pub/sub de Redis. El consumidor publica un evento de forma mínima por cada clic que coincida con una sesión en vivo activa. Esta es la única parte del pipeline con sensación síncrona, y es por mejor esfuerzo — se descartan eventos cuando el canal está congestionado.
  • Disparo de píxeles. Los píxeles de conversión (retargeting y conversión fuera de línea) se disparan desde el consumidor basándose en la configuración por enlace. El disparo de píxeles es su propio dominio de fallos; los fallos se registran pero no ejercen contrapresión sobre el escritor de ClickHouse.

Todo esto se ejecuta después de la confirmación del desplazamiento pero antes de la siguiente encuesta. Un endpoint de píxel lento puede ralentizar el rendimiento efectivo del consumidor. Un tiempo de espera por reenvío (límite estricto de 1 segundo) y un límite de concurrencia por lote (16 en vuelo) evitan que la ruta lenta domine.

Por qué este esquema y no Kinesis o una cola#

Algunas formas alternativas de bus de eventos evaluadas y no elegidas.

SQS o RabbitMQ como cola. Ninguno tiene el rendimiento por bróker que ofrece Redpanda al volumen de eventos de clics. SQS factura por solicitud, lo que encarece los flujos de alto volumen; RabbitMQ ejerce presión en tópicos densos.

AWS Kinesis. Razonable si residiéramos en AWS. No es así — Hetzner FRA, Hetzner ASH, OVH SGP. Kafka o Redpanda autohospedados es la forma adecuada para un despliegue centrado en la UE.

Plain Kafka. Funciona. Elegimos Redpanda por el perfil operativo — binario único, sin Zookeeper, sin ajuste de JVM. El protocolo de cable es idéntico y franz-go no nota la diferencia. Un despliegue de Elido autohospedado puede intercambiarse por Apache Kafka sin cambios en el código.

Servicios gestionados como Confluent Cloud. No residen en la UE de la forma que queremos. La capa de redireccionamiento necesita latencia de bus de mensajes en la misma región.

La decisión está documentada con más detalle en la página de arquitectura de edge-redirect, que es la fuente de verdad para las opciones de configuración de la capa de redireccionamiento.

Qué haríamos diferente la próxima vez#

El patrón de disparar y olvidar es correcto. La implementación tiene asperezas que vale la pena señalar para cualquiera que copie el diseño.

Drenaje al apagar. El tiempo de espera de drenaje de 2 segundos de franz-go ha perdido eventos durante los despliegues cuando el búfer está ocupado. La solución es un gancho (hook) SIGTERM que vacíe de forma síncrona antes de que el proceso finalice, con un tiempo de espera más largo y una finalización forzada si el bróker es inalcanzable.

Ruta de mensajes no entregados para fallos de decodificación. Marcar los registros venenosos como confirmados y continuar está bien para el rendimiento, pero pierde observabilidad. Una futura iteración escribirá los bytes sin procesar más el error de decodificación en una tabla click_events_decode_failures para que el equipo pueda auditar lo que aparece.

Concurrencia de reenvío por workspace. Hoy, los reenvíos de cada workspace comparten el grupo global del consumidor. Un workspace ruidoso con un endpoint de Mixpanel lento puede dejar sin recursos a otros. Un límite por workspace es la solución obvia; no la hemos construido.

Ninguno de estos ha causado un incidente en producción. Son el tipo de cosas que registras en el backlog de ADR y vas resolviendo poco a poco.

Lecturas relacionadas#

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
ingestión de clics disparar y olvidar
eventos de clics de Redpanda
inserción por lotes en ClickHouse
pipeline de analíticas de acortador de URLs
Kafka para redireccionamiento en el borde
productor franz-go
durabilidad de eventos de clics

Seguir leyendo