Migramos 5 microservicios a event streaming y esto fue lo que encontramos

Era martes, alrededor de las 2am. No el típico viernes-de-despliegue-que-sale-mal, sino un martes aburrido. Habíamos subido un cambio al servicio de inventario esa tarde — nada dramático, una validación extra en el endpoint de stock — y dos horas después llegó el primer Slack de nuestro sistema de monitoreo. Y luego otro. Y luego ya era yo con una taza de café frío mirando un grafo de Grafana donde las latencias del servicio de pedidos habían pasado de 120ms a más de 8 segundos.

El problema: nuestro servicio de pedidos llamaba de forma síncrona al servicio de inventario. El de inventario llamaba al de pricing para calcular el costo actualizado. El de pricing, a su vez, necesitaba confirmar ciertas reglas con inventario. Sí — un ciclo. Nadie lo había documentado explícitamente, pero ahí estaba, enterrado en semanas de desarrollo incremental. Cuando inventario empezó a responder lento bajo la nueva validación, toda la cadena colapsó.

Éramos cinco personas. Cuatro microservicios que llevaban un año en producción más uno recién separado del monolito. Y esa noche, mientras reconstruíamos el flujo de llamadas en una pizarra virtual a las 3am, fue cuando uno de mis compañeros dijo algo que me quedó dando vueltas: “Si esto fuera basado en eventos, al menos el fallo estaría contenido.”

Por Qué el Modelo Request-Response se Quiebra Cuando los Servicios se Hablan Entre Sí

Mira, el patrón síncrono tiene sentido cuando tu arquitectura es simple. Un cliente llama a una API, la API responde. Perfecto. Pero cuando los microservicios empiezan a llamarse entre sí — lo que inevitablemente pasa en cuanto el sistema crece — se acumulan problemas que no son obvios hasta que ya duelen.

El primero es el acoplamiento temporal. Si el servicio A necesita que B esté disponible para completar una operación, A y B están acoplados en tiempo real. No importa que tengan APIs separadas y bases de datos distintas: en el momento en que uno falla, el otro también falla (o espera). En nuestro caso, un servicio de inventario lento propagó su lentitud a cuatro servicios más.

El segundo es más sutil y, en mi experiencia, más difícil de notar hasta que ya está roto: la distribución de responsabilidades se distorsiona. Cuando A llama a B para obtener información, ¿quién es responsable de reintentar si B falla? ¿A? ¿Con qué lógica de backoff? ¿Durante cuánto tiempo? En la práctica, cada equipo lo implementa diferente, y terminas con cinco estrategias de retry distintas que interactúan de maneras impredecibles.

Después del incidente empecé a documentar cuántas llamadas síncronas cruzadas teníamos. El número fue incómodo: 23 endpoints de API interna que dependían de respuestas síncronas de otros servicios.

Ahí fue cuando event streaming dejó de ser un tema de conferencias y pasó a ser una necesidad concreta. El modelo es distinto: en vez de que A le pida información a B, A publica un evento (“pedido.creado”) y B lo consume cuando puede. B no necesita estar disponible en el momento exacto en que A procesa. Si B está caído, el evento queda en el stream esperando. Cuando B vuelve, lo procesa. El fallo está contenido.

Claro que esto introduce su propio set de complejidad — y eso lo aprendí de primera mano durante las semanas siguientes.

Tres Semanas Configurando Kafka 3.7 con un Equipo de Cinco Personas

No éramos expertos en Kafka. Yo había hecho algunos tutoriales y un proyecto pequeño dos años atrás, pero en producción a escala real — no. Así que lo primero fue ser honestos sobre eso y reservar tres semanas para la migración del primer servicio (el de notificaciones, que tenía el menor riesgo).

Kafka 3.7 simplificó bastante la configuración con KRaft — ya no necesitas ZooKeeper separado, lo cual nos quitó una capa de infraestructura que no queríamos mantener. Para el stack de Python que usábamos (FastAPI + aiokafka), la configuración básica del producer quedó así:

from aiokafka import AIOKafkaProducer
import json, uuid
from datetime import datetime

async def get_producer() -> AIOKafkaProducer:
    producer = AIOKafkaProducer(
        bootstrap_servers="kafka:9092",
        # acks='all' garantiza que el mensaje llegó a todas las réplicas
        acks="all",
        # enable_idempotence evita duplicados si el producer reintenta
        enable_idempotence=True,
        value_serializer=lambda v: json.dumps(v).encode("utf-8"),
        # lz4 tiene buen balance velocidad/ratio para payloads JSON
        compression_type="lz4",
    )
    await producer.start()
    return producer

async def publish_order_event(
    producer: AIOKafkaProducer,
    order_id: str,
    event_type: str,
    payload: dict,
):
    event = {
        "event_id": str(uuid.uuid4()),
        "event_type": event_type,  # e.g. "order.created", "order.cancelled"
        "order_id": order_id,
        "timestamp": datetime.utcnow().isoformat(),
        "payload": payload,
    }
    await producer.send_and_wait(
        topic="orders",
        # La clave garantiza que eventos del mismo pedido van a la misma partición
        key=order_id.encode("utf-8"),
        value=event,
    )

El enable_idempotence=True fue algo que no teníamos en la primera versión y que nos costó: sin eso, en ciertos escenarios de retry el producer puede duplicar mensajes. Lo descubrimos porque en staging empezamos a ver notificaciones dobles a usuarios de prueba. Nada catastrófico, pero suficiente para que alguien revisara la documentación con más cuidado.

