Deteksi duplikasi job dengan locking dan cache yang konsisten bukan sekadar masalah optimasi, tetapi mekanisme perlindungan agar satu pekerjaan tidak dieksekusi dua kali saat sistem mengalami retry, race condition, atau gangguan jaringan. Dalam sistem queue nyata, duplikasi sering muncul bukan karena bug tunggal, melainkan kombinasi delivery ulang dari broker, worker crash, timeout, TTL lock yang salah, dan state cache yang tertinggal dari sumber kebenaran.
Solusi yang paling aman biasanya bukan satu teknik, melainkan lapisan pertahanan: idempotency key untuk mengenali pekerjaan yang sama, distributed lock untuk mencegah dua worker memproses job identik secara bersamaan, dan penyimpanan status yang konsisten agar hasil eksekusi tetap benar walaupun lock hilang atau pesan dikirim ulang. Artikel ini membahas pendekatan praktis, trade-off, serta checklist observability dan runbook insiden yang bisa langsung dipakai.
Masalah yang Sebenarnya Terjadi di Sistem Queue
Secara teori, job idealnya hanya diproses sekali. Dalam praktik, banyak sistem queue memberi jaminan at-least-once delivery, artinya pesan bisa dikirim lebih dari satu kali. Konsekuensinya, aplikasi harus siap menerima duplikasi sebagai kondisi normal.
Beberapa skenario umum:
- Pesan terkirim dua kali: producer mengirim ulang karena tidak menerima konfirmasi, padahal enqueue pertama sebenarnya berhasil.
- Retry berulang: worker gagal sebelum mengirim acknowledgment, broker menganggap job belum selesai lalu mengirim ulang.
- Worker balapan: dua worker mengambil job yang secara semantik sama, meskipun ID pesan berbeda.
- Worker crash setelah lock diambil: lock masih aktif, tetapi pekerjaan tidak selesai.
- TTL lock terlalu pendek: lock kedaluwarsa sebelum proses selesai, worker kedua masuk dan mengeksekusi pekerjaan yang sama.
- Cache stale: cache menyatakan job belum pernah diproses, padahal database sudah menyimpan hasilnya.
- Eventual consistency: status pada sistem terdistribusi belum tersinkron saat keputusan eksekusi dibuat.
Jika job hanya menulis log, duplikasi mungkin tidak terasa. Tetapi jika job melakukan charge pembayaran, mengirim email, membuat invoice, atau memutakhirkan stok, pemrosesan ganda menjadi insiden bisnis.
Pola Dasar: Idempotency Key + Lock + Status yang Persisten
Pendekatan yang aman adalah memisahkan tiga pertanyaan berbeda:
- Apakah ini job yang sama? Dijawab dengan idempotency key.
- Apakah ada worker lain yang sedang memprosesnya? Dijawab dengan distributed lock.
- Apakah job ini sudah pernah selesai diproses? Dijawab dengan status persisten pada database atau store yang dapat dipercaya.
Jangan mengandalkan cache saja untuk menentukan apakah job sudah selesai. Cache cepat, tetapi bisa hilang, stale, atau tertinggal dari data utama. Cache sebaiknya dipakai untuk mempercepat keputusan, bukan sebagai satu-satunya sumber kebenaran untuk side effect penting.
Menentukan Idempotency Key
Idempotency key harus mewakili identitas bisnis dari pekerjaan, bukan sekadar ID pesan dari broker. Contoh:
payment:{order_id}jika satu order hanya boleh dibayar sekali.email:{template}:{recipient}:{business_event_id}jika pengiriman email harus unik per peristiwa bisnis.invoice:{tenant_id}:{period}jika pembuatan invoice periodik tidak boleh dobel.
Kesalahan umum adalah memakai UUID pesan queue sebagai kunci deduplikasi. Itu gagal mendeteksi dua pesan berbeda yang merepresentasikan pekerjaan bisnis yang sama.
Alur Eksekusi yang Disarankan
- Hitung idempotency key dari payload bisnis.
- Cek tabel status atau constraint unik untuk melihat apakah pekerjaan sudah selesai.
- Ambil distributed lock berdasarkan key tersebut.
- Setelah lock didapat, cek ulang status persisten karena mungkin worker lain baru saja selesai.
- Proses job.
- Simpan hasil dan tandai status selesai secara atomik di penyimpanan persisten.
- Baru lepaskan lock.
- Opsional: tulis cache hasil/status untuk mempercepat pembacaan berikutnya.
Pengecekan ganda sebelum dan sesudah lock penting untuk mengurangi race condition. Cache bisa dipakai di awal sebagai fast path, tetapi keputusan akhir tetap perlu divalidasi terhadap state persisten jika side effect-nya kritis.
Implementasi Praktis dengan Redis Lock dan Status Persisten
Contoh Alur Pseudocode
fn process_job(job) -> Result {
let key = build_idempotency_key(&job);
let lock_key = format!("lock:{}", key);
if db.is_completed(&key) {
return Ok(()); // duplikat aman, sudah selesai sebelumnya
}
let lock_token = random_token();
let acquired = redis_set_nx_ex(&lock_key, &lock_token, 300);
if !acquired {
return RetryLater; // worker lain sedang memproses
}
defer {
redis_unlock_if_value_matches(&lock_key, &lock_token);
}
if db.is_completed(&key) {
return Ok(());
}
db.mark_processing_if_absent(&key)?;
let result = do_business_operation(&job)?;
db.mark_completed(&key, &result)?;
cache_set_completed(&key, &result, 3600);
Ok(())
}Beberapa hal penting dari alur di atas:
- Lock token diperlukan agar worker tidak menghapus lock milik worker lain saat unlock.
- Cek ulang setelah lock mencegah eksekusi dobel ketika state berubah di sela-sela pengambilan lock.
- Status selesai di database tetap menjadi benteng utama jika Redis restart, lock hilang, atau cache dibersihkan.
Redis SET NX EX: Kapan Cocok, Kapan Tidak
Pola yang umum dipakai untuk lock sederhana adalah SET key value NX EX ttl. Artinya: simpan key hanya jika belum ada, dan beri masa berlaku. Ini praktis karena satu operasi cukup untuk akuisisi lock.
Kelebihan:
- Cepat dan sederhana.
- Cocok untuk menahan worker paralel pada job identik.
- Baik sebagai lapisan koordinasi ringan di atas queue.
Keterbatasan:
- Bergantung pada TTL yang benar. Jika terlalu pendek, lock habis sebelum job selesai.
- Tidak otomatis menjamin pekerjaan hanya selesai sekali.
- Jika Redis bermasalah atau failover terjadi, asumsi eksklusivitas bisa terganggu.
- Tidak menyimpan riwayat hasil; hanya menyatakan siapa yang sedang memegang lock.
Karena itu, Redis lock tidak cukup sebagai satu-satunya mekanisme deduplikasi. Ia mencegah balapan, tetapi bukan pengganti idempotency dan persistence.
Masalah TTL Lock Terlalu Pendek
Ini salah satu sumber duplikasi paling umum. Misalnya job biasanya selesai dalam 10 detik, tetapi sesekali butuh 90 detik saat downstream lambat. Jika TTL lock diset 30 detik, worker pertama masih bekerja ketika lock kedaluwarsa. Worker kedua lalu mengambil lock dan memproses job yang sama.
Mitigasi yang umum:
- Set TTL berdasarkan worst-case realistis, bukan rata-rata.
- Gunakan heartbeat atau perpanjangan lock berkala jika pekerjaan panjang.
- Simpan status processing/completed di database agar worker kedua tetap bisa berhenti setelah cek ulang.
- Pastikan operasi bisnis sendiri idempotent jika memungkinkan.
Jika Anda harus memilih antara TTL terlalu panjang dan terlalu pendek, TTL terlalu panjang biasanya lebih aman untuk mencegah duplikasi, tetapi meningkatkan risiko job tertahan saat worker crash. Itu sebabnya perlu strategi pemulihan dan observability yang baik.
Skenario Sulit dan Cara Menanganinya
1. Pesan Terkirim Dua Kali
Kasus ini lazim terjadi saat producer timeout ketika mengirim pesan, lalu mencoba lagi. Broker akhirnya menyimpan dua pesan yang identik secara bisnis.
Solusi:
- Buat idempotency key dari identitas bisnis, bukan ID pesan broker.
- Simpan status eksekusi berdasarkan key tersebut di database.
- Jika dua pesan masuk, salah satunya akan melihat state
completedatau gagal pada constraint unik.
2. Worker Crash Setelah Lock Diambil
Jika worker crash setelah lock diambil tetapi sebelum status selesai disimpan, lock akan bertahan sampai TTL habis. Selama itu job mungkin tidak diproses ulang.
Yang perlu diperhatikan:
- Tanpa TTL, lock bisa orphan selamanya.
- Dengan TTL terlalu lama, pemulihan lambat.
- Dengan TTL terlalu pendek, risiko duplikasi naik.
Pendekatan yang lebih seimbang:
- Pakai TTL yang cukup panjang.
- Tambahkan heartbeat untuk job panjang.
- Simpan status
processingdengan timestamp terakhir. - Gunakan proses rekonsiliasi yang bisa menandai job
processingterlalu lama sebagai kandidat retry aman.
3. Cache Stale
Misalnya worker menulis completed ke database, tetapi cache belum diperbarui atau malah berisi nilai lama not_found. Worker lain membaca cache stale dan memutuskan memproses lagi.
Prinsip aman:
- Untuk keputusan yang memicu side effect penting, cache jangan jadi sumber kebenaran tunggal.
- Gunakan cache sebagai akselerator: jika cache menyatakan
completed, Anda bisa short-circuit. Jika cache negatif atau kosong, validasi ke database. - Hindari pola write ke cache saja tanpa persistence.
4. Eventual Consistency
Dalam arsitektur terdistribusi, status bisa menyebar dengan jeda. Misalnya satu service sudah menandai job selesai, tetapi replica database atau cache regional lain belum melihat perubahan itu.
Konsekuensinya, worker di node lain bisa membuat keputusan dengan data terlambat. Karena itu:
- Tempatkan keputusan deduplikasi pada store yang memiliki konsistensi cukup untuk kebutuhan bisnis.
- Jika memakai replica baca yang asynchronous, jangan gunakan replica itu untuk pengecekan final idempotency.
- Untuk operasi kritis, lebih aman menulis dan memverifikasi pada primary atau store yang memberi jaminan unik secara kuat.
Trade-off: Redis Lock vs Unique Constraint vs Outbox/Inbox
Redis SET NX EX
Pilih ini jika:
- Anda perlu menahan eksekusi paralel dengan latensi rendah.
- Job bisa datang bersamaan di banyak worker.
- Anda butuh koordinasi cepat tanpa membebani database untuk setiap kompetisi lock.
Jangan mengandalkan ini sendirian jika:
- Side effect bersifat kritis seperti pembayaran, mutasi saldo, atau pembuatan dokumen legal.
- Anda perlu bukti kuat bahwa job hanya selesai sekali.
Database Unique Constraint
Constraint unik di database adalah salah satu mekanisme paling kuat untuk idempotency. Contohnya, tabel processed_jobs memiliki kolom idempotency_key dengan unique index.
CREATE TABLE processed_jobs (
idempotency_key TEXT PRIMARY KEY,
status TEXT NOT NULL,
result_ref TEXT NULL,
updated_at TIMESTAMP NOT NULL
);Atau untuk operasi domain, unik langsung di tabel bisnis, misalnya satu payment per order_id.
Kelebihan:
- Jaminan kuat dari sumber kebenaran utama.
- Sederhana untuk audit dan debugging.
- Tahan terhadap restart cache atau hilangnya lock.
Kekurangan:
- Tidak otomatis mencegah dua worker bekerja bersamaan sebelum salah satunya gagal commit.
- Bisa menambah kontensi di database pada beban tinggi.
- Perlu desain transaksi yang rapi agar error duplicate key ditangani sebagai kondisi normal, bukan insiden.
Kapan ideal: hampir selalu layak dipakai untuk operasi penting, minimal sebagai lapisan akhir pencegahan duplikasi.
Outbox/Inbox Pattern
Pattern ini relevan saat duplikasi terjadi lintas layanan. Secara ringkas:
- Outbox: perubahan data dan event yang akan dikirim ditulis dalam satu transaksi lokal, lalu event dipublikasikan secara terpisah dari tabel outbox.
- Inbox: service penerima menyimpan ID atau key pesan yang sudah diproses, sehingga delivery ulang tidak mengeksekusi side effect lagi.
Kelebihan:
- Sangat cocok untuk sistem event-driven dan integrasi antarservice.
- Mengurangi kehilangan event dan membantu deduplikasi konsumen.
- Lebih konsisten dibanding mengirim pesan langsung dari dalam transaksi bisnis.
Kekurangan:
- Lebih kompleks secara operasional.
- Perlu tabel tambahan, proses relay, dan housekeeping.
- Tidak menghilangkan kebutuhan idempotent consumer.
Kapan dipilih: saat Anda perlu keandalan tinggi pada aliran event antarservice, bukan hanya proteksi worker lokal.
Desain yang Direkomendasikan untuk Sistem Nyata
Untuk sebagian besar backend production, kombinasi berikut biasanya paling masuk akal:
- Idempotency key berbasis identitas bisnis.
- Database unique constraint atau tabel status untuk sumber kebenaran akhir.
- Redis lock untuk menahan worker balapan dan mengurangi kerja sia-sia.
- Cache status hanya sebagai akselerator baca, bukan penentu final.
- Retry yang sadar idempotency, bukan sekadar mengulang buta.
Dengan desain ini:
- Jika dua worker masuk bersamaan, lock menahan salah satunya.
- Jika lock gagal karena TTL habis, unique constraint tetap melindungi hasil akhir.
- Jika cache stale, database masih bisa mengoreksi keputusan.
- Jika pesan dikirim dua kali, idempotency key memetakan keduanya ke pekerjaan yang sama.
Implementasi Retry yang Aman
Retry perlu dibedakan antara retry teknis dan retry bisnis. Retry teknis terjadi karena timeout, koneksi putus, atau broker redelivery. Retry bisnis terjadi ketika operasi memang belum bisa diselesaikan, misalnya layanan downstream mengembalikan status sementara.
Praktik yang aman:
- Simpan jumlah percobaan dan alasan gagal.
- Gunakan backoff bertahap agar worker tidak menyerbu resource yang sedang bermasalah.
- Jangan membuat idempotency key baru untuk setiap retry dari pekerjaan yang sama.
- Bedakan error yang boleh diulang dengan error yang harus dihentikan.
Kesalahan umum adalah membuat deduplikasi berdasarkan kombinasi job_id + attempt. Itu justru menganggap setiap retry sebagai pekerjaan baru.
Observability: Metrik, Logging, dan Tracing yang Wajib Ada
Tanpa observability, duplikasi job sering baru ketahuan setelah dampak bisnis muncul. Minimal, siapkan sinyal berikut.
Metrik Utama
- duplicate_detected_total: jumlah job yang dikenali sebagai duplikat.
- lock_acquire_success_total dan lock_acquire_failed_total: seberapa sering worker gagal mendapat lock.
- lock_expired_before_completion_total: indikasi TTL terlalu pendek.
- job_retry_total dengan label penyebab error.
- job_processing_duration: distribusi waktu proses, bukan hanya rata-rata.
- idempotency_conflict_total: jumlah benturan unique constraint atau insert konflik.
- stale_processing_records_total: job berstatus processing melewati ambang wajar.
Structured Logging
Setiap log terkait job sebaiknya memuat:
- job ID dari broker,
- idempotency key,
- worker ID atau hostname,
- attempt number,
- lock key dan hasil akuisisi,
- status transisi: received, locked, processing, completed, duplicate, failed.
Dengan begitu, Anda bisa membedakan apakah duplikasi berasal dari producer, broker redelivery, TTL lock, atau worker crash.
Tracing
Jika memakai distributed tracing, hubungkan jejak dari producer ke consumer hingga downstream dependency. Ini sangat membantu saat satu event bisnis memicu banyak queue dan salah satu service melakukan retry beruntun.
Runbook Insiden: Saat Duplikasi Mulai Terjadi
Runbook yang baik harus membantu tim bergerak cepat tanpa menebak-nebak. Berikut checklist praktis.
Langkah Diagnostik
- Identifikasi scope: job apa yang terdampak dan side effect apa yang berpotensi dobel.
- Cari idempotency key yang paling sering bentrok atau muncul berulang.
- Bandingkan jumlah
lock_acquire_failed, retry, dan duplicate detection. - Periksa apakah durasi proses meningkat hingga melewati TTL lock.
- Periksa crash loop worker, restart container, atau eviction node.
- Pastikan Redis, database primary, dan replica tidak mengalami lag atau failover baru-baru ini.
- Audit apakah duplicate disebabkan producer resend, redelivery broker, atau race antarworker.
Langkah Mitigasi Cepat
- Naikkan TTL lock sementara jika indikasi utama adalah lock habis terlalu cepat.
- Kurangi concurrency worker untuk jenis job yang bermasalah.
- Aktifkan atau perketat pengecekan status persisten sebelum eksekusi side effect.
- Jika perlu, pause consumer tertentu sambil membersihkan backlog berisiko tinggi.
- Tambahkan filter sementara berbasis idempotency key pada ingress consumer.
Langkah Pemulihan
- Rekonsiliasi data bisnis: transaksi dobel, email ganda, invoice duplikat, dan sebagainya.
- Jalankan script koreksi hanya dengan kriteria yang dapat diaudit.
- Pastikan replay job lama tidak memicu duplikasi baru.
- Setelah stabil, evaluasi ulang TTL, retry policy, dan sumber kebenaran deduplikasi.
Kesalahan Desain yang Sering Terjadi
- Menganggap queue exactly-once padahal implementasinya at-least-once.
- Mengandalkan cache saja untuk menyatakan job sudah selesai.
- Memakai ID pesan sebagai idempotency key padahal pekerjaan bisnis sama.
- Tidak menyimpan hasil/status persisten sehingga sistem lupa bahwa job pernah dijalankan.
- Unlock tanpa verifikasi token, berisiko melepas lock milik worker lain.
- TTL diambil dari rata-rata durasi tanpa memperhitungkan tail latency.
- Tidak memperlakukan duplicate key sebagai kondisi normal di path idempotent.
Kapan Memilih Pendekatan yang Mana
Ringkasnya:
- Jika masalah utama adalah worker balapan: tambah Redis lock.
- Jika side effect sangat kritis: wajib ada unique constraint atau status persisten yang kuat.
- Jika duplikasi terjadi antarservice dan event sering redelivered: pertimbangkan outbox/inbox pattern.
- Jika performa baca penting: gunakan cache, tetapi tetap validasi ke sumber kebenaran untuk operasi sensitif.
Pada sistem nyata, pilihan terbaik hampir selalu bukan salah satu, melainkan kombinasi beberapa lapisan yang saling menutup celah kegagalan.
Penutup
Deteksi duplikasi job dengan locking dan cache yang konsisten bekerja baik jika Anda memisahkan fungsi setiap komponen: lock untuk koordinasi, cache untuk akselerasi, dan database atau store persisten untuk kebenaran akhir. Tambahkan idempotency key yang tepat, lalu desain retry dan observability agar sistem tetap aman saat pesan dikirim ulang, worker crash, atau state antar node belum konsisten.
Jika harus memilih prinsip utama, gunakan ini: anggap duplikasi sebagai kondisi normal, lalu rancang job agar aman saat dijalankan lebih dari sekali. Lock membantu mengurangi balapan, tetapi idempotency dan status persisten adalah fondasi yang benar-benar mencegah dampak bisnis dari pemrosesan ganda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!