El problema
Un mensaje entra en tu cola con un campo que tu código no espera. El consumidor lanza una excepción. El mensaje no se borra, así que al expirar el visibility timeout vuelve a la cola. Se vuelve a entregar. Vuelve a fallar.
Eso, solo, no es un desastre: es lo que hace fiable a una cola. El desastre es que no hay nada que detenga el bucle. Sin una DLQ configurada, ese mensaje se reintentará hasta agotar la retención de la cola: por defecto cuatro días, y hasta catorce si alguien subió el valor.
Mientras tanto pasan tres cosas, y ninguna sale en un dashboard hasta que la buscas:
- Pagas por fallar. Cada reintento es una invocación de Lambda y varias peticiones de SQS. Un mensaje venenoso puede costarte miles de invocaciones que están garantizadas a no servir para nada.
- Contamina los lotes. Si tu función no reporta fallos parciales, ese mensaje arrastra a los otros nueve del lote en cada vuelta.
- Envenena tus alarmas. Tu tasa de error nunca baja, así que dejas de mirarla, y el día que se rompe otra cosa no te enteras.
Y al final el mensaje desaparece. A los cuatro días se borra por retención, sin avisar y sin dejar copia. El dato que necesitabas para entender el bug se ha ido.
La solución
Una segunda cola y un número. En la redrive policy de la cola de origen
declaras dos cosas: cuál es la DLQ y cuántas recepciones aguanta un mensaje
antes de ir a parar allí (maxReceiveCount).
Cuando la cuenta de recepciones supera ese número, SQS mueve el mensaje a la DLQ. El consumidor deja de verlo, el bucle se corta y el mensaje se conserva para que puedas mirarlo.
La segunda mitad del patrón —la que más gente se salta— es el redrive: una vez arreglado el bug, SQS puede mover los mensajes de vuelta de la DLQ a la cola original para que se reprocesen. Una DLQ sin un plan de redrive es un cementerio con buenas vistas.
Cuándo usarlo
- Siempre que haya una cola SQS. No hay un caso razonable en el que quieras reintentos infinitos.
- En invocaciones asíncronas de Lambda, donde el equivalente es un on-failure destination (o una DLQ de función).
- En suscripciones de SNS, con el matiz importante de la trampa 2.
Cuándo NO usarlo
Cómo implementarlo
- Crea la DLQ primero. Es una cola normal; lo que la convierte en DLQ es que otra la referencie.
- Del mismo tipo que la de origen y —esto lo exige AWS— en la misma cuenta y la misma región.
- Dale más retención que a la cola de origen. 14 días frente a los 4 por defecto. La trampa 1 explica por qué no es opcional.
- Fija
maxReceiveCountentre 3 y 5. Suficiente para absorber fallos transitorios, poco para no quemar dinero. - Restringe quién puede usarla con la redrive allow policy:
byQueueadmite hasta 10 ARN de colas de origen. - Pon una alarma sobre
ApproximateNumberOfMessagesVisiblede la DLQ, con umbral 1. Cualquier mensaje ahí es una noticia. - Escribe el procedimiento de redrive antes de necesitarlo.
El código
import * as cdk from 'aws-cdk-lib';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cw from 'aws-cdk-lib/aws-cloudwatch';
const dlq = new sqs.Queue(this, 'ColaCobrosDlq', {
// Mas larga que la de origen: el mensaje llega con su timestamp
// original, no con uno nuevo. Ver trampas.
retentionPeriod: cdk.Duration.days(14),
});
const cola = new sqs.Queue(this, 'ColaCobros', {
retentionPeriod: cdk.Duration.days(4),
// Con margen sobre el timeout de la funcion: si el mensaje reaparece
// mientras la Lambda sigue viva, la cuenta de recepciones sube por
// una razon que no es un fallo.
visibilityTimeout: cdk.Duration.minutes(6),
deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});
// Una DLQ sin alarma es una falsa sensacion de seguridad.
new cw.Alarm(this, 'AlarmaDlq', {
metric: dlq.metricApproximateNumberOfMessagesVisible({
period: cdk.Duration.minutes(5),
statistic: 'Maximum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: cw.TreatMissingData.NOT_BREACHING,
});
Te va a morder
Coste
Una DLQ vacía no cuesta prácticamente nada: las colas no se facturan por existir ni por almacenar, solo por peticiones, y a una DLQ ociosa nadie le manda peticiones salvo tu alarma de CloudWatch.
Lo caro es no tenerla. La cuenta de un mensaje venenoso sin freno, con la retención por defecto de 4 días y un visibility timeout de 30 segundos, son del orden de once mil reintentos — cada uno con su invocación de Lambda y sus peticiones de SQS. Por un solo mensaje. Multiplícalo por el número de mensajes que trae un despliegue con un bug de deserialización.
Fuentes
Política de redrive, maxReceiveCount, redrive allow policy, requisito de misma
cuenta y región, comportamiento del timestamp de retención en colas estándar y
FIFO, y el reordenado al final de la cola:
Using dead-letter queues in Amazon SQS.
Retención por defecto y máxima de una cola:
Amazon SQS message quotas.
Política de reintentos de SNS a destinos gestionados:
Amazon SNS message delivery retries.