Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Mensajería

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

Resumen teórico

Imagina una pizzería. La persona que toma tu pedido no corre a la cocina ni se queda esperando a que la pizza se hornee. Escribe un papelito y lo cuelga en un tendedero. El pizzero toma los papelitos del tendedero cuando está libre. Si está ocupado, los papelitos esperan. Si se va al baño, los papelitos siguen ahí cuando vuelve. Ese tendedero es el tema de todo este resumen. En computación se llama mensajería (messaging).

En el software, los programas necesitan hablar entre sí. El sitio de una tienda debe avisar al programa de cobro, al de envío y al de correo que llegó un pedido. Podrían llamarse directamente, como por teléfono, pero entonces todos tendrían que estar despiertos al mismo tiempo y ser rápidos. Con la mensajería, un programa escribe un y se lo entrega a un intermediario llamado . Los otros programas lo leen después, a su propio ritmo.

Te encuentras con la mensajería todos los días sin verla: el correo que llega después de una compra, la notificación en el celular, el video que se está convirtiendo mientras usas el sitio, la transferencia bancaria que se procesa en segundo plano. Las grandes empresas mueven millones de mensajes por segundo.

En este resumen aprenderás: las tres formas básicas (cola, publish/subscribe y stream), qué promesas de entrega puede hacer un sistema, cómo funciona el orden y por qué se rompe, qué hacer cuando un mensaje falla (confirmaciones, reintentos y dead-letter queues), cómo hacer un programa seguro frente a mensajes repetidos, cómo evitar que quien envía rápido ahogue a quien recibe despacio, y cómo funcionan las herramientas reales: RabbitMQ, Kafka, BullMQ, SQS y SNS. Al final también conocerás tres patrones para sistemas grandes: outbox, saga y event sourcing. Entonces estarás listo para el quiz.

El panorama general: ¿quién habla con quién?

Pongamos nombre a los personajes, usando la pizzería. El producer (productor) es quien crea los mensajes (la persona que toma los pedidos). El consumer (consumidor) es quien los lee y los atiende (el pizzero). El broker es el tendedero del medio, que guarda los papelitos. Y el mensaje es el papelito mismo.

  PRODUCER            BROKER             CONSUMER
  (mostrador)         (el tendedero)     (el pizzero)

  [ papelito ] ---->  [ t3 | t2 | t1 ] ------> [ papelito ]
   lo escribe         los guarda a salvo         lo lee y hace
   y se va            hasta que alguien          el trabajo
                      los toma
El productor y el consumidor nunca necesitan encontrarse. El broker está entre ellos.

¿Por qué poner un intermediario ahí? Por dos grandes regalos. El primero es el : el productor no sabe quién lee y no le importa cuántos lectores hay. El segundo es la independencia en el tiempo: el pizzero puede ausentarse diez minutos y no se pierde nada, porque los papelitos esperan en el tendedero.

Palabras que vas a encontrar

  • Producer (también llamado publisher o emisor): crea los mensajes.
  • Consumer (también llamado subscriber o worker): lee y procesa los mensajes.
  • Broker: el servidor que guarda los mensajes y los entrega, por ejemplo RabbitMQ, Kafka o Amazon SQS.
  • Acknowledgement (ack): el consumidor diciendo 'terminé con este, puedes olvidarlo'.
  • Entrega (delivery): un mensaje que llega a un consumidor. Si un mensaje se envía a tres consumidores, son tres entregas.
  • Tópico (topic): un lugar con nombre donde se envían los mensajes de un tema.
↑ Volver al índice

Cola, publish/subscribe y stream

Hay tres formas básicas de mover mensajes. De lejos parecen iguales, pero se comportan de manera muy distinta. Elegir la equivocada es el error más común de los principiantes.

1. Cola: repartir el trabajo

Una cola (queue) es como la fila de la cafetería de la escuela con tres mostradores. Cada niño va a un solo mostrador. Si abres un cuarto mostrador, la fila avanza más rápido, pero cada niño sigue siendo atendido una vez. En mensajería esto se llama (competing consumers). Cada mensaje lo procesa exactamente un consumidor. Es la manera de repartir una pila grande de trabajo entre muchos trabajadores.

2. Publish/subscribe: avisar a todos

En publish/subscribe (pub/sub), un publicador envía un mensaje a un tópico y cada suscriptor recibe su propia copia. Es como la maestra que anuncia 'hoy no hay tarea' a todo el salón: lo dice una vez y todos los niños lo oyen. Si un tópico tiene cuatro suscriptores, un mensaje produce cuatro entregas. Una cola divide el trabajo, un tópico lo multiplica.

3. Stream: un registro que se puede releer

Un stream (como un tópico de Kafka) es un log append-only: los registros nuevos solo se agregan al final, y se quedan ahí un tiempo aunque alguien ya los haya leído. Piensa en un diario. Leer una página no la arranca. Cada lector guarda un marcador, llamado offset, así que muchos lectores pueden leer las mismas páginas, y un lector puede volver atrás y releer páginas viejas. Una cola clásica es lo contrario: el mensaje se elimina después de atenderlo.

Las tres formas lado a lado.
CaracterísticaColaTópico pub/subStream (log)
¿Quién recibe el mensaje?Un consumidorTodo suscriptorTodo grupo de lectores; un lector dentro de cada grupo
Después de leerseSe eliminaDesaparece, salvo que cada suscriptor tenga su colaSe queda hasta que acaba el tiempo de retención
¿Se puede leer de nuevo después?NoNoSí, basta mover el marcador hacia atrás
Bueno paraRepartir tareas entre trabajadoresAvisar a muchos servicios de un eventoHistorial, replay, construir reportes nuevos a partir de eventos viejos
Entregas por minuto cuando llegan 600 mensajes por minuto
Cola, 2 consumidores
600 entregas/min
Cola, 3 consumidores
600 entregas/min
Tópico, 2 suscriptores
1,200 entregas/min
Tópico, 3 suscriptores
1,800 entregas/min

