DLQ #

Dalam sistem terdistribusi modern, kegagalan bukanlah sesuatu yang bisa dihindari. Network glitch, bug logic, dependency eksternal yang lambat atau down, hingga data anomali adalah hal yang pasti terjadi. Yang membedakan sistem yang matang dan rapuh bukanlah apakah ia gagal, tetapi bagaimana ia gagal. Salah satu mekanisme paling penting untuk menangani kegagalan dalam sistem berbasis message queue adalah Dead Letter Queue (DLQ) — sebuah “ruang karantina” untuk pesan yang terus-menerus gagal diproses, sehingga mereka tidak menyumbat queue utama maupun diproses berulang tanpa akhir. Artikel ini membahas DLQ dari masalah konkret yang ia selesaikan, mengapa retry saja tidak cukup, konsep dasar DLQ secara umum, studi kasus implementasi nyata di Amazon SQS lengkap dengan alur pesan dari awal sampai masuk DLQ, strategi penanganan, hingga best practice dan kesalahan umum yang sering terjadi.

Apa Itu Dead Letter Queue (DLQ)? #

Dead Letter Queue (DLQ) adalah queue khusus yang digunakan untuk menyimpan pesan-pesan yang gagal diproses setelah melewati sejumlah percobaan (retry) yang telah ditentukan.

Alih-alih:

  • terus di-retry tanpa henti,
  • dibuang tanpa jejak,
  • atau membuat sistem macet,

pesan tersebut dipindahkan ke DLQ untuk dianalisis, di-debug, diperbaiki, atau diproses ulang secara manual maupun otomatis.

DLQ adalah tempat karantina untuk pesan bermasalah.

flowchart LR
    P[Producer] --> Q[("(Main Queue\norder-created)")]
    Q --> C["Consumer\nOrder Processor"]
    C -- "✅ Sukses" --> Done[Selesai, pesan dihapus]
    C -- "❌ Gagal" --> R{"Retry\ncount < max?"}
    R -- Ya --> Q
    R -- "Tidak\nmax tercapai" --> DLQ[("(Dead Letter Queue\norder-created-dlq)")]
    DLQ --> M["Manual Inspection\n/ Reprocessing"]

Masalah Nyata Tanpa DLQ #

Mari kita lihat masalah klasik jika tidak menggunakan DLQ.

Contoh Kasus #

Sebuah sistem memiliki SQS Queue order-created dan consumer order-processor dengan tugas: validasi order, simpan ke database, panggil payment service.

Masalah Terjadi #

Tiba-tiba ada pesan dengan kondisi bermasalah — amount bernilai null, format JSON tidak sesuai, atau ada logic bug di consumer.

{
  "order_id": "123",
  "amount": null
}

Akibatnya, consumer selalu gagal memproses pesan ini. Message akan muncul kembali setelah visibility timeout, diproses ulang, dan gagal lagi — berulang tanpa akhir.

Dampak #

sequenceDiagram
    participant Q as Main Queue
    participant C as Consumer

    loop Tanpa DLQ — berulang selamanya
        Q->>C: deliver poison message
        C->>C: error! amount = null
        Note over C: pesan tidak dihapus
        Note over Q: visibility timeout habis
        Q->>C: deliver poison message LAGI
        C->>C: error! amount = null lagi
    end
    Note over Q,C: 🔥 Resource terbuang\n🔥 Queue blocking\n🔥 Debugging sulit

Tanpa DLQ, pesan ini akan diproses berulang tanpa akhir — menghabiskan resource, mengganggu pesan lain (queue blocking), dan menyulitkan debugging. Inilah yang disebut poison message.


Kenapa Retry Saja Tidak Cukup? #

Retry memang penting, tapi tidak semua error bisa diselesaikan dengan retry.

Jenis ErrorCocok dengan Retry?Contoh
Transient✅ YaNetwork timeout, dependency sementara down, race condition
Permanent❌ TidakData invalid, bug logic, constraint database, schema mismatch
// ANTI-PATTERN: retry tanpa batas untuk semua error
func consumeMessage(msg Message) {
    for {
        err := process(msg)
        if err == nil {
            return
        }
        // Jika error permanen (data invalid), loop ini TIDAK PERNAH selesai
        time.Sleep(time.Second)
    }
}

// BENAR: retry terbatas, setelah itu kirim ke DLQ
func consumeMessage(msg Message, maxReceiveCount int) error {
    if msg.ApproximateReceiveCount >= maxReceiveCount {
        return sendToDLQ(msg, "max retry exceeded")
    }

    if err := process(msg); err != nil {
        if !isRetryable(err) {
            // Error permanen — langsung ke DLQ, tidak perlu habiskan retry
            return sendToDLQ(msg, fmt.Sprintf("permanent error: %v", err))
        }
        return err // biarkan SQS redeliver, ApproximateReceiveCount naik
    }
    return nil
}

Tanpa DLQ: retry → gagal → retry → gagal → loop selamanya.

Dengan DLQ: retry dibatasi, dan setelah limit tercapai, pesan dipindahkan ke DLQ.


Konsep Dasar DLQ #

Secara umum, DLQ memiliki karakteristik:

flowchart TD
    DLQ[Dead Letter Queue] --> A["Queue terpisah\ndari main queue"]
    DLQ --> B["Tidak diproses\noleh consumer utama"]
    DLQ --> C["Menyimpan payload\n+ metadata kegagalan"]
    DLQ --> D["Digunakan untuk:\ndebugging, reprocessing, audit"]
KarakteristikPenjelasan
Queue terpisahDLQ adalah resource queue tersendiri, bukan bagian dari main queue
Tidak diproses otomatisConsumer utama tidak subscribe ke DLQ — butuh proses terpisah
Menyimpan payload + metadataPesan asli tetap utuh, plus informasi kegagalan (receive count, timestamp)
Untuk debugging dan reprocessingDLQ adalah titik awal investigasi, bukan tempat sampah

Dead Letter Queue di Amazon SQS #

Amazon SQS memiliki dukungan native DLQ melalui fitur Redrive Policy.

Redrive Policy #

Redrive policy memungkinkan main queue mengirim pesan ke DLQ setelah pesan gagal diproses sebanyak maxReceiveCount.

Komponen #

  • Main Queue: order-created
  • Dead Letter Queue: order-created-dlq
flowchart TD
    subgraph SQS["Amazon SQS"]
        MQ[("(order-created\nMain Queue)")]
        DLQ[("(order-created-dlq\nDead Letter Queue)")]
    end
    MQ -- "Redrive Policy:\nmaxReceiveCount = 3" --> DLQ

Studi Kasus: DLQ di SQS #

Arsitektur #

flowchart TD
    P[Producer] --> Q["(SQS: order-created)"]
    Q --> C["Order Processor\nConsumer"]
    C -- success --> Done["✅ Done\npesan dihapus"]
    C -- failure --> Retry{Retry}
    Retry --> Q
    Retry -- "max retry reached" --> DLQ["(SQS: order-created-dlq)"]

Alur Lengkap Pesan #

1. Pesan Dikirim #

Producer mengirim pesan:

{
  "order_id": "123",
  "amount": null
}

2. Consumer Gagal Memproses #

Consumer error saat validasi karena amount = null. Consumer tidak menghapus pesan dari queue.

3. Visibility Timeout #

Pesan menjadi invisible selama periode visibility timeout. Setelah timeout berakhir, pesan muncul kembali di queue agar bisa diproses ulang (oleh consumer yang sama atau instance lain).

4. Retry Berulang #

SQS menaikkan counter ApproximateReceiveCount setiap kali pesan diterima:

receive #1 → gagal → ApproximateReceiveCount = 1
receive #2 → gagal → ApproximateReceiveCount = 2
receive #3 → gagal → ApproximateReceiveCount = 3

5. Masuk DLQ #

Jika maxReceiveCount = 3, maka pada kegagalan ke-3, pesan otomatis dipindahkan ke DLQ.

sequenceDiagram
    participant Q as Main Queue
    participant C as Consumer
    participant DLQ as Dead Letter Queue

    Q->>C: deliver (ApproximateReceiveCount=1)
    C->>C: ❌ gagal, amount=null
    Note over Q: visibility timeout habis
    Q->>C: deliver (ApproximateReceiveCount=2)
    C->>C: ❌ gagal lagi
    Note over Q: visibility timeout habis
    Q->>C: deliver (ApproximateReceiveCount=3)
    C->>C: ❌ gagal lagi
    Note over Q: maxReceiveCount tercapai!
    Q->>DLQ: pindahkan pesan otomatis
    Note over DLQ: pesan + metadata\nmenunggu investigasi

Konfigurasi DLQ di SQS #

Buat DLQ #

Buat queue baru: order-created-dlq. Biasanya:

  • Retention lebih lama (7–14 hari) — beri waktu cukup untuk investigasi
  • Tidak ada consumer otomatis — mencegah pesan diproses tanpa sadar

Pasang Redrive Policy #

Di main queue order-created, atur:

  • Dead-letter queue: order-created-dlq
  • maxReceiveCount: misal 3 atau 5
{
  "deadLetterTargetArn": "arn:aws:sqs:ap-southeast-1:123456789:order-created-dlq",
  "maxReceiveCount": 3
}

Artinya: jika satu pesan diterima lebih dari N kali tanpa berhasil dihapus, kirim ke DLQ.

DLQ harus punya retention period yang lebih lama dari main queue. Jika retention DLQ sama atau lebih singkat dari waktu yang dibutuhkan tim untuk menyadari dan menginvestigasi masalah, pesan akan terhapus permanen sebelum sempat dianalisis.

Apa yang Ada di DLQ? #

Pesan di DLQ tetap berisi payload asli, plus metadata SQS:

FieldIsi
Message BodyPayload asli yang dikirim producer
Message AttributesCustom attributes yang disertakan saat publish
ApproximateReceiveCountBerapa kali pesan ini gagal diproses
TimestampKapan pesan pertama dikirim dan kapan masuk DLQ

Informasi ini sangat penting untuk root cause analysis dan replay pesan setelah bug diperbaiki.


Strategi Menangani Pesan di DLQ #

DLQ bukan akhir, tapi bagian dari workflow.

flowchart TD
    DLQ["(Pesan di DLQ)"] --> A{Jenis masalah?}
    A -- "Bug di consumer" --> B["Manual Inspection\nEngineer baca pesan,\ncari root cause, fix bug"]
    A -- "Bug sudah diperbaiki" --> C["Automated Reprocessing\nPesan dikirim kembali\nke main queue"]
    A -- "Data benar-benar invalid" --> D["Filtering & Discard\nSimpan sebagai audit,\ntidak diproses ulang"]
    B --> C

Manual Inspection #

Engineer membaca pesan, mencari root cause, dan memperbaiki bug yang menyebabkan kegagalan.

Automated Reprocessing #

Setelah bug diperbaiki, pesan dipindahkan kembali ke main queue untuk diproses ulang.

// BENAR: replay pesan dari DLQ ke main queue setelah bug fix
func replayFromDLQ(ctx context.Context, dlqURL, mainQueueURL string, maxMessages int) error {
    for i := 0; i < maxMessages; i++ {
        msgs, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            aws.String(dlqURL),
            MaxNumberOfMessages: 1,
        })
        if err != nil || len(msgs.Messages) == 0 {
            break
        }

        msg := msgs.Messages[0]

        // Kirim kembali ke main queue
        _, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
            QueueUrl:    aws.String(mainQueueURL),
            MessageBody: msg.Body,
        })
        if err != nil {
            log.Errorf("failed to replay message: %v", err)
            continue
        }

        // Hapus dari DLQ setelah berhasil dikirim ulang
        sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
            QueueUrl:      aws.String(dlqURL),
            ReceiptHandle: msg.ReceiptHandle,
        })

        log.Infof("replayed message %s from DLQ to main queue", *msg.MessageId)
    }
    return nil
}

Filtering & Discard #

Jika data benar-benar invalid (misal duplikat lama atau test data yang nyasar), simpan sebagai audit log tapi tidak perlu diproses ulang.


Best Practice DLQ di SQS #

Jangan Jadikan DLQ Sebagai Tempat Sampah #

DLQ harus dimonitor, dibersihkan, dan ditindaklanjuti — bukan dibiarkan menumpuk tanpa perhatian.

// ANTI-PATTERN: DLQ dibiarkan menumpuk tanpa monitoring
// Setelah 6 bulan: 50.000 pesan di DLQ, tidak ada yang tahu kenapa

// BENAR: dashboard dan proses rutin untuk review DLQ
func reportDLQStatus(ctx context.Context) {
    attrs, _ := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
        QueueUrl: aws.String(dlqURL),
        AttributeNames: []types.QueueAttributeName{
            types.QueueAttributeNameApproximateNumberOfMessages,
        },
    })

    count := attrs.Attributes["ApproximateNumberOfMessages"]
    metrics.Gauge("dlq.message_count", parseFloat(count))

    if parseFloat(count) > 0 {
        log.Warnf("DLQ order-created-dlq has %s messages — needs investigation", count)
    }
}

Pasang Alarm #

Gunakan CloudWatch Alarm: jika DLQ memiliki pesan > 0, itu sinyal ada masalah serius yang butuh perhatian segera.

CloudWatch Alarm:
  Metric: ApproximateNumberOfMessagesVisible
  Queue: order-created-dlq
  Threshold: > 0
  Period: 5 menit
  Action: notify on-call engineer via SNS/Slack

Tentukan maxReceiveCount dengan Bijak #

KondisimaxReceiveCount yang Disarankan
Error non-transient (validasi, schema)3–5
Dependency sering fluktuatifLebih besar (5–10) dengan exponential backoff
Operasi finansial kritisLebih kecil (2–3) — gagal cepat dan investigasi

Pisahkan Error Transient vs Permanent #

// BENAR: klasifikasi error sebelum decide retry atau DLQ
func handleMessage(msg Message) error {
    err := process(msg)
    if err == nil {
        return nil
    }

    if isPermanentError(err) {
        // Error permanen — kirim ke DLQ segera, tidak perlu habiskan retry
        log.Errorf("permanent error, sending to DLQ immediately: %v", err)
        return sendToDLQDirectly(msg, err)
    }

    // Error transient — biarkan SQS redeliver sesuai redrive policy
    log.Warnf("transient error, will retry: %v", err)
    return err
}

func isPermanentError(err error) bool {
    var validationErr *ValidationError
    var schemaErr *SchemaError
    return errors.As(err, &validationErr) || errors.As(err, &schemaErr)
}

Simpan Context Error #

Tambahkan correlation ID, request ID, dan versi schema/aplikasi ke metadata pesan agar debugging DLQ lebih mudah.

// BENAR: enrich pesan dengan context error sebelum masuk DLQ
type DLQMessage struct {
    OriginalBody  string            `json:"original_body"`
    ErrorMessage  string            `json:"error_message"`
    CorrelationID string            `json:"correlation_id"`
    ReceiveCount  int               `json:"receive_count"`
    FailedAt      time.Time         `json:"failed_at"`
    ConsumerVersion string          `json:"consumer_version"`
    Attributes    map[string]string `json:"attributes"`
}

Kesalahan Umum dalam Implementasi DLQ #

// ✗ Tidak pernah melihat DLQ — pesan menumpuk tanpa disadari
// ✓ Dashboard + alarm untuk ApproximateNumberOfMessages > 0

// ✗ maxReceiveCount terlalu besar — pesan permanen-error menumpuk lama di main queue
// maxReceiveCount: 100  ← terlalu besar, queue blocking lebih lama
// ✓ maxReceiveCount: 3-5 untuk kebanyakan kasus

// ✗ DLQ diproses otomatis tanpa filtering — pesan invalid masuk lagi ke main queue
func autoReplay() {
    for _, msg := range getAllDLQMessages() {
        sendToMainQueue(msg) // ← jika masih invalid, akan balik ke DLQ lagi (loop!)
    }
}
// ✓ Filter dan validasi sebelum replay, atau fix root cause dulu

// ✗ Menghapus pesan DLQ tanpa analisa — kehilangan informasi penting
deleteAllMessages(dlqURL) // ← root cause tidak pernah ditemukan
// ✓ Inspeksi, catat root cause, baru hapus atau replay

Kapan DLQ Wajib Digunakan? #

flowchart TD
    A["Sistem menggunakan\nmessage queue?"] --> B{"Async /\nevent-driven?"}
    B -- Tidak --> C[DLQ tidak relevan]
    B -- Ya --> D{"Boleh kehilangan\ndata jika gagal?"}
    D -- Ya, non-kritis --> E["DLQ opsional\ntapi disarankan"]
    D -- Tidak --> F[DLQ WAJIB]
    F --> G["SQS / Pub/Sub / Kafka\nsemua punya konsep DLQ"]

DLQ wajib jika sistem bersifat async/event-driven, tidak boleh kehilangan data, processing kompleks, atau bergantung pada banyak dependency. Jika kamu memakai SQS, Pub/Sub, atau Kafka, konsep DLQ (atau ekuivalennya) bukan opsional.


Checklist Implementasi DLQ #

KONFIGURASI:
  □ DLQ terpisah dari main queue, dengan nama yang jelas (order-created-dlq)
  □ maxReceiveCount ditetapkan sesuai jenis error (umumnya 3-5)
  □ Retention period DLQ lebih lama dari main queue (7-14 hari)
  □ Tidak ada consumer otomatis yang subscribe ke DLQ

KLASIFIKASI ERROR:
  □ Error transient dibiarkan retry via redrive policy normal
  □ Error permanen (validasi, schema) dikirim ke DLQ segera tanpa habiskan retry
  □ Context error (correlation ID, request ID, versi) disertakan di metadata

MONITORING:
  □ CloudWatch Alarm (atau setara) untuk ApproximateNumberOfMessages > 0
  □ Dashboard menampilkan jumlah pesan di DLQ per queue
  □ Proses rutin (harian/mingguan) untuk review pesan di DLQ

PENANGANAN:
  □ Tools/script untuk replay pesan dari DLQ ke main queue
  □ Proses dokumentasi: siapa yang investigasi, kapan, dan bagaimana
  □ Tidak ada penghapusan pesan DLQ tanpa analisa root cause

Ringkasan #

  • DLQ adalah tempat karantina untuk pesan bermasalah — bukan tempat sampah, melainkan bagian penting dari workflow penanganan kegagalan.
  • Poison message adalah pesan yang selalu gagal diproses; tanpa DLQ, pesan ini diproses berulang tanpa akhir, menghabiskan resource dan memblokir queue.
  • Retry cocok untuk error transient (network timeout, dependency down sementara); tidak cocok untuk error permanen (data invalid, bug logic, schema mismatch).
  • Redrive Policy di SQS memindahkan pesan ke DLQ secara otomatis setelah ApproximateReceiveCount melebihi maxReceiveCount.
  • Pesan di DLQ membawa metadata penting — message body, attributes, receive count, dan timestamp — semua dibutuhkan untuk root cause analysis.
  • Tiga strategi penanganan: manual inspection (cari root cause), automated reprocessing (replay setelah fix), filtering & discard (untuk data yang benar-benar invalid).
  • Retention DLQ harus lebih lama dari main queue — beri waktu cukup untuk tim menyadari dan menginvestigasi sebelum pesan terhapus permanen.
  • Alarm untuk DLQ wajib — pesan di DLQ > 0 adalah sinyal ada masalah serius yang butuh perhatian, bukan kondisi normal yang boleh diabaikan.
  • Pisahkan error transient dan permanen di consumer — error permanen sebaiknya langsung ke DLQ tanpa menghabiskan retry quota yang seharusnya untuk error transient.
  • DLQ wajib untuk sistem async/event-driven yang tidak boleh kehilangan data — SQS, Pub/Sub, dan Kafka semua punya konsep DLQ atau ekuivalennya.

← Sebelumnya: Backoff Strategy   Berikutnya: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact