DEV Community

Cover image for RabbitMQ de los fundamentos a producción, Parte 1: exchanges, colas y bindings
Juan Gómez
Juan Gómez

Posted on

RabbitMQ de los fundamentos a producción, Parte 1: exchanges, colas y bindings

RabbitMQ de los fundamentos a producción, Parte 1: exchanges, colas y bindings

Aurora Coffee Co. recibe un pedido y hay cuatro cosas que tienen que pasar: cobrar la tarjeta, reservar el café, enviar el recibo por correo y registrar la venta para el equipo de analítica. Si resuelves eso con cuatro llamadas HTTP dentro del handler del checkout, acabas de construir una máquina con cuatro puntos únicos de fallo. El servicio de analítica se redespliega, su socket rechaza la conexión, y un cliente que pagó sin problema recibe un 500.

El primer impulso es lanzarlas sin esperar respuesta ni revisar si fallaron, para que el checkout deje de esperarlas. Eso arregla la latencia y conserva el error: ya nadie se acuerda de que el recibo nunca salió. Un broker de mensajes existe exactamente para acordarse. Este artículo trata de la parte de RabbitMQ que todo el mundo se salta camino al "hola mundo" —el modelo de enrutamiento— porque equivocarse ahí es lo que hace que la gente concluya que las colas son impredecibles.

Una cola no es lo mismo que async

async/await y una tarea en segundo plano sacan el trabajo del hilo de la petición. No lo sacan del proceso. Lo que programaste vive en la memoria de tu servicio, y en el momento en que ese pod se reprograma, el trabajo desaparece sin dejar ni rastro de que existió.

Un broker cambia quién es el dueño del trabajo. El productor le entrega el mensaje a RabbitMQ y recibe una confirmación de que ahora es RabbitMQ quien lo tiene. A partir de ahí:

  • Sobrevive a tu proceso. Redespliega el consumidor a mitad de un lote y los mensajes sin confirmar vuelven a la cola.
  • Sobrevive al reinicio del broker, si lo pediste — colas duraderas y mensajes persistentes, dos ajustes distintos que se confunden todo el tiempo y que veremos más abajo.
  • Puede ir a más de un sitio. Un solo mensaje de "pedido creado", cuatro consumidores independientes, y ninguno sabe que los otros existen.
  • Amortigua los picos en vez de caerse. Un pico que habría tumbado al servicio de analítica se convierte en una cola que crece y luego se vacía.

Ahí está la contrapartida, y conviene decirla sin adornos: una cola no vuelve rápido el trabajo lento. Lo saca de la petición, lo deja para después, y te da un número —la profundidad de la cola— que te avisa cuando ese "después" dejó de llegar.


El modelo: broker, exchange, cola, binding

Esta es la regla que los tutoriales entierran, y es el artículo entero:

Un productor nunca publica en una cola. Publica en un exchange.

El productor nombra un exchange y le adjunta una routing key: una cadena corta que describe qué pasó. El exchange no guarda mensajes. Su único trabajo es mirar sus bindings y decidir qué colas reciben una copia. Si ningún binding coincide, no se hace ninguna copia y el mensaje se descarta.

Cuatro piezas, y cada una tiene exactamente una responsabilidad:

Pieza Trabajo Qué conoce
Exchange Recibe los mensajes publicados y los enruta Sus propios bindings
Binding Una regla que une un exchange con una cola, normalmente con una binding key Un exchange y una cola
Cola Guarda los mensajes hasta que un consumidor los confirma Nada de lo que hay arriba
Consumidor Lee de una cola y confirma Una cola

La ganancia es que los productores y los consumidores nunca aprenden el nombre del otro. El checkout publica order.us.placed en el exchange orders. Si ese mensaje lo lee un consumidor, cuatro, o ninguno, lo deciden por completo los bindings, que puedes agregar y quitar en caliente sin redesplegar el productor. Sumar el consumidor nuevo del equipo antifraude a un flujo de eventos que ya existe es una llamada a bindQueue, no un proyecto.