Agregar un consumidor a una cola no cambia el total (600): el mismo trabajo se corta en pedazos más pequeños. Agregar un suscriptor a un tópico agrega una copia completa del flujo.

Mezclar los modelos, y otras ideas

  • Los sistemas reales combinan los modelos. Un evento se copia a una cola por servicio (pub/sub entre servicios), y dentro de cada servicio varias instancias compiten por los mensajes de su cola. El número de veces que se procesa un mensaje es igual al número de colas (o grupos de consumidores), no al número de procesos. Dos servicios con 3 instancias cada uno procesan el mensaje 2 veces en total.
  • Para escalar un servicio, sus instancias deben compartir una cola (o un grupo de consumidores). Si cada instancia recibe su propia suscripción, no escalas: duplicas, y cada correo se envía tres veces.
  • Independencia en el tiempo. Con una cola durable, si el servicio de correo está caído diez minutos, los pedidos esperan y se atienden cuando vuelve. El puro es distinto: solo reciben el mensaje los suscriptores conectados en ese momento. Quien estaba desconectado lo pierde para siempre.
  • Comando y evento. Un comando (SendInvoice) es una orden con un receptor previsto y encaja en una cola. Un evento (OrderPlaced) es un hecho del pasado, no sabe quién escucha, y encaja en pub/sub. La diferencia está en la intención, no en la tecnología.
  • Replay. Solo un stream puede llenar un servicio de reportes nuevo con los eventos de los últimos 30 días, porque los registros viejos siguen ahí. Una cola ya los tiró.
↑ Volver al índice

Garantías de entrega: ¿cuántas veces llega?

Las redes fallan. Una carta se puede perder, y también el recibo que dice que la carta llegó. Así que un broker tiene que elegir qué promete sobre cuántas veces llega un mensaje. Hay tres promesas.

PromesaQué significaPrecio
At-most-once (como máximo una vez)Nunca reintenta. El mensaje llega una vez o no llega.Puede perder mensajes. Es la más barata.
At-least-once (al menos una vez)Reintenta hasta estar seguro. Nunca pierde un mensaje.Puede entregar duplicados. El consumidor debe saber manejarlos.
Exactly-once (exactamente una vez)Llega una vez y solo una.No se puede garantizar en una red que falla. Se construye a partir de las otras dos.

Por qué 'exactly once' es una trampa

El emisor manda un mensaje y no recibe respuesta. ¿Se perdió el mensaje o se perdió la respuesta? Desde el lado del emisor se ven idénticos. Solo reintentar evita perder el mensaje, pero reintentar puede crear un duplicado. Entonces lo que la gente hace en realidad es: entregar al menos una vez y hacer que el consumidor ignore los duplicados. El resultado es que el efecto ocurre una vez, aunque la entrega haya ocurrido dos. El efecto es exactly-once, la entrega no.

Dónde pones el ack decide la promesa

// A: ack primero, trabajo después -> at-most-once
await broker.ack(msg);
await sendEmail(msg.body);   // crash antes de esta línea: mensaje PERDIDO

// B: trabajo primero, ack después -> at-least-once
await sendEmail(msg.body);
await broker.ack(msg);       // crash antes de esta línea: mensaje REPETIDO
Dos líneas intercambiadas dan dos garantías distintas.

En la versión B hay una ventanita entre 'el trabajo está hecho' y 'el ack llegó'. Un crash en esa ventana repite un trabajo que ya estaba hecho, por ejemplo cobrar dos veces una tarjeta de crédito. No puedes cerrar esa ventana. Solo puedes volverla inofensiva con un consumidor idempotente (mira la sección de consumidores idempotentes).

Los duplicados también pueden empezar en el productor. Si el productor publica y la confirmación agota el tiempo, no sabe si el broker recibió el mensaje. Para no perderlo debe intentar otra vez, y ahora el broker puede tener dos copias.

Dos confirmaciones diferentes

Un broker tiene dos señales separadas de 'lo recibí'. El publisher confirm va del broker al productor: 'tu mensaje está guardado a salvo'. El consumer ack va del consumidor al broker: 'terminé de procesarlo'. Para estar seguro de punta a punta, necesitas las dos.

Sobrevivir a un reinicio o a una caída del broker

  • RabbitMQ: dos cosas separadas deben ser ciertas a la vez. La cola debe ser durable (sobrevive a un reinicio) y el mensaje debe ser persistente (se escribe en disco). Una sin la otra no basta.
  • `acks` del productor de Kafka: con acks=1, solo la copia del líder tiene el registro cuando responde, así que si el líder muere antes de que los seguidores copien, un registro confirmado desaparece. Con acks=all, el líder responde solo después de que todas las réplicas sincronizadas (in-sync) lo tienen.
  • Commit del consumidor de Kafka: Kafka solo recuerda el progreso de un grupo como un offset confirmado (committed). Confirmar después de procesar da at-least-once. Confirmar antes de terminar de procesar da at-most-once. El commit automático confirma lo que devolvió poll(), así que si pasas el trabajo a un hilo en segundo plano, registros que aún no terminan pueden confirmarse y luego perderse en un crash.
  • Las transacciones de Kafka hacen atómico 'leer offsets y escribir registros', pero solo dentro de Kafka. Un correo que ya salió por un servidor externo no se puede deshacer, así que los efectos fuera de Kafka siguen siendo at-least-once y necesitan su propia protección.
