Módulo 3 · Mensajería, entrega e idempotencia
Por qué esto importa en la entrevista
Sección titulada «Por qué esto importa en la entrevista»Si trabajas con microservicios, la mitad de tus incidentes reales serán de mensajería: duplicados, mensajes perdidos, orden roto, lag creciente, DLQ llena a las 3 a.m. Y en la entrevista es el terreno donde más rápido se distingue quien ha operado un sistema de quien ha leído sobre uno.
La frase que quieres poder decir con naturalidad: “asumo entrega at-least-once, así que todos mis consumidores son idempotentes”.
Modelo mental: solo existen dos garantías reales
Sección titulada «Modelo mental: solo existen dos garantías reales»- At-most-once: entregas y no reintentas. Puedes perder mensajes. Válido para telemetría de baja importancia.
- At-least-once: reintentas hasta confirmar. Nunca pierdes, pero duplicas. Es el default sensato de todo broker.
- Exactly-once: no existe a nivel de red. Lo que existe es procesamiento efectivamente-una-vez, y se consigue de dos formas: (a) at-least-once + idempotencia del consumidor, o (b) transacciones dentro de un mismo sistema (Kafka transactions: consumir→procesar→producir + offsets en la misma transacción, válido solo mientras no salgas de Kafka).
⚠️ Trampa: decir “activo
enable.idempotence=truey ya tengo exactly-once”. Ese flag da idempotencia del productor frente a reintentos de red (deduplica porproducerId+secuencia dentro de una sesión). No protege del duplicado que ocurre cuando tu consumidor procesa, muere antes de commitear el offset y otro consumidor reprocesa.
Idempotencia: la herramienta central
Sección titulada «Idempotencia: la herramienta central»Una operación es idempotente si ejecutarla N veces deja el mismo estado que ejecutarla una vez. HTTP ya lo modela: GET, PUT y DELETE son idempotentes por definición; POST no.
Las cuatro formas de hacerlo, de mejor a peor:
- Naturalmente idempotente:
SET estado = 'PAGADO'es idempotente;saldo = saldo - 100no lo es. Reformular la operación como asignación de un estado deseado resuelve muchos casos sin infraestructura. - Clave de idempotencia + tabla de deduplicación (el patrón general):
CREATE TABLE idempotencia ( clave TEXT PRIMARY KEY, -- generada por el CLIENTE, una por intención huella TEXT NOT NULL, -- hash del payload: detecta reutilización con otro cuerpo estado TEXT NOT NULL, -- EN_CURSO | COMPLETADO respuesta JSONB, -- para devolver exactamente lo mismo creado_en TIMESTAMPTZ DEFAULT now());El flujo correcto, todo dentro de la misma transacción que el efecto de negocio:
BEGIN INSERT INTO idempotencia(clave, huella, estado) VALUES (?, ?, 'EN_CURSO') -- ON CONFLICT: si existe y está COMPLETADO → devolver respuesta guardada (200) -- si existe con otra huella → 422 (misma clave, cuerpo distinto) -- si existe y está EN_CURSO → 409 "en proceso, reintenta" ...efecto de negocio (crear cobro, reservar stock)... UPDATE idempotencia SET estado='COMPLETADO', respuesta=? WHERE clave=?COMMIT- Dedupe por id de mensaje en el consumidor: tabla
mensajes_procesados(id, procesado_en)conINSERTque falla por clave primaria si ya se procesó. Necesita TTL/purga. - Detección por estado de negocio: “¿ya existe un cobro para el pedido A1 en estado PAGADO?”. Funciona, pero es frágil ante concurrencia si no tienes un índice único que lo garantice. Regla: si tu idempotencia no está respaldada por una restricción única en la BD, no es idempotencia, es una carrera.
Dónde vive la clave importa: debe representar la intención del usuario, no el request HTTP. Si el usuario pulsa “Pagar” una vez y el frontend reintenta 3 veces, las 3 llevan la misma clave. Si el usuario pulsa “Pagar” dos veces a propósito (dos pedidos), son claves distintas. Generarla en el servidor no sirve para nada.
El dual write y el patrón Outbox
Sección titulada «El dual write y el patrón Outbox»El bug estructural más frecuente:
// ❌ Dos sistemas, ninguna atomicidadpedidoRepo.save(pedido); // commit en la BDkafka.send("pedidos", evento); // si el proceso muere aquí, el evento no existe jamásOutbox: escribe el evento en una tabla de la misma BD, dentro de la misma transacción. Un proceso aparte (relay CDC con Debezium, o un poller) lee la tabla y publica al broker, marcando lo publicado.
┌──────────── transacción ─────────────┐│ INSERT INTO pedidos ... ││ INSERT INTO outbox(id, tipo, payload)│└──────────────────────────────────────┘ │ ▼ relay (CDC o poll) Kafka ──► consumidores idempotentes (at-least-once garantizado)El relay publica at-least-once (puede morir tras publicar y antes de marcar). De ahí la regla de oro: outbox garantiza que el evento sale; idempotencia garantiza que procesarlo dos veces no duele. Van siempre juntos.
El simétrico es la inbox: el consumidor guarda el id del mensaje procesado en su propia BD, en la misma transacción que su efecto.
Orden: el requisito que casi nunca necesitas globalmente
Sección titulada «Orden: el requisito que casi nunca necesitas globalmente»En Kafka el orden solo se garantiza dentro de una partición. Por tanto: si necesitas orden para una entidad, esa entidad debe ir siempre a la misma partición → clave de partición = id de la entidad (pedido_id, cliente_id).
Consecuencias que debes anticipar:
- Hot partition: si eliges una clave mal distribuida (
pais, o un cliente enorme), una partición concentra el tráfico y un consumidor va ahogado mientras el resto duerme. Mitigaciones: clave más granular, salting (cliente_id#bucket) sacrificando orden global del cliente, o proceso dedicado para el tenant caliente. - Paralelismo máximo = nº de particiones. Añadir consumidores por encima de eso no sirve: quedan idle.
- Reintentos rompen el orden. Si mandas el mensaje fallido a una cola de reintento, ese mensaje llegará después que los siguientes. Si el orden importa de verdad, debes bloquear la partición o versionar el estado para descartar lo viejo.
Alternativa: idempotencia + versión monótona. En vez de exigir orden, incluye version en el evento y haz UPDATE ... WHERE version < :nueva. Un evento antiguo que llega tarde simplemente no aplica. Esto es mucho más barato que garantizar orden global y es la respuesta que impresiona.
Consumo, offsets y lag
Sección titulada «Consumo, offsets y lag»- Commit después de procesar, nunca antes. Auto-commit + procesamiento asíncrono = mensajes perdidos silenciosamente (el offset avanza sin que el trabajo termine).
- Lag = último offset producido − último committeado. Es la métrica de salud de un consumidor: alertar sobre lag creciente sostenido, no sobre lag > X puntual.
- Rebalancing: al entrar/salir un consumidor, el grupo redistribuye particiones y (en el protocolo eager) todos paran. Causas típicas de rebalanceos infinitos: procesar un lote tarda más que
max.poll.interval.ms, osession.timeout.msmal ajustado con pausas de GC. Solución: lotes más pequeños, procesamiento fuera del hilo del poll con pausa/reanudación, oCooperativeStickyAssignor. - DLQ (dead letter queue): después de N reintentos, aparta el mensaje con su causa y contexto. Una DLQ sin alerta ni herramienta de reproceso es un cementerio: define quién la vigila y cómo se reinyecta.
Sagas: transacciones largas con compensación
Sección titulada «Sagas: transacciones largas con compensación»Coreografía: pedido→(evento)→pago→(evento)→inventario→(evento)→envío simple, sin punto central, pero nadie sabe el estado global
Orquestación: un Orquestador de Pedido llama/escucha a cada paso y guarda una máquina de estados persistida: sabe dónde está y qué compensarElige coreografía con ≤3 pasos y acoplamiento bajo; orquestación cuando hay ramas, timeouts por paso o necesitas responder “¿en qué punto está el pedido 123?”. En ambos casos:
- Cada paso debe ser idempotente (se reintentará).
- Cada compensación debe ser idempotente y tolerar que el paso original nunca se aplicara.
- Hay estados no compensables (email enviado, dinero movido a un tercero): diséñalos al final del flujo, o usa reserva → confirmación en dos fases de negocio.
- Registra la saga: sin persistir la máquina de estados no puedes reanudar tras un reinicio.
Errores comunes que delatan a un no-senior
Sección titulada «Errores comunes que delatan a un no-senior»- “Uso exactly-once, lo activa Kafka” (ver trampa arriba).
- Generar la clave de idempotencia en el servidor.
- Idempotencia comprobada con
SELECT ... IF NOT EXISTSsin restricción única (carrera bajo concurrencia). - Publicar el evento fuera de la transacción y decir “es que uso try/catch”.
- Pedir orden global de mensajes sin justificarlo, y no saber que eso implica una sola partición.
- No tener plan para la DLQ.
- Reintentar indefinidamente un mensaje envenenado (bloquea la partición para siempre).
🧪 Laboratorio — construye el trío outbox + dedupe + DLQ
Sección titulada «🧪 Laboratorio — construye el trío outbox + dedupe + DLQ»Con Docker Compose (Kafka + Postgres) o RabbitMQ si lo prefieres:
- Reproduce el duplicado: consumidor que procesa un pago, y
kill -9al proceso justo después de escribir en BD y antes de commitear el offset. Al reiniciar, verás el cobro doble. Anota el saldo resultante. - Arregla con dedupe: tabla
mensajes_procesados(message_id PK)insertada en la misma transacción que el pago. Repite elkill -9: ahora el reproceso es inofensivo. - Implementa outbox: transacción que inserta pedido + evento; un poller que publica y marca
publicado_en. Mata el poller entre publicar y marcar: verifica que el duplicado resultante tampoco duele. - Provoca un rebalanceo: pon
max.poll.interval.ms=10000y haz que el procesamiento tarde 15 s. Observa en los logs el ciclo de rebalanceo infinito. Arréglalo. - Hot partition: produce 100.000 mensajes con
key = "PERU"para el 90% y observa el lag por partición (kafka-consumer-groups --describe). Aplica salting y compara. - DLQ: manda a
pedidos.DLQtras 3 reintentos con cabeceras de causa y stacktrace; escribe el script de reinyección.
Entregable: un README propio con las 6 gráficas/salidas y una frase de qué aprendiste en cada paso. Esto es material de anécdota para la entrevista.
✅ Autoevaluación
Sección titulada «✅ Autoevaluación»- ¿Por qué exactly-once no existe y qué es lo que sí puedes conseguir?
- Diseña la clave de idempotencia para “pagar un pedido” y justifica quién la genera y cuánto vive.
- ¿Qué es un dual write y por qué el outbox lo resuelve si el relay también puede duplicar?
- Necesitas orden por cliente pero un cliente genera el 40% del tráfico. ¿Qué haces?
- El consumer lag crece sin parar pero el consumidor procesa a ritmo normal. Da tres hipótesis.
- ¿Cuándo orquestación en vez de coreografía? ¿Qué haces si una compensación falla?
🎯 Preguntas del banco que ya puedes responder
Sección titulada «🎯 Preguntas del banco que ya puedes responder»java-microservicios/02-spring-y-microservicios.md— 5, 6, 7, 8 (Kafka, exactly-once, saga, outbox)java-microservicios/03-casos-y-problemas.md— 5 (lag), 6 (duplicados), 14 (pérdida), 15 (hot partition)casos-de-estudio/02-incidentes-en-produccion.md— 4 (saga rota), 7 (cobros duplicados), 10 (pérdida de mensajes)casos-de-estudio/01-system-design.md— 1 (pagos), 3 (notificaciones)versionamiento-apis/02-versionamiento-de-servicios-y-datos.md— evolución de esquemas de eventos
Para profundizar
Sección titulada «Para profundizar»- Chris Richardson, Microservices Patterns — capítulos de saga y outbox.
- Documentación de Debezium (CDC como relay de outbox).
- “Delivering billions of messages exactly once” (Segment) — buen ejemplo de dedupe a escala.
Anterior: Módulo 2 · Siguiente: Módulo 4 · Resiliencia