Routing key y binding key no son lo mismo

Dos nombres para dos cosas distintas, y confundirlos es la causa número uno de "¿por qué mi cola está vacía?":

  • La routing key la pone el productor, mensaje por mensaje, al publicar.
  • La binding key se pone en el binding, una sola vez, cuando conectas una cola a un exchange.

El exchange compara una con la otra. Cómo las compara es lo que decide el tipo de exchange.


Primero levanta RabbitMQ en local

Todo lo que viene abajo se puede ejecutar, así que levanta el broker antes de seguir leyendo. Un archivo, sin cuenta en la nube y sin registrarte en ningún servicio administrado:

# docker-compose.yml
services:
  rabbitmq:
    image: rabbitmq:4-management
    container_name: aurora-rabbitmq
    ports:
      - "5672:5672"    # AMQP — aquí se conecta tu app
      - "15672:15672"  # UI de administración — aquí te conectas tú
    environment:
      RABBITMQ_DEFAULT_USER: aurora
      RABBITMQ_DEFAULT_PASS: aurora-dev
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  rabbitmq-data:
Enter fullscreen mode Exit fullscreen mode
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

En el tag -management vale la pena insistir. Abre http://localhost:15672 (aurora / aurora-dev) y tienes una vista en vivo de cada exchange, cada cola y cada binding, además de la profundidad y el ritmo de cada cola. Es de los pocos paneles de administración que de verdad te alegra encontrar, porque "¿mi binding está mal?" deja de ser una adivinanza: la pestaña Exchanges te muestra exactamente qué binding keys tiene registradas.

El proyecto en sí son dos dependencias:

{
  "name": "aurora-orders-queue",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "dependencies": { "amqplib": "^0.10.5" },
  "devDependencies": { "@types/amqplib": "^0.10.6", "typescript": "^5.9.0" }
}
Enter fullscreen mode Exit fullscreen mode

Node 24 ejecuta archivos TypeScript directamente, así que cada fragmento de aquí corre con node src/publisher.ts: sin paso de compilación y sin tsx. Por eso mismo los imports de abajo llevan la extensión .ts explícita — Node resuelve el archivo real, no reescribe el especificador por ti. (Qué cubre y qué no cubre esa capacidad da para un artículo propio, que llega más adelante este mes.)


Los tres tipos de exchange que importan

RabbitMQ trae cuatro tipos. Tres cubren prácticamente todo lo que vas a construir.

fanout — todos reciben una copia

Un exchange fanout ignora por completo la routing key y copia cada mensaje a todas las colas asociadas. Este es el caso de "se creó un pedido y a cuatro equipos les interesa":

await channel.assertExchange('order.events', 'fanout', { durable: true });

await channel.assertQueue('inventory.reserve', { durable: true });
await channel.assertQueue('analytics.ingest', { durable: true });

// Fanout ignora la routing key, así que la binding key también se ignora.
// Pasa una cadena vacía en vez de inventar un valor que confunda a quien lea esto después.
await channel.bindQueue('inventory.reserve', 'order.events', '');
await channel.bindQueue('analytics.ingest', 'order.events', '');

channel.publish('order.events', '', payload, { persistent: true });
Enter fullscreen mode Exit fullscreen mode

Las dos colas reciben ahora su propia copia independiente. Que inventory.reserve confirme un mensaje no tiene ningún efecto sobre la copia que está en analytics.ingest: colas separadas, vidas separadas, fallos separados. Fanout es la forma más barata de sumar un consumidor a un evento más adelante sin tocar el productor.

direct — coincidencia exacta de la cadena

Un exchange direct entrega a las colas cuya binding key es igual a la routing key, carácter por carácter:

await channel.assertExchange('receipts', 'direct', { durable: true });

await channel.assertQueue('receipts.email', { durable: true });
await channel.assertQueue('receipts.sms', { durable: true });

await channel.bindQueue('receipts.email', 'receipts', 'email');
await channel.bindQueue('receipts.sms', 'receipts', 'sms');