↑ Volver al índice

Orden y particionamiento

En una sola fila del cine, la gente entra en el orden en que llegó. Eso es FIFO (first in, first out: el primero que entra es el primero que sale). La promesa de orden más simple es justo esa: una cola y un consumidor que atiende un mensaje a la vez. Cualquier otro arreglo (varios consumidores, varias particiones, reintentos) cede un poco de orden a cambio de más velocidad o disponibilidad.

Una cola ordena el reparto, no el final del trabajo

Dos trabajadores toman el mensaje M1 ('crear cuenta 7') y el M2 ('cerrar cuenta 7') de la misma cola. M1 se entrega primero, pero su trabajo toma 300 ms. M2 toma solo 50 ms. Así que M2 termina primero, y la cuenta se cierra antes de existir. La cola repartió los mensajes en orden, pero los efectos ocurrieron en otro orden. La cura es hacer que los mensajes relacionados vayan al mismo consumidor.

Kafka: orden solo dentro de una partición

Un tópico de Kafka se divide en particiones, como una biblioteca dividida en estantes. Dentro de un estante los libros quedan en el orden en que se pusieron. Entre estantes no hay promesa. Así que Kafka garantiza orden por partición solamente. Con 3 particiones, un lector ve cada partición en orden, pero las tres pueden mezclarse de cualquier manera.

Para mantener juntos los eventos relacionados, das a cada registro una clave (key). El particionador por defecto calcula hash(clave) mod número de particiones, así que todo registro con la misma clave cae en la misma partición y conserva su orden. Sin clave, Kafka solo reparte los registros entre las particiones: genial para la velocidad, pero no hay orden entre registros relacionados.

clave "order-42" --hash--> partición 1  [placed][paid][shipped]
clave "order-77" --hash--> partición 3  [placed][paid]
clave "order-90" --hash--> partición 1  [placed]

Misma clave  =  misma partición  =  mismo orden.
Elige como clave la cosa cuya historia debe quedar en orden.

La clave debe ser la identidad de la cosa cuya historia debe quedar ordenada: el pedido, la cuenta, el dispositivo. Así los eventos de un pedido quedan en orden, mientras pedidos distintos corren en paralelo.

Amazon SQS

  • Cola standard: muy rápida, at-least-once, y el orden es solo de mejor esfuerzo (best effort).
  • Cola FIFO: entrega ordenada por grupo de mensajes, con un límite de rendimiento menor. El MessageGroupId cumple el papel de la clave de Kafka: un grupo por cliente da orden para cada cliente y paralelismo entre clientes.

Lo que rompe el orden

  • Varios consumidores en una cola, como se explicó arriba.
  • Reintento devolviendo el mensaje a la cola. M1 falla y vuelve al final; M2 y M3 funcionan; luego M1 funciona. Los efectos corrieron como M2, M3, M1. El orden estricto exigiría que el consumidor se detenga y reintente M1 hasta que funcione, lo que bloquea todo lo que viene detrás. No se puede tener orden estricto y reintentos sin bloqueo a la vez.
  • Agregar particiones. Digamos que hash("order-42") = 10. Con 4 particiones cae en la partición 2 (10 mod 4). Después de que el tópico crece a 6 particiones, cae en la partición 4 (10 mod 6). Los registros viejos no se mueven, así que el evento nuevo va a otro estante y el orden de esa clave se rompe alrededor del cambio. Planifica el número de particiones con anticipación.
  • Una clave caliente (hot key). Si un cliente genera el 80% de los registros, una partición recibe el 80% del trabajo. Su único consumidor se vuelve el cuello de botella, y ni más consumidores ni más particiones ayudan, porque una partición tiene un solo lector dentro de un grupo. La solución está en el diseño de la clave o en un manejador más rápido.
↑ Volver al índice

Confirmación, reintento y dead-letter queue

Cuando pides prestado un libro en la biblioteca, la bibliotecaria anota tu nombre en un cuaderno. Si nunca lo devuelves, ella sigue teniendo el registro y puede ir tras de ti. Un broker hace lo mismo con los mensajes. Hasta que el consumidor dice 'listo', el broker conserva el mensaje y lo entrega de nuevo si el consumidor desaparece. Ese 'listo' es la confirmación (ack).

Cuando el consumidor desaparece

En RabbitMQ con acks manuales, una conexión abierta funciona como un arriendo de los mensajes entregados a ella. Si la conexión se cae mientras un consumidor sostiene 5 mensajes sin confirmar, RabbitMQ devuelve los 5 a la cola y se los da a otro. Llegan con la marca redelivered, que avisa al siguiente consumidor de que el trabajo pudo haberse hecho a medias.

Amazon SQS funciona distinto, sin conexión que vigilar. Cuando un consumidor recibe un mensaje, SQS no lo elimina. Lo oculta durante el visibility timeout (por ejemplo 30 segundos). Si el consumidor borra el mensaje (la llamada DeleteMessage es el ack), desaparece para siempre. Si el tiempo se acaba antes, el mensaje vuelve a ser visible y otro consumidor lo recibe. Por eso el timeout debe ser mayor que el trabajo. Si el trabajo toma 45 segundos con un timeout de 30, un segundo consumidor empieza el mismo mensaje mientras el primero aún trabaja. Se puede extender el timeout mientras se trabaja.

Reintentar sin empeorar las cosas

Algunas fallas se van si esperas: una base de datos que se reinicia, un tropiezo de red. Son errores transitorios, y merecen reintentos. Otras nunca van a funcionar, como un mensaje al que le falta un campo obligatorio. Son errores permanentes, y reintentar solo gasta esfuerzo. Un buen consumidor primero clasifica el error: el transitorio va a reintento con espera, el permanente va directo a la dead-letter queue.

