Acknowledgement #

Dalam siklus hidup pesan di dalam broker RabbitMQ (message lifecycle), setelah pesan diterbitkan, dirutekan, diantrekan, dan berhasil dikirimkan ke aplikasi konsumen (Delivery), kita tiba pada tahap penentu yang menetapkan akhir dari nasib pesan tersebut: fase Acknowledgement (Konfirmasi Penerimaan).

Fase Konfirmasi Penerimaan adalah mekanisme krusial yang digunakan oleh broker dan aplikasi konsumen untuk menyelaraskan status pemrosesan data. Melalui fase ini, konsumen dapat memberi tahu broker apakah pesan telah berhasil dieksekusi dengan sukses sehingga aman untuk dihapus dari penyimpanan fisik disk/RAM, atau justru gagal akibat kendala sistem sehingga perlu dialihkan ke jalur lain. Kesalahan dalam merancang mekanisme konfirmasi ini dapat berdampak fatal bagi keandalan sistem kita, mulai dari kehilangan data secara misterius (data loss), kebocoran memori RAM akibat backlog tidak terkonfirmasi, hingga loop kegagalan tak terbatas yang melumpuhkan performa CPU (poison message loop). Artikel ini akan mengupas tuntas semantik internal konfirmasi di RabbitMQ, perbedaan instruksi pengakuan sukses dan gagal, pengelolaan pengidentifikasi pesan, serta implementasi Go untuk mengamankan integritas data sistem kita.

Posisi Acknowledgement dalam Siklus Hidup Pesan #

Fase Acknowledgement merupakan tahap penutup yang bersifat mutlak dalam daur hidup sebuah pesan di broker.

flowchart TD
    Deliver["Pesan Terkirim (basic.deliver)"] --> StateUnacked["Pesan Masuk Status: Unacknowledged"]
    StateUnacked --> Logic["Konsumen Mengeksekusi Logika Bisnis"]
    
    Logic --> Result{"Evaluasi Hasil Eksekusi"}
    
    Result -->|Sukses Kompleks| Ack["Kirim basic.ack"]
    Result -->|Gagal (Format Salah)| RejectDLX["Kirim basic.reject (requeue=false)"]
    Result -->|Gagal (Koneksi DB Putus)| Requeue["Kirim basic.nack (requeue=true)"]
    
    Ack --> Delete["Broker Menghapus Pesan dari Disk/RAM"]
    RejectDLX --> DLXRouting["Pesan Dialihkan ke Dead Letter Exchange"]
    Requeue --> RequeueQueue["Kembali ke Antrean Asal (Posisi Terdepan)"]

Selama pesan berada dalam status Unacknowledged, broker menahan pesan tersebut di penyimpanan. Transisi status dari Unacknowledged ke penghapusan permanen atau pengantrean kembali (requeue) sepenuhnya dikendalikan oleh instruksi konfirmasi yang dikirimkan oleh konsumen kembali ke broker melalui soket TCP.


Jenis-Jenis Sinyal Konfirmasi AMQP 0-9-1: basic.ack, basic.nack, dan basic.reject #

Protokol AMQP 0-9-1 menyediakan tiga perintah utama bagi konsumen untuk merespon pesan yang diterimanya dari broker. Masing-masing perintah memiliki implikasi yang berbeda terhadap keberadaan data di antrean.

1. basic.ack (Positive Acknowledgement) #

Instruksi ini dikirimkan oleh konsumen untuk memberi tahu broker bahwa pesan telah berhasil diproses dengan sukses tanpa kendala.

  • Tindakan Broker: Begitu menerima basic.ack, broker RabbitMQ akan menghapus metadata dan payload bodi pesan tersebut secara permanen dari memori RAM serta disk. Kuota batas pra-ambil (prefetch quota) konsumen yang bersangkutan akan dibebaskan kembali.

2. basic.nack (Negative Acknowledgement) #

Instruksi ini diperkenalkan oleh RabbitMQ sebagai ekstensi dari protokol AMQP 0-9-1 standar untuk menandakan bahwa pemrosesan pesan mengalami kegagalan.

  • Kelebihan: Berbeda dengan instruksi penolakan biasa, basic.nack mendukung pemrosesan batch (bulk rejection). Kita dapat menolak banyak pesan sekaligus dalam satu perintah TCP untuk menghemat latensi jaringan.
  • Opsi: Memiliki argumen penentu requeue. Jika disetel true, pesan dikembalikan ke antrean asal; jika false, pesan dibuang atau dikirim ke Dead Letter Exchange (DLX).

