El módulo 6 terminó con un diagnóstico incómodo. La capa de datos de MercadoFresco está bien repartida —Aurora para pedidos y stock, DynamoDB para carritos y sesiones, Redshift para los informes de Sara, ElastiCache para el catálogo—, pero la petición que confirma un pedido sigue haciendo ocho cosas seguidas antes de responder. Marta lo midió en el pico del viernes: 2.900 ms de mediana, 9.100 ms en el percentil 95, y un 0,4 % de pedidos que fallan después de haber cobrado porque el ERP del almacén devolvió un 503.

Amazon SQS (Simple Queue Service) rompe esa cadena. Es una cola de mensajes gestionada: un componente escribe un mensaje, otro lo lee cuando puede, y ninguno necesita que el otro esté vivo en ese instante. Es el servicio más antiguo de AWS y también el más aburrido en el mejor sentido: sin servidores, sin versiones, sin capacidad que dimensionar, y escala de cero a millones de mensajes por segundo sin que nadie toque nada. Aquí Luis convierte las ocho llamadas encadenadas en una confirmación de 400 ms más siete tareas que ocurren después, y aprende por el camino el concepto que más incidentes causa en producción: el tiempo de espera de visibilidad.

Aviso de coste. SQS cobra por petición a la API: 1 millón gratis al mes de forma permanente y 0,40 USD por millón adicional en colas estándar (0,50 en FIFO). Una cola vacía no cuesta nada, pero un consumidor con sondeo corto puede generar cientos de millones de peticiones vacías al mes. Al final tienes los comandos de limpieza. Todos los datos son ficticios.

Contenido

  1. Síncrono frente a asíncrono: qué gana MercadoFresco
  2. Anatomía de una cola y el modelo de sondeo
  3. Colas estándar y colas FIFO
  4. Qué cola usa cada tarea de MercadoFresco
  5. Ciclo de vida de un mensaje
  6. El tiempo de espera de visibilidad, en detalle
  7. Sondeo corto y sondeo largo
  8. Retención, retardo, temporizadores y cargas grandes
  9. Colas de mensajes fallidos y redrive
  10. Crear las colas por CLI, política de cola y cifrado
  11. Productor y consumidor en Python
  12. Consumir desde Lambda: mapeo de origen de eventos
  13. Métricas, alarmas y autoescalado por profundidad de cola
  14. Coste real y limpieza
  15. Errores comunes y consejos
  16. Ejercicios
  17. Conclusión

Síncrono frente a asíncrono: qué gana MercadoFresco

Una llamada síncrona es aquella en la que quien llama espera la respuesta antes de continuar. No tiene nada de malo mientras se use para lo que corresponde: obtener un dato que se necesita ahora para poder responder. El problema aparece al encadenar llamadas síncronas a cosas que no se necesitan ahora. Estas son las ocho tareas de MercadoFresco, medidas en el pico del viernes:

# Tarea Destino p50 p95 ¿El cliente necesita el resultado?
1 Cobrar la tarjeta Pasarela externa 240 ms 3.100 ms
2 Escribir el pedido Aurora 38 ms 60 ms
3 Vaciar el carrito DynamoDB 11 ms 22 ms No
4 Invalidar la caché ElastiCache 4 ms 9 ms No
5 Avisar al ERP del almacén HTTP del socio 1.180 ms 6.500 ms No
6 Correo de confirmación Proveedor SMTP 820 ms 4.200 ms No
7 Notificar al repartidor API del socio 410 ms 2.900 ms No
8 Publicar evento de analítica Firehose 190 ms 700 ms No

Solo las dos primeras son imprescindibles para decir «tu pedido está confirmado». Las seis restantes son consecuencias del pedido, no requisitos, y sin embargo el cliente las paga en tiempo de espera y en riesgo. La suma secuencial da 2.893 ms de mediana, pero lo grave es la fragilidad multiplicativa: si cada tarea tiene un 99,9 % de disponibilidad, la cadena de ocho tiene 99,2 %. Con 900 pedidos por hora en el pico, son siete pedidos rotos por hora, todos ya cobrados.

flowchart TD
    C[Cliente pulsa Confirmar pedido] --> A[Servidor de la tienda]
    A --> P1[1. Pasarela de pago 240 ms]
    P1 --> P2[2. Aurora INSERT 38 ms]
    P2 --> P3[3. DynamoDB vaciar carrito 11 ms]
    P3 --> P4[4. ElastiCache invalidar 4 ms]
    P4 --> P5[5. ERP del almacen 1.180 ms]
    P5 --> P6[6. Correo de confirmacion 820 ms]
    P6 --> P7[7. API del repartidor 410 ms]
    P7 --> P8[8. Evento de analitica 190 ms]
    P8 --> R[Respuesta al cliente 2.893 ms p50]
    P5 -. 503 del ERP .-> X[Pedido cobrado y perdido]
    style X fill:#f8d7da,stroke:#c00
    style R fill:#fff3cd

Una llamada asíncrona rompe el encadenamiento: la tienda deja constancia de que hay trabajo pendiente y responde. El trabajo se hace después, por otro proceso, con sus propios reintentos.

flowchart TD
    C[Cliente pulsa Confirmar pedido] --> A[Servidor de la tienda]
    A --> P1[1. Pasarela de pago 240 ms]
    P1 --> P2[2. Aurora INSERT 38 ms]
    P2 --> Q[(cola-mercadofresco-pedidos<br/>SendMessage 12 ms)]
    Q --> R[Respuesta al cliente ~400 ms p95]
    Q --> W1[Consumidor almacen]
    Q --> W2[Consumidor correo]
    Q --> W3[Consumidor analitica]
    W1 --> ERP[ERP del almacen]
    W2 --> SMTP[Proveedor de correo]
    W3 --> FH[Firehose y S3]
    ERP -. 503 .-> RT[Reintento automatico:<br/>el mensaje sigue en la cola]
    style R fill:#d4edda
    style RT fill:#fff3cd

Lo que se gana no es solo velocidad: el fallo del ERP deja de ser el fallo del pedido. El mensaje sigue en la cola, el consumidor lo reintenta, y si el ERP tarda dos horas en volver, los pedidos de esas dos horas se procesan cuando vuelva. El cliente nunca se entera.

Anatomía de una cola y el modelo de sondeo

Cuatro conceptos y nada más. El productor envía mensajes; en MercadoFresco, la tienda en asg-mercadofresco-tienda, que solo necesita sqs:SendMessage y la URL de la cola. La cola es el almacén duradero: SQS replica cada mensaje en varios servidores de varias AZ de eu-west-1 antes de confirmar el envío, y no tiene tamaño máximo ni capacidad que aprovisionar. El mensaje tiene un cuerpo (Body) de hasta 256 KiB de texto, hasta 10 atributos de mensaje tipados que viajan fuera del cuerpo, y atributos del sistema (MessageId, SentTimestamp, ApproximateReceiveCount). El consumidor lee, trabaja y borra; puede haber uno o mil sin que se pisen.