La espera debe crecer. El backoff exponencial duplica la espera después de cada falla, para dar tiempo a que un servicio enfermo se recupere. Con una base de 1 segundo, la espera antes del reintento n es 1 s x 2^(n-1).

Espera antes de cada reintento (base de 1 segundo)
Reintento 1
1 segundos
Reintento 2
2 segundos
Reintento 3
4 segundos
Reintento 4
8 segundos

El tiempo total esperado antes de que empiece el reintento 4 es 1 + 2 + 4 + 8 = 15 segundos. La fórmula general para n reintentos es base x (2^n - 1), que para base 1 y n = 4 da 15.

Hay un truco más. Imagina que 500 consumidores fallan en el mismo instante porque una API se cayó. Si todos usan el mismo backoff, todos reintentan en el mismo instante y vuelven a tumbar la API. Eso es una (thundering herd). El jitter es un poco de aleatoriedad que se suma a cada espera, para que la multitud se disperse.

Mensajes venenosos y la dead-letter queue

Un mensaje venenoso (poison message) falla siempre, por ejemplo un texto que no es JSON válido. Con reentrega automática se vuelve un bucle. En RabbitMQ, si el consumidor responde a la falla con una confirmación negativa (nack) y requeue = true, el mismo mensaje salta de vuelta a la cola y falla otra vez tan rápido como el consumidor puede fallar. Gasta CPU y retrasa a los mensajes sanos que vienen detrás.

La cura es un límite de reintentos y una dead-letter queue (DLQ): un lugar aparte donde se aparcan los mensajes que fallaron demasiadas veces o que nunca funcionarán. La cola principal sigue avanzando, y la evidencia se conserva para que una persona encuentre la causa y reenvíe el mensaje después.

  • RabbitMQ: una cola puede tener una dead-letter exchange (x-dead-letter-exchange). Un mensaje va allí cuando es rechazado sin requeue, cuando expira (TTL), cuando el límite de longitud de la cola lo empuja fuera, o cuando se supera el límite de entregas de una quorum queue. Devolver el mensaje a la cola no es una causa.
  • SQS: no existe una señal de 'fallé'. Una falla es solo un mensaje que se recibió y no se borró antes de que terminara el visibility timeout. Una redrive policy con maxReceiveCount: 3 cuenta las recepciones y mueve el mensaje a la DLQ después de que se recibió 3 veces y está a punto de recibirse una 4ª. En otras palabras, el consumidor lo ve 3 veces en la cola de origen.
↑ Volver al índice

Consumidores idempotentes: seguros de repetir

Apretar cinco veces el botón de un ascensor es lo mismo que apretarlo una. El ascensor ya viene. Eso es idempotencia: una operación es idempotente cuando hacerla muchas veces deja el sistema en el mismo estado final que hacerla una vez.

¿Por qué nos importa? Porque la entrega at-least-once manda duplicados. El broker promete que el mensaje llega. El consumidor debe prometer que llegar dos veces no hace daño.

Sentencia¿Idempotente?Por qué
UPDATE accounts SET balance = 100 WHERE id = 7SíFija un valor absoluto. Diez veces sigue dando 100.
UPDATE accounts SET balance = balance - 10 WHERE id = 7NoDepende del valor actual. Diez veces quita 100.
INSERT INTO log VALUES (...)NoAgrega una fila más cada vez.
Fijar el estado de un usuario en 'shipped'SíMismo estado final.

Cuando la operación no es idempotente por naturaleza, la volvemos así con una tabla de deduplicación: antes de hacer el trabajo, guarda el id del mensaje, y si ya lo viste, sáltalo.

BEGIN;
INSERT INTO processed_messages (message_id) VALUES ('m-81'); -- PRIMARY KEY
UPDATE accounts SET balance = balance - 10 WHERE id = 7;
COMMIT;
-- Una segunda entrega hace fallar el INSERT por clave duplicada:
-- se detiene toda la transacción y el mensaje se salta.
La marca y el efecto están en la misma transacción.

Tres detalles que importan

  • Misma transacción. La marca y el efecto deben guardarse juntos. Así el registro y el efecto nunca discrepan.
  • Deja que decida la base de datos. Si haces 'ver si ya lo vi' y luego 'escribir' como dos pasos, dos copias del mensaje que llegan en el mismo momento pasan las dos la verificación, y las dos aplican el efecto. Esto es una carrera check-then-act. Una clave primaria o una restricción única sobre el id del mensaje lo arregla, porque la base de datos rechaza el segundo insert de forma atómica.
  • Un solo almacén, no dos. Si marcas en Redis y aplicas el efecto en PostgreSQL, un crash entre los dos te deja atascado. Marcar primero puede perder el efecto para siempre. Aplicar primero puede hacerlo dos veces. Dos almacenes distintos no pueden confirmarse como uno solo. Mantén la marca y el efecto en la misma transacción de la base de datos.

¿Qué es una buena clave de idempotencia?

La clave debe identificar el evento de negocio, no la entrega. Créala una vez, donde nace el evento (por ejemplo, un id de solicitud de pago hecho por el cliente), y deja que viaje dentro del mensaje. Así todo duplicado lleva el mismo valor. El delivery tag del broker o un timestamp cambia en cada entrega, así que nunca coincidiría.

¿Por cuánto tiempo hay que recordar?

Si guardas los ids procesados con caducidad (un TTL de 1 hora en Redis, por ejemplo), solo estás protegido dentro de esa ventana. Supón que el broker estuvo caído por mantenimiento y un duplicado de un mensaje procesado a las 09:00 llega a las 11:30. El id ya se olvidó, y el trabajo se hace de nuevo. La ventana debe ser mayor que el mayor retraso con que un duplicado puede volver, incluidas las caídas y los replays.

↑ Volver al índice

Backpressure: cuando quien envía es más rápido

Imagina un embudo. Si viertes agua más rápido de lo que sale, el embudo se desborda. Un buffer entre un productor y un consumidor es un embudo. Una ráfaga corta cabe dentro. Pero si el productor es más rápido durante mucho tiempo, cualquier buffer se llena.

Backpressure (contrapresión) es la señal que viaja hacia atrás, de la etapa lenta a la rápida, diciendo 'espera, estoy lleno'. Mantiene la memoria acotada frenando la fuente en lugar de apilar datos en el medio.

La aritmética de un backlog

Un backlog crece con la diferencia entre la tasa de llegada y la tasa de procesamiento. Si un productor envía 500 mensajes por segundo y el único consumidor atiende 300 por segundo, la cola crece 200 por segundo.

Mensajes esperando en una cola sin límite (200 de más por segundo)
Después de 1 minuto
12,000 mensajes
Después de 10 minutos
120,000 mensajes
Después de 1 hora
720,000 mensajes

Un buffer sin límite no resuelve este problema. Solo lo pospone y hace más grande la caída: la memoria crece hasta que el proceso muere. Cuando el productor es más rápido de forma permanente, solo hay tres opciones: crecer (hasta que se acabe la memoria), descartar mensajes, o frenar al productor. La tercera es el backpressure.

Tres lugares donde aplicarlo

  • Dentro de un programa: una cola acotada cuyo push espera cuando está llena. El productor se detiene solo, y la memoria se mantiene estable.
  • Entre un broker y un consumidor, con prefetch. RabbitMQ empuja mensajes a los consumidores, así que sin límite empuja todo. El prefetch count (basic.qos) dice: nunca más de esta cantidad de mensajes sin confirmar por consumidor. Con prefetch(10), el broker envía 10 y solo envía el siguiente cuando llega un ack. El backlog espera en el broker, que está hecho para guardarlo y puede compartirlo con otros consumidores.
  • Entre etapas, con demanda. En GenStage de Elixir, los datos bajan y la demanda sube. Un consumidor dice 'envíame hasta 10' (max_demand) y el productor envía como máximo eso. La etapa más lenta marca la velocidad del pipeline, y los buffers quedan tan pequeños como los números que configuraste.
Mensajes retenidos por un consumidor lento (3.000 en cola, medido en el laboratorio de este repositorio)
Sin límite de prefetch
2,908 mensajes
prefetch(10)
10 mensajes

Los números de arriba se midieron en el mini-proyecto pubsub-backpressure de este repositorio: la cantidad de trabajo hecho fue la misma, solo cambió el lugar donde esperó el backlog.

Por eso un consumidor sin límite de prefetch es peligroso: el primer consumidor que se conecta se queda con casi toda la cola en su memoria, y un segundo consumidor que se conecta después no encuentra nada que hacer. Con límite, el backlog se queda en el broker, donde todos pueden compartirlo.

↑ Volver al índice

RabbitMQ: exchanges, colas y enrutamiento

RabbitMQ es un broker que sigue el modelo AMQP. Tiene una sorpresa que confunde a todo principiante: un productor nunca escribe en una cola. Envía el mensaje a una exchange, y la exchange decide qué colas reciben una copia.

              routing key = "invoice.created"
producer ---> [ EXCHANGE ] --binding--> [ cola A ] --> consumer
                           \--binding--> [ cola B ] --> consumer

binding = el enlace entre una exchange y una cola,
          a menudo con una binding key para comparar.
Productor, exchange, bindings, colas, consumidores.

Tres piezas forman el mapa de enrutamiento: exchanges, colas y bindings. Un binding es el enlace entre una exchange y una cola, y puede llevar una binding key que la exchange compara con la routing key del mensaje.

Tipos de exchange

TipoCómo decideEjemplo
directLa routing key es exactamente igual a la binding keyla clave sms va a la cola enlazada con sms
fanoutIgnora la clave y copia a toda cola enlazadaTres colas enlazadas: las tres reciben todo mensaje, aunque la clave sea invoice.created
topicCompara la clave con un patrón de palabras separadas por puntosPatrón shop.*.paid
default exchangeUna direct exchange sin nombre (nombre "") donde toda cola ya está enlazada por su propio nombrePublicar en "" con la clave emails va a la cola emails

En una exchange topic, la clave se compone de palabras separadas por puntos. La estrella * reemplaza exactamente una palabra. El numeral # reemplaza cero o más palabras. La forma confiable de evaluar un patrón es contar palabras. Toma la clave shop.eu.paid (3 palabras):

Patrón del binding¿Coincide?Por qué
shop.*.paidSíUna palabra entre shop y paid
shop.#Sí# toma el resto, cualquier cantidad de palabras
*.paidNoSolo 2 palabras; la clave tiene 3
#.paidSí# toma shop.eu
shop.*No* es una palabra, pero sobran 2

¿Cola o copia? Cuenta las colas

En RabbitMQ una copia existe por cola, no por consumidor. Dos consumidores en la misma cola forman una cola de trabajo: los mensajes se reparten por turnos y cada uno llega a un solo consumidor. Si audit, search y mail deben recibir cada user.registered, da a cada servicio su propia cola y enlázalas todas a una exchange (fanout, o topic con el mismo patrón).

Un suscriptor que debe recibir lo publicado mientras se reiniciaba necesita una cola durable y con nombre. Las colas temporales (exclusive, auto-delete, con nombre dado por el servidor) son para suscriptores que solo se interesan por los mensajes mientras están conectados.

