Problém: Co se stane se zprávami, které selžou

Tento vzor používají asynchronní systémy zasílání zpráv, jako jsou SQS, SNS, Kafka a EventBridge alespoň jednou doručení: zpráva je doručena alespoň jednou, ale mohlo by být doručeno vícekrát (duplikáty v případě opakování). To vytváří dva kritické scénáře:

  1. Přechodné chyby: Navazující služba je dočasně nedostupná. Automatické opakování problém řeší. Po několika pokusech je zpráva zpracována správně.
  2. Trvalé chyby (jedová pilulka): zpráva má nesprávný formát, obsahuje data které porušují obchodní invarianty nebo má spotřebitelský kodex chybu. Opakování nepomůže: zpráva bude i nadále selhávat po neomezenou dobu a potenciálně spotřebovávat zdroje blokování zpracování následných zpráv.

DLQ řeší druhý scénář: po konfigurovatelném počtu neúspěšných pokusů (maxReceiveCount v SQS, MAX_RETRY_ATTEMPTS v Kafkovi), zpráva se přesune do fronty nedoručených dopisů kde může být analyzován a znovu zpracován kontrolovaným způsobem.

DLQ: Smlouva o odolnosti

  • Nulová ztráta dat: Žádné zprávy nejsou tiše zahozeny
  • Izolace problému: Jedovaté pilulky neblokují dobré zprávy
  • Viditelnost: Zprávy v DLQ lze zkontrolovat pro ladění
  • Kontrolované přepracování: Po odstranění problému budou zprávy znovu zpracovány

DLQ v Amazon SQS

V SQS je DLQ prostě další fronta SQS nakonfigurovaná jako cíl pro zprávy které přesahují maxReceiveCount. Mechanismus je založen na časový limit viditelnosti: když spotřebitel obdrží zprávu, stane se po dobu „neviditelné“ pro ostatní spotřebitele časového limitu viditelnosti. Pokud nebude během této doby odstraněn (spotřebitel selhal nebo havaroval), SQS jej znovu zviditelní pro další pokus.

Počítadlo ApproximateReceiveCount se zvyšuje s každým příjmem. Když dosáhne maxReceiveCount, SQS přesune zprávu do nakonfigurovaného DLQ.

# Configurazione DLQ per SQS con Terraform

# 1. Crea la DLQ (stessa tipologia della coda principale)
resource "aws_sqs_queue" "ordini_dlq" {
  name                       = "ordini-queue-dlq"
  message_retention_seconds  = 1209600  # 14 giorni (massimo SQS)
  visibility_timeout_seconds = 300      # 5 minuti per elaborare dalla DLQ

  # CloudWatch alarm sulla DLQ
  tags = {
    Environment = "production"
    Alert       = "critical"
  }
}

# 2. Crea la coda principale con redrive policy che punta alla DLQ
resource "aws_sqs_queue" "ordini" {
  name                       = "ordini-queue"
  visibility_timeout_seconds = 60       # 60s per elaborare ogni messaggio
  receive_wait_time_seconds  = 20       # long polling
  message_retention_seconds  = 345600   # 4 giorni

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.ordini_dlq.arn
    maxReceiveCount     = 3  # 3 tentativi falliti -> DLQ
  })
}

