Mensajería y streaming de eventos
Patrones de mensajería y garantías de entrega: idempotencia, outbox, reintentos y orden
Avanzado13 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Todos los brokers prometen fiabilidad; ninguno puede hacer fiable tu aplicación por sí solo. Esta guía explica qué significan de verdad at-most-once, at-least-once y exactly-once y de dónde salen los duplicados y las pérdidas, y después recorre los patrones que cierran esos huecos — consumidores idempotentes, transactional outbox, reintentos con backoff y dead-letter queues, orden por clave, request-reply, competing consumers, claim check y versionado de esquemas — mostrando cómo se traslada cada uno a ActiveMQ, RabbitMQ y Kafka.
Lo que aprenderás
- Explicar las tres garantías de entrega e identificar exactamente dónde puede producirse un duplicado o una pérdida en un pipeline
- Diseñar un consumidor idempotente con un almacén de deduplicación y una ventana de retención razonable
- Implementar el transactional outbox para que las escrituras en base de datos y los eventos publicados no puedan divergir
- Construir un tratamiento de reintentos, backoff y dead letters que no bloquee los mensajes sanos
- Preservar el orden donde importa mediante claves, message groups, particiones y single active consumers
- Aplicar correctamente request-reply, competing consumers, claim check y la evolución de esquemas en cada broker
Los brokers presumen de durabilidad, confirmaciones y transacciones, y es fácil concluir que usar uno hace fiable un sistema. No es así. La fiabilidad es una propiedad del camino completo — productor, broker, consumidor y cada efecto secundario que provoca el consumidor — y el broker solo controla la parte central. Los patrones de esta guía son los que hacen fiables también los extremos. Son independientes del broker; la última sección muestra cómo se traslada cada uno a ActiveMQ Classic, RabbitMQ y Kafka, que las guías anteriores de esta materia tratan en detalle.
Qué significan de verdad las tres garantías
Las garantías de entrega describen qué le ocurre a un mensaje cuando algo falla entre el momento en que un productor lo envía y el momento en que un consumidor termina con él.
At-most-once. El productor envía sin esperar confirmación, o el consumidor confirma antes de procesar. Un fallo en cualquier punto pierde el mensaje; nunca se duplica nada. Es la opción adecuada para datos que se quedan obsoletos rápido — muestras de métricas, actualizaciones de presencia, invalidaciones de caché —, donde el siguiente mensaje corrige uno perdido.
At-least-once. El productor espera la confirmación del broker y reintenta si no llega; el consumidor confirma solo después de procesar. No se pierde nada, pero cada reintento puede crear un duplicado: el primer envío del productor pudo tener éxito antes de que se perdiera la confirmación, y el consumidor pudo terminar su trabajo y caerse sin llegar a confirmar. Es lo que ejecutan casi todos los sistemas en producción.
Exactly-once. Cada mensaje afecta al sistema exactamente una vez. Ningún broker puede garantizarlo para un consumidor cualquiera, porque los efectos secundarios del consumidor — una llamada HTTP, una fila en otra base de datos — están fuera del control del broker. Lo que los brokers sí pueden ofrecer es exactly-once dentro de un perímetro: las transacciones de Kafka hacen atómico un ciclo read-process-write entre topics de Kafka, y una sesión JMS transaccional hace atómicos el consumo y la producción en un mismo broker. Fuera de ese perímetro, el exactly-once es algo que construyes con entrega at-least-once y procesamiento idempotente.
Merece la pena memorizar de dónde salen los duplicados y las pérdidas, porque cada patrón de los que siguen ataca uno de estos puntos:
| Punto de fallo | Síntoma sin patrón | Patrón |
|---|---|---|
| El productor se cae tras el commit en la base de datos, antes de publicar | Evento perdido, sistemas incoherentes | Transactional outbox |
| Se pierde la confirmación del productor y este reintenta | Mensaje duplicado | Productor idempotente (Kafka), deduplicación en el broker o consumidor idempotente |
| El broker se cae antes de persistir | Mensaje perdido | Entrega persistente, confirms, replicación |
| El consumidor se cae tras el efecto secundario, antes de confirmar | Efecto secundario duplicado | Consumidor idempotente |
| El consumidor no puede procesar un mensaje | Cola bloqueada o bucle infinito | Reintentos con backoff, dead-letter queue |
| Consumidores en paralelo | Desorden | Orden por clave |
Consumidor idempotente
Un consumidor idempotente llega al mismo estado final tanto si procesa un mensaje una vez como si lo procesa varias. Es el patrón más valioso de la mensajería, porque hace segura la entrega at-least-once, y at-least-once es lo que tienes.
Algunas operaciones son idempotentes por naturaleza: poner un estado a SHIPPED, hacer un upsert de una fila por id de pedido, borrar un registro. Otras no lo son — incrementar un contador, cobrar una tarjeta, enviar un correo — y necesitan un almacén de deduplicación: una tabla o una entrada clave-valor que registra qué identificadores de mensaje se han procesado, y que se consulta y se escribe en la misma transacción que el efecto secundario.
@Transactional
public void handle(OrderPaid event, String messageId) {
// INSERT ... ON CONFLICT DO NOTHING; returns 0 rows when the id was already recorded
if (processedMessages.insertIfAbsent(messageId, Instant.now()) == 0) {
return; // duplicate: already handled, acknowledge and move on
}
ledger.credit(event.merchantId(), event.amount());
// commit writes the ledger entry and the de-duplication row atomically
}
Tres detalles deciden si esto funciona. El identificador debe asignarlo el productor — un id de mensaje que genera una sola vez, o una clave de negocio como el id de pedido más el tipo de evento —, porque un id asignado por el broker cambia cuando el productor reintenta. La comprobación y el efecto secundario deben ir en una sola transacción; si no, una caída entre ambos recrea el problema. Y el almacén debe purgar las entradas pasada una ventana más larga que cualquier reentrega posible, o crecerá sin fin. Cuando el efecto secundario es una llamada a otro sistema, pasa ese mismo identificador como clave de idempotencia para que el sistema de destino también pueda deduplicar; los proveedores de pagos y la mayoría de las API bien diseñadas la aceptan.
Transactional outbox
Un servicio que guarda un pedido y publica un evento OrderPlaced tiene un problema de doble escritura: la base de datos y el broker son dos sistemas, y no hay transacción que abarque ambos. Publicar después del commit te expone a un pedido guardado sin evento; publicarlo antes, a un evento de un pedido que acabó en rollback.
El transactional outbox saca el segundo sistema de la transacción. El servicio inserta el evento en una tabla outbox dentro de la misma transacción que el pedido. Un relay independiente lee las filas nuevas del outbox y las publica, marcando cada una como enviada. Si el relay se cae después de publicar y antes de marcar, el evento se publica dos veces — lo cual es at-least-once, y el consumidor idempotente de antes lo absorbe.
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- becomes the message id
aggregate_id TEXT NOT NULL, -- becomes the routing key or partition key
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
El relay puede ser un poller programado (SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED, publicar, actualizar) o una herramienta de change data capture como Debezium, que sigue el log de transacciones de la base de datos y publica en Kafka cada inserción en el outbox sin polling y con orden estricto. El polling es más sencillo y basta con volúmenes moderados; el CDC escala más y no añade carga a las tablas. En ambos casos, borra o archiva las filas publicadas para que la tabla siga siendo pequeña.
El outbox también te da gratis el orden de los eventos por agregado: las filas se publican en orden de inserción, y usar aggregate_id como message group o clave de partición mantiene ese orden en el lado del consumidor.
Reintentos, backoff y dead letters
Un consumidor que falla con un mensaje tiene tres opciones honestas: reintentar, saltarlo o apartarlo. La opción equivocada — reintentar de inmediato y para siempre — es el comportamiento por defecto de muchos listener containers, y convierte un solo poison message en una cola que nunca se vacía y en una dependencia machacada hasta que se cae.
Una política sensata tiene cuatro partes:
- Clasificar el fallo. Un timeout o un
503es transitorio y merece un reintento. Un error de validación o un404de una entidad referenciada es permanente; reintentarlo desperdicia intentos. - Aplicar backoff exponencial con jitter. Un segundo, dos, cuatro, ocho, más una fracción aleatoria para que cien consumidores que fallan a la vez no reintenten al unísono. Pon un tope al retardo y al número de intentos.
- Reintentar sin bloquear la cola. El reintento en memoria retiene el thread del consumidor y los mensajes que vienen detrás. Para retardos de más de unos segundos, vuelve a publicar el mensaje en un destino de reintento con retardo — una cola con TTL que lo devuelve como dead letter a la cola de trabajo en RabbitMQ, una entrega programada en ActiveMQ, un topic de reintento que se consume tras una espera en Kafka — y confirma el original.
- Mandar a dead letter lo que quede. Tras el último intento, mueve el mensaje a una dead-letter queue (DLQ) con la excepción, la stack trace, el número de intentos y el destino original adjuntos como cabeceras. Una DLQ sin nadie que la lea es un agujero negro: alerta sobre su profundidad y ten una herramienta para inspeccionar y reprocesar su contenido.
Saltar mensajes merece una mención: en flujos donde un registro defectuoso no debe detener el pipeline — analítica, logs —, registrarlo en el log y seguir adelante es una política legítima, siempre que sea una decisión y no un accidente.
Orden
Los brokers preservan el orden en un único sitio: una sola cola o partición leída por un solo consumidor. Todo lo que añade throughput — competing consumers, varias particiones, reentregas tras un fallo — lo debilita. La postura viable es el orden por clave: todos los mensajes sobre una misma entidad llegan en orden, mientras que los mensajes sobre entidades distintas pueden intercalarse.
| Broker | Mecanismo | Lado del consumidor |
|---|---|---|
| ActiveMQ Classic | Message groups con JMSXGroupID: todos los mensajes con un group id van a un solo consumidor mientras siga vivo | Nada especial; el broker fija el grupo |
| RabbitMQ | Una cola por rango de claves (plugin consistent-hash exchange) o una sola cola con x-single-active-consumer | Un consumidor activo por cola |
| Kafka | Particionado por clave; el partitioner por defecto ya lo hace | Un consumidor por partición dentro de un grupo |
Incluso con orden por clave quedan dos sutilezas. Un mensaje reintentado puede adelantar a mensajes posteriores de la misma clave, a menos que los reintentos también sean por clave — la solución más segura es detener el procesamiento de esa clave hasta que el reintento tenga éxito, algo que Kafka hace de forma natural al bloquear la partición. Y el significado de un evento no debería depender de un orden estricto cuando se puede evitar: llevar un número de versión o una marca de tiempo en cada evento permite al consumidor ignorar una actualización obsoleta que llega tarde, lo que sale más barato que un orden perfecto.
Patrones de interacción
Competing consumers
Varios consumidores que leen de una cola se reparten sus mensajes, y añadir un consumidor aumenta el throughput hasta que el productor o una dependencia posterior se convierte en el límite. Es el modelo de escalado por defecto para colas y, con los consumer groups, para Kafka. Mantén moderados el prefetch o max.poll.records para que el trabajo se reparta de forma equitativa y una caída provoque pocas reentregas.
Publish-subscribe
Un evento, muchos lectores independientes, cada uno con su propia posición y sus propios fallos. Se implementa con topics y suscripciones duraderas en JMS, un exchange fanout o topic que alimenta una cola por suscriptor en RabbitMQ, y consumer groups independientes en Kafka. Cada suscriptor debe gestionar sus propios reintentos y dead letters; un suscriptor lento nunca debe poder bloquear a los demás.
Request-reply
Un mensaje que espera respuesta necesita un identificador de correlación que la respuesta lleve de vuelta y una dirección de respuesta a la que envíe quien responde. Pon un timeout a cada petición y decide qué hace el solicitante cuando expira — una respuesta que llega después del timeout debe reconocerse como obsoleta y descartarse. Como request-reply reintroduce el acoplamiento temporal, resérvalo para los casos en que quien llama de verdad no puede continuar sin la respuesta.
Claim check
Los brokers están pensados para mensajes pequeños; un payload de 20 MB ralentiza cada salto y puede superar los límites de trama. El patrón claim check guarda el payload en un almacenamiento de objetos o en una base de datos y envía un mensaje que solo contiene una referencia y los metadatos que necesita un enrutador. El consumidor recupera el payload cuando procesa el mensaje. Purga los payloads almacenados con la misma disciplina que los registros de deduplicación.
Enrutamiento basado en contenido y traducción de mensajes
Cuando un mensaje debe ir a consumidores distintos según sus atributos, enruta por cabeceras o routing keys en el broker (un topic exchange, un selector, un topic por tipo), en lugar de que cada consumidor lo reciba todo y descarte la mayor parte. Cuando dos sistemas no coinciden en el formato, pon la traducción en un único lugar — un consumidor dedicado que vuelve a publicar — en vez de en cada consumidor.
Evolución de esquemas
Los mensajes sobreviven al código que los produjo. Una cola puede contener el formato de ayer cuando se despliega el consumidor de hoy, y un topic de Kafka guarda una semana de historial. Por eso cada mensaje necesita una versión de esquema, y cada cambio debe clasificarse como compatible o incompatible:
- Compatible: añadir un campo opcional con valor por defecto, ampliar un tipo numérico, añadir un nuevo tipo de evento con una nueva routing key. Los consumidores ignoran los campos desconocidos.
- Incompatible: renombrar o eliminar un campo, cambiar un tipo o su significado, cambiar la clave. Estos cambios exigen un nuevo topic o routing key, o un paso de traducción, y un periodo en el que se producen o consumen ambas versiones.
Un schema registry (Avro, Protobuf o JSON Schema) impone las reglas de compatibilidad en el momento de publicar y merece la pena adoptarlo en cuanto más de un equipo consume un topic. Sin él, una cabecera schemaVersion y un consumidor que valide antes de procesar son el mínimo; usa el formateador JSON para inspeccionar payloads y el decodificador Base64 cuando una herramienta muestre el cuerpo codificado.
Correspondencia entre patrones y brokers
| Patrón | ActiveMQ Classic | RabbitMQ | Kafka |
|---|---|---|---|
| At-least-once | Entrega persistente, ack AUTO/CLIENT o sesión transaccional | Quorum queue duradera, mensaje persistente, confirms, ack manual | acks=all, productor idempotente, commit tras procesar |
| Perímetro exactly-once en el broker | Sesión transaccional (consumo + producción en un broker), XA a través de un servidor de aplicaciones | Ninguno; usa consumidores idempotentes | Transacciones con consumidores read_committed, EOS de Kafka Streams |
| Deduplicación en el productor | Ninguna; usa un id de outbox | Ninguna; usa un id de outbox | Productor idempotente (por sesión) |
| Reintento diferido | Entrega programada (AMQ_SCHEDULED_DELAY) | Cola con TTL y dead-letter exchange de vuelta a la cola de trabajo | Topics de reintento consumidos tras un retardo |
| Dead letters | ActiveMQ.DLQ o DLQ por destino, automática según la redelivery policy | Dead-letter exchange, x-delivery-limit en quorum queues | No integrado; topic de errores escrito por el consumidor o el framework |
| Orden por clave | JMSXGroupID | Single active consumer, consistent-hash exchange | Clave de partición |
| Pub-sub | Topics, suscripciones duraderas y compartidas | Exchange fanout/topic con una cola por suscriptor | Consumer groups |
| Replay | No | Solo streams | Reiniciar offsets |
| Request-reply | Colas temporales, JMSReplyTo, JMSCorrelationID | Direct reply-to, propiedades reply_to y correlation_id | Topic de respuesta con el id de correlación como clave; poco práctico |
La tabla deja una cosa a la vista: Kafka ofrece las garantías más fuertes en el productor y en el broker y las herramientas más pobres por mensaje, mientras que los brokers de colas ofrecen un manejo rico de cada mensaje y esperan que resuelvas tú la deduplicación. Ninguno elimina la necesidad de consumidores idempotentes y de un outbox.
Errores comunes
- Fiarse del «exactly-once» que anuncia la caja. Se aplica dentro del perímetro del broker. Los efectos secundarios fuera de él necesitan idempotencia.
- Identificadores asignados por el broker para deduplicar. Cambian cuando el productor reintenta. Genera el id en el productor o derívalo de una clave de negocio.
- Deduplicación fuera de la transacción. Consultar el almacén, hacer el trabajo y luego registrar el id deja una ventana de caída que reproduce el duplicado. Una sola transacción.
- Publicar después del commit sin outbox. Funciona hasta la primera caída entre ambos pasos, que después es casi imposible de reconstruir.
- Reintentos inmediatos e ilimitados. Los poison messages bloquean las colas y los reintentos amplifican las caídas. Backoff, tope y dead letter.
- Dar por hecho un orden global. No existe más allá de un consumidor sobre una cola o partición. Diseña por clave y lleva números de versión.
- Payloads grandes en el bus. Usa un claim check; mantén los mensajes en kilobytes.
Ideas clave
- At-least-once es lo que obtienes de cualquier broker en producción; exactly-once solo existe dentro del perímetro transaccional de un broker, y at-most-once es una elección deliberada para datos desechables.
- Los duplicados vienen de los reintentos del productor y de las caídas del consumidor antes de confirmar; el consumidor idempotente, con un id asignado por el productor y comprobado dentro de la transacción del efecto secundario, absorbe ambos.
- El transactional outbox hace coherentes las escrituras en base de datos y los eventos publicados al poner el evento en la base de datos y reenviarlo después.
- Reintenta los fallos transitorios con backoff exponencial con tope, aparta los fallos permanentes en una dead-letter queue con el motivo adjunto y no dejes nunca que un mensaje defectuoso detenga la cola.
- El orden se preserva por cola o partición con un solo consumidor; diseña para orden por clave con message groups, single active consumers o claves de partición, y lleva números de versión.
- Elige los patrones de interacción de forma deliberada — competing consumers, pub-sub, request-reply con timeouts, claim check para payloads grandes — y versiona cada esquema de mensaje desde la primera release.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- Si mi broker soporta exactly-once, ¿sigo necesitando consumidores idempotentes?
- Sí, salvo que todos los efectos secundarios de tu consumidor estén a su vez dentro de la transacción del broker. El exactly-once de Kafka cubre el procesamiento de Kafka a Kafka; en cuanto un consumidor llama a una API de pagos o escribe una fila en una base de datos, ese efecto queda fuera de la transacción y puede repetirse tras una caída. La idempotencia en el consumidor es el único mecanismo que funciona sea cual sea el broker.
- ¿Por qué no publicar simplemente el evento después del commit en la base de datos?
- Porque el proceso puede morir entre el commit y la publicación, dejando un pedido guardado sin evento, o la publicación puede tener éxito y el commit fallar, dejando un evento de un pedido que no existe. El transactional outbox escribe el evento en la misma transacción de base de datos que los datos y deja que un relay independiente lo publique, así que ambos nunca pueden discrepar.
- ¿Cuánto tiempo debo conservar los registros de deduplicación?
- Más que la reentrega más larga que sea plausible. Ese plazo lo acota la retención o la ventana de reentrega del broker: unas horas cubren la mayoría de las reentregas de colas, mientras que unos consumidores de Kafka que rebobinan una semana de historial necesitan una semana. Guarda el identificador del mensaje con una marca de tiempo y purga las entradas con un job en segundo plano o con un TTL de la base de datos.
- ¿Hay que reintentar de inmediato los mensajes que fallan?
- Solo una o dos veces, para cortes de red puntuales. Más allá de eso, los reintentos inmediatos machacan una dependencia que ya está sufriendo y bloquean los mensajes que vienen detrás. Usa backoff exponencial con jitter, limita los intentos y mueve el mensaje a un destino de dead letters con el motivo del fallo adjunto, para que los mensajes sanos sigan fluyendo.