La falla silenciosa: mensajes sin ruta

Si un mensaje se publica en una direct exchange con la clave sms, y ninguna cola está enlazada con esa clave, la publicación tiene éxito y el mensaje simplemente desaparece. Para notarlo, activa la bandera mandatory (el broker devuelve el mensaje al productor) o configura una alternate exchange que recoja lo que no pudo enrutarse.

Dead-letter exchanges y quorum queues

Una cola con x-dead-letter-exchange envía allí los mensajes muertos: rechazados sin requeue, expirados, empujados por el límite de longitud, o por encima del límite de entregas de una quorum queue (la sección anterior sobre reintentos tiene los detalles).

Una quorum queue se replica en varios servidores con el algoritmo Raft. Una escritura cuenta cuando la mayoría de los miembros la tiene, y un líder necesita la mayoría para ser elegido. Un grupo de n miembros sobrevive a la pérdida de floor((n - 1) / 2) de ellos.

Miembros que pueden estar caídos sin que la quorum queue deje de estar disponible
3 miembros
1 miembros
5 miembros
2 miembros
7 miembros
3 miembros
↑ Volver al índice

Kafka: tópicos, particiones, consumer groups y offsets

Kafka es el stream de la sección anterior, hecho realidad. Un tópico (topic) es una categoría de registros con nombre, como una carpeta llamada orders. Cada tópico se divide en particiones, y cada partición es un log ordenado, append-only, guardado en los brokers. Las particiones son lo que permite escribir y leer un tópico en paralelo.

tópico "orders"

partición 0:  [0][1][2][3][4][5] -> los registros nuevos entran aquí
partición 1:  [0][1][2][3]       -> los registros nuevos entran aquí
partición 2:  [0][1][2][3][4]    -> los registros nuevos entran aquí

(partición, offset) = la dirección exacta de un registro
Cada registro recibe un número en su partición: el offset.

Un offset es el número secuencial que recibe un registro al agregarse a una partición. El par (partición, offset) identifica un registro. El progreso de un consumidor es solo un marcador: el próximo offset que leerá en cada partición.

Consumer groups: competir y difundir a la vez

Un consumer group es la manera de Kafka de hacer consumidores competidores. Las particiones del tópico se reparten entre los miembros del grupo, y en cada momento cada partición tiene un solo lector dentro de ese grupo. Tres consumidores en un grupo, en un tópico de 3 particiones, se quedan con una partición cada uno.

Los grupos distintos son independientes. Si los grupos billing y analytics leen orders con 1.000 registros, cada grupo lee los 1.000. Así que dentro de un grupo los consumidores compiten (comportamiento de cola), y entre grupos todos reciben todo (comportamiento de pub/sub).

  • Las particiones son el techo del paralelismo. Un tópico con 4 particiones y un grupo de 6 consumidores: solo 4 reciben registros. Los otros 2 quedan de reserva, esperando para reemplazar a un miembro que falle.
  • Consumir no borra. Los registros se quedan el tiempo de retención configurado y luego se eliminan, los haya leído alguien o no. Por eso varios grupos pueden leer los mismos datos, y un grupo puede retroceder y leer otra vez.
  • Sin clave, sin orden entre registros relacionados, como vimos en la sección de orden.

Offsets confirmados, rebalance y reinicios

Un grupo guarda su progreso al confirmar (commit) un offset: el próximo offset a leer. Si un consumidor se cae, o un miembro entra o sale, el grupo hace un rebalance: las particiones se reparten de nuevo. El nuevo dueño de una partición empieza desde el último offset confirmado.

Imagina que un consumidor procesó los offsets 40 a 49, pero su último commit decía 'próximo a leer: 45'. Después de un reinicio retoma desde el 45, así que los registros 45 a 49 se procesan por segunda vez. Confirmar con frecuencia cuesta más; confirmar rara vez significa más reprocesamiento.

¿Y si un grupo nuevo no tiene ningún offset confirmado? La configuración auto.offset.reset responde solo esa pregunta. earliest empieza en el registro disponible más antiguo. latest empieza al final, así que un grupo nuevo con latest ignora los 10.000 registros viejos y lee solo lo que se produzca después de que se unió. Una vez que el grupo ha confirmado, esa configuración ya no se usa.

Lag: ¿cuánto atraso llevamos?

El log end offset es el offset que recibirá el próximo registro producido. El consumer lag es ese número menos el offset confirmado del grupo. Si el log end es 1.500 y el grupo confirmó 1.320, el lag es de 180 registros. Un lag que no deja de crecer significa que los consumidores son más lentos que los productores.

No perder datos: replicación

Cada partición se copia en varios brokers (factor de replicación, por ejemplo 3). Las copias al día son las réplicas sincronizadas (in-sync replicas). Con acks=all, el líder responde solo después de que todas las réplicas sincronizadas tienen el registro. La configuración min.insync.replicas=2 agrega una regla: si hay menos de 2 réplicas sincronizadas, la partición rechaza las escrituras en lugar de aceptar datos que una falla más perdería. Elige seguridad antes que disponibilidad.

Compactación y el productor idempotente

Con cleanup.policy=compact, Kafka conserva solo el último registro de cada clave, como una tabla. Un registro con clave y valor null es un tombstone (lápida), y borra esa clave. Así un tópico con user-1: Lisboa, luego user-1: Porto, luego user-1: null termina, tras la compactación, sin ningún user-1.

enable.idempotence=true vuelve seguros los reintentos internos del propio productor: si reenvía un lote después de un timeout, el broker nota el número del lote y no lo guarda dos veces. No elimina los duplicados hechos por la aplicación, por otro proceso o por la reentrega al consumidor. Esos aún necesitan un consumidor idempotente.

↑ Volver al índice

BullMQ sobre Redis

A veces no quieres un broker grande. Solo quieres decir 'envía este correo después' desde un programa Node.js. BullMQ es una biblioteca que convierte Redis (una base de datos muy rápida que vive en memoria) en una cola de jobs. Un job es una tarea con algunos datos.

  • Un objeto `Queue` escribe jobs en Redis (usa listas, conjuntos ordenados y hashes por debajo).
  • Un `Worker`, en cualquier proceso, toma jobs de Redis y ejecuta una función (el procesador) para cada uno.
  • Redis es el broker. BullMQ no tiene servidor propio.
const queue = new Queue("payments", { connection });
await queue.add("charge", { orderId: 7 }, {
  attempts: 3,                                   // corre como máximo 3 veces
  backoff: { type: "exponential", delay: 1000 }, // 1 s, luego 2 s
  delay: 60_000,                                 // no antes de 60 s
});

new Worker("payments", async (job) => {
  await gateway.charge(job.data);
}, { connection, concurrency: 5 });
Opciones de un job y de un worker de BullMQ.

Qué significan las opciones

  • `delay`: el job no puede correr antes de ese tiempo. BullMQ lo guarda en un conjunto ordenado por la hora de vencimiento y lo pasa a la lista de espera cuando llega la hora. El delay es un mínimo, no un horario exacto.
  • `attempts`: cuenta toda ejecución del job, la primera incluida. Con attempts: 3 y un procesador que siempre lanza un error, el job corre 3 veces en total.
  • `backoff`: la espera entre intentos (fija o exponencial). Cuando se acaban los intentos, el job pasa al conjunto failed, que hace el papel de una dead-letter queue.
  • `concurrency`: cuántos jobs atiende un worker al mismo tiempo. En Node.js esto significa varias llamadas asíncronas que comparten un event loop, lo ideal para E/S como enviar correo. El paralelismo total es la concurrency multiplicada por el número de procesos worker.

Redis debe estar configurado para conservar los jobs

Redis guarda los datos en memoria. Si corre con la persistencia desactivada (sin snapshots RDB y sin log AOF) y se reinicia, todos los jobs en espera desaparecen. Para una cola quieres la persistencia activada (AOF o RDB), y quieres maxmemory-policy noeviction, para que cuando la memoria se llene Redis rechace nuevas escrituras en lugar de tirar claves que guardan jobs.

Jobs atascados: el lock

Mientras un worker procesa un job, mantiene un lock sobre él y lo renueva cada cierto tiempo, como alguien en el mostrador de la biblioteca que sigue diciendo 'todavía estoy usando este libro'. Si el worker muere, nadie renueva. El lock expira, BullMQ declara el job stalled (atascado) y lo devuelve para que se procese de nuevo.

↑ Volver al índice

Amazon SQS y SNS

Amazon Web Services ofrece dos servicios simples de mensajería, y son dos formas distintas de la primera sección.

CaracterísticaSQS (Simple Queue Service)SNS (Simple Notification Service)
FormaUna cola punto a puntoUn tópico de publish/subscribe
Cómo se entregan los mensajesPull: los consumidores piden los mensajesPush: el servicio envía a cada suscriptor
Quién recibe el mensajeUn consumidorTodos los endpoints suscritos (colas, funciones, direcciones HTTP)
¿Guarda mensajes para después?Sí, hasta que se borran o expiranNo, solo los reenvía

Cómo funciona un consumidor de SQS

  • Recibir un mensaje: SQS no lo elimina, lo oculta durante el visibility timeout.
  • Procesarlo.
  • Borrarlo con DeleteMessage y el receipt handle. Esa llamada es el ack.
  • Si olvidas borrarlo, el mensaje vuelve después del timeout y se procesa una y otra vez.
const { Messages } = await sqs.send(new ReceiveMessageCommand({
  QueueUrl,
  WaitTimeSeconds: 20,        // long polling
}));
for (const m of Messages ?? []) {
  await sendEmail(m.Body);
  await sqs.send(new DeleteMessageCommand({
    QueueUrl, ReceiptHandle: m.ReceiptHandle,   // el ack
  }));
}

Long polling frente a short polling

Con short polling, una cola vacía responde de inmediato, así que un bucle hace muchas solicitudes inútiles, y pagas por solicitudes. Con long polling (WaitTimeSeconds hasta 20), la solicitud se mantiene abierta hasta que llega un mensaje o se acaba el tiempo. Cuesta menos, y el mensaje igual se entrega en el instante en que llega.

Colas standard y FIFO

Una cola standard es muy rápida, pero entrega at-least-once con orden de mejor esfuerzo. Una cola FIFO mantiene el orden por grupo de mensajes (MessageGroupId) y además elimina duplicados del productor: dos envíos con el mismo MessageDeduplicationId dentro de 5 minutos cuentan como un solo mensaje. Esto vuelve seguros los reintentos del productor dentro de ese intervalo, pero no reemplaza a un consumidor idempotente.

La receta estándar: SNS más SQS

En AWS, OrderPlaced debe llegar a cobro, envío y analítica, y cada uno debe trabajar a su ritmo y sobrevivir a estar desconectado. La respuesta estándar es publicar en un tópico SNS y suscribir una cola SQS por servicio. El tópico reparte, y cada cola guarda su copia hasta que el servicio esté listo.

               +--> [SQS cobro]    --> servicio de cobro
OrderPlaced -> [SNS]--> [SQS envío]    --> servicio de envío
               +--> [SQS analítica] --> servicio de analítica