La distinción entre cuerpo y atributos importa: los atributos se inspeccionan sin deserializar el cuerpo y —clave en 07-02— SNS filtra por atributos. Regla de MercadoFresco: en atributos va lo que sirve para decidir qué hacer con el mensaje; en el cuerpo, el dato.

{
  "MessageAttributes": {
    "tipo_evento":    { "DataType": "String", "StringValue": "PedidoConfirmado" },
    "franja_entrega": { "DataType": "String", "StringValue": "24h" },
    "version":        { "DataType": "Number", "StringValue": "1" }
  },
  "MessageBody": "{\"pedido_id\":\"PED-2026-084417\",\"cliente_id\":\"CLI-30912\",\"importe_eur\":48.20,\"franja_entrega\":\"24h\",\"lineas\":[{\"sku\":\"FRUT-FRES-011\",\"unidades\":2}],\"confirmado_en\":\"2026-08-02T18:41:07Z\"}"
}

El cuerpo es una cadena, no un objeto: SQS no sabe qué hay dentro. La serialización y el versionado del contrato son cosa tuya, y volveremos a ello en 07-03.

Sondeo (pull) frente a inserción (push). Esta es la característica que define a SQS: nunca llama a nadie. No hay forma de decirle «cuando llegue un mensaje, envíalo a esta URL». Es el consumidor quien pregunta.

Sondeo (SQS) Inserción (SNS, webhooks)
Quién inicia El consumidor pregunta El servicio entrega
Ritmo lo marca El consumidor El productor
Si el consumidor está caído Los mensajes esperan Se pierden o se reintentan a ciegas
Contrapresión natural Sí: se lee lo que se puede No: llega lo que llega
Necesita endpoint público No Sí (salvo destinos internos de AWS)
Reintento Implícito: si no borras, vuelve Configurado por el emisor

La consecuencia arquitectónica es enorme: la cola actúa de amortiguador. Si entran 900 pedidos/hora y los consumidores procesan 600, no se pierde nada: la cola crece y a las 22:00 se vacía. Esa propiedad —la contrapresión— es la razón de que las colas sigan siendo la pieza básica de integración, y la retomaremos en 07-05.

La aparente excepción es Lambda: cuando conectas una cola a una función parece que SQS «empuja». No es así; el servicio Lambda mantiene sondeadores propios que llaman a ReceiveMessage por ti. El modelo sigue siendo de sondeo, solo que el sondeador lo pone AWS.

Colas estándar y colas FIFO

Cola estándar Cola FIFO
Nombre Libre Debe acabar en .fifo
Orden Best-effort: casi siempre, no garantizado Estricto dentro de cada grupo
Entrega Al menos una vez (puede duplicar) Exactamente una vez dentro de la ventana de deduplicación
Rendimiento Prácticamente ilimitado 300 msg/s (3.000 con lotes); alto rendimiento: decenas de miles
Grupos No existen MessageGroupId obligatorio
Deduplicación No Por MessageDeduplicationId o hash del cuerpo, ventana de 5 min
Paralelismo Total Un mensaje en vuelo por grupo
Precio 0,40 USD/millón 0,50 USD/millón

Tres ideas que conviene interiorizar. «Al menos una vez» significa que habrá duplicados: no es teórico, SQS almacena cada mensaje en varios servidores y ocasionalmente uno no se entera de que ya fue borrado y lo reentrega. Si tu consumidor cobra una tarjeta, un duplicado es cobrar dos veces, y la solución no es FIFO sino la idempotencia del consumidor (07-05).

El «exactamente una vez» de FIFO tiene letra pequeña: solo actúa dentro de una ventana de 5 minutos y solo cubre el envío duplicado del mismo mensaje; si el consumidor procesa y muere antes de borrar, el mensaje vuelve. FIFO reduce mucho la probabilidad de duplicado; no la elimina.

Los grupos son la unidad de orden y de paralelismo a la vez. Para garantizar el orden, SQS no entrega un segundo mensaje del grupo hasta que el primero se ha borrado. Un único grupo para toda la cola da orden global y un solo consumidor efectivo; el sku como grupo da orden por producto y tanto paralelismo como productos haya. Es la misma decisión que elegir clave de partición en DynamoDB (06-02).

Qué cola usa cada tarea de MercadoFresco

Tarea Cola Tipo Por qué
Preparar la caja cola-mercadofresco-almacen Estándar El almacén reconcilia por pedido_id; el orden entre pedidos da igual
Correo de confirmación cola-mercadofresco-correo Estándar Duplicar un correo es molesto, no catastrófico; se deduplica en el consumidor
Notificar al repartidor cola-mercadofresco-reparto Estándar Ídem
Evento de analítica cola-mercadofresco-analitica Estándar Redshift agrega; los duplicados se filtran en la carga
Movimientos de stock cola-mercadofresco-stock.fifo FIFO, grupo = sku «Reservar 2» y «liberar 2» del mismo SKU deben ir en orden
Miniaturas de fotos cola-mercadofresco-miniaturas Estándar Regenerar una miniatura es idempotente por naturaleza

Solo una de las seis necesita FIFO, y es justo la que toca contadores. Es lo habitual: la mayoría de las cargas no necesitan orden global, necesitan un consumidor idempotente. Pagar el precio de FIFO cuando no hace falta es un error de diseño caro.

En esta lección Luis empieza por lo mínimo viable: cola-mercadofresco-pedidos recoge todo el trabajo posterior a la confirmación, y cola-mercadofresco-correo separa el envío de correos, el consumidor más lento y el que más falla. En 07-02 veremos por qué esa cola única acaba siendo un problema.

Ciclo de vida de un mensaje

sequenceDiagram
    participant P as Productor (tienda)
    participant Q as cola-mercadofresco-pedidos
    participant C as Consumidor
    P->>Q: SendMessage(Body, MessageAttributes)
    Q-->>P: MessageId (replicado y duradero)
    Note over Q: Estado: visible
    C->>Q: ReceiveMessage(Max=10, WaitTimeSeconds=20)
    Q-->>C: Mensajes + ReceiptHandle
    Note over Q: Estado: en vuelo (invisible)<br/>durante VisibilityTimeout
    alt Trabajo correcto
        C->>Q: DeleteMessage(ReceiptHandle)
        Note over Q: El mensaje desaparece
    else El consumidor falla o se cae
        Note over Q: Expira el tiempo de visibilidad
        Note over Q: Visible otra vez<br/>ApproximateReceiveCount += 1
        Q-->>C: Se vuelve a entregar
    end

Tres estados y solo tres: visible, en vuelo y borrado. Con eso se explica todo el comportamiento de SQS, incluidos los que parecen fallos del servicio. La parte que sorprende siempre: recibir un mensaje no lo elimina. SQS lo entrega y lo esconde del resto de consumidores, pero sigue ahí; si tu proceso muere a mitad, si el contenedor se reinicia, si el ASG apaga la instancia, el mensaje reaparece y otro consumidor lo coge. Esa es la garantía de durabilidad.

El corolario: no borrar equivale a reprocesar. Si el consumidor lanza una excepción y no llega al DeleteMessage, el mensaje volverá. Y si tarda más que el tiempo de visibilidad, volverá aunque acabe bien, con lo que el trabajo se hará dos veces: la causa número uno de duplicados en producción.

El tiempo de espera de visibilidad, en detalle

El VisibilityTimeout es el número de segundos que un mensaje permanece invisible tras ser recibido. Por defecto 30 s; máximo 12 horas. La regla es una sola frase:

El tiempo de visibilidad debe ser mayor que el tiempo que tarda el consumidor más lento en procesar el mensaje y borrarlo.

Veamos qué pasa si no se cumple. El consumidor de correo llama a un SMTP que en el p99 tarda 42 s, y el tiempo de visibilidad está en los 30 por defecto:

t Consumidor A Consumidor B Estado del mensaje
0 s Recibe PED-084417 En vuelo
5 s Llama al SMTP En vuelo
30 s Sigue esperando Vuelve a ser visible
31 s Sigue esperando Recibe el mismo mensaje En vuelo (para B)
42 s Responde el SMTP, DeleteMessage Llama al SMTP Borrado
73 s DeleteMessageReceiptHandleIsInvalid

El cliente ha recibido dos correos. Y el fallo es silencioso, difícil de reproducir en pruebas y solo aparece bajo carga. Hay tres formas de configurarlo:

1. En la cola, como valor por defecto:

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-correo \
  --attributes VisibilityTimeout=90 --profile mercadofresco-dev --region eu-west-1

2. En la recepción, pasando VisibilityTimeout a ReceiveMessage, útil cuando el mismo consumidor atiende trabajos de coste dispar.

3. Extendiéndolo sobre la marcha (heartbeat). Si el trabajo puede durar mucho y no sabes cuánto, no pongas 12 horas: pon un valor corto y aláragalo mientras trabajas con ChangeMessageVisibility. Si el proceso muere, el mensaje vuelve en segundos en lugar de en horas.

def latido(receipt_handle, parar):
    """Extiende la visibilidad a 60 s cada 30 s mientras el trabajo siga en curso."""
    while not parar.wait(30):
        try:
            sqs.change_message_visibility(QueueUrl=COLA, ReceiptHandle=receipt_handle,
                                          VisibilityTimeout=60)  # 60 s DESDE AHORA
        except sqs.exceptions.ReceiptHandleIsInvalid:
            break                       # ya se borró o expiró: nada que extender

def procesar_con_latido(mensaje):
    parar = threading.Event()
    hilo = threading.Thread(target=latido, args=(mensaje["ReceiptHandle"], parar), daemon=True)
    hilo.start()
    try:
        preparar_caja_en_almacen(mensaje["Body"])   # de 2 s a 8 minutos
        sqs.delete_message(QueueUrl=COLA, ReceiptHandle=mensaje["ReceiptHandle"])
    finally:
        parar.set()                                  # detiene el latido pase lo que pase
        hilo.join(timeout=2)

Dos detalles: VisibilityTimeout=60 significa «invisible 60 segundos desde este momento», no «añade 60 a lo que quedaba»; y el total acumulado desde la primera recepción no puede superar 12 horas. El mismo comando sirve para devolver un mensaje de inmediato con VisibilityTimeout=0 cuando detectas que no puedes procesarlo ahora, o aplazarlo cinco minutos con 300.

Sondeo corto y sondeo largo

Con sondeo corto (WaitTimeSeconds=0, valor por defecto en una cola recién creada) SQS consulta un subconjunto de servidores y responde de inmediato, aunque sea con una lista vacía. Puedes recibir una respuesta vacía con mensajes en la cola, y tu bucle genera peticiones a máxima velocidad. Con sondeo largo (1 a 20 s) SQS consulta todos los servidores y mantiene la conexión abierta hasta que hay un mensaje o se agota la espera.

Sondeo corto Sondeo largo (20 s)
Respuestas vacías con cola no vacía Posible No
Peticiones/hora de un consumidor ocioso Hasta ~360.000 180
Latencia al llegar un mensaje Hasta el siguiente sondeo Prácticamente inmediata
Coste mensual de 4 consumidores ociosos ~415 USD ~0,21 USD
Cuándo usarlo Casi nunca Siempre

Los 415 USD no son retórica: cuatro procesos sondeando en bucle generan del orden de mil millones de peticiones al mes. Es el error de novato más caro de SQS y aparece en Cost Explorer (11-03) como una línea desproporcionada. Configúralo en la cola y en cada llamada:

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-pedidos \
  --attributes ReceiveMessageWaitTimeSeconds=20 --profile mercadofresco-dev --region eu-west-1

Si usas sondeo largo, sube el tiempo de espera de lectura del cliente HTTP por encima de 20 segundos o el SDK cortará la conexión antes de tiempo.

Retención, retardo, temporizadores y cargas grandes

Atributo Rango Por defecto Uso en MercadoFresco
MessageRetentionPeriod 60 s – 14 días 4 días 14 días en las DLQ, 4 en las normales
DelaySeconds de cola 0 – 15 min 0 10 s en cola-mercadofresco-correo
DelaySeconds por mensaje 0 – 15 min 900 s en «tu pedido sale del almacén»
MaximumMessageSize 1 KiB – 256 KiB 256 KiB Por defecto

Retención. Un mensaje que nadie borra se elimina al cumplir el periodo. Es una red de seguridad, no una función: si tus mensajes caducan, tienes un consumidor parado y una alarma que no saltó. En las DLQ se pone el máximo porque ahí el mensaje espera a que un humano lo mire.

Cola de retardo. Con DelaySeconds a nivel de cola, todos los mensajes nacen invisibles. Luis lo usa en la cola de correo con 10 segundos: da margen a que la escritura en Aurora se replique a aurora-mf-lector-1/-2 antes de que el consumidor lea el pedido. Sin ese margen, un 0,3 % de los correos salían con «pedido no encontrado».

Temporizador por mensaje. El mismo efecto pero por mensaje, con DelaySeconds en SendMessage. No está disponible en colas FIFO, donde solo existe el retardo a nivel de cola.

Cargas grandes. 256 KiB es mucho para un pedido y poco para una factura PDF. La solución no es comprimir: es la biblioteca de cliente extendida (Extended Client Library), que guarda la carga en S3 y envía por la cola solo un puntero {"s3_bucket": ..., "s3_key": ...}. A mano son tres líneas: un put_object en mercadofresco-informes-analitica y un send_message con la referencia. Cuidado con el ciclo de vida: si el consumidor borra el mensaje y nadie borra el objeto, pagas almacenamiento indefinidamente; una regla de ciclo de vida de S3 (02-03) con caducidad a 7 días lo resuelve.

Colas de mensajes fallidos y redrive

Un mensaje que el consumidor no puede procesar vuelve a la cola. Si la causa es transitoria —el ERP caído— es justo lo que quieres. Si es permanente —JSON mal formado, sku inexistente, un None donde debía haber un número— el mensaje volverá para siempre, consumiendo peticiones, bloqueando el orden en colas FIFO y ensuciando los registros. Es un mensaje envenenado (poison pill).

La cola de mensajes fallidos (DLQ) es una cola normal a la que SQS mueve los mensajes recibidos demasiadas veces. Se configura con una política de redrive en la cola de origen:

{
  "deadLetterTargetArn": "arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas",
  "maxReceiveCount": 3
}

maxReceiveCount cuenta recepciones, no fallos: con 3, el mensaje se intenta tres veces y a la cuarta recepción se mueve.

maxReceiveCount Efecto
1 Sin reintentos: cualquier fallo transitorio manda el mensaje a la DLQ. Casi nunca correcto
3 – 5 Recomendado: absorbe caídas cortas y aísla rápido los mensajes envenenados
50+ El envenenado se reintenta durante horas; los registros se llenan de ruido

Aquí formalizamos mercadofresco-miniaturas-fallidas, que apareció en el módulo 2 junto a mercadofresco-generar-miniaturas y nunca se configuró del todo. La arquitectura queda así: al subir una foto a mercadofresco-catalogo-fotos, S3 publica en cola-mercadofresco-miniaturas; la Lambda consume; si falla tres veces, el mensaje acaba en mercadofresco-miniaturas-fallidas.

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-miniaturas \
  --attributes "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}" \
  --profile mercadofresco-dev --region eu-west-1

Ese escapado triple es tan feo como parece: RedrivePolicy es un JSON dentro de una cadena dentro de otro JSON. En 09-01 lo escribiremos en CloudFormation y el problema desaparece.

Qué mirar cuando algo cae en la DLQ, en este orden: (1) el ApproximateReceiveCount —si es exactamente maxReceiveCount + 1, agotó los reintentos—; (2) el cuerpo: ¿JSON válido?, ¿campos esperados?, ¿versión del contrato conocida?; (3) los registros de CloudWatch del consumidor filtrados por el MessageId. Aquí se paga el haber puesto el MessageId en cada línea de registro.

Redrive. Corregido el fallo, no hay que reenviar a mano:

aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas \
  --max-number-of-messages-per-second 20 --profile mercadofresco-dev --region eu-west-1

Sin --destination-arn los mensajes vuelven a su cola de origen. El límite de velocidad no es opcional en la práctica: reprocesar 40.000 mensajes de golpe puede tumbar el sistema que ya estaba frágil. En 07-05 escribiremos el runbook completo de Marta.

Regla dura de MercadoFresco: toda cola tiene DLQ, y toda DLQ tiene alarma. Una DLQ sin alarma es un cubo de basura donde se pierden ventas en silencio.

Crear las colas por CLI, política de cola y cifrado

Empezamos por la DLQ, porque la cola principal necesita su ARN.

export PERFIL="--profile mercadofresco-dev --region eu-west-1"
export ETIQUETAS='Proyecto=mercadofresco,Entorno=produccion,Componente=integracion,Propietario=marta,CentroCoste=tecnologia'

aws sqs create-queue --queue-name mercadofresco-pedidos-fallidos \
  --attributes MessageRetentionPeriod=1209600 --tags "$ETIQUETAS" $PERFIL
DLQ=$(aws sqs get-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/mercadofresco-pedidos-fallidos \
  --attribute-names QueueArn --query 'Attributes.QueueArn' --output text $PERFIL)

aws sqs create-queue --queue-name cola-mercadofresco-pedidos --tags "$ETIQUETAS" $PERFIL \
  --attributes "{\"VisibilityTimeout\":\"120\",\"MessageRetentionPeriod\":\"345600\",
    \"ReceiveMessageWaitTimeSeconds\":\"20\",\"KmsMasterKeyId\":\"alias/mercadofresco-datos\",
    \"KmsDataKeyReusePeriodSeconds\":\"300\",
    \"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ\\\",\\\"maxReceiveCount\\\":\\\"4\\\"}\"}"

aws sqs create-queue --queue-name cola-mercadofresco-correo --tags "$ETIQUETAS" $PERFIL \
  --attributes '{"VisibilityTimeout":"90","DelaySeconds":"10",
                 "ReceiveMessageWaitTimeSeconds":"20",
                 "KmsMasterKeyId":"alias/mercadofresco-datos"}'

Cada valor responde a una medición: VisibilityTimeout=120 en pedidos porque el consumidor del ERP tarda 6,5 s en el p95 y 38 s en el peor caso observado; 90 en correo porque el SMTP llega a 42 s en el p99; maxReceiveCount=4 para tres reintentos reales antes de aislar; y KmsDataKeyReusePeriodSeconds=300, que hace que SQS reutilice la clave de datos 5 minutos y reduce drásticamente las llamadas a KMS sin comprometer la seguridad de forma apreciable. La cola FIFO de stock se crea distinto:

aws sqs create-queue --queue-name cola-mercadofresco-stock.fifo \
  --attributes '{"FifoQueue":"true","ContentBasedDeduplication":"false",
                 "DeduplicationScope":"messageGroup","FifoThroughputLimit":"perMessageGroupId",
                 "VisibilityTimeout":"60","ReceiveMessageWaitTimeSeconds":"20"}' \
  --tags "$ETIQUETAS" $PERFIL

DeduplicationScope=messageGroup con FifoThroughputLimit=perMessageGroupId activan el modo de alto rendimiento: el límite pasa a aplicarse por grupo en lugar de por cola. Con el sku como grupo y 3.400 SKU, el paralelismo es más que suficiente.

Las dos capas de autorización. La política de identidad (IAM, 04-01) se adjunta al rol que llama. La política de cola (basada en recurso) se adjunta a la cola y es imprescindible cuando quien envía no es un principal IAM de tu cuenta: otro servicio de AWS (SNS, S3, EventBridge) u otra cuenta.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PermitirAvisosDeS3Catalogo",
    "Effect": "Allow",
    "Principal": { "Service": "s3.amazonaws.com" },
    "Action": "sqs:SendMessage",
    "Resource": "arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-miniaturas",
    "Condition": {
      "ArnLike":      { "aws:SourceArn": "arn:aws:s3:::mercadofresco-catalogo-fotos" },
      "StringEquals": { "aws:SourceAccount": "111122223333" }
    }
  }]
}

aws:SourceArn limita el permiso a ese bucket; sin él, cualquier bucket de S3 del mundo podría escribir en tu cola. aws:SourceAccount cierra el problema del diputado confundido, en el que un servicio de AWS actúa legítimamente en nombre de un tercero (04-01).

Cifrado. SQS ofrece SSE-SQS (claves de AWS, gratis, activo por defecto en colas nuevas) y SSE-KMS con tu clave. MercadoFresco usa alias/mercadofresco-datos en las colas con datos personales —los pedidos llevan dirección de entrega— por coherencia con el módulo 4 y porque las llamadas a KMS quedan en trail-mercadofresco. Ojo con el detalle que rompe despliegues: si la cola está cifrada con KMS, el productor necesita kms:GenerateDataKey y el consumidor kms:Decrypt sobre la clave, no solo sobre la cola; y si el productor es un servicio de AWS, la política de la clave debe permitírselo.

Productor y consumidor en Python

import json, os
from datetime import datetime, timezone
import boto3
from botocore.config import Config

# Un solo cliente reutilizado: crearlo por petición resuelve credenciales y abre
# conexiones nuevas cada vez, un error de rendimiento clásico.
sqs = boto3.client("sqs", config=Config(
    region_name="eu-west-1",
    retries={"max_attempts": 5, "mode": "standard"},  # reintentos ante 5xx y throttling
    connect_timeout=2, read_timeout=5,                # acotan el peor caso del envío
))
COLA_PEDIDOS = os.environ["URL_COLA_PEDIDOS"]


def confirmar_pedido(carrito, cliente, tarjeta):
    """Confirma un pedido: cobra, persiste y encola. Nada más."""
    cobro = pasarela.cobrar(tarjeta, carrito.importe_eur)                   # imprescindible
    pedido_id = aurora.insertar_pedido(carrito, cliente, cobro.referencia)  # imprescindible
    cuerpo = {
        "version": 1, "pedido_id": pedido_id, "cliente_id": cliente.id,
        "importe_eur": float(carrito.importe_eur),
        "franja_entrega": carrito.franja,          # "24h" o "estandar"
        "referencia_cobro": cobro.referencia,
        "lineas": [{"sku": l.sku, "unidades": l.unidades} for l in carrito.lineas],
        "confirmado_en": datetime.now(timezone.utc).isoformat(),
    }
    r = sqs.send_message(
        QueueUrl=COLA_PEDIDOS,
        MessageBody=json.dumps(cuerpo, ensure_ascii=False),
        MessageAttributes={
            "tipo_evento":    {"DataType": "String", "StringValue": "PedidoConfirmado"},
            "franja_entrega": {"DataType": "String", "StringValue": carrito.franja},
            "version":        {"DataType": "Number", "StringValue": "1"},
        },
    )
    log.info("pedido encolado", extra={"pedido_id": pedido_id, "message_id": r["MessageId"]})
    return pedido_id

Tres decisiones que merecen comentario. El send_message va después del INSERT: si se encolara primero y el INSERT fallara, habría un mensaje anunciando un pedido inexistente. En este orden el fallo posible es el contrario —pedido escrito y mensaje no enviado—, menos grave pero real, y tiene nombre: el problema de la doble escritura, que resolveremos con el patrón outbox en 07-05. Los reintentos del SDK están configurados con retroceso exponencial ante 5xx. Y los tiempos de espera son cortos: sin ellos botocore usa 60 segundos, de sobra para agotar el pool de conexiones de la tienda durante un incidente de SQS.

Para volúmenes altos —la carga nocturna encola 40.000 movimientos de stock— usa send_message_batch, que acepta 10 mensajes por petición (Entries con Id y MessageBody) y divide la factura por diez. La trampa: devuelve HTTP 200 aunque haya entradas fallidas, así que hay que recorrer r.get("Failed", []) y registrar cada fallo. Si no lo haces, pierdes mensajes sin enterarte.

El consumidor que corre en una instancia o contenedor tiene una forma canónica que conviene copiar tal cual:

_seguir = True

def _parar(signum, frame):
    """Apagado ordenado: con SIGTERM del ASG o de ECS, termina el lote en curso."""
    global _seguir
    _seguir = False

signal.signal(signal.SIGTERM, _parar)


def bucle_de_consumo():
    while _seguir:
        respuesta = sqs.receive_message(
            QueueUrl=COLA,
            MaxNumberOfMessages=10,         # el máximo: menos peticiones, menos coste
            WaitTimeSeconds=20,             # sondeo largo: imprescindible
            MessageAttributeNames=["All"],  # sin esto, MessageAttributes llega vacío
            AttributeNames=["ApproximateReceiveCount", "SentTimestamp"],
        )
        mensajes = respuesta.get("Messages", [])   # ¡la clave NO existe si no hay mensajes!
        if not mensajes:
            continue

        borrables = []
        for m in mensajes:
            intentos = int(m["Attributes"]["ApproximateReceiveCount"])
            try:
                procesar_pedido(json.loads(m["Body"]), intentos)
                borrables.append({"Id": m["MessageId"], "ReceiptHandle": m["ReceiptHandle"]})
            except ErrorPermanente as e:
                # Datos inválidos: reintentar no arregla nada. Se archiva y se borra para
                # que no consuma cuatro recepciones antes de acabar en la DLQ.
                log.error("mensaje invalido", extra={"message_id": m["MessageId"], "error": str(e)})
                archivar_para_revision(m)
                borrables.append({"Id": m["MessageId"], "ReceiptHandle": m["ReceiptHandle"]})
            except Exception as e:
                # Transitorio: NO se borra. Volverá tras el tiempo de visibilidad.
                log.warning("fallo transitorio", extra={"message_id": m["MessageId"],
                                                        "intentos": intentos, "error": str(e)})

        if borrables:
            r = sqs.delete_message_batch(QueueUrl=COLA, Entries=borrables)
            for fallo in r.get("Failed", []):
                log.error("no se pudo borrar", extra={"id": fallo["Id"], "code": fallo["Code"]})

Lo que separa un consumidor correcto de uno que da problemas a las tres semanas: usar respuesta.get("Messages", []) (la respuesta no incluye la clave cuando está vacía, y respuesta["Messages"] lanza KeyError en el primer sondeo); pedir MessageAttributeNames=["All"] o los atributos llegarán vacíos; distinguir error permanente de transitorio; borrar por lotes; manejar SIGTERM para que el ASG no mate al proceso a mitad de mensaje; y usar ApproximateReceiveCount como señal para registrar más detalle o pasar a un camino degradado en el tercer intento.

Consumir desde Lambda: mapeo de origen de eventos

Para el consumidor de correo, montar instancias es desproporcionado: trabajo corto, esporádico y sin estado. La conexión entre cola y función se llama mapeo de origen de eventos.

aws lambda create-event-source-mapping \
  --function-name mercadofresco-enviar-correo-pedido \
  --event-source-arn arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-correo \
  --batch-size 10 --maximum-batching-window-in-seconds 5 \
  --scaling-config MaximumConcurrency=20 \
  --function-response-types ReportBatchItemFailures $PERFIL
Parámetro Qué hace Valor en MercadoFresco
--batch-size Mensajes por invocación (1–10.000) 10: el SMTP no se beneficia de más
--maximum-batching-window-in-seconds Espera a llenar el lote (0–300) 5: menos invocaciones en valle
MaximumConcurrency Tope de instancias concurrentes (2–1.000) 20: protege el límite del SMTP
--function-response-types Activa los fallos parciales de lote ReportBatchItemFailures

El tope de concurrencia es la pieza defensiva clave. Lambda escala agresivamente con la profundidad de la cola: puede pasar de 5 a 60 sondeadores en un minuto. Si detrás hay Aurora con 200 conexiones o un SMTP con límite de 50 envíos por segundo, esa elasticidad se convierte en una denegación de servicio que te haces a ti mismo. Es la misma idea de contrapresión que generalizaremos en 07-05.

El problema del lote entero. Por defecto, si la función lanza una excepción, Lambda considera fallido todo el lote y no borra ninguno de los diez mensajes: si nueve iban bien y uno mal, los nueve se reprocesan. ReportBatchItemFailures lo arregla devolviendo solo los que fallaron.

import json

def handler(event, context):
    """Consumidor de cola-mercadofresco-correo con fallos parciales de lote."""
    fallidos = []
    for registro in event["Records"]:
        try:
            pedido = json.loads(registro["body"])
            franja = registro.get("messageAttributes", {}) \
                             .get("franja_entrega", {}).get("stringValue", "estandar")
            enviar_correo_confirmacion(pedido["cliente_id"], pedido["pedido_id"],
                                       pedido["importe_eur"], franja)
        except Exception as e:
            print(json.dumps({"nivel": "ERROR", "message_id": registro["messageId"],
                              "error": str(e)}))
            # Solo este mensaje vuelve a la cola; el resto del lote se borra.
            fallidos.append({"itemIdentifier": registro["messageId"]})
    return {"batchItemFailures": fallidos}

Dos condiciones para que funcione: declarar ReportBatchItemFailures en el mapeo y devolver la estructura exacta {"batchItemFailures": [{"itemIdentifier": "..."}]}. Si el nombre de la clave no es ese, Lambda lo ignora en silencio.

Además: no hay que llamar a delete_message —Lambda borra los mensajes al terminar bien— y el tiempo de visibilidad de la cola debe ser al menos 6 veces el timeout de la función, que es la recomendación de AWS y evita que Lambda reciba el mismo mensaje mientras aún lo procesa.

Métricas, alarmas y autoescalado por profundidad de cola

SQS publica métricas en CloudWatch cada minuto y sin coste adicional.

Métrica Qué significa Señal
ApproximateNumberOfMessagesVisible Mensajes esperando consumidor Profundidad de la cola
ApproximateNumberOfMessagesNotVisible Mensajes en vuelo Trabajo en curso
ApproximateAgeOfOldestMessage Segundos del mensaje más antiguo La métrica de salud real
NumberOfMessagesSent / Deleted Flujo Sent > Deleted sostenido = se acumula
NumberOfEmptyReceives Sondeos vacíos Alto = sondeo corto = factura
SentMessageSize Tamaño medio Cerca de 256 KiB = pasar a S3

Si solo puedes vigilar una, vigila ApproximateAgeOfOldestMessage. La profundidad engaña: 5.000 mensajes con consumidores rápidos es normal un viernes a las 19:00, y 40 mensajes parados una hora es un incidente. La antigüedad responde a la pregunta que importa: ¿cuánto tarda hoy un pedido en llegar al almacén?

aws cloudwatch put-metric-alarm --alarm-name mercadofresco-pedidos-cola-retrasada \
  --namespace AWS/SQS --metric-name ApproximateAgeOfOldestMessage \
  --dimensions Name=QueueName,Value=cola-mercadofresco-pedidos \
  --statistic Maximum --period 60 --evaluation-periods 3 --threshold 300 \
  --comparison-operator GreaterThanThreshold --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco $PERFIL

La segunda alarma es la misma orden cambiando la métrica a ApproximateNumberOfMessagesVisible, la dimensión a mercadofresco-pedidos-fallidos, --threshold 0 y --period 300: cualquier mensaje en la DLQ es un incidente. --treat-missing-data notBreaching es importante en ambas, porque SQS deja de publicar métricas de una cola que lleva horas vacía y sin ese ajuste la alarma pasaría a INSUFFICIENT_DATA generando ruido nocturno. Las dos se añaden al panel mercadofresco-produccion.

Autoescalado por profundidad. El ASG de trabajadores asg-mercadofresco-trabajadores no debe escalar por CPU: un consumidor que espera al ERP tiene la CPU al 4 % y está saturado. La métrica correcta es el retraso por instancia.

def publicar_retraso_por_instancia():
    """Se ejecuta cada minuto (EventBridge Scheduler, que veremos en 07-03)."""
    a = sqs.get_queue_attributes(QueueUrl=COLA, AttributeNames=[
        "ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"])["Attributes"]
    pendientes = int(a["ApproximateNumberOfMessages"]) + \
                 int(a["ApproximateNumberOfMessagesNotVisible"])
    grupo = asg.describe_auto_scaling_groups(
        AutoScalingGroupNames=["asg-mercadofresco-trabajadores"])["AutoScalingGroups"][0]
    en_servicio = max(1, sum(1 for i in grupo["Instances"]
                             if i["LifecycleState"] == "InService"))
    cw.put_metric_data(Namespace="MercadoFresco/Tienda", MetricData=[{
        "MetricName": "MensajesPendientesPorInstancia",
        "Dimensions": [{"Name": "Cola", "Value": "cola-mercadofresco-pedidos"}],
        "Value": pendientes / en_servicio, "Unit": "Count"}])

Sobre esa métrica se define una política de seguimiento de objetivo con valor 35. El número no es arbitrario: un trabajador procesa unos 7 mensajes por minuto contra el ERP y Marta quiere vaciar la cola en 5 minutos como máximo, así que 7 × 5 = 35.

Coste real y limpieza

SQS cobra exclusivamente por petición a la API, con el primer millón mensual gratis de forma permanente. Un envío, una recepción (traiga 0 o 10 mensajes) y un borrado son una petición cada uno; las operaciones por lotes cuentan como una sola, que es la razón económica de usarlas siempre.

Concepto Peticiones/mes
Envíos a la cola de pedidos (240.000 pedidos/mes) 240.000
Recepciones con sondeo largo y lotes de 10 190.000
Borrados por lotes 24.000
Cola de correo (envío + recepción + borrado) 300.000
Miniaturas y stock 160.000
Total ~914.000 → dentro del millón gratuito
KMS (GenerateDataKey, reutilización 300 s) ~8.600 → 0,03 USD

Coste total: prácticamente cero. Todo el desacoplamiento de MercadoFresco cabe en el nivel gratuito permanente, y el único gasto apreciable son las llamadas a KMS. A cambio, 2.500 ms menos por petición liberan hilos en asg-mercadofresco-tienda mucho antes y el grupo escala a 3 en lugar de a 4 los viernes. Dos formas de convertir ese cero en una factura desagradable: sondeo corto en bucle (unos 100 USD al mes por proceso ocioso) y un KmsDataKeyReusePeriodSeconds bajo con muchos consumidores.

for C in cola-mercadofresco-pedidos cola-mercadofresco-correo \
         cola-mercadofresco-stock.fifo mercadofresco-pedidos-fallidos; do
  aws sqs delete-queue --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/$C $PERFIL
done
aws lambda delete-event-source-mapping --uuid <UUID_DEL_MAPEO> $PERFIL
aws cloudwatch delete-alarms --alarm-names mercadofresco-pedidos-cola-retrasada \
  mercadofresco-pedidos-dlq-con-mensajes $PERFIL

Borrar una cola tarda hasta 60 segundos y no se puede crear otra con el mismo nombre durante ese minuto, un detalle molesto en scripts que borran y recrean.

Errores Comunes y Consejos

Tiempo de visibilidad más corto que el procesamiento. El error número uno. Síntoma: duplicados aleatorios que crecen con la carga y ReceiptHandleIsInvalid en los registros. Mide el p99 de tu consumidor, multiplícalo por dos, y si no puedes acotarlo usa latido con ChangeMessageVisibility.

Dejar el sondeo corto por defecto. Una cola creada por consola tiene ReceiveMessageWaitTimeSeconds=0. Ponlo a 20 en la cola y en cada llamada.

Suponer que un mensaje llega una sola vez. Las colas estándar duplican por diseño. Todo consumidor con efectos externos —cobrar, enviar correo, llamar a un socio— debe ser idempotente (07-05).

Crear una cola FIFO «por si acaso». Limita el rendimiento, complica el consumidor e induce a creer que ya no hacen falta reintentos idempotentes. Y un solo MessageGroupId para toda la cola FIFO convierte una cola distribuida en un proceso serie: un mensaje en vuelo como máximo, tengas los consumidores que tengas.

Cola sin DLQ. Un mensaje envenenado se reintenta hasta agotar la retención y —en FIFO— bloquea todo su grupo durante días. Y DLQ sin alarma es peor que no tenerla: da falsa sensación de control mientras los pedidos se acumulan en silencio.

Leer respuesta["Messages"] sin .get() da KeyError en el primer sondeo vacío; olvidar MessageAttributeNames=["All"] hace que los atributos lleguen vacíos y el consumidor decida con valores por defecto; e ignorar Failed en send_message_batch y delete_message_batch esconde fallos parciales bajo un HTTP 200.

Cifrar con KMS y olvidar los permisos de clave. El síntoma es un AccessDenied confuso que menciona KMS y no SQS.

Consejo: mete el MessageId en todas las líneas de registro. Cuando investigues un mensaje de la DLQ tres días después, será lo único que te permita reconstruir la historia.

Consejo: una cola por tipo de trabajo. Una cola compartida hace que el consumidor lento del ERP retrase los correos. Colas separadas permiten visibilidad, concurrencia y prioridades distintas. Y no uses SQS para prioridades: no existe la prioridad de mensaje; si necesitas urgente y normal, crea dos colas y da más consumidores a la urgente.

Ejercicios

Ejercicio 1: dimensionar la cola del almacén

El consumidor de cola-mercadofresco-almacen llama al ERP del socio. Marta mide: p50 1.180 ms, p95 6.500 ms, p99 31 s, peor caso en un mes 74 s. El ERP acepta como máximo 12 peticiones concurrentes. El viernes entran 900 pedidos/hora y ningún pedido puede tardar más de 10 minutos en llegar al almacén.

Determina: (a) el VisibilityTimeout y su justificación; (b) si conviene Lambda o instancias; (c) los parámetros del mapeo de origen de eventos si eliges Lambda; (d) maxReceiveCount y retención de la DLQ; (e) dos alarmas con umbrales calculados a partir de los datos, no inventados.

Ejercicio 2: el diagnóstico del correo duplicado

Tres viernes seguidos llegan quejas de correos de confirmación duplicados —dos, a veces tres— siempre entre las 18:00 y las 21:00. Los datos: ApproximateNumberOfMessagesVisible oscila entre 0 y 40; ApproximateAgeOfOldestMessage máximo 55 s; la Lambda tiene timeout 60 s, duración p50 900 ms y p99 47 s; la cola tiene VisibilityTimeout 60 s, batch-size 10 y sin ReportBatchItemFailures; la DLQ está vacía; aparecen Task timed out after 60.00 seconds ocasionales.

Explica: (a) las dos causas independientes de duplicado; (b) por qué solo ocurre en el pico; (c) por qué la DLQ vacía no es tranquilizadora; (d) las correcciones con valores concretos; (e) cuál elimina el problema de raíz y cuál solo reduce su frecuencia.

Ejercicio 3: FIFO para el stock

Diseña cola-mercadofresco-stock.fifo. Los mensajes son {"sku": "...", "delta": -2, "pedido_id": "...", "motivo": "reserva|liberacion|reposicion"}. Hay 3.400 SKU y en el pico se generan 2.800 movimientos por hora, concentrados en los 200 SKU más vendidos.

Responde: (a) qué usarías como MessageGroupId y qué tres alternativas descartas; (b) qué usarías como MessageDeduplicationId y si activarías ContentBasedDeduplication; (c) qué pasa si un mensaje del SKU FRUT-FRES-011 es envenenado y cómo lo mitigas; (d) si el modo de alto rendimiento es adecuado; (e) por qué esta cola no puede usar DelaySeconds por mensaje y qué harías si lo necesitaras.

Soluciones

Solución 1

(a) El peor caso es 74 s, así que 180 segundos es defendible: 2,4 veces el peor caso registrado. No conviene subirlo a 900, porque si el trabajador muere el mensaje quedaría bloqueado 15 minutos y el objetivo es de 10. Alternativa superior: 120 s con latido, que da recuperación rápida ante caída del trabajador sin límite superior real de duración.

(b) Lambda, con reservas. A favor: trabajo esporádico, sin estado y de duración variable; con casi nada de tráfico de madrugada, pagar instancias encendidas es absurdo. En contra: el p99 de 31 s obliga a un timeout alto y una función que espera se paga íntegra. El factor decisivo es el límite de 12 concurrentes del ERP: MaximumConcurrency lo impone de forma declarativa, mientras que con instancias haría falta un semáforo distribuido.

(c) --batch-size 1 (con lotes de 10 y 74 s por mensaje, una invocación podría necesitar 740 s); --maximum-batching-window-in-seconds 0; MaximumConcurrency=12; timeout de la función 120 s; y VisibilityTimeout de la cola 720 s, aplicando la regla de 6 × timeout, que aquí gana a la estimación de (a). Comprobación de capacidad: 12 ÷ 1,18 s = 10 pedidos/s = 36.000/hora, y aun en el p95 (6,5 s) son 6.600/hora. Margen de sobra sobre 900.

(d) maxReceiveCount=4: con 720 s de visibilidad, tres reintentos cubren unos 36 minutos de indisponibilidad antes de aislar. Retención de la DLQ 14 días, el máximo, porque lo que cae ahí son ventas cobradas que Marta debe poder recuperar aunque el incidente sea un viernes de puente.

(e) ApproximateAgeOfOldestMessage > 480 durante 2 periodos de 60 s: el requisito son 600 s, así que se avisa a los 8 minutos para dejar margen de reacción. Y ApproximateNumberOfMessagesVisible > 0 en la DLQ. Una tercera recomendable: Errors de la función > 10 en 5 minutos, que detecta el ERP caído antes.

Solución 2

(a) Causa 1: el tiempo de visibilidad es igual al timeout de la función. Ambos son 60 s: cuando una invocación tarda 47 s o agota el timeout, el mensaje se hace visible antes o justo cuando la función termina, y Lambda lo entrega otra vez. Falta el factor 6 recomendado. Causa 2: no está activado ReportBatchItemFailures. Con batch-size 10, si el mensaje número 7 falla o el lote agota el timeout, ninguno de los diez se borra y los otros nueve se reprocesan; eso explica los casos de tres duplicados.

(b) Ambas causas dependen de la latencia del SMTP, que se degrada cuando se envían muchos correos a la vez. En valle la función tarda 900 ms y no se acerca a ningún límite; a partir de las 18:00 la concurrencia crece con la profundidad de la cola, el proveedor estrangula, la duración se dispara al p99 y las dos causas se activan a la vez. Es un fallo que no se reproduce en pruebas porque solo aparece con concurrencia real.

(c) Que la DLQ esté vacía significa que ningún mensaje agotó sus recepciones, no que todo vaya bien. Aquí el fallo es de doble entrega con éxito: el mensaje se procesa correctamente dos veces y se borra. Un duplicado exitoso nunca llega a la DLQ. La DLQ detecta trabajo que no se hizo, jamás trabajo que se hizo de más.

(d) VisibilityTimeout a 360 s (6 × 60); activar ReportBatchItemFailures y devolver batchItemFailures; MaximumConcurrency=20 para no estrangular al SMTP; batch-size a 5 para acotar la duración por invocación; idempotencia en el consumidor escribiendo PEDIDO#<id>#correo-confirmacion en DynamoDB con escritura condicional y TTL de 24 h; y alarmas sobre Duration p99 y Throttles.

(e) Las cuatro primeras reducen la frecuencia: hacen los duplicados mucho más raros pero no imposibles, porque las colas estándar duplican por diseño. La única que elimina el problema de raíz es la idempotencia: recibir el mensaje dos veces pasa a ser inocuo. Esta es la tesis de 07-05, y el motivo de que la configuración correcta sea necesaria pero nunca suficiente.

Solución 3

(a) MessageGroupId = sku. El orden solo importa entre movimientos del mismo producto, y con 3.400 SKU hay paralelismo de sobra. Descartadas: un grupo fijo, que impone orden global innecesario y un solo mensaje en vuelo; pedido_id, que garantiza el orden dentro de un pedido pero no entre pedidos distintos del mismo SKU, que es justo donde está la condición de carrera; y categoria, que deja una docena de grupos y crea grupos calientes en las categorías más vendidas —el mismo error que una clave de partición caliente en DynamoDB (06-02)—.

(b) Un identificador determinista y único por movimiento lógico: f"{pedido_id}:{sku}:{motivo}". Si el productor reintenta tras un tiempo de espera agotado, el mismo movimiento produce el mismo identificador y SQS lo descarta dentro de la ventana de 5 minutos. ContentBasedDeduplication se deja desactivado: el cuerpo incluye una marca de tiempo, con lo que el hash cambiaría entre reintentos; y peor aún, dos movimientos legítimamente idénticos —dos reposiciones de 10 unidades del mismo SKU en la misma ventana— se deduplicarían por error, perdiendo stock real.

(c) Es el escenario más grave de FIFO: como SQS no entrega el siguiente mensaje del grupo hasta que el actual se borra, todo el grupo FRUT-FRES-011 queda bloqueado, y con retención de 4 días ese SKU pasaría días sin actualizar stock mientras los demás funcionan —un fallo parcial silencioso—. Mitigación: DLQ con maxReceiveCount=3; alarma sobre la DLQ; validación de esquema en el productor; distinguir permanente de transitorio en el consumidor; y vigilar ApproximateAgeOfOldestMessage, que en FIFO delata un grupo bloqueado aunque la profundidad total sea baja.

(d) Sí, y es gratuito. DeduplicationScope=messageGroup con FifoThroughputLimit=perMessageGroupId levanta el límite de 300 msg/s por cola. Aunque 2.800 movimientos/hora son solo 0,8 msg/s, activarlo protege ante campañas o reposiciones masivas; la única consecuencia es que la deduplicación pasa a ser por grupo, irrelevante porque el identificador ya incluye el sku.

(e) Las colas FIFO no admiten temporizador por mensaje, solo el retardo a nivel de cola. Para aplazar un movimiento concreto —liberar el stock de un pedido no pagado a los 15 minutos— hay tres opciones: DelaySeconds de cola, descartada porque retrasaría también las reservas urgentes; devolver el mensaje con ChangeMessageVisibility, descartada porque en FIFO bloquea el grupo entero; y la correcta, sacar el aplazamiento de la cola y usar un estado Wait de Step Functions (07-04) o una regla programada de EventBridge (07-03), de modo que el mensaje solo entre en la cola cuando haya que aplicarlo.

Conclusión

Confirmar un pedido en MercadoFresco ha pasado de 2.893 ms de mediana a unos 400 ms en el percentil 95, y de ocho puntos de fallo encadenados a dos: cobrar la tarjeta y escribir en Aurora. Lo demás vive ahora en cola-mercadofresco-pedidos y cola-mercadofresco-correo, con cola-mercadofresco-stock.fifo para lo único que necesitaba orden real y mercadofresco-pedidos-fallidos recogiendo lo que no se pudo procesar. El ERP del almacén puede caerse dos horas sin que se pierda una sola venta.

En el camino has visto los conceptos que se repetirán todo el módulo: el modelo de sondeo, que convierte la cola en amortiguador y da contrapresión gratis; el tiempo de espera de visibilidad, fuente número uno de duplicados cuando se queda corto; el hecho de que no borrar es reprocesar, que es a la vez la garantía de durabilidad y la razón de que los consumidores deban ser idempotentes; el sondeo largo, que separa una factura de cero de una de cientos de euros; y las colas de mensajes fallidos con su maxReceiveCount, su alarma obligatoria y su redrive.

Pero ha quedado una costura a la vista. cola-mercadofresco-pedidos es un canal punto a punto: cada mensaje lo procesa un consumidor y desaparece. Con almacén, correo, reparto y analítica interesados en el mismo hecho —«se ha confirmado un pedido»—, o la tienda envía cuatro mensajes a cuatro colas distintas —y entonces vuelve a saber quiénes son sus consumidores, justo lo que queríamos evitar— o un consumidor único reparte el trabajo y se convierte en el nuevo punto de fallo. Cuando el mes que viene márketing pida enterarse también de los pedidos para su programa de fidelización, habrá que volver a tocar el código de la tienda. El desacoplamiento está a medias.

En 07-02, «Amazon SNS», resolveremos esa mitad que falta: un tema al que la tienda publica una vez y varias colas suscritas que reciben cada una su copia. Veremos el patrón fan-out SNS→SQS, por qué conviene poner una cola entre el tema y cada consumidor en lugar de suscribir funciones Lambda directamente, cómo filtrar por atributos para que la cola del reparto en 24 horas solo reciba lo que le incumbe, y por qué el tema alertas-mercadofresco que usamos desde el módulo 5 es exactamente el mismo mecanismo aplicado a las alarmas.

Curso de AWS

Módulo 1: Introducción a AWS

Módulo 2: Servicios principales de AWS

Módulo 3: Redes y entrega de contenido

Módulo 4: Seguridad e identidad

Módulo 5: Monitorización y gestión

Módulo 6: Bases de datos

Módulo 7: Integración de aplicaciones

Módulo 8: Herramientas para desarrolladores

Módulo 9: Infraestructura como código y gobierno de cuentas

Módulo 10: Contenedores en AWS

Módulo 11: Mejores prácticas y gestión de costos

© Copyright 2026. Todos los derechos reservados