Saltar al contenido
Como construimos un pipeline de datos en tiempo real con Kafka y Flink

Como 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 vehiculos 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 practica, el retraso era mayor. El batch tardaba entre 3 y 8 minutos en procesarse dependiendo del volumen. Si un vehiculo reportaba justo despues de un ciclo de batch, el operador no veia esa posicion hasta el siguiente ciclo mas 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 vehiculo que se desvia de ruta, que tiene una averia, que llega a un punto de entrega. 47 minutos para que el equipo de operaciones lo sepa. En logistica de ultima milla, eso es inaceptable.

El objetivo era bajar ese retraso a menos de 5 segundos. Lo conseguimos en 4.2 segundos de p95. Este articulo cuenta como.

La arquitectura batch tenia un problema fundamental: acoplaba la ingestion de datos con el procesamiento. El mismo proceso que recibia los datos GPS los transformaba, los enriquecia con datos de ruta, y los escribia en la base de datos. Si alguno de esos pasos fallaba, se perdia la ventana de datos completa.

Necesitabamos desacoplar. Y para streaming con las garantias que necesitabamos, la combinacion de Kafka y Flink es la que mejor conocemos.

Evaluamos alternativas. Kafka Streams habria sido suficiente para la logica de procesamiento, pero necesitabamos ventanas temporales complejas (tumbling y sliding) con late arrivals, y Flink maneja eso con mucha mas flexibilidad. Pulsar era una opcion, pero el ecosistema de conectores era menos maduro en ese momento. Google Pub/Sub + Dataflow era viable, pero el cliente queria evitar lock-in con un solo proveedor cloud.

Kafka como bus de mensajes. Flink como motor de procesamiento. PostgreSQL (con TimescaleDB) como almacen de posiciones historicas. Redis como cache de estado actual para el panel de operaciones.

Arquitectura del pipeline

El flujo completo tiene cinco etapas:

Ingestion. Los dispositivos GPS envian posiciones via MQTT a un broker Mosquitto. Un conector Kafka Connect (MQTT Source) publica cada posicion 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).

Validacion. Un job de Flink consume el topic raw, valida el schema, descarta posiciones con coordenadas invalidas (fuera de la peninsula iberica, 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 mayoria son glitches de GPS al arrancar el vehiculo.

Enriquecimiento. Un segundo job de Flink toma las posiciones validadas y las enriquece con datos de ruta. Para cada posicion, busca la ruta asignada al vehiculo (cache en Flink state, refrescado cada 5 minutos desde la API de planificacion), calcula la distancia al siguiente punto de entrega, y determina si el vehiculo esta en ruta, desviado, o parado.

El enriquecimiento es la etapa mas costosa computacionalmente. La geolocalizacion inversa (convertir coordenadas en direccion legible) la descartamos del flujo en tiempo real por latencia: anade entre 50 y 200 ms por posicion si se hace contra un servicio externo. En su lugar, la hacemos en un pipeline batch separado que alimenta las vistas historicas.

Estado actual. Las posiciones enriquecidas se escriben en Redis con una clave por vehiculo. Solo se mantiene la ultima posicion 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 historico. En paralelo, las posiciones enriquecidas se escriben en TimescaleDB via Kafka Connect (JDBC Sink). Particionado por dia, retencion de 90 dias online, archivado a S3 despues.

Los problemas que no esperabamos

Schema evolution

A las tres semanas de produccion, el proveedor de dispositivos GPS actualizo su firmware. Los mensajes ahora incluian un campo nuevo: battery_level. Nuestro schema de Avro no lo esperaba. Y Kafka, correctamente, rechazo los mensajes porque no conformaban al schema registrado.

Leccion 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 manana debuggeando por que 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 posicion se procesa exactamente una vez. No podemos perder posiciones (un vehiculo desaparece del mapa). No podemos duplicar posiciones (un vehiculo aparece en dos sitios a la vez, o las metricas 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 mas delicado es la escritura a Redis y TimescaleDB.

Para Redis usamos operaciones idempotentes: cada escritura es un SET con la clave del vehiculo. Si se ejecuta dos veces, el resultado es el mismo. Exactly-once es trivial cuando la operacion 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 via ON CONFLICT DO NOTHING. Esto convierte la escritura en idempotente y nos da exactly-once end-to-end.

El coste de esta configuracion: un 15% mas 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 deduplicacion manual) requeria mas complejidad operativa.

Backpressure

A las seis semanas, el pipeline empezo a acumular lag en horas punta. El topic gps.validated.positions crecia a un ritmo de 200 mensajes por segundo mas rapido de lo que Flink podia consumir. El job de enriquecimiento era el cuello de botella: la consulta al cache de rutas se habia degradado porque el state de Flink habia crecido mas de lo esperado.

El diagnostico tardo dos dias. 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 habiamos configurado mal los niveles de compactacion. El fix fue ajustar state.backend.rocksdb.compaction.level.use-dynamic-size a true y aumentar el write buffer.

Despues del fix, el throughput del job de enriquecimiento paso 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 leccion: monitorizar el lag de cada consumer group y el backpressure de cada operador de Flink es absolutamente esencial. Usamos las metricas de Flink exportadas a Prometheus con dashboards en Grafana. Cualquier lag sostenido por encima de 10 segundos durante mas de 5 minutos genera una alerta.

Late arrivals

Los dispositivos GPS no siempre tienen conectividad. Un vehiculo que entra en un parking subterraneo pierde senal durante 10 minutos. Cuando sale, envia 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 mas tarde se envian a un side output que alimenta un topic de “posiciones tardias” procesado por un pipeline batch independiente.

En la practica, 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 mas tarde y va al pipeline batch. Esos porcentajes determinaron nuestros umbrales.

Metricas de produccion

Despues de cuatro meses en produccion, estas son las metricas 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 actualizacion de Kafka que requirio 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, vehiculos 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 hariamos diferente

Si empezasemos de nuevo, tres cosas cambiarian.

Primero, habriamos usado Apache Iceberg en lugar de TimescaleDB para el almacenamiento historico. TimescaleDB funciona bien, pero Iceberg nos daria schema evolution nativa, time travel, y la posibilidad de usar Spark o Trino para analytics sin mover los datos. La decision de TimescaleDB fue por familiaridad, no por merito tecnico.

Segundo, habriamos invertido mas tiempo en testing antes de produccion. Nuestro entorno de staging tenia un 10% del trafico real, lo que era insuficiente para descubrir el problema de backpressure. Un generador de carga sintetica que simulase patrones reales (incluyendo los bursts de late arrivals) nos habria ahorrado dos semanas de debugging en produccion.

Tercero, habriamos implementado dead letter queues desde el dia uno. Los mensajes que fallan en validacion se descartaban con un log. No habia forma de reprocesarlos. Cuando descubrimos que nuestro filtro de coordenadas invalidas era demasiado agresivo (descartaba posiciones validas de Ceuta y Melilla porque nuestro bounding box solo cubria la peninsula), tardamos tres dias en recuperar las posiciones perdidas de los logs de Kafka.

Para equipos que estan evaluando un pipeline de datos en tiempo real, nuestro consejo es directo: empezar con la pregunta de negocio (que latencia realmente necesitas), no con la tecnologia. 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 fraccion del coste y la complejidad.

Si quieres una vision mas amplia antes de comprometerte con una arquitectura especifica, nuestra guia practica de pipelines en tiempo real cubre el proceso de evaluacion. Para proyectos de ingenieria de datos en el sector logistico 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.