# 3. CloudWatch Alarm: alert quando la DLQ ha messaggi
resource "aws_cloudwatch_metric_alarm" "dlq_not_empty" {
  alarm_name          = "ordini-dlq-not-empty"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "ApproximateNumberOfMessagesVisible"
  namespace           = "AWS/SQS"
  period              = "60"
  statistic           = "Sum"
  threshold           = "0"
  alarm_description   = "CRITICO: messaggi in DLQ ordini"

  dimensions = {
    QueueName = aws_sqs_queue.ordini_dlq.name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
}

Zkontrolujte a znovu zpracujte pomocí DLQ SQS

// DlqReprocessor.java - Riprocessa messaggi dalla DLQ SQS
import software.amazon.awssdk.services.sqs.*;
import software.amazon.awssdk.services.sqs.model.*;

public class SqsDlqReprocessor {

    private final SqsClient sqsClient;
    private final String dlqUrl;
    private final String mainQueueUrl;

    // Riprocessa tutti i messaggi dalla DLQ verso la coda principale
    public void reprocessAll() {
        int reprocessed = 0;
        List<Message> messages;

        do {
            messages = receiveMessages(dlqUrl, 10);

            for (Message message : messages) {
                try {
                    // Analizza il messaggio per capire il tipo di errore
                    System.out.printf("Riprocesso messaggio: id=%s, receiveCount=%s%n",
                        message.messageId(),
                        message.attributes().get(MessageSystemAttributeName.APPROXIMATE_RECEIVE_COUNT)
                    );

                    // Rimanda alla coda principale (con delay opzionale)
                    sqsClient.sendMessage(
                        SendMessageRequest.builder()
                            .queueUrl(mainQueueUrl)
                            .messageBody(message.body())
                            .messageAttributes(message.messageAttributes())
                            .delaySeconds(0)
                            .build()
                    );

                    // Elimina dalla DLQ
                    sqsClient.deleteMessage(
                        DeleteMessageRequest.builder()
                            .queueUrl(dlqUrl)
                            .receiptHandle(message.receiptHandle())
                            .build()
                    );

                    reprocessed++;

                } catch (Exception e) {
                    System.err.println("Errore reprocessing: " + e.getMessage());
                    // Non eliminare: rimane in DLQ
                }
            }

        } while (!messages.isEmpty());

        System.out.printf("Reprocessing completato: %d messaggi rimandati%n", reprocessed);
    }

    private List<Message> receiveMessages(String queueUrl, int maxMessages) {
        return sqsClient.receiveMessage(
            ReceiveMessageRequest.builder()
                .queueUrl(queueUrl)
                .maxNumberOfMessages(maxMessages)
                .waitTimeSeconds(5)
                .attributeNames(QueueAttributeName.ALL)
                .build()
        ).messages();
    }
}

DLQ v AWS Lambda: Funkční úroveň vs. Úroveň fronty

V AWS Lambda lze DLQ konfigurovat na dvou různých úrovních s odlišnou sémantikou:

  • SQS Queue DLQ: Nakonfigurováno ve zdrojové frontě SQS. Zprávy jsou přesunuty v DLQ, když SQS překročí maxReceiveCount. To se stává Před že je vyvolána Lambda. Toto je doporučená konfigurace pro Lambda + SQS.
  • Lambda funkce DLQ: konfigurováno na samotné Lambdě (pouze pro asynchronní vyvolání, ne pro mapování zdroje událostí pomocí SQS). Zachyťte selhání vyvolání Lambda, nikoli frontu.
# Terraform: Lambda con SQS event source e DLQ configurata sulla coda

resource "aws_lambda_function" "ordini_consumer" {
  function_name = "ordini-consumer"
  handler       = "handler.lambda_handler"
  runtime       = "python3.12"
  role          = aws_iam_role.lambda_role.arn
  timeout       = 30  # 30 secondi per messaggio

  # DLQ a livello di Lambda (solo per invocazioni async dirette)
  dead_letter_config {
    target_arn = aws_sqs_queue.lambda_dlq.arn
  }
}

# SQS come event source per Lambda
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
  event_source_arn = aws_sqs_queue.ordini.arn
  function_name    = aws_lambda_function.ordini_consumer.arn
  batch_size       = 10
  enabled          = true

  # Bisection: in caso di errore batch, prova prima con metà messaggi
  # Aiuta a isolare il poison pill senza mandare tutti in DLQ
  bisect_batch_on_function_error = true

  # Report batch item failures: Lambda può indicare quali specifici
  # messaggi nel batch hanno fallito (solo quelli vanno in DLQ)
  function_response_types = ["ReportBatchItemFailures"]
}

ReportBatchItemFailures: Granulární DLQ pro dávku

// Handler Lambda Python con batch item failures
// Solo i messaggi falliti vanno in DLQ, non l'intero batch

def lambda_handler(event, context):
    """
    ReportBatchItemFailures: ritorna solo i message ID falliti.
    SQS mandrà in DLQ solo quelli, non il batch intero.
    """
    failed_items = []

    for record in event['Records']:
        message_id = record['messageId']
        try:
            # Elabora il messaggio
            payload = json.loads(record['body'])
            process_ordine(payload)
            print(f"Successo: {message_id}")

        except PermanentError as e:
            # Errore permanente: vai in DLQ subito
            print(f"PERMANENTE: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

        except TransientError as e:
            # Errore transitorio: riprova (non aggiungere a failed)
            # SQS ritenterà l'intero batch se almeno uno fallisce
            # Con ReportBatchItemFailures, solo i falliti vengono ritentati
            print(f"TRANSITORIO: {message_id} - {e}")
            failed_items.append({'itemIdentifier': message_id})

    return {'batchItemFailures': failed_items}

DLQ v EventBridge

EventBridge má vlastní úroveň DLQ cíl: je-li doručení event do cíle (Lambda, SQS) selže po všech nakonfigurovaných opakováních, událost je odeslána v SQS DLQ specifikovaném v dead_letter_config pravidla.

# EventBridge DLQ per target Lambda
resource "aws_cloudwatch_event_target" "ordini_lambda" {
  rule           = aws_cloudwatch_event_rule.ordini.name
  event_bus_name = aws_cloudwatch_event_bus.mioapp.name
  arn            = aws_lambda_function.ordini_consumer.arn

  # Retry policy di EventBridge: quanti tentativi prima di DLQ
  retry_policy {
    maximum_event_age_in_seconds = 86400  # Riprova per max 24h
    maximum_retry_attempts       = 185    # ~exponential backoff su 24h
  }

  # DLQ per eventi non consegnati
  dead_letter_config {
    arn = aws_sqs_queue.eventbridge_dlq.arn
  }
}

# L'evento in DLQ EventBridge include metadata di debug
# {
#   "version": "1.0",
#   "timestamp": "...",
#   "requestId": "...",
#   "condition": "...",
#   "approximateInvokeCount": 185,
#   "requestParameters": {
#     "FunctionName": "ordini-consumer"
#   },
#   "responseParameters": {
#     "statusCode": 500,
#     "errorCode": "Lambda.ServiceException"
#   },
#   "originalEvent": { ... l'evento originale ... }
# }

Pokročilý vzor: Opakujte pokus s progresivním stažením pomocí SQS Delay

SQS umožňuje konfigurovat a zpoždění pro jednu zprávu (až 15 minut). V kombinaci s frontou FIFO a skupina zpráv, je možné realizovat exponenciální vzor opakování bez blokování dalších zpráv:

// RetryWithSqsDelay.java - Retry progressivo con SQS message delay
public class SqsExponentialRetry {

    private static final int MAX_ATTEMPTS = 5;
    private static final int MAX_DELAY_SECONDS = 900;  // 15 minuti (max SQS)

    public void handleWithRetry(String queueUrl, Message sqsMessage) {
        // Leggi il numero di tentativi corrente dal message attribute
        int currentAttempt = Integer.parseInt(
            sqsMessage.messageAttributes()
                .getOrDefault("retryAttempt",
                    MessageAttributeValue.builder().stringValue("0").build())
                .stringValue()
        );

        try {
            processMessage(sqsMessage.body());
            // Successo: elimina dalla coda
            sqsClient.deleteMessage(...);

        } catch (TransientException e) {
            if (currentAttempt >= MAX_ATTEMPTS) {
                // Troppi tentativi: manda in DLQ manuale
                sendToManualDLQ(sqsMessage, e);
                sqsClient.deleteMessage(...);
                return;
            }

            // Calcola delay esponenziale (1s, 2s, 4s, 8s, 16s...)
            int delaySeconds = (int) Math.min(
                Math.pow(2, currentAttempt),
                MAX_DELAY_SECONDS
            );

            // Rimanda il messaggio con delay e contatore incrementato
            sqsClient.sendMessage(
                SendMessageRequest.builder()
                    .queueUrl(queueUrl)
                    .messageBody(sqsMessage.body())
                    .delaySeconds(delaySeconds)
                    .messageAttributes(Map.of(
                        "retryAttempt", MessageAttributeValue.builder()
                            .stringValue(String.valueOf(currentAttempt + 1))
                            .dataType("Number")
                            .build()
                    ))
                    .build()
            );

            // Elimina il messaggio originale (non usare la DLQ automatica)
            sqsClient.deleteMessage(...);
        }
    }
}

Monitorování DLQ: Základní upozornění

DLQ je nutné aktivně sledovat. Zpráva v DLQ indikuje skutečný problém to vyžaduje pozornost. Metriky SQS ke sledování na CloudWatch:

# CloudWatch Metric Alarms per DLQ - AWS CLI

# Alert: qualsiasi messaggio in DLQ (soglia 0)
aws cloudwatch put-metric-alarm \
  --alarm-name "ordini-dlq-not-empty" \
  --alarm-description "CRITICO: messaggi in DLQ ordini" \
  --metric-name "ApproximateNumberOfMessagesVisible" \
  --namespace "AWS/SQS" \
  --dimensions Name=QueueName,Value=ordini-queue-dlq \
  --period 60 \
  --evaluation-periods 1 \
  --statistic Sum \
  --comparison-operator GreaterThanThreshold \
  --threshold 0 \
  --alarm-actions "arn:aws:sns:eu-west-1:123456:alerts-topic"

# Metriche importanti da monitorare su DLQ:
# - ApproximateNumberOfMessagesVisible: messaggi pronti per essere letti
# - ApproximateNumberOfMessagesNotVisible: messaggi in processing
# - NumberOfMessagesSent: rate di arrivo in DLQ (crescita = problema)
# - NumberOfMessagesDeleted: rate di reprocessing

Osvědčené postupy pro DLQ v asynchronních systémech

  • DLQ povinné pro každého spotřebitele: Ve výrobě není žádný asynchronní systém bez DLQ. Pokud chybí, neúspěšné zprávy jsou ztraceny nebo blokují tok.
  • Monitorujte DLQ s nulovými prahovými výstrahami: jakákoli zpráva v DLQ je známkou problému. Nečekejte, až se jich nashromáždí stovky, než zareagujete.
  • Obohaťte zprávy DLQ o metadata: přidat záhlaví nebo atributy s typem chyby, stacktrace, počtem opakování a časovým razítkem selhání. Bez těchto dat je ladění téměř nemožné.
  • Dlouhé uchování na DLQ: konfigurace 14denního uchování (maximální SQS) nebo alespoň 30 dní na Kafku. Zprávy v DLQ musí být k dispozici pro odloženou analýzu.
  • Pravidelně testujte přepracování: DLQ je k ničemu, pokud nevíte jak znovu zpracovat zprávy. Zdokumentujte a otestujte proces přepracování.
  • Samostatné typy chyb: Zvažte samostatné DLQ pro trvalé chyby (poškozené užitečné zatížení) a přechodné chyby (výpadek služeb). Strategie přepracování je odlišná.

Anti-Pattern: Ignorujte DLQ

Nejnebezpečnějším vzorem je nakonfigurovat DLQ a pak jej nesledovat. Zprávy se tiše hromadí týdny, pak si toho někdo všimne chybí kritická data. Vždy nastavte upozornění na DLQ: je to záchranná síť které byste nikdy neměli ignorovat.

Další kroky v sérii

  • Článek 9 – Impotence spotřebitelů: opakované pokusy a opětovné zpracování z DLQ může způsobit duplicitní zprávy. Vzorec klíče idempotence je hlavní obranou.
  • Článek 10 – Vzor pošty k odeslání: zajistit zveřejnění události se vždy stane, dokonce i v případě selhání výrobce, pomocí tabulky k odeslání v databázi.

Propojení s ostatními sériemi

  • SQS vs SNS vs EventBridge (článek 7): Každá služba AWS má svou vlastní sémantika DLQ a opakování. Tento článek popisuje rozdíly v konfiguraci mezi službami.
  • Fronta na mrtvé dopisy Kafky (článek 10 série Kafka): vzor DLQ v Kafkovi se skupinou spotřebitelů, opakování tématu a jeho přepracování má stejné základy koncepční, ale odlišná implementace než SQS.