Pada at-least-once queue, pesan bisa diproses lebih dari sekali, dan itu bukan bug semata tetapi bagian dari model delivery-nya. Karena itu, masalah utamanya bukan bagaimana memaksa pesan hanya diproses sekali, melainkan bagaimana memastikan pemrosesan ulang tidak merusak data, tidak menggandakan efek samping, dan tidak membuat worker macet karena pesan bermasalah.
Artikel ini fokus pada tiga hal praktis: mengapa job bisa diproses lebih dari sekali, cara merancang consumer yang idempoten, dan cara menangani poison message dengan dead-letter queue (DLQ). Pendekatan ini relevan secara umum untuk worker berbasis Redis, RabbitMQ, SQS, maupun sistem bergaya Kafka consumer/worker, tanpa bergantung pada vendor tertentu.
Memahami at-least-once queue dalam praktik
Model at-least-once berarti broker atau sistem antrean menjamin sebuah pesan akan dikirim minimal sekali. Konsekuensinya, pesan yang sama bisa diterima dua kali atau lebih. Hal ini terjadi karena sistem lebih memilih duplikasi daripada kehilangan pesan.
Secara operasional, alurnya biasanya mirip seperti ini:
- Producer mengirim pesan ke queue.
- Worker mengambil pesan.
- Worker memproses bisnis logic.
- Jika sukses, worker mengirim acknowledgement atau commit offset/state.
- Jika ack tidak terjadi, terlambat, atau status sukses tidak terlihat oleh broker, pesan dianggap belum selesai dan bisa dikirim ulang.
Trade-off ini masuk akal untuk sistem yang lebih takut kehilangan pekerjaan daripada menjalankan pekerjaan dua kali. Tetapi begitu ada efek samping seperti mengirim email, memotong saldo, membuat invoice, atau memanggil API eksternal, duplikasi menjadi masalah nyata.
Gejala yang sering terlihat di produksi
- Email atau notifikasi terkirim ganda.
- Order, invoice, atau transaksi tercatat lebih dari sekali.
- Stok berkurang dua kali untuk event yang sama.
- Lonjakan retry pada queue tertentu.
- Satu pesan terus gagal dan memenuhi antrean.
- Throughput turun karena worker sibuk memproses ulang pesan yang sama.
- DLQ mulai terisi, tetapi tim tidak punya prosedur analisis atau replay.
Mengapa job bisa diproses lebih dari sekali
Akar masalah duplikasi hampir selalu berkaitan dengan celah antara pemrosesan bisnis dan konfirmasi ke broker. Jika salah satunya berhasil dan yang lain tidak, sistem tidak lagi punya pandangan tunggal yang konsisten.
1. Worker sukses, tetapi ack gagal
Ini kasus paling klasik. Misalnya worker berhasil menulis ke database, lalu proses crash sebelum sempat ack. Dari sisi bisnis, pekerjaan sudah selesai. Dari sisi queue, pekerjaan belum selesai. Hasilnya: pesan dikirim ulang.
2. Visibility timeout atau lease habis
Pada banyak sistem queue, pesan yang sedang diproses akan disembunyikan sementara dari worker lain melalui visibility timeout, lock, atau lease. Jika pemrosesan lebih lama dari batas ini dan tidak diperpanjang, pesan bisa muncul lagi dan diambil worker lain. Akibatnya dua worker memproses payload yang sama secara paralel.
3. Crash, restart, atau deploy di tengah proses
Worker bisa berhenti karena OOM, node mati, autoscaling, deploy rolling, jaringan putus, atau proses dibunuh oleh supervisor. Jika status akhir belum tersimpan dengan aman, pesan akan dianggap gagal dan diulang.
4. Retry dari aplikasi atau broker
Aplikasi sering melakukan retry untuk error sementara seperti timeout jaringan atau database lock. Broker juga bisa punya mekanisme redelivery. Jika strategi ini tidak dirancang hati-hati, satu kegagalan sementara bisa berubah menjadi banyak pemrosesan ulang dan efek samping ganda.
5. Producer mengirim duplikat
Tidak semua duplikasi berasal dari consumer. Producer bisa mengirim pesan yang sama dua kali karena retry publish tanpa deduplication key yang stabil, atau karena race condition pada sisi upstream.
6. Commit offset/ack terlalu dini
Pada sistem bergaya stream atau log, jika offset dikomit sebelum efek samping benar-benar aman, Anda berisiko kehilangan pesan. Sebaliknya, jika commit dilakukan terlalu lambat, Anda berisiko duplikasi. Ini inti trade-off antara kehilangan dan duplikasi.
Catatan penting: dalam model at-least-once, duplicate processing adalah asumsi desain. Jika sistem Anda rusak saat pesan diproses dua kali, masalah utamanya biasanya ada pada desain consumer yang belum idempoten.
Merancang consumer yang idempoten
Idempoten berarti operasi yang sama bisa dijalankan berulang kali dengan hasil akhir yang tetap benar. Bukan berarti kode tidak pernah dieksekusi dua kali, tetapi eksekusi berulang tidak menciptakan efek samping tambahan yang salah.
Prinsip dasar idempotensi
- Setiap pesan harus punya identitas unik yang stabil, misalnya message ID, event ID, atau operation ID.
- Consumer harus bisa mendeteksi apakah operasi untuk ID tersebut sudah pernah berhasil diselesaikan.
- Efek samping harus dijaga agar hanya terjadi sekali, atau aman jika terulang.
- Status keberhasilan perlu disimpan di tempat yang andal, biasanya database yang sama dengan perubahan bisnis, agar tidak mudah terpecah secara inkonsisten.
Pola yang umum dipakai
1. Tabel atau store untuk idempotency key
Simpan message ID atau operation ID ke storage yang mendukung pengecekan unik. Saat pesan datang:
- Cek apakah key sudah pernah diproses sukses.
- Jika sudah, anggap berhasil dan ack.
- Jika belum, proses bisnis logic.
- Simpan hasil sukses dan tandai key sebagai selesai.
Pola ini efektif jika penyimpanan state idempotensi dekat dengan state bisnis utama. Misalnya, saat membuat invoice dari event tertentu, simpan event ID sebagai unique reference di tabel invoice atau tabel terpisah untuk processed_messages.
2. Constraint unik di database
Jika output bisnis punya identitas alami, gunakan unique constraint. Contoh: satu order hanya boleh punya satu invoice untuk external_event_id tertentu. Jika pesan terulang, insert kedua akan gagal secara aman atau berubah menjadi no-op, tergantung implementasi.
Ini sering lebih kuat daripada sekadar cache, karena constraint database lebih tahan terhadap restart dan race condition.
3. Upsert atau compare-and-set
Alih-alih pola read-then-write yang rawan race condition, gunakan operasi atomik seperti upsert, insert-if-not-exists, atau compare-and-set. Tujuannya agar dua worker yang memproses pesan sama tidak sama-sama menganggap dirinya yang pertama.
4. Outbox/inbox pattern untuk integrasi
Jika layanan Anda menerima event lalu menghasilkan efek samping lain, pola inbox/outbox membantu memisahkan konsumsi pesan dan publikasi event lanjutan secara konsisten. Intinya, catat pesan masuk dan perubahan bisnis dalam transaksi yang terkontrol, lalu publikasikan hasilnya dari outbox. Ini mengurangi celah inkonsistensi antara database dan message broker.
Apa yang bukan idempotensi
- Mengandalkan retry tanpa menyimpan state keberhasilan.
- Menyimpan deduplication key hanya di memori proses worker.
- Menggunakan cache volatile tanpa TTL yang masuk akal sebagai satu-satunya sumber kebenaran.
- Melakukan
SELECTlaluINSERTterpisah tanpa proteksi atomik. - Menganggap HTTP endpoint downstream pasti aman dipanggil dua kali tanpa bukti desain yang jelas.
Contoh pseudo-code consumer idempoten
function handleMessage(message):
key = message.idempotency_key
begin transaction
existing = processed_message_repository.find_for_update(key)
if existing and existing.status == "done":
commit transaction
ack(message)
return
if not existing:
processed_message_repository.insert({
key: key,
status: "processing",
started_at: now()
})
apply_business_change(message) // mis. buat invoice, update saldo, kirim perintah internal
processed_message_repository.mark_done(key, finished_at=now())
commit transaction
ack(message)Inti pseudo-code di atas bukan sintaksnya, tetapi urutannya:
- Gunakan
idempotency_keyyang stabil. - Lakukan pengecekan di storage yang andal.
- Gunakan transaksi atau operasi atomik bila memungkinkan.
- Baru ack setelah perubahan yang dibutuhkan benar-benar aman.
Bagaimana jika ada panggilan ke sistem eksternal?
Ini bagian yang paling sulit. Jika Anda perlu memanggil payment gateway, email provider, atau API pihak ketiga, Anda perlu strategi tambahan:
- Gunakan idempotency key yang juga dikirim ke downstream jika didukung.
- Catat request ID dan hasil respons agar retry tidak menciptakan operasi baru.
- Jika downstream tidak mendukung idempotensi, bungkus dengan lapisan internal yang menyimpan state request dan hasilnya.
- Pisahkan operasi yang bisa diulang aman dari operasi yang tidak bisa diulang.
Jika satu langkah benar-benar tidak aman untuk diulang, pertimbangkan desain kompensasi atau workflow yang lebih eksplisit, bukan berharap queue akan memberi exactly-once secara otomatis.
Deduplication key, visibility timeout, retry, dan backoff
Deduplication key harus stabil dan bermakna
Deduplication key yang baik mewakili satu operasi bisnis, bukan sekadar satu percobaan teknis. Misalnya, payment_attempt_id atau order_event_id biasanya lebih baik daripada hash dari seluruh payload yang mungkin berubah urutan field-nya.
Kesalahan umum:
- Key dibuat berbeda tiap retry, sehingga deduplikasi tidak berguna.
- Key terlalu luas, sehingga dua operasi valid dianggap duplikat.
- Key hanya unik di satu proses, bukan secara global.
Visibility timeout harus mengikuti durasi kerja nyata
Jika timeout terlalu pendek, pesan akan muncul lagi saat worker masih bekerja. Jika terlalu panjang, pesan gagal akan terlalu lama terkunci sehingga pemulihan menjadi lambat. Pilih berdasarkan durasi normal, durasi p95/p99 jika Anda punya metrik, dan pertimbangkan mekanisme perpanjangan lease untuk job yang memang panjang.
Prinsip praktis:
- Timeout harus lebih panjang dari durasi normal pemrosesan.
- Untuk job panjang, sediakan heartbeat atau lease extension jika sistem mendukung.
- Jangan menyamakan timeout untuk semua jenis job jika karakteristiknya sangat berbeda.
Retry perlu dibedakan: transient vs permanent failure
Tidak semua error layak di-retry. Retry masuk akal untuk kegagalan sementara seperti:
- timeout jaringan,
- service downstream tidak tersedia sesaat,
- database lock contention sementara.
Tetapi retry biasanya tidak membantu untuk:
- payload invalid,
- field wajib hilang,
- format data salah,
- referensi entitas tidak akan pernah ditemukan,
- bug deterministik pada kode untuk jenis payload tertentu.
Jika semua error diperlakukan sama, antrean akan dipenuhi retry sia-sia.
Gunakan backoff, jangan retry ketat
Retry langsung tanpa jeda membuat sistem memperparah beban saat downstream sedang bermasalah. Gunakan backoff, lebih baik lagi jika ada jitter, agar retry tersebar dan tidak menimbulkan gelombang serangan serentak ke dependency yang sedang lemah.
Trade-off retry dan backoff:
- Retry agresif: pemulihan cepat jika gangguan sangat singkat, tetapi mudah memicu thundering herd.
- Backoff konservatif: lebih ramah ke sistem, tetapi menambah latency penyelesaian job.
- Batas retry rendah: cepat memindahkan masalah ke DLQ, tetapi bisa terlalu sensitif pada gangguan sementara.
- Batas retry tinggi: peluang sukses lebih besar, tetapi antrean bisa tersumbat pesan yang sama.
Poison message dan kapan memindahkannya ke DLQ
Poison message adalah pesan yang terus gagal diproses dan cenderung akan tetap gagal jika dicoba ulang tanpa perubahan. Pesan seperti ini perlu dipisahkan agar tidak mengganggu throughput pesan sehat.
Tanda-tanda poison message
- Pesan gagal berkali-kali dengan error yang sama.
- Payload tidak valid secara struktural atau semantik.
- Pesan memicu bug deterministik pada worker.
- Data referensi yang dibutuhkan tidak akan tersedia.
- Pemrosesan selalu melebihi batas karena isi pesan memang bermasalah.
Fungsi dead-letter queue
DLQ adalah antrean khusus untuk pesan yang gagal melewati batas retry atau melanggar aturan tertentu. Tujuannya bukan membuang masalah, tetapi mengisolasi pesan bermasalah agar:
- worker utama tetap memproses pesan lain,
- tim bisa menganalisis akar masalah,
- ada proses replay setelah perbaikan,
- insiden lebih mudah dipantau dan diaudit.
Kapan pesan dipindahkan ke DLQ?
Tidak ada angka tunggal yang selalu benar, tetapi aturan umumnya adalah: pindahkan ke DLQ ketika retry tambahan kecil kemungkinan membantu. Contohnya:
- Sudah melewati jumlah retry maksimum.
- Error diklasifikasikan sebagai permanen.
- Payload jelas invalid dan tidak bisa diparse.
- Pesan terlalu lama berputar tanpa progres.
- Ada batas umur pesan, dan setelah itu hasilnya tidak lagi relevan.
Praktik yang baik adalah membedakan kebijakan berdasarkan tipe error. Error sementara boleh beberapa kali retry dengan backoff. Error permanen bisa langsung ke DLQ atau setelah sedikit verifikasi.
Apa yang perlu disimpan bersama pesan di DLQ
- payload asli,
- message ID atau idempotency key,
- jumlah attempt,
- timestamp pertama dan terakhir gagal,
- ringkasan error dan stack trace yang aman,
- metadata routing atau partition/offset bila relevan,
- versi skema atau tipe event.
Tanpa metadata ini, DLQ hanya menjadi kuburan pesan yang sulit dianalisis.
Alur pemrosesan yang sehat
receive message
- validate basic schema
- classify retryability if possible
- check idempotency key
- process business logic
- on success: persist final state, ack
- on transient failure: retry with backoff
- on permanent failure or retry exhausted: move to DLQ
- emit metrics and structured logs at each stepChecklist desain consumer untuk at-least-once queue
- Apakah setiap pesan punya idempotency key yang stabil?
- Apakah deduplikasi disimpan di storage yang andal, bukan hanya memori?
- Apakah perubahan bisnis dan pencatatan status diproteksi secara atomik?
- Apakah ack/commit dilakukan setelah state aman?
- Apakah visibility timeout cukup untuk durasi kerja nyata?
- Apakah job panjang punya heartbeat atau lease extension?
- Apakah error dibedakan menjadi transient dan permanent?
- Apakah retry memakai backoff dan idealnya jitter?
- Apakah ada batas retry yang jelas per tipe job?
- Apakah poison message dipindahkan ke DLQ beserta metadata yang cukup?
- Apakah ada prosedur replay dari DLQ yang aman dan idempoten?
- Apakah log dan metrik cukup untuk membedakan duplikasi, timeout, dan bug deterministik?
Metrik yang perlu dipantau
Tanpa observabilitas, masalah queue sering baru terlihat setelah efek samping ganda merusak data. Pantau metrik berikut:
- Queue depth: jumlah pesan yang menunggu.
- Oldest message age: umur pesan tertua di antrean.
- Processing latency: durasi pemrosesan per job.
- Success rate dan failure rate.
- Retry rate: seberapa sering pesan dicoba ulang.
- Redelivery/duplicate rate: indikator masalah ack atau visibility timeout.
- DLQ size dan laju pertambahannya.
- Poison message concentration: apakah satu tipe payload mendominasi kegagalan.
- Timeout rate ke dependency eksternal.
- Idempotency conflict rate: berapa banyak pesan yang terdeteksi sebagai duplikat.
Selain metrik, gunakan structured logging dengan field seperti message_id, idempotency_key, attempt, job_type, dan error_class. Ini sangat membantu saat menelusuri mengapa satu pesan terus berputar atau diproses ganda.
Kesalahan implementasi yang sering terjadi
1. Menganggap broker akan menjamin exactly-once end-to-end
Beberapa sistem bisa memberi jaminan lebih kuat pada level tertentu, tetapi efek samping di database, cache, dan API eksternal tetap perlu desain idempoten. Exactly-once pada satu lapisan tidak otomatis berarti exactly-once untuk keseluruhan workflow.
2. Ack terlalu cepat
Jika ack dilakukan sebelum perubahan bisnis benar-benar aman, Anda bisa kehilangan pekerjaan saat proses mati sesudah ack tetapi sebelum commit state.
3. Ack terlalu lambat tanpa memperpanjang lease
Jika job lama dan visibility timeout pendek, worker lain bisa mengambil pesan yang sama. Ini memicu duplikasi paralel.
4. Deduplikasi hanya berbasis cache volatile
Cache berguna untuk optimasi, tetapi sebagai satu-satunya mekanisme dedup biasanya rapuh terhadap restart, eviksi, dan race condition.
5. Menjadikan DLQ sebagai tempat buang tanpa proses tindak lanjut
DLQ harus punya alur operasional: analisis, klasifikasi, perbaikan, replay, atau discard yang terdokumentasi. Tanpa itu, backlog masalah hanya berpindah tempat.
6. Retry semua exception tanpa klasifikasi
Akibatnya, bug deterministik dan payload invalid ikut diputar berkali-kali. Ini memboroskan kapasitas worker dan menunda pesan sehat.
7. Tidak menguji skenario crash dan redelivery
Banyak tim hanya menguji jalur sukses. Padahal bug queue sering muncul saat crash di tengah transaksi, saat network timeout, atau saat worker berhenti tepat sebelum ack.
Strategi debugging saat terjadi duplikasi atau DLQ meningkat
- Ambil satu message ID konkret dan telusuri seluruh lifecycle-nya di log.
- Bandingkan waktu proses dengan visibility timeout atau lease yang dikonfigurasi.
- Lihat urutan commit dan ack: mana yang terjadi duluan?
- Periksa idempotency store: apakah key stabil, unik, dan benar-benar disimpan?
- Klasifikasikan error: sementara atau permanen?
- Periksa dependency eksternal: apakah timeout atau rate limit memicu retry berlebihan?
- Sampling payload DLQ: apakah ada pola field hilang, skema lama, atau tipe event baru yang belum ditangani?
Jika banyak duplikasi muncul setelah deploy, curigai perubahan durasi pemrosesan, perubahan urutan ack/commit, atau bug pada penentuan idempotency key.
Penutup
Pada at-least-once queue, pertanyaan yang benar bukan “bagaimana mencegah semua duplikasi”, melainkan “bagaimana membuat duplikasi tidak berbahaya”. Jawabannya adalah kombinasi dari consumer yang idempoten, retry dengan backoff yang masuk akal, pengaturan visibility timeout yang sesuai, dan DLQ untuk poison message.
Jika Anda hanya mengandalkan broker tanpa merancang idempotensi di level aplikasi, masalah akan muncul sebagai efek samping ganda, antrean tersumbat, dan insiden yang sulit dijelaskan. Sebaliknya, dengan idempotency key yang stabil, penyimpanan status yang andal, klasifikasi error, dan alur DLQ yang jelas, sistem queue akan jauh lebih tahan terhadap crash, retry, dan kondisi produksi yang tidak ideal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!