3. basic.reject (Rejection) #

Perintah standar AMQP 0-9-1 untuk menolak pesan yang gagal diproses.

  • Perilaku: Identik dengan basic.nack dalam hal penanganan kesalahan dan opsi requeue. Namun, basic.reject hanya dapat digunakan untuk menolak satu pesan tunggal per panggilan protokol. Tidak mendukung penolakan batch.

Parameter requeue dan Implikasinya Terhadap Aliran Data #

Saat mengirimkan sinyal kegagalan (basic.nack atau basic.reject), keputusan paling krusial yang harus diambil oleh aplikasi konsumen adalah menentukan nilai dari parameter requeue (angka boolean true atau false).

1. Dampak requeue = true #

Ketika kita menolak pesan dengan parameter requeue = true:

  • Broker akan menaruh kembali pesan tersebut ke antrean asalnya.
  • Pesan yang dikembalikan akan diletakkan sedekat mungkin dengan posisi kepala antrean (head of the queue). Artinya, pesan ini akan dikirimkan kembali (redelivered) ke konsumen yang sama (atau konsumen lain) hampir secara instan.
  • Skenario Penggunaan: Sangat cocok untuk kegagalan yang bersifat sementara (transient failures), seperti koneksi database yang terputus sejenak, atau layanan API pihak ketiga yang sedang mengalami RTO (Request Timeout).

2. Dampak requeue = false #

Ketika kita menolak pesan dengan parameter requeue = false:

  • Broker tidak akan pernah mengembalikan pesan tersebut ke antrean asalnya.
  • Pesan akan dihapus dari antrean saat itu. Namun, jika antrean tersebut dikonfigurasi dengan Dead Letter Exchange (DLX), payload pesan tidak dibuang secara fisik melainkan dialihkan ke DLX tersebut untuk dianalisis lebih lanjut.
  • Skenario Penggunaan: Wajib digunakan untuk kegagalan yang bersifat permanen (hard failures), seperti format payload JSON yang rusak (tidak dapat di-parse), data input yang tidak valid secara bisnis (misalnya nilai transfer minus), atau ID pengguna yang tidak ditemukan.

Delivery Tag dan Batasan Ruang Lingkup Channel (Channel Scope) #

Untuk mencocokkan konfirmasi dengan pesan yang dikirimkan, RabbitMQ menggunakan pengidentifikasi numerik unik bernama delivery_tag.

Bagaimana Delivery Tag Dihasilkan? #

  1. Setiap kali broker mendorong pesan ke konsumen melalui channel tertentu, broker memberikan nomor urut integer 64-bit yang dimulai dari 1.
  2. Nomor urut ini terus bertambah secara linier untuk setiap pesan berikutnya yang dikirimkan dalam channel yang sama.
  3. Nomor urut ini unik hanya dalam cakupan (scope) channel tersebut. Jika kita membuka dua channel yang berbeda pada satu koneksi TCP yang sama, masing-masing channel akan memiliki urutan delivery_tag sendiri yang dimulai dari 1.

Batasan Penting: Channel Scope Lock #

Semua instruksi konfirmasi (basic.ack, basic.nack, atau basic.reject) wajib dikirimkan menggunakan channel yang sama dengan channel yang digunakan saat menerima pesan tersebut.

sequenceDiagram
    autonumber
    actor Consumer as Consumer
    participant Ch1 as "Channel 1"
    participant Ch2 as "Channel 2"
    
    Ch1 ->> Consumer: "basic.deliver (DeliveryTag: 100)"
    Note over Consumer: Mengirimkan ACK
    Consumer ->> Ch2: "basic.ack (DeliveryTag: 100)"
    Note over Ch2: "Bukan channel asal! Memicu PRECONDITION_FAILED Channel Exception"

Jika aplikasi konsumen kita menerima pesan dengan delivery_tag = 100 di Channel 1, kemudian karena kesalahan desain multi-threading (goroutine) aplikasi kita mengirimkan instruksi basic.ack(100) melalui Channel 2:

  • Broker akan menganggap ini sebagai pelanggaran protokol yang serius.
  • Broker akan memicu pengecekan kesalahan PRECONDITION_FAILED di tingkat broker.
  • Broker akan secara otomatis menutup secara paksa (crash close) Channel 2 yang digunakan untuk mengirim ACK salah tersebut, dan mengembalikan pesan asli di Channel 1 ke antrean.

Mekanisme Batch Acknowledgement (Multiple Ack) #

Untuk menghemat beban I/O jaringan pada sistem dengan throughput tinggi (ratusan ribu pesan per detik), RabbitMQ mendukung fitur Batch Acknowledgement menggunakan parameter kustom bernama multiple.

Ketika konsumen memanggil fungsi basic.ack dengan parameter multiple = true:

  • Instruksi ini memberi tahu broker: “Tandai sukses dan hapus seluruh pesan yang memiliki delivery_tag kurang dari atau sama dengan tag yang saya kirimkan saat ini.”
  • Sebagai contoh, jika konsumen telah menerima pesan dengan delivery tag 1, 2, 3, 4, dan 5 pada satu channel. Jika konsumen mengirimkan satu perintah basic.ack(5, multiple = true), broker akan mengkonfirmasi dan menghapus kelima pesan tersebut sekaligus dari disk/RAM dalam satu operasi transaksi tunggal.
flowchart TD
    subgraph PesanDiterima["Pesan Diterima"]
        direction LR
        T1["Tag 1"]
        T2["Tag 2"]
        T3["Tag 3"]
        T4["Tag 4"]
        T5["Tag 5"]
    end
    
    T5 -->|"basic.ack(Tag 5, multiple = true)"| Ack["Hasil: Kelima pesan di-ACK secara kolektif."]

Risiko Batch Ack di Produksi #

Meskipun meningkatkan performa throughput secara signifikan, penggunaan multiple = true memiliki risiko:

  • Jika aplikasi konsumen crash di tengah jalan saat memproses pesan 4 (sebelum sempat mengirim ACK batch untuk pesan 5), broker akan menganggap seluruh pesan dari 1 hingga 5 belum selesai dikonfirmasi. Akibatnya, kelima pesan tersebut akan dikirimkan kembali (redelivered) ke konsumen baru, yang memperbesar peluang terjadinya pemrosesan data ganda (duplikasi).

Fenomena Poison Message dan Strategi Pemutus Loop Retry #

Salah satu ancaman terbesar bagi stabilitas sistem antrean di lingkungan produksi adalah Poison Message (Pesan Beracun).

Bagaimana Poison Message Terbentuk? #

Poison message terjadi ketika produsen mempublikasikan pesan yang memiliki struktur data rusak, nilai kosong pada parameter wajib, atau memicu bug logis di dalam kode aplikasi konsumen. Ketika pesan ini dikonsumsi:

  1. Kode aplikasi konsumen mengalami error saat memprosesnya.
  2. Karena penanganan kesalahan yang kurang matang, konsumen mengirimkan sinyal basic.nack(requeue = true).
  3. Pesan dikembalikan ke antrean asal di posisi paling depan.
  4. Konsumen yang sama (atau konsumen paralel lain) langsung membaca pesan itu kembali dari antrean.
  5. Aplikasi kembali error, mengirim NACK requeue kembali, dan siklus ini berulang tanpa henti (infinite retry loop).

Siklus ini mengonsumsi utilisasi CPU broker dan konsumen hingga 100%, membanjiri log file dengan baris pesan error yang sama jutaan kali, dan menyumbat antrean sehingga pesan-pesan normal lainnya tidak pernah mendapatkan kesempatan untuk diproses.

flowchart TD
    A["Konsumsi (basic.deliver)"] -->|"Aplikasi Crash"| B["basic.nack (requeue=true)"]
    B -->|"Pesan Kembali ke Kepala Antrean"| A

Solusi Desain Pengelolaan Kesalahan yang Kokoh #

Untuk memutus siklus racun ini di lingkungan produksi, kita wajib mengombinasikan strategi berikut:

1. Dead Letter Exchange (DLX) dan Dead Letter Queue (DLQ) #

Konfigurasikan antrean kita dengan argumen x-dead-letter-exchange dan x-dead-letter-routing-key. Ketika konsumen mendeteksi kesalahan permanen (seperti format data tidak valid), konsumen harus mengirimkan basic.reject(requeue = false). Broker akan secara otomatis mengarahkan pesan rusak tersebut ke DLQ khusus untuk diisolasi tanpa memblokir antrean utama.