Fan-out con suscripciones durables. Es la misma forma que una fanout exchange de RabbitMQ con una cola por servicio.
↑ Volver al índice

Outbox, saga y event sourcing

Ahora tres patrones que los sistemas grandes usan sobre la mensajería. Cada uno resuelve un problema que aparece cuando varios servicios deben mantenerse consistentes.

El outbox: guardar y anunciar juntos

Un servicio de pedidos debe guardar el pedido en su base de datos y publicar OrderPlaced. Son dos sistemas distintos, y ninguna transacción única cubre los dos. Si guarda y se cae antes de publicar, el evento se pierde. Si publica primero y el guardado falla, anunció algo que no ocurrió.

El transactional outbox lo arregla. Dentro de una sola transacción de la base de datos, el servicio guarda el pedido y una fila en una tabla outbox que contiene el evento. Un programa aparte, el relay, lee las filas no enviadas, las publica y las marca como enviadas.

for (const row of unsentRows) {
  await broker.publish(row.event);                      // 1
  // <- un crash aquí significa: publicado, pero no marcado
  await db.query("UPDATE outbox SET sent = true WHERE id = $1", [row.id]); // 2
}
El relay de un outbox.

El outbox garantiza que el evento nunca se pierde, porque se guardó junto con los datos de negocio. No garantiza una única publicación: si el relay se cae entre las líneas 1 y 2, el evento se publica de nuevo al reiniciar. Los consumidores posteriores deben deduplicar.

La saga: una historia larga entre servicios

En un checkout, el pedido, el pago y el envío viven en servicios distintos, cada uno con su base de datos. No existe una transacción gigante. Una saga es una secuencia de pasos locales. Si un paso falla, los pasos anteriores se deshacen con compensaciones (por ejemplo, un reembolso). Hay dos estilos:

EstiloCómo funcionaBuenoCosto
CoreografíaSin jefe. Cada servicio reacciona a los eventos de los otros: OrderPlaced, luego PaymentCompleted, luego el envíoBajo acoplamiento, ninguna pieza centralTodo el proceso queda repartido y es difícil de ver
OrquestaciónUn coordinador envía comandos a cada servicio y guarda el estadoEl flujo es explícito y está en un solo lugarUn componente central que debe mantenerse funcionando

Event sourcing: guardar lo que pasó

Un sistema normal guarda el estado actual: 'saldo = 70'. Un sistema con event sourcing guarda cada evento: 'depositó 100', 'retiró 30'. El log de eventos es la fuente de la verdad. El estado actual, y cualquier cantidad de reportes (read models), se calculan reproduciendo los eventos.

Reproducir un millón de eventos cada vez sería lento, así que guardamos un snapshot: el estado en una posición conocida del log. Para cargar una cuenta con 1.000.000 de eventos y un snapshot después del evento 999.900, carga el snapshot y reproduce solo los 100 eventos posteriores. Un snapshot es solo un caché, ya que los eventos siguen siendo la verdad. Si se pierde, se puede calcular de nuevo reproduciendo el log.

↑ Volver al índice

Juntando todo

Comparemos las herramientas en un solo lugar y luego enumeremos los errores que más ocurren.

Una interfaz puede ocultar la API de los brokers, nunca sus garantías.
CaracterísticaBullMQRabbitMQKafkaSQS
Dónde vive el mensajeListas y conjuntos de RedisUna cola dentro del brokerUna partición de un log, conservada por la retenciónUna cola detrás de una API HTTP
Cómo lo recibe el consumidorEl worker lo toma de RedisEl broker lo empuja, hasta el prefetchEl consumidor lo pide desde un offsetEl consumidor hace polling
Qué es el ackJob movido a completedack de la entregaCommit del próximo offsetDeleteMessage
Cómo se nota un consumidor muertoLock no renovado (stalled)La conexión se cierraLos heartbeats se detienen, la sesión expiraNada: el visibility timeout termina
OrdenUna cola, FIFOUna cola, FIFO (un consumidor)Por particiónMejor esfuerzo (standard), por grupo (FIFO)

El terreno común

Los cuatro son at-least-once en la práctica: un consumidor que muere antes del ack hace que el mensaje vuelva. La diferencia es cuánto tarda: de inmediato en RabbitMQ, tras un timeout en los demás. La consecuencia práctica es la misma en todas partes: un manejador puede correr dos veces para un mensaje, así que su efecto debe ser idempotente.

Una lista de verificación para un consumidor seguro

  1. Decide la garantía: ¿puedo perder, o puedo repetir?
  2. Da el ack solo después de que el trabajo esté guardado.
  3. Haz el trabajo idempotente (tabla de deduplicación con clave única, en la misma transacción).
  4. Clasifica los errores: reintenta los transitorios con backoff y jitter, manda los permanentes a la dead-letter queue.
  5. Limita los intentos y ten una dead-letter queue.
  6. Limita lo que retiene el consumidor (prefetch, buffers acotados).
  7. Si el orden importa, elige la clave, la partición o el grupo de mensajes a propósito.

Errores comunes

  • Creer que la 'entrega exactly-once' existe y saltarse la idempotencia.
  • Creer que más consumidores o más particiones siempre arreglan a un lector lento. No lo hacen cuando el problema es una hot key o una sola partición.
  • Contar consumidores en lugar de colas o grupos al predecir cuántas veces se procesa un mensaje.
  • Reintentar para siempre, sin backoff, sin jitter y sin DLQ.
  • Usar un buffer sin límite o ningún límite de prefetch.
  • Poner la marca de deduplicación y el efecto en dos almacenes distintos.
  • Olvidar que una cola de RabbitMQ y sus mensajes deben ser ambos durables, y que Redis necesita persistencia para BullMQ.
↑ Volver al índice