Un problema más interesante fue el sizing de particiones. Para un topic de pedidos con volumen moderado configuramos inicialmente 3 particiones. Cuando empezamos a escalar consumers, nos dimos cuenta de que Kafka limita el paralelismo al número de particiones — no puedes tener más consumers activos en un grupo que particiones en el topic. Tuvimos que aumentar a 12, y cambiar el número de particiones en un topic existente tiene implicaciones en el orden de los mensajes si usas claves. Hay que planearlo desde el principio, no después.

Lo Que Me Dejó Completamente Confundido: Consumer Groups y la Semántica de Exactly-Once

Okay, necesito ser honesto aquí. Entendí consumer groups conceptualmente antes de la migración. Los leí en la documentación, vi algunos diagramas. Pero lo que no entendí — hasta que lo vi roto en producción — es la interacción entre consumer groups, offsets y rebalancing cuando corres múltiples instancias.

La situación concreta: teníamos el servicio de notificaciones leyendo del topic de pedidos. Todo funcionaba en staging con una instancia. Cuando desplegamos en producción con dos instancias para redundancia, el sistema empezó a comportarse de forma errática. Algunos eventos se procesaban dos veces, otros no se procesaban.

El problema era que manejábamos el commit de offsets mal. Hacíamos consumer.commit() justo después de recibir el mensaje, antes de procesarlo. Si el procesamiento fallaba a mitad camino, el offset ya estaba commiteado y el mensaje se perdía silenciosamente. La solución es commitear solo después de que el procesamiento sea exitoso:

from aiokafka import AIOKafkaConsumer
import logging

logger = logging.getLogger(__name__)

async def consume_orders():
    consumer = AIOKafkaConsumer(
        "orders",
        bootstrap_servers="kafka:9092",
        group_id="notifications-service",
        # 'earliest' para no perderse mensajes si el consumer arranca nuevo
        auto_offset_reset="earliest",
        # Manejamos el commit manualmente para control preciso
        enable_auto_commit=False,
        value_deserializer=lambda m: json.loads(m.decode("utf-8")),
    )
    await consumer.start()

    try:
        async for msg in consumer:
            event = msg.value
            try:
                # Primero procesamos, luego commiteamos
                await handle_order_event(event)
                await consumer.commit()
            except Exception as e:
                # No commiteamos — el mensaje se reintentará
                # En producción, aquí enviamos a un dead letter topic
                logger.error(
                    f"Error procesando evento {event.get('event_id')}: {e}"
                )
    finally:
        await consumer.stop()

Lo que me sorprendió — y esto no lo vi en ningún tutorial — es lo traicionera que es la semántica de exactly-once en la práctica. Técnicamente Kafka la soporta desde la versión 0.11 con transacciones, pero la implementación real requiere que tanto el consumer como el producer participen en la transacción, y que el sistema destino soporte idempotencia. En la mayoría de los casos prácticos terminás con at-least-once delivery y diseñás tus handlers para ser idempotentes. No es lo mismo, pero funciona si eres disciplinado con eso desde el principio. Yo pensaba que exactly-once sería la norma y que sería fácil de activar — resultó que at-least-once con idempotencia es lo que la mayoría de equipos usa en la práctica.

No estoy 100% seguro de que nuestro enfoque actual escale bien más allá de los 50.000 eventos por hora que manejamos en pico. Para volúmenes más altos probablemente habría que revisar la configuración de batching y las estrategias de procesamiento.

Un Año Después: Métricas Reales y Mi Recomendación Sin Rodeos

Doce meses después de la migración inicial — los cinco servicios en total, los últimos tres fueron bastante más rápidos porque ya sabíamos qué hacer — los números son concretos.

La latencia promedio del servicio de pedidos bajó de 340ms a 45ms. Eso porque eliminamos las llamadas síncronas en cadena: ahora el servicio publica el evento y termina, sin esperar respuestas de otros servicios. El procesamiento downstream ocurre de forma asíncrona.

Más importante para mí: no hemos tenido un incidente de cascada desde la migración. Hemos tenido fallos individuales — el servicio de notificaciones estuvo caído tres horas en noviembre por un problema de configuración — pero el impacto se limitó a ese servicio. Los eventos se acumularon en el topic de Kafka y cuando el servicio volvió, los procesó sin pérdida de datos.

El costo operativo subió. Kafka requiere atención: monitorear consumer lag, alertar cuando un grupo se queda atrás, gestionar la retención de logs. Nosotros usamos Confluent Cloud para no tener que operar el cluster nosotros mismos, y eso tiene un costo mensual que con REST era cero. Para un equipo pequeño, eso importa y hay que incluirlo en la evaluación.

¿Lo haría igual de nuevo? Básicamente sí, con dos cambios. Primero: empezaría con más particiones desde el principio — 12 mínimo para topics de alta actividad, no 3 que luego hay que migrar. Segundo: definiría un esquema de eventos más rígido desde el día uno. Usamos JSON libre durante los primeros meses y cuando quisimos agregar Schema Registry para validar contratos entre servicios, tuvimos que migrar mensajes existentes. Fue más trabajo del necesario.

Si tienes más de tres microservicios llamándose entre sí de forma síncrona y estás viendo fallos en cascada o latencias que se propagan, migrar a event streaming vale la inversión. Empieza con el servicio que tiene la mayor cantidad de llamadas síncronas entrantes y que sea el menos crítico — úsalo como proyecto de aprendizaje real. La curva de aprendizaje existe (consumer groups, offset management, particiones), pero no es insuperable en un equipo pequeño. Para proyectos nuevos, yo diseñaría con eventos desde el principio, antes de que el grafo de dependencias síncronas se vuelva lo que era el nuestro a las 2am de ese martes.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top