// Cae solo en receipts.email. receipts.sms no lo ve nunca.
channel.publish('receipts', 'email', payload, { persistent: true });
Enter fullscreen mode Exit fullscreen mode

Vale la pena saberlo: nada impide que dos colas compartan la misma binding key en un exchange direct, y si lo hacen, las dos reciben copia. Direct significa "coincidencia exacta", no "exactamente un destino".

topic — coincidencia por patrón, y el que vas a usar de verdad

Un exchange topic trata la routing key como palabras separadas por puntos y admite binding keys con dos comodines. * coincide con exactamente una palabra; # coincide con cero o más palabras.

Adopta desde el principio una convención para las routing keys —<entidad>.<calificador>.<evento> funciona bien— y los comodines hacen el resto. Con productores publicando order.us.placed, order.mx.placed y order.us.payment.failed:

Binding key Con qué coincide
order.us.placed solo con esa routing key, con nada más
order.*.placed con order.us.placed y order.mx.placed, pero no con order.us.payment.failed
order.us.# con toda clave bajo order.us, incluida order.us.payment.failed
# con todo lo que se publique en el exchange
await channel.assertExchange('orders', 'topic', { durable: true });

// A facturación solo le interesan los pedidos nuevos, de cualquier región.
await channel.bindQueue('orders.billing', 'orders', 'order.*.placed');

// Al log de auditoría le interesa el flujo completo y ya se organiza solo.
await channel.bindQueue('orders.audit', 'orders', '#');
Enter fullscreen mode Exit fullscreen mode

Un exchange topic con un binding # se comporta igual que un fanout, así que lo razonable es declarar todos los exchanges como topic desde el día uno y dejar que los bindings decidan. Lo único que pierdes es un costo de enrutamiento despreciable, y conservas la posibilidad de acotar un consumidor después sin volver a crear el exchange.

(El cuarto tipo, headers, hace la coincidencia sobre las cabeceras del mensaje en vez de la routing key. Existe para cuando el enrutamiento depende de varios atributos independientes que no se dejan combinar en una sola cadena. Eso es poco común; recurre a él cuando de verdad te haga falta.)

El exchange por defecto, y por qué parece que todos los tutoriales te mienten

Todos los "hola mundo" que has leído publican directamente contra un nombre de cola:

channel.sendToQueue('orders.billing', payload, { persistent: true });
Enter fullscreen mode Exit fullscreen mode

Eso no rompe la regla. Es exactamente equivalente a esto:

channel.publish('', 'orders.billing', payload, { persistent: true });
Enter fullscreen mode Exit fullscreen mode

Esa cadena vacía es el exchange por defecto: un exchange direct al que toda cola queda asociada automáticamente, usando su propio nombre como binding key. Publicar en una cola no existe; lo que existe es un exchange sin nombre que lo hace de tu parte.

Para una cola de trabajos con un único consumidor está perfectamente bien. También es la puerta por la que vuelve el acoplamiento: el productor ahora deja fijo en el código el nombre de la cola del consumidor, que es justo lo que querías evitar al meter un broker. Úsalo para una cola de tareas de la que controlas los dos extremos, y nombra un exchange de verdad para cualquier cosa que se parezca a un evento.


El consumidor, y la confirmación que lo decide todo

El enrutamiento mete el mensaje en una cola. Lo que pasa después lo decide una sola llamada dentro de tu handler.

// src/topology.ts
import type { Channel } from 'amqplib';

export const ORDERS_EXCHANGE = 'orders';
export const RETRY_EXCHANGE = 'orders.retry';
export const DEAD_EXCHANGE = 'orders.dead';

export const BILLING_QUEUE = 'orders.billing';
export const BILLING_RETRY_QUEUE = 'orders.billing.retry';
export const BILLING_DEAD_QUEUE = 'orders.billing.dead';

export const RETRY_DELAY_MS = 30_000;

/**
 * Declarar la topología es idempotente, así que cada proceso declara la topología
 * completa al arrancar en vez de depender de quién arrancó primero. Tenerla en un
 * solo módulo evita que el productor y el consumidor terminen en desacuerdo sobre
 * los bindings.
 */
export async function assertTopology(channel: Channel): Promise<void> {
  await channel.assertExchange(ORDERS_EXCHANGE, 'topic', { durable: true });
  await channel.assertExchange(RETRY_EXCHANGE, 'topic', { durable: true });
  await channel.assertExchange(DEAD_EXCHANGE, 'topic', { durable: true });

  await channel.assertQueue(BILLING_QUEUE, {
    durable: true,
    arguments: {
      // Las colas quorum son el valor por defecto recomendado en RabbitMQ 4.x:
      // replicadas por Raft, y ponen su propio tope a las reentregas (x-delivery-limit).
      'x-queue-type': 'quorum',
      // Un mensaje reentregado esta cantidad de veces pasa a dead-letter en vez de
      // seguir dando vueltas. Sin un exchange de dead-letter configurado, se descarta.
      'x-delivery-limit': 5,
      'x-dead-letter-exchange': DEAD_EXCHANGE,
    },
  });
  await channel.bindQueue(BILLING_QUEUE, ORDERS_EXCHANGE, 'order.*.placed');

  // Retiene un mensaje fallido durante RETRY_DELAY_MS y luego lo manda por
  // dead-letter de vuelta a `orders`. Fíjate en la *ausencia* de
  // x-dead-letter-routing-key: omitirla conserva la routing key original, así que el
  // mensaje vuelve a entrar exactamente en las colas de donde salió.
  await channel.assertQueue(BILLING_RETRY_QUEUE, {
    durable: true,
    arguments: {
      'x-message-ttl': RETRY_DELAY_MS,
      'x-dead-letter-exchange': ORDERS_EXCHANGE,
    },
  });
  await channel.bindQueue(BILLING_RETRY_QUEUE, RETRY_EXCHANGE, 'order.*.placed');

  // La parada final. Esta cola no la consume nadie; la lee una persona.
  await channel.assertQueue(BILLING_DEAD_QUEUE, { durable: true });
  await channel.bindQueue(BILLING_DEAD_QUEUE, DEAD_EXCHANGE, '#');
}
Enter fullscreen mode Exit fullscreen mode

Ahí dentro hay dos ajustes que la gente mezcla. durable: true en una cola significa que la definición de la cola sobrevive al reinicio del broker. persistent: true en un mensaje publicado significa que ese mensaje se escribe en disco. Necesitas los dos: un mensaje persistente en una cola no duradera muere con la cola, y una cola duradera llena de mensajes no persistentes vuelve vacía. Ninguna de las dos combinaciones da error, y por eso normalmente esto se descubre durante un incidente.

Ahora el consumidor:

// src/billing-consumer.ts
import amqp from 'amqplib';
import type { Channel, ConsumeMessage } from 'amqplib';
import { assertTopology, BILLING_QUEUE, RETRY_EXCHANGE } from './topology.ts';

const RABBIT_URL = process.env.RABBIT_URL ?? 'amqp://aurora:aurora-dev@localhost:5672';
const PAYMENTS_URL = process.env.PAYMENTS_URL ?? 'http://localhost:4001';
const MAX_RETRIES = 3;

type OrderPlaced = {
  orderId: string;
  sku: string;
  quantity: number;
  totalCents: number;
};

type DeathRecord = { queue: string; reason: string; count: number };

/** Cuántas veces ha dado ya este mensaje la vuelta por la cola de reintentos. */
function retriesSoFar(message: ConsumeMessage): number {
  const deaths = message.properties.headers?.['x-death'] as DeathRecord[] | undefined;
  return deaths?.find((death) => death.reason === 'expired')?.count ?? 0;
}

function parseOrder(message: ConsumeMessage): OrderPlaced {
  const parsed: unknown = JSON.parse(message.content.toString('utf8'));

  if (
    typeof parsed !== 'object' || parsed === null ||
    typeof (parsed as OrderPlaced).orderId !== 'string' ||
    typeof (parsed as OrderPlaced).totalCents !== 'number'
  ) {
    throw new SyntaxError('order payload is missing orderId or totalCents');
  }

  return parsed as OrderPlaced;
}

async function chargeCard(order: OrderPlaced): Promise<string> {
  const response = await fetch(`${PAYMENTS_URL}/charges`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      // La entrega es al menos una vez, así que el mismo pedido puede llegar dos
      // veces. Usar el id del pedido como clave de idempotencia hace que el segundo
      // intento devuelva el primer cargo en vez de cobrarle otra vez al cliente.
      'idempotency-key': order.orderId,
    },
    body: JSON.stringify({ amountCents: order.totalCents, reference: order.orderId }),
    signal: AbortSignal.timeout(5_000),
  });

  if (!response.ok) {
    throw new Error(`payments service returned ${response.status} for ${order.orderId}`);
  }

  const { chargeId } = (await response.json()) as { chargeId: string };
  return chargeId;
}

async function handle(channel: Channel, message: ConsumeMessage): Promise<void> {
  let order: OrderPlaced;

  try {
    order = parseOrder(message);
  } catch (error) {
    // Payload mal formado. Reintentar no arregla un mensaje que nunca va a parsear,
    // así que pasa directo a la cola de dead-letter en el primer intento.
    console.error('[billing] unparseable message, dead-lettering', error);
    channel.nack(message, false, false);
    return;
  }

  try {
    const chargeId = await chargeCard(order);
    console.log(`[billing] charged ${order.orderId} -> ${chargeId}`);
    channel.ack(message);
    return;
  } catch (error) {
    const attempts = retriesSoFar(message);

    if (attempts >= MAX_RETRIES) {
      console.error(`[billing] giving up on ${order.orderId} after ${attempts} retries`, error);
      channel.nack(message, false, false); // -> DEAD_EXCHANGE -> orders.billing.dead
      return;
    }

    console.warn(`[billing] retry ${attempts + 1}/${MAX_RETRIES} for ${order.orderId}`, error);

    // Lo aparca en la cola de espera con su routing key y sus cabeceras originales,
    // para que el contador x-death siga acumulando entre intentos.
    channel.publish(RETRY_EXCHANGE, message.fields.routingKey, message.content, {
      persistent: true,
      headers: message.properties.headers,
      messageId: message.properties.messageId,
    });
    channel.ack(message);
  }
}

const connection = await amqp.connect(RABBIT_URL);
const channel = await connection.createChannel();
await assertTopology(channel);

// Sin prefetch, RabbitMQ le empuja la cola entera al primer consumidor que se
// conecta y las demás réplicas se quedan sin hacer nada. Una ventana moderada
// reparte la carga entre los consumidores y aun así mantiene el pipeline.
await channel.prefetch(20);

await channel.consume(BILLING_QUEUE, (message) => {
  // Llega null cuando el servidor cancela el consumidor; no hay nada que confirmar.
  if (message === null) return;
  void handle(channel, message);
});

console.log(`[billing] consuming ${BILLING_QUEUE}`);

for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.once(signal, () => {
    // Cerrar el canal devuelve a la cola todo lo que quedó sin confirmar, así que un
    // despliegue progresivo le pasa el trabajo en curso al siguiente pod en vez de
    // perderlo.
    void channel
      .close()
      .then(() => connection.close())
      .then(() => process.exit(0));
  });
}
Enter fullscreen mode Exit fullscreen mode

La confirmación es el contrato completo, y tiene exactamente tres movimientos:

  • ack(message) — listo, bórralo. Solo después de que el trabajo salió bien de verdad.
  • nack(message, false, false) — falló, no lo devuelvas a la cola. Va al exchange de dead-letter si hay uno configurado, y se descarta si no.
  • nack(message, false, true) — falló, devuélvelo a la cola. Esta última es la que trae problemas.

