Saltar al contenido
Cómo construimos un pipeline de datos en tiempo real con Kafka y Flink

Cómo construimos un pipeline de datos en tiempo real con Kafka y Flink

A
abemon
| | 12 min de lectura
Compartir

El problema: 47 minutos de retraso

Nuestro cliente operaba una flota de 340 vehículos de reparto. El sistema de tracking existente era un batch de 15 minutos: los dispositivos GPS enviaban posiciones a un servidor, el servidor las almacenaba en PostgreSQL, y cada 15 minutos un cron generaba las posiciones actualizadas para el panel de operaciones.

En la práctica, el retraso era mayor. El batch tardaba entre 3 y 8 minutos en procesarse dependiendo del volumen. Si un vehículo reportaba justo después de un ciclo de batch, el operador no veía esa posición hasta el siguiente ciclo más el tiempo de procesamiento. Caso peor: 23 minutos. Caso real promedio: 12 minutos. Pero con incidencias de carga y fallos de cron, hemos medido retrasos de hasta 47 minutos.

47 minutos. Un vehículo que se desvía de ruta, que tiene una avería, que llega a un punto de entrega. 47 minutos para que el equipo de operaciones lo sepa. En logística de última milla, eso es inaceptable.

El objetivo era bajar ese retraso a menos de 5 segundos. Lo conseguimos en 4.2 segundos de p95. Este artículo cuenta cómo.

La arquitectura batch tenía un problema fundamental: acoplaba la ingestión de datos con el procesamiento. El mismo proceso que recibía los datos GPS los transformaba, los enriquecía con datos de ruta, y los escribía en la base de datos. Si alguno de esos pasos fallaba, se perdía la ventana de datos completa.

Necesitábamos desacoplar. Y para streaming con las garantías que necesitábamos, la combinación de Kafka y Flink es la que mejor conocemos.

Evaluamos alternativas. Kafka Streams habría sido suficiente para la lógica de procesamiento, pero necesitábamos ventanas temporales complejas (tumbling y sliding) con late arrivals, y Flink maneja eso con mucha más flexibilidad. Pulsar era una opción, pero el ecosistema de conectores era menos maduro en ese momento. Google Pub/Sub + Dataflow era viable, pero el cliente quería evitar lock-in con un solo proveedor cloud.

Kafka como bus de mensajes. Flink como motor de procesamiento. PostgreSQL (con TimescaleDB) como almacén de posiciones históricas. Redis como cache de estado actual para el panel de operaciones.

Arquitectura del pipeline

El flujo completo tiene cinco etapas:

Ingestión. Los dispositivos GPS envían posiciones vía MQTT a un broker Mosquitto. Un conector Kafka Connect (MQTT Source) publica cada posición en un topic de Kafka (gps.raw.positions). Volumen: entre 800 y 1,200 mensajes por segundo en horas punta. Cada mensaje pesa unos 200 bytes (latitud, longitud, velocidad, rumbo, timestamp, vehicle_id).

Validación. Un job de Flink consume el topic raw, valida el schema, descarta posiciones con coordenadas inválidas (fuera de la península ibérica, velocidad superior a 200 km/h, timestamp futuro), y publica en un topic limpio (gps.validated.positions). Entre el 2% y el 4% de los mensajes se descartan. La mayoría son glitches de GPS al arrancar el vehículo.

Enriquecimiento. Un segundo job de Flink toma las posiciones validadas y las enriquece con datos de ruta. Para cada posición, busca la ruta asignada al vehículo (cache en Flink state, refrescado cada 5 minutos desde la API de planificación), calcula la distancia al siguiente punto de entrega, y determina si el vehículo está en ruta, desviado, o parado.

El enriquecimiento es la etapa más costosa computacionalmente. La geolocalización inversa (convertir coordenadas en dirección legible) la descartamos del flujo en tiempo real por latencia: añade entre 50 y 200 ms por posición si se hace contra un servicio externo. En su lugar, la hacemos en un pipeline batch separado que alimenta las vistas históricas.

Estado actual. Las posiciones enriquecidas se escriben en Redis con una clave por vehículo. Solo se mantiene la última posición conocida. El panel de operaciones lee de Redis con polling cada 2 segundos (lo intentamos con WebSockets, pero el overhead de mantener 50 conexiones persistentes no compensaba para un panel de operaciones con refresh rate de 2s).

Almacenamiento histórico. En paralelo, las posiciones enriquecidas se escriben en TimescaleDB vía Kafka Connect (JDBC Sink). Particionado por día, retención de 90 días online, archivado a S3 después.

Los problemas que no esperábamos

Schema evolution

A las tres semanas de producción, el proveedor de dispositivos GPS actualizó su firmware. Los mensajes ahora incluían un campo nuevo: battery_level. Nuestro schema de Avro no lo esperaba. Y Kafka, correctamente, rechazó los mensajes porque no conformaban al schema registrado.

Lección aprendida: siempre usar backward-compatible schema evolution. Configuramos el Schema Registry de Confluent en modo BACKWARD. Todos los campos nuevos deben ser opcionales con valores por defecto. Los campos existentes no se pueden eliminar ni cambiar de tipo. Es restrictivo, pero te ahorra las 3 de la mañana debuggeando por qué el pipeline se ha parado.

Ahora cada cambio de schema pasa por un PR que incluye el test de compatibilidad contra el Schema Registry. Si no pasa, no se mergea.

Exactly-once semantics

El requisito de negocio era claro: cada posición se procesa exactamente una vez. No podemos perder posiciones (un vehículo desaparece del mapa). No podemos duplicar posiciones (un vehículo aparece en dos sitios a la vez, o las métricas de distancia se duplican).

Kafka ofrece exactly-once semantics con transacciones. Flink tiene checkpointing con barreras alineadas que garantizan exactly-once en el procesamiento. Pero el tramo más delicado es la escritura a Redis y TimescaleDB.

Para Redis usamos operaciones idempotentes: cada escritura es un SET con la clave del vehículo. Si se ejecuta dos veces, el resultado es el mismo. Exactly-once es trivial cuando la operación es idempotente.

Para TimescaleDB, el conector JDBC Sink de Kafka Connect soporta idempotencia con claves primarias. Configuramos un constraint UNIQUE sobre (vehicle_id, timestamp). Los duplicados se ignoran vía ON CONFLICT DO NOTHING. Esto convierte la escritura en idempotente y nos da exactly-once end-to-end.

El coste de esta configuración: un 15% más de latencia en Flink por el checkpointing con barreras alineadas. Pasamos de 2.8 segundos de p95 a 4.2 segundos. Aceptamos el trade-off porque la alternativa (at-least-once con deduplicación manual) requería más complejidad operativa.

Backpressure

A las seis semanas, el pipeline empezó a acumular lag en horas punta. El topic gps.validated.positions crecía a un ritmo de 200 mensajes por segundo más rápido de lo que Flink podía consumir. El job de enriquecimiento era el cuello de botella: la consulta al cache de rutas se había degradado porque el state de Flink había crecido más de lo esperado.

El diagnóstico tardó dos días. Flink reporta backpressure en su UI, pero la causa no siempre es obvia. En nuestro caso, el state backend (RocksDB) estaba haciendo compactaciones frecuentes porque habíamos configurado mal los niveles de compactación. El fix fue ajustar state.backend.rocksdb.compaction.level.use-dynamic-size a true y aumentar el write buffer.

Después del fix, el throughput del job de enriquecimiento pasó de 1,000 a 1,800 mensajes por segundo. Margen suficiente para las horas punta actuales y para un crecimiento del 50% de la flota.

La lección: monitorizar el lag de cada consumer group y el backpressure de cada operador de Flink es absolutamente esencial. Usamos las métricas de Flink exportadas a Prometheus con dashboards en Grafana. Cualquier lag sostenido por encima de 10 segundos durante más de 5 minutos genera una alerta.

Late arrivals

Los dispositivos GPS no siempre tienen conectividad. Un vehículo que entra en un parking subterráneo pierde señal durante 10 minutos. Cuando sale, envía las 600 posiciones acumuladas de golpe. Estas posiciones llegan “tarde” respecto al flujo de tiempo real.

Flink maneja esto con watermarks y allowed lateness. Configuramos un watermark con 30 segundos de tolerancia y un allowed lateness de 5 minutos. Las posiciones que llegan dentro de esos 5 minutos se procesan normalmente. Las que llegan más tarde se envían a un side output que alimenta un topic de “posiciones tardías” procesado por un pipeline batch independiente.

En la práctica, el 99.2% de las posiciones llegan dentro de los 30 segundos de watermark. El 0.6% llega entre 30 segundos y 5 minutos. Solo el 0.2% restante llega más tarde y va al pipeline batch. Esos porcentajes determinaron nuestros umbrales.

Métricas de producción

Después de cuatro meses en producción, estas son las métricas estables:

  • Throughput: 1,100 msg/s promedio, 1,600 msg/s en pico
  • Latencia end-to-end (GPS a Redis): p50 = 1.8s, p95 = 4.2s, p99 = 6.1s
  • Disponibilidad del pipeline: 99.94% (32 minutos de downtime en 4 meses, causado por una actualización de Kafka que requirió rolling restart)
  • Mensajes perdidos: 0 (exactly-once funciona)
  • Mensajes duplicados en TimescaleDB: 0 (idempotencia funciona)
  • Coste de infraestructura: 1,200 euros/mes (3 brokers Kafka en instancias m5.large, 2 TaskManagers de Flink en c5.xlarge, Redis cache.t3.medium, todo en AWS)

El coste anterior del sistema batch era de 400 euros/mes. Triplicamos el coste de infraestructura, pero el valor de negocio de pasar de 12 minutos de retraso a 4 segundos no se mide en euros de infraestructura. Se mide en entregas que llegan a tiempo, vehículos desviados detectados al instante, y un equipo de operaciones que toma decisiones con datos de hace 4 segundos en vez de datos de hace un cuarto de hora.

Lo que haríamos diferente

Si empezásemos de nuevo, tres cosas cambiarían.

Primero, habríamos usado Apache Iceberg en lugar de TimescaleDB para el almacenamiento histórico. TimescaleDB funciona bien, pero Iceberg nos daría schema evolution nativa, time travel, y la posibilidad de usar Spark o Trino para analytics sin mover los datos. La decisión de TimescaleDB fue por familiaridad, no por mérito técnico.

Segundo, habríamos invertido más tiempo en testing antes de producción. Nuestro entorno de staging tenía un 10% del tráfico real, lo que era insuficiente para descubrir el problema de backpressure. Un generador de carga sintética que simulase patrones reales (incluyendo los bursts de late arrivals) nos habría ahorrado dos semanas de debugging en producción.

Tercero, habríamos implementado dead letter queues desde el día uno. Los mensajes que fallan en validación se descartaban con un log. No había forma de reprocesarlos. Cuando descubrimos que nuestro filtro de coordenadas inválidas era demasiado agresivo (descartaba posiciones válidas de Ceuta y Melilla porque nuestro bounding box solo cubría la península), tardamos tres días en recuperar las posiciones perdidas de los logs de Kafka.

Para equipos que están evaluando un pipeline de datos en tiempo real, nuestro consejo es directo: empezar con la pregunta de negocio (qué latencia realmente necesitas), no con la tecnología. Si 30 segundos es suficiente, Kafka Streams probablemente baste. Si necesitas ventanas complejas, joins entre streams, o procesamiento con estado pesado, Flink justifica su complejidad adicional. Y si no necesitas tiempo real en absoluto, un batch bien hecho cada 5 minutos es una fracción del coste y la complejidad.

Si quieres una visión más amplia antes de comprometerte con una arquitectura específica, nuestra guía práctica de pipelines en tiempo real cubre el proceso de evaluación. Para proyectos de ingeniería de datos en el sector logístico y otros, la arquitectura correcta depende del problema. No del hype.

Sobre el autor

A

abemon engineering

Equipo de ingenieria

Equipo multidisciplinar de ingenieria, datos e IA con sede en Canarias. Construimos, desplegamos y operamos soluciones de software a medida para empresas de cualquier escala.

Síguenos: LinkedIn GitHub