Capítulo 03 · Checkout
Colas
El usuario no tiene que esperar el PDF. Pedido 456, at-least-once, outbox, DLQ.
18 min intro
Alguien pulsa Comprar
Un usuario elige unas zapatillas, ingresa la tarjeta y pulsa Comprar. El sistema tiene que guardar el pedido, reservar stock, cobrar, mandar un email, a veces generar una invoice en PDF.
La forma ingenua es hacerlo todo en la misma request HTTP. El navegador espera. La API guarda la fila en Postgres, llama a Stripe, llama al inventario, llama al email, genera el PDF y entonces responde. Si Stripe tarda tres segundos y el PDF otros ocho, esa persona mira un spinner once segundos. Si el gateway corta a los diez, el cobro puede haber salido y el usuario ve un error. Un infierno de soporte.
Hay otro problema, más silencioso. Ese endpoint ahora conoce a Payment, a Inventory, a Email y a Billing. Si Email se cae, ¿falla el checkout? Si mañana añadís fraude, ¿tocás otra vez el mismo código?
El 456 no necesita que el email exista antes de decirle «pedido recibido». El cobro sí importa, pero no tiene por qué bloquear el hilo HTTP hasta el final. Parte del trabajo puede ocurrir después. Esa es la puerta de entrada a las colas.
Qué es una cola, sin misterio
Una cola es un intermediario. Un proceso deja un mensaje — «nació el pedido 456» — y sigue. Otro proceso, cuando puede, lo recoge. Quien produce no espera a quien consume. Ni siquiera tiene que saber su nombre.
Ese intermediario suele llamarse broker. RabbitMQ, SQS, Kafka: personalidades distintas, la misma idea. Escribís un mensaje. El broker lo guarda un rato. Un worker lo lee y actúa.
Order API → broker → worker de pago
→ worker de email
→ worker de stock
El mensaje es un trozo de datos, casi siempre JSON:
{
"event_id": "evt_789",
"type": "OrderCreated",
"order_id": 456,
"amount": 89.90
}
event_id va a importar mucho. De momento: la API ya no hace el trabajo pesado. Deja constancia de que pasó algo y responde al usuario.
Lo que ganás al dejar de esperar
Si la API solo guarda el pedido y publica OrderCreated, puede responder en milisegundos. El cobro, el stock y el email ocurren en workers.
Desacople. Mañana un worker de fraude también escucha OrderCreated. No tocás la API.
Trabajo que puede esperar. PDF, thumbnails, newsletter: nada de eso merece bloquear a un humano con el dedo en la pantalla.
Un colchón para los picos. Black Friday. Los workers de email no dan abasto. Sin cola, esas requests fallan. Con cola, los mensajes esperan. Vale para un pico corto. Si el pico es el nuevo ritmo, la cola solo esconde que no hay capacidad.
Que un proceso muerto no se lleve el trabajo. Si el worker de email se estrella a mitad, el mensaje puede volver. El precio de ese milagro: el mensaje puede aparecer otra vez.
También escalas aparte. Si el email va lento, añadís workers de email. No redeployás el checkout.
Lo que no deberías meter en una cola
Una cola no es el pegamento por defecto de los microservicios. Es un desvío. Añade latencia, duplicados, un sistema más que puede romperse y una caja negra: «¿dónde está el 456?» deja de ser una query.
Si el usuario necesita la respuesta en esta request — el precio final, si la tarjeta es válida, si queda talla 42 — eso no se va a una cola. Una regla práctica: si tu SLA es de unos pocos cientos de milisegundos, meter una cola en medio suele romperlo. El checkout puede aceptar el pedido rápido. No puede decir «éxito» y cobrar mañana si el producto promete cobro inmediato: ahí el cobro sigue en el camino crítico, o diseñás un PENDING honesto.
Nunca empieces por «¿pongo Kafka?». Empieza por «¿este trabajo puede esperar, y qué pasa si se hace dos veces?».
El worker se muere a la mitad
El 456 está en la cola. Un worker de pagos lo recoge. Llama a Stripe, Stripe dice OK, y justo entonces la máquina se reinicia. ¿El mensaje se perdió? ¿Se cobró? ¿Las dos cosas?
El broker no adivina si el trabajo terminó. Espera un ACK. «Ya lo hice, podés olvidarte». Si el worker muere antes del ACK, el broker asume que no se hizo y lo reentrega. Redelivery.
At-most-once: cero o una. Si el worker muere, el mensaje puede perderse. Sirve para un contador de «usuarios mirando esto». No sirve para cobrar.
At-least-once: como mínimo una vez. El broker insiste hasta el ACK. El 456 se va a intentar cobrar. Si el crash fue después de cobrar y antes del ACK, el siguiente worker lo vuelve a intentar. Dos cobros. Esta es la semántica real de casi todos los brokers que vas a usar.
Exactly-once suena a lo que querés. Extremo a extremo — clic, Stripe, tu DB, el email — es mucho más resbaladizo de lo que parece. Kafka tiene un modo que llama exactly-once dentro de su mundo. Eso no convierte en exactly-once tu HTTP a Stripe. En una entrevista, «Kafka garantiza exactly-once» sin decir dónde es yellow flag. Lo honesto: diseñamos para at-least-once y hacemos que repetir no rompa el negocio.
Si el ACK va demasiado pronto y el worker se cae, el mensaje ya desapareció y nadie hace el trabajo. Lo sano:
- Recibir el mensaje
- Transacción en tu DB
- Trabajo local
- COMMIT
- ACK al broker
Si morís antes del commit, no hay rastro y el mensaje vuelve. Si morís después del commit y antes del ACK, el mensaje vuelve pero tu base ya sabe que el 456 se procesó. Eso es idempotencia.
El mismo mensaje, dos veces
Idempotente: una vez o diez, el mismo resultado. Poner el pedido en CONFIRMED diez veces sigue siendo CONFIRMED. Cobrar 89,90 € diez veces es 899 €.
Cuando asumís at-least-once, todo consumer tiene que soportar el mismo event_id otra vez. La forma amateur:
SELECT * FROM processed_events WHERE event_id = 'evt_789'
-- si no existe, proceso e inserto
Dos workers hacen ese SELECT a la vez, los dos ven vacío, los dos cobran. La barrera tiene que ser la DB: event_id UNIQUE, en la misma transacción que el update de negocio.
BEGIN
INSERT INTO processed_events(event_id) VALUES ('evt_789')
UPDATE orders SET status = 'CONFIRMED' WHERE id = 456
COMMIT
Si el segundo llega, el INSERT choca, rollback, se va.
Cómo no cobrar dos veces
El worker llama a Stripe, Stripe cobra, y antes del COMMIT el proceso muere. Stripe ya tiene el dinero. En tu DB el cobro sigue PENDING. El broker reentrega. processed_events no ayuda: el primer intento no llegó a escribir.
Hace falta una segunda capa: que Stripe trate la operación como idempotente. Una clave que vos elegís y reutilizás:
POST /payments
Idempotency-Key: payment-order-456
La primera vez cobra. La segunda, misma clave, no vuelve a mover dinero. Sin esta capa, un consumer «idempotente» en tu DB igual duplica pagos.
Dos capas. Una evita reprocesar el evento adentro. La otra evita repetir un efecto afuera. El email de gracias dos veces es feo. El cobro doble es un incidente.
El mensaje que no se puede procesar
Si Stripe responde 503, tiene sentido reintentar. Si el mensaje trae un customer_id que no existe, o un JSON roto, reintentar es calentar CPU. Poison message: va a fallar siempre.
Si reintentás al instante y hay diez mil mensajes porque Stripe está caído, cuando Stripe vuelve todos disparan a la vez. Retry storm. Por eso backoff — 1s, 2s, 4s — más jitter, y un techo: después de N intentos, Dead Letter Queue.
La DLQ no es una papelera. Es una sala de espera con alarma. Alguien mira, arregla y hace replay si el negocio lo pide. «Falló → DLQ → nos olvidamos» es cómo se pierden pedidos en silencio.
Created después de Cancelled
El usuario se arrepiente. Salen OrderCreated y después OrderCancelled. Si un worker lento procesa primero la cancelación y después la creación, el pedido termina «creado» cuando ya lo habían anulado. El orden importa para este pedido. No el de toda la tienda.
La herramienta: partition key. En Kafka, partition_key = order_id. Todo el 456 en la misma partición, un worker a la vez. El 457 puede ir a otra. Más orden estricto, menos paralelismo. FIFO global es el throughput de un worker. Casi nunca lo necesitás. La pregunta no es «¿hace falta orden?». Es «¿orden de qué?».
La cola que no para de crecer
Entran 300 pedidos por segundo. Los workers de pago hacen 200. Cada segundo la cola gana cien. Un minuto es un colchón. Una hora son 360.000 de retraso. La cola no absorbía. Escondía que no hay capacidad.
Queue depth y consumer lag. Si crecen sin parar, no es un pico: es un déficit. Añadir workers ayuda hasta que el cuello es Stripe, Postgres, o una partición caliente. En Kafka, el paralelismo de un group no supera el número de particiones.
Cuando no da: 429 en el checkout, priorizar (cobrar antes que el email), cortar lo no esencial. Backpressure. Autoescalar workers mirando el lag tiene sentido. Autoescalar la API porque su CPU está baja, mientras la cola se hincha, no.
Guardar el pedido y avisarle al resto
El checkout hace dos cosas que parecen una: INSERT en orders y publicar OrderCreated. Son dos sistemas. Postgres puede decir que sí y el broker que no. Pedido existe, 200 al usuario, nadie cobró. Al revés: el evento vuela y la orden no está.
Eso es el dual write. No se arregla con un try/catch. Se arregla haciendo las dos escrituras una sola transacción en un solo sitio: Postgres.
BEGIN
INSERT INTO orders ...
INSERT INTO outbox (event_id, type, payload) ...
COMMIT
Un relay lee la outbox y publica. Si hubo commit, el evento existe y tarde o temprano sale. El relay también se muere: puede publicar y caer antes de marcar published_at. Entonces republica. Volvemos a at-least-once. El outbox elimina el agujero «está en DB y no se enteró nadie». Los workers siguen siendo idempotentes.
CDC (Debezium y el WAL) es la versión «si esto crece». En entrevista, mencionar outbox ya te pone en el sitio correcto.
Pedido, stock y pago no caben en un BEGIN
El 456 necesita stock y cobro y pedido confirmado. A veces en empresas distintas (Stripe). No hay un BEGIN que las cubra. Si reservás stock y Stripe falla, tenés que compensar: soltar el stock. Si cobrás y no hay stock, devolver el dinero. Eso es una saga.
Un orden que suele doler menos: reservar stock antes de cobrar. Si no hay talla 42, ni tocás la tarjeta.
Coreografía: cada servicio escucha y publica. Elástico, y se vuelve un ovillo.
Orquestación: un director dice el siguiente paso. El flujo se lee en un sitio. El riesgo es un dios que conoce todos los recovecos.
Para un checkout de tres o cuatro pasos, un orquestador se explica mejor en una pizarra. El 456 puede orquestar stock+pago+confirmar y aún así emitir OrderConfirmed para que email y analítica escuchen sin estar en la saga.
Una cola, un tablón o un diario
Cola de trabajo. Un mensaje, un worker, desaparece con el ACK. «Cobra este pedido». Rabbit y SQS viven acá.
Pub/sub. El mismo suceso lo ven varios. OrderCreated lo quieren fraude, analítica y el dashboard. Muchos son efímeros: si el consumidor estaba caído, se lo perdió.
Event stream (Kafka). Diario. Los sucesos se quedan, en orden por partición. Cada equipo lee a su ritmo, con su offset. Un consumidor nuevo reprocesa el mes pasado. Eso no lo hace una cola clásica.
Podés usar Kafka como si fuera una cola. El modelo mental sigue siendo diario. Brilla cuando vas a tener varios lectores o replay. Brilla menos si solo querías mandar emails y ya estás en AWS: SQS existe para eso.
Rabbit, Kafka o SQS
No elijas por velocidad de folleto. Elige por el modelo.
SQS si el problema es una bandeja de jobs, estás en AWS, y no querés operar un cluster. El 456 como «cobra esto» encaja.
RabbitMQ si necesitás enrutado fino y el patrón es productor → broker → worker.
Kafka si el suceso es el dato: varios equipos leen orders, retener, replay, particiones.
«Kafka es rápido y Rabbit lento» no es un argumento. «Necesito replay y tres consumidores independientes» sí lo es.
Cómo te enterás de que esto va mal
Profundidad o lag (capacidad). Latencia de proceso. Tasa de error y de retry. Tamaño de la DLQ. Si la DLQ pasa de cero, alguien se entera. Si el lag crece veinte minutos, también.
Un trace_id que nace en el HTTP del Comprar y viaja hasta Stripe. Si no, reconstruir el 456 es arqueología entre tres logs que no se conocen.
El pedido 456, entero
El usuario pulsa Comprar. La API, en una transacción, escribe orders (PENDING) y una fila en outbox. Commit. 201. El usuario ya no espera.
Un relay publica OrderCreated. Inventory reserva la talla 42. Si no hay, CANCELLED, no se toca el dinero. Si hay, Payment cobra con Idempotency-Key: payment-order-456. Si Stripe falla un rato, retry con backoff. Si el mensaje está podrido, DLQ. Si Stripe cobra y el worker muere, el redelivery no vuelve a cobrar: la clave de Stripe lo impide. El UNIQUE de event_id impide el doble CONFIRMED.
Los eventos del 456 van con partition_key = 456. Email y analítica escuchan OrderConfirmed en otro grupo. Si Black Friday hincha la cola, workers; si aún no da, el checkout dice 429.
Usuario: Comprar
→ Order API
→ Postgres: pedido + outbox
→ Relay → broker
→ Stock
→ Pago (idempotency key) → Stripe
→ Email
Eso no es un diagrama para memorizar. Es el relato, dibujado.
Qué espera cada nivel
Un mid pone una cola, dice at-least-once e idempotencia, y nombra una DLQ.
Un senior no ACK-ea antes de commitear, pone UNIQUE en la misma transacción, idempotency key hacia Stripe, outbox para el dual write, orden por order_id (no global), y trata el lag como un problema de capacidad.
Si te lo preguntan
The checkout should not wait for the PDF; we accept the order, persist it, and process payments and mail asynchronously.
Si el worker crashea: we assume at-least-once, so the message may come back; consumers are idempotent.
Dos veces: unique event id in the same database transaction as the business update — not a SELECT then INSERT.
Cobrar dos veces: idempotency key on the payment provider.
DB sí y el evento no: transactional outbox. The relay can still duplicate; consumers still must be idempotent.
Falla siempre: retries with backoff for outages; poison messages go to a DLQ that we actually look at.
Orden: per order, not global — partition by order_id.
La cola crece: that is a capacity problem hiding in the buffer; scale consumers and apply backpressure.
Kafka o Rabbit: Rabbit or SQS for a work queue; Kafka when we need a durable log, replay, and several independent readers.