Fíjate en que nada confirma antes de hacer el trabajo. Si confirmas primero, convertiste "el consumidor se cayó" en "el pedido nunca se envió y nadie se enteró".


Dead-letter y mensajes envenenados

Un mensaje envenenado (poison pill) es un mensaje que falla siempre, sin excepción: un payload que tu parser rechaza, una referencia a una fila que alguien borró, un bug en una ruta de código concreta. No es un fallo transitorio y no hay paciencia que lo arregle.

Ahora combina eso con nack(message, false, true). El mensaje vuelve a la cola, se reentrega de inmediato, falla de inmediato, vuelve a la cola. En ese ciclo no hay ninguna espera. No estás reintentando una vez por segundo, estás reintentando tan rápido como la red lo permita, y tus logs se llenan al mismo ritmo: un solo mensaje, ocupando una CPU entera, fallando con una eficiencia admirable. Es el incidente clásico de las tres de la mañana, y la solución es estructural: nunca devuelvas a la cola a ciegas.

Hay tres capas que lo frenan, y la topología de arriba usa las tres:

1. x-delivery-limit en la cola. Las colas quorum cuentan ellas mismas las reentregas. Pasado el límite, el broker manda el mensaje a dead-letter sin que tu código participe en nada — y eso importa, porque cubre además el caso que tu handler no puede cubrir: un consumidor que se cae a mitad del mensaje y nunca llega a llamar a nack. RabbitMQ 4.x aplica un límite por defecto a las colas quorum; ponerlo explícito deja tu intención documentada.

2. Un exchange de dead-letter. x-dead-letter-exchange indica adónde van los mensajes rechazados, expirados y pasados de límite. Es un exchange, no una cola, así que aplican las mismas reglas de enrutamiento: por eso orders.billing.dead está asociada con #, para atrapar todo sin importar la routing key. Si dejas el DLX sin configurar, cada fallo se descarta en silencio, que es una manera muy discreta de perder dinero.

3. Un reintento con espera y con cuenta. Para los fallos realmente transitorios —el servicio de pagos está redesplegándose— reintentar es lo correcto, pero no al instante y no para siempre. El consumidor republica en RETRY_EXCHANGE, la cola de reintentos retiene el mensaje durante su x-message-ttl, y al expirar el dead-letter lo devuelve a orders con la routing key original intacta. Cada vuelta incrementa el contador x-death, retriesSoFar lo lee, y pasados los MAX_RETRIES el mensaje termina en la cola final.

Conviene advertir algo sobre esa tercera capa: republicar y luego confirmar son dos operaciones, no una transacción. Si el proceso se cae entre las dos, el mensaje se entrega dos veces. Por eso chargeCard manda una clave de idempotencia — con entrega al menos una vez, los handlers tienen que tolerar volver a ver el mismo mensaje. Cerrar el hueco equivalente del lado de la publicación es para lo que sirven los publisher confirms, y eso es la Parte 2.


Dos trampas que conviene conocer antes de producción, no durante

Los mensajes que no se pueden enrutar desaparecen sin hacer ruido. Publica en un exchange cuyos bindings no coincidan con nada y RabbitMQ descarta el mensaje con la tranquilidad de haber hecho exactamente lo que le pediste. channel.publish sigue devolviendo true. Pide que te avisen:

import { once } from 'node:events';

channel.on('return', (message) => {
  console.error(
    `[publisher] unroutable: ${message.fields.routingKey} on ${message.fields.exchange}`,
  );
});

const accepted = channel.publish(ORDERS_EXCHANGE, 'order.us.placed', body, {
  persistent: true,
  contentType: 'application/json',
  messageId: order.orderId,
  mandatory: true, // sin binding que coincida -> evento `return` en vez de descarte silencioso
});

if (!accepted) {
  // El búfer de escritura del socket está lleno. Publicar de todas formas hace crecer
  // una cola sin tope dentro de tu propio proceso, que es justo el fallo que el broker
  // tenía que evitar.
  await once(channel, 'drain');
}
Enter fullscreen mode Exit fullscreen mode

Sin eso, una routing key mal escrita es indistinguible de "a ningún consumidor le interesa", y la alternativa es enterarte por el equipo de finanzas.

Los argumentos de una cola no se pueden cambiar en el sitio. assertQueue es idempotente solo mientras los argumentos coincidan. Declara una cola que ya existe con un x-dead-letter-exchange distinto —exactamente lo que pasa el día en que agregas dead-letter a un servicio en marcha— y el broker responde PRECONDITION_FAILED y cierra el canal. La cola sale intacta; tu proceso no. Migrar significa declarar una cola nueva, asociarla en paralelo a la vieja, drenar la original y luego eliminarla. Decide tu topología de dead-letter antes de que haya mensajes en vuelo, porque agregarla después ya no es un despliegue, es una migración.


Cuándo usar una cola, y cuándo no

Recurre a una cuando:

  • El trabajo puede terminar después, sin que quien llamó se quede esperando: facturas, recibos, miniaturas, sincronizar con un tercero.
  • Varios consumidores independientes necesitan el mismo evento, y quieres poder sumar un cuarto sin redesplegar el productor.
  • El tráfico llega en picos y lo que está detrás no los aguanta. Una cola convierte una avalancha en una acumulación que se drena.
  • Dos servicios se despliegan en calendarios distintos y ninguno debería poder tumbar al otro.

Déjala en paz cuando:

  • Quien llama necesita la respuesta ahora. El checkout tiene que saber si la tarjeta fue rechazada. Eso es una llamada sincrónica —HTTP o gRPC— y disfrazarla de petición/respuesta sobre un broker te cuesta la latencia de un salto extra más un broker que operar.
  • Necesitas un orden global estricto entre varios consumidores. RabbitMQ conserva el orden dentro de una cola entregada a un único consumidor, y en el momento en que escalas a dos consumidores para tener más rendimiento, el "en orden" se acabó. Si el orden por clave es imprescindible, eso tiene forma de log particionado: Kafka, o los streams de RabbitMQ.
  • Necesitas reprocesar el histórico. Una cola borra los mensajes una vez confirmados. Volver a procesar el martes pasado pide un log de eventos, no una cola.
  • Solo quieres que una función deje de bloquear la petición. Para eso están await y una tarea en segundo plano. Un broker es infraestructura que ahora tienes que monitorear, asegurar, actualizar y explicarle a quien esté de guardia.

Regla práctica: una cola se gana su costo operativo cuando el mensaje tiene que sobrevivir al proceso que lo produjo. Si solo tiene que sobrevivir a la petición, tienes opciones más baratas.


Puntos clave

  • Los productores publican en exchanges, nunca en colas. sendToQueue es el exchange por defecto sin nombre haciéndolo por ti, y de paso deja fijo en el productor el nombre de la cola del consumidor.
  • La routing key viene del mensaje; la binding key viene del binding. El tipo de exchange decide cómo se comparan las dos: exacta en direct, ignorada en fanout, por patrón en topic.
  • Usa topic por defecto. Un binding # lo hace comportarse como un fanout, así que conservas la opción de acotar un consumidor más adelante sin volver a crear el exchange.
  • durable y persistent son dos ajustes distintos: uno conserva la cola tras un reinicio, el otro conserva los mensajes. Si pones uno sin el otro pierdes datos y no aparece ningún error.
  • Nunca devuelvas a la cola a ciegas. nack(msg, false, true) sobre un mensaje envenenado es un bucle sin freno. Un exchange de dead-letter, un límite de entregas y un reintento con espera y cuenta son las tres capas que lo evitan, y declararlas cuesta unas quince líneas.

Siguiente en la serie: RabbitMQ de los fundamentos a producción, Parte 2: construir un servicio escalable — publisher confirms, el ciclo de vida de conexiones y canales, escalado de consumidores, y qué pasa de verdad con todo esto cuando el broker se reinicia bajo carga.

Top comments (0)