2. Pembatasan Jumlah Percobaan (Retry Limit Tracking) #

RabbitMQ Quorum Queue memiliki fitur internal bernama x-delivery-count yang otomatis mencatat berapa kali pesan tersebut telah dikirim ulang ke konsumen. Kita dapat membaca header ini di sisi aplikasi konsumen. Jika x-delivery-count melebihi batas (misalnya 5 kali), konsumen harus menghentikan requeue dan membuang pesan tersebut ke DLQ secara manual.

3. Delayed Retry (Plugin Delayed Message Exchange) #

Jika kegagalan disebabkan oleh gangguan jaringan sementara (misalnya API eksternal down), jangan langsung me-requeue pesan secara instan. Kirim pesan ke exchange tunda (delayed exchange) untuk menunda pengiriman ulang selama 5 menit agar sistem eksternal memiliki waktu untuk pulih terlebih dahulu.


Implementasi Kode Go: Menangani Manual ACK, Nack, dan DLX Routing #

Berikut adalah implementasi lengkap dalam bahasa Go untuk mendemonstrasikan penanganan konfirmasi manual secara aman. Kode ini menyertakan evaluasi tipe kesalahan untuk memutuskan apakah pesan harus di-ACK, di-requeue, atau dikirim ke Dead Letter Queue (DLQ).

package main

import (
	"context"
	"encoding/json"
	"log"
	"os"
	"os/signal"
	"syscall"
	"time"

	amqp "github.com/rabbitmq/amqp091-go"
)

// Struktur data pesan pesanan transaksi
type Order struct {
	OrderID string  `json:"order_id"`
	Amount  float64 `json:"amount"`
}

func main() {
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	if err != nil {
		log.Fatalf("Gagal terhubung ke RabbitMQ: %s", err)
	}
	defer conn.Close()

	ch, err := conn.Channel()
	if err != nil {
		log.Fatalf("Gagal membuka channel: %s", err)
	}
	defer ch.Close()

	// Membatasi prefetch QoS
	err = ch.Qos(10, 0, false)
	if err != nil {
		log.Fatalf("Gagal menyetel QoS: %s", err)
	}

	queueName := "orders-processing-queue"

	msgs, err := ch.Consume(
		queueName,
		"",    // consumer tag
		false, // autoAck: setel false untuk konfirmasi manual!
		false, // exclusive
		false, // noLocal
		false, // noWait
		nil,   // arguments
	)
	if err != nil {
		log.Fatalf("Gagal mendaftarkan konsumen: %s", err)
	}

	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		for {
			select {
			case <-ctx.Done():
				return
			case d, ok := <-msgs:
				if !ok {
					return
				}

				var order Order
				// Evaluasi format data payload
				err := json.Unmarshal(d.Body, &order)
				if err != nil {
					// KESALAHAN PERMANEN (Format JSON Rusak): Jangan lakukan requeue!
					log.Printf("[ERROR] Payload rusak untuk Tag %d: %s. Mengirim ke DLX...", d.DeliveryTag, err)
					
					// basic.reject dengan requeue = false akan mengarahkan pesan ke DLX
					rejectErr := d.Reject(false)
					if rejectErr != nil {
						log.Printf("Gagal reject pesan: %s", rejectErr)
					}
					continue
				}

				// Evaluasi validitas logika bisnis
				if order.Amount <= 0 {
					// KESALAHAN LOGIKA BISNIS (Nilai transaksi minus): Tidak bisa diproses ulang!
					log.Printf("[ERROR] Nilai transaksi tidak valid (Amount: %.2f). Menolak pesan...", order.Amount)
					
					// Kirim ke DLX agar tidak menyumbat antrean
					rejectErr := d.Reject(false)
					if rejectErr != nil {
						log.Printf("Gagal reject pesan: %s", rejectErr)
					}
					continue
				}

				// Simulasi pemrosesan transaksi ke database
				dbSuccess := processOrderInDatabase(order)
				if !dbSuccess {
					// KESALAHAN SEMENTARA (Koneksi database putus): Lakukan requeue agar dicoba lagi nanti
					log.Printf("[WARN] Gagal menulis ke database. Mengembalikan ke antrean (requeue = true)...")
					
					// basic.nack dengan requeue = true
					nackErr := d.Nack(false, true)
					if nackErr != nil {
						log.Printf("Gagal nack pesan: %s", nackErr)
					}
					continue
				}

				// SUKSES: Kirimkan positive acknowledgement (basic.ack)
				ackErr := d.Ack(false)
				if ackErr != nil {
					log.Printf("Gagal mengirim ACK: %s", ackErr)
				} else {
					log.Printf("✓ Transaksi %s sukses terproses dan di-ACK.", order.OrderID)
				}
			}
		}
	}()

	<-sigChan
	log.Println("[*] Memulai graceful shutdown...")
	cancel()
	time.Sleep(1 * time.Second)
}

func processOrderInDatabase(order Order) bool {
	// Simulasi fungsi database (kembalikan false jika ada simulasi error jaringan)
	return true
}

Anti-Pattern vs Solusi Praktis di Lingkungan Produksi #

Hindari kesalahan fatal dalam merancang alur konfirmasi pesan berikut:

1. Mengirim Sinyal ACK Menggunakan Koneksi/Channel yang Berbeda #

Menerima pesan di dalam loop utama pembaca soket, lalu memecah proses penyimpanan database ke thread goroutine lain, dan mengirimkan sinyal ACK menggunakan objek channel baru yang dideklarasikan secara dinamis oleh thread tersebut.

Mengapa ini salah? #

Seperti dijelaskan sebelumnya, RabbitMQ mengunci kegunaan delivery_tag per channel. Mengirimkan ACK dengan tag yang sama pada channel yang berbeda akan memicu kesalahan PRECONDITION_FAILED dan merusak koneksi soket channel.

  • Solusi: Pastikan objek channel asli yang menerima pesan dioperasikan secara aman sebagai parameter masukan (dependency injection) ke dalam thread goroutine pemroses. Semua ACK harus dipanggil melalui objek channel asli tersebut.

2. Melakukan Requeue Tanpa Batas pada Kegagalan Kode Aplikasi Internal #

Menangkap semua exception error di kode aplikasi (termasuk error Null Pointer Dereference, pembagian dengan nol, dsb) lalu mengembalikan pesan ke antrean menggunakan requeue = true dengan harapan bug tersebut akan hilang dengan sendirinya.

Mengapa ini salah? #

Bug kode di aplikasi bersifat statis dan tidak akan sembuh sendiri tanpa adanya rilis perbaikan kode baru. Requeue tanpa batas untuk bug kode hanya akan memicu siklus pemrosesan sia-sia yang menghabiskan utilisasi CPU server.

  • Solusi: Buat klasifikasi jenis error yang jelas. Gunakan penanganan try-catch yang disiplin. Hanya lakukan requeue (requeue = true) untuk kesalahan infrastruktur eksternal (jaringan terputus). Untuk kesalahan kode internal atau validasi data, salurkan langsung ke Dead Letter Queue (requeue = false).

Ringkasan #

  • Semantik Sinyal AMQP — Gunakan basic.ack untuk sukses, basic.nack untuk penolakan kolektif/batch, dan basic.reject untuk penolakan satu pesan tunggal.
  • Opsi Requeue Strategis — Setel requeue = true hanya untuk kegagalan infrastruktur sementara, dan gunakan requeue = false (mengalihkan ke DLX) untuk kegagalan logika bisnis permanen.
  • Cakupan Delivery Tag — Delivery tag bersifat unik per channel. Pengiriman ACK di channel yang salah akan memicu crash penutupan channel oleh broker.
  • Batching dengan Multiple Ack — Setelan multiple = true meng-ACK semua pesan hingga tag spesifik sekaligus, meningkatkan throughput namun berisiko memicu redelivery besar jika konsumen crash.
  • Bahaya Poison Message — Requeue tanpa batas untuk pesan rusak akan memicu loop kegagalan tak terbatas yang memblokir antrean dan membebani utilisasi CPU.
  • Mitigasi DLQ & Retry Limits — Gunakan Dead Letter Queue untuk isolasi data, delayed exchange untuk penundaan percobaan, dan track metrik x-delivery-count untuk membatasi jumlah retry.

← Sebelumnya: Delivery   Berikutnya: At-Least-Once →

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