Pada sistem queue + worker, job ganda bukan anomali, melainkan perilaku yang harus diasumsikan bisa terjadi. Penyebabnya bisa berupa duplicate delivery dari broker, worker crash setelah memproses tetapi sebelum ack, retry otomatis, race condition antar instance, atau lock yang kedaluwarsa terlalu cepat.
Di Spring Boot, cara mencegahnya biasanya bukan dengan satu mekanisme saja. Redis distributed lock berguna untuk mencegah dua worker mengeksekusi job yang sama secara bersamaan, tetapi idempotency tetap lebih penting untuk memastikan hasil akhir tetap benar walaupun pesan terkirim atau diproses lebih dari sekali. Artikel ini fokus pada implementasi praktis, trade-off, dan langkah operasional saat job tetap terproses dua kali.
Kapan masalah job ganda benar-benar terjadi?
Beberapa skenario produksi yang paling sering memicu duplikasi:
- Duplicate delivery: broker mengirim ulang pesan karena ack tidak diterima tepat waktu.
- Retry consumer: aplikasi melempar exception, framework atau broker menjadwalkan ulang job.
- Worker crash: proses bisnis selesai, tetapi worker mati sebelum menyimpan status akhir atau sebelum ack.
- Race condition: dua worker membaca pesan berbeda yang sebenarnya mereferensikan entitas bisnis yang sama, misalnya dua job pembayaran untuk order yang sama.
- Lock kedaluwarsa: job berjalan lebih lama dari TTL lock, worker lain lalu mengambil lock baru dan mengeksekusi job yang sama.
- Cache inconsistency: status diputuskan dari Redis/cache yang belum sinkron dengan database sumber kebenaran.
Intinya, jangan mengasumsikan queue menjamin exactly once processing. Dalam banyak arsitektur nyata, yang realistis adalah at-least-once delivery, sehingga aplikasi harus tahan terhadap duplikasi.
Redis lock vs idempotency: pilih yang mana?
Kapan memakai Redis distributed lock
Gunakan Redis lock jika tujuan Anda adalah mencegah eksekusi paralel untuk satu kunci bisnis tertentu, misalnya:
- satu
orderIdhanya boleh diproses oleh satu worker pada satu waktu, - satu sinkronisasi akun eksternal tidak boleh berjalan bersamaan,
- satu proses settlement per merchant tidak boleh overlap.
Lock cocok untuk mengurangi konflik operasional, beban sistem eksternal, dan efek samping karena proses bersamaan.
Kapan idempotency lebih penting
Gunakan idempotency sebagai lapisan utama jika Anda perlu memastikan bahwa hasil akhir tetap sama walaupun job diterima lebih dari sekali. Contohnya:
- pembuatan invoice tidak boleh menghasilkan dua invoice untuk event yang sama,
- pembebanan biaya tidak boleh dilakukan dua kali,
- pengiriman webhook ulang tidak boleh mengubah status menjadi inkonsisten.
Idempotency biasanya diimplementasikan lewat idempotency key, unique constraint, tabel status pemrosesan, atau pengecekan state sebelum menulis efek samping.
Trade-off keduanya
- Redis lock mencegah paralelisme, tetapi tidak cukup jika lock hilang, TTL habis, atau pesan dikirim ulang setelah lock dilepas.
- Idempotency menjaga konsistensi hasil, tetapi tidak mencegah dua worker menjalankan pekerjaan berat yang sama secara bersamaan.
- Di produksi, pola yang paling aman biasanya: lock untuk membatasi eksekusi bersamaan + idempotency untuk menjamin hasil akhir.
Jika Anda harus memilih satu, pilih idempotency untuk melindungi data bisnis. Lock adalah optimisasi koordinasi; idempotency adalah pagar terakhir terhadap duplikasi.
Desain alur yang lebih aman di Spring Boot
Alur yang praktis untuk sistem queue worker terdistribusi:
- Producer membuat job dengan jobId dan business key yang stabil, misalnya
orderId. - Consumer menerima pesan dan membangun lock key berdasarkan business key, bukan sekadar ID pesan broker.
- Consumer mencoba mengambil Redis lock dengan TTL yang cukup.
- Jika lock gagal didapat, jangan langsung anggap error permanen. Biasanya job bisa requeue dengan backoff, atau di-skip bila status bisnis sudah selesai.
- Setelah lock didapat, lakukan pengecekan idempotency di database.
- Eksekusi proses bisnis.
- Simpan status hasil secara atomik sebisa mungkin.
- Lepaskan lock hanya jika token lock masih milik worker yang sama.
Poin penting: jangan menjadikan cache sebagai sumber kebenaran final untuk memutuskan apakah job sudah selesai. Gunakan database atau penyimpanan status yang punya konsistensi lebih kuat untuk keputusan bisnis utama.
Contoh implementasi Spring Boot
Model payload job
public record PaymentJobMessage(
String jobId,
String orderId,
String idempotencyKey,
int attempt
) {}
jobId berguna untuk pelacakan, tetapi orderId atau idempotencyKey biasanya lebih relevan untuk pencegahan job ganda.
Producer: kirim pesan dengan business key yang stabil
@Service
public class PaymentJobProducer {
private final RabbitTemplate rabbitTemplate;
public PaymentJobProducer(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void publish(String orderId) {
PaymentJobMessage message = new PaymentJobMessage(
UUID.randomUUID().toString(),
orderId,
"payment:" + orderId,
0
);
rabbitTemplate.convertAndSend("payment.exchange", "payment.process", message);
}
}
Nama broker di atas hanya contoh. Prinsipnya tetap sama untuk sistem queue lain: selalu kirim key bisnis yang bisa dipakai ulang di sisi consumer.
Wrapper Redis lock yang aman
Lock harus menyimpan token unik agar pelepasan lock tidak menghapus lock milik worker lain. Jangan memakai pola: GET key lalu DEL key secara terpisah tanpa verifikasi token.
@Component
public class RedisLockService {
private final StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end",
Long.class
);
public RedisLockService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public String tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? token : null;
}
public boolean unlock(String key, String token) {
Long result = redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(key),
token
);
return result != null && result > 0;
}
}
Mengapa perlu token? Karena lock bisa kedaluwarsa, lalu worker lain mengambil lock baru dengan key yang sama. Jika worker lama tetap memanggil DEL tanpa verifikasi token, ia bisa menghapus lock milik worker baru.
Repository idempotency sederhana
Lapisan ini menyimpan status pemrosesan berdasarkan key bisnis. Implementasi nyatanya bisa berupa tabel database dengan unique constraint pada idempotency_key.
public enum JobStatus {
PROCESSING,
SUCCEEDED,
FAILED
}
public interface JobExecutionRepository {
boolean markProcessingIfAbsent(String idempotencyKey);
boolean isSucceeded(String idempotencyKey);
void markSucceeded(String idempotencyKey);
void markFailed(String idempotencyKey, String reason);
}
Implementasi yang baik sebaiknya memakai operasi atomik di database, misalnya insert if not exists atau unique constraint agar dua worker tidak sama-sama menandai entri baru.
Consumer: gabungkan lock + idempotency
@Service
public class PaymentJobConsumer {
private final RedisLockService lockService;
private final JobExecutionRepository jobExecutionRepository;
private final PaymentService paymentService;
public PaymentJobConsumer(
RedisLockService lockService,
JobExecutionRepository jobExecutionRepository,
PaymentService paymentService
) {
this.lockService = lockService;
this.jobExecutionRepository = jobExecutionRepository;
this.paymentService = paymentService;
}
@RabbitListener(queues = "payment.queue")
public void handle(PaymentJobMessage message) {
String lockKey = "lock:payment:order:" + message.orderId();
String token = lockService.tryLock(lockKey, Duration.ofMinutes(2));
if (token == null) {
throw new RetryableJobException("Lock sedang dipakai worker lain");
}
try {
if (jobExecutionRepository.isSucceeded(message.idempotencyKey())) {
return;
}
boolean reserved = jobExecutionRepository
.markProcessingIfAbsent(message.idempotencyKey());
if (!reserved && !jobExecutionRepository.isSucceeded(message.idempotencyKey())) {
throw new RetryableJobException("Job sedang diproses atau status belum final");
}
paymentService.processPayment(message.orderId(), message.idempotencyKey());
jobExecutionRepository.markSucceeded(message.idempotencyKey());
} catch (ExternalServiceTemporaryException e) {
jobExecutionRepository.markFailed(message.idempotencyKey(), e.getMessage());
throw e;
} catch (Exception e) {
jobExecutionRepository.markFailed(message.idempotencyKey(), e.getMessage());
throw e;
} finally {
lockService.unlock(lockKey, token);
}
}
}
Ada beberapa hal penting dari pola ini:
- Lock key memakai business key, bukan random jobId, supaya dua pesan untuk order yang sama tetap saling mengunci.
- Idempotency tetap dicek setelah lock didapat. Ini penting karena pesan bisa datang ulang setelah proses sebelumnya selesai.
- Unlock ada di blok
finallyagar tidak bocor saat exception.
Catatan tentang status FAILED
Jangan terlalu cepat menganggap FAILED sebagai final. Untuk error temporer, sering kali lebih tepat menyimpan status seperti RETRYABLE atau menyimpan jumlah percobaan. Kalau semua retry menulis FAILED lalu worker berikutnya menolak memproses, Anda justru memblokir pemulihan otomatis.
Strategi pelepasan lock yang aman
Masalah klasik pada Redis distributed lock bukan hanya mengambil lock, tetapi melepaskannya dengan aman.
Aturan dasarnya
- Set lock dengan TTL, agar lock tidak tertinggal permanen saat worker crash.
- Simpan token unik sebagai value lock.
- Saat unlock, hapus key hanya jika token cocok.
- Jangan melepas lock berdasarkan key saja.
Apa yang terjadi jika lock kedaluwarsa saat job masih berjalan?
Ini skenario berbahaya. Misalnya TTL lock 2 menit, tetapi panggilan ke layanan eksternal butuh 5 menit. Setelah menit ke-2, worker lain bisa mengambil lock yang sama dan memproses order yang sama.
Mitigasinya:
- Tetapkan TTL berdasarkan durasi p95/p99 job dengan buffer yang masuk akal.
- Untuk job panjang, pertimbangkan lock renewal atau heartbeat.
- Jangan mengandalkan lock saja; tetap butuh idempotency di level data bisnis.
Kapan perlu lock renewal?
Jika durasi kerja sangat bervariasi atau sering bergantung pada API eksternal yang lambat, lock renewal bisa membantu. Namun implementasinya menambah kompleksitas:
- worker harus memperpanjang TTL secara berkala selama masih sehat,
- renewal harus berhenti bila worker tidak lagi memegang token yang sama,
- renewal yang terlalu agresif bisa menyamarkan job macet.
Jika belum benar-benar butuh, mulai dengan TTL konservatif + idempotency + observabilitas yang baik.
Retry, backoff, dan dead-letter queue
Jangan retry semua error dengan cara yang sama
Kelompokkan error minimal menjadi dua:
- Retryable: timeout jaringan, lock sedang dipakai, layanan eksternal sementara gagal.
- Non-retryable: payload tidak valid, entitas tidak ditemukan permanen, pelanggaran kontrak data.
Jika semua exception diperlakukan sama, queue bisa penuh oleh job yang sebenarnya tidak akan pernah sukses.
Gunakan backoff, jangan retry rapat
Retry langsung tanpa jeda sering memperparah masalah:
- memicu thundering herd,
- menambah kontensi lock,
- membebani database dan API eksternal saat sedang tidak sehat.
Pakai exponential backoff atau setidaknya jeda bertahap. Jika broker mendukung delayed retry atau retry queue terpisah, itu biasanya lebih aman daripada loop retry sinkron di dalam consumer.
Dead-letter queue wajib untuk kasus gagal berulang
Setelah jumlah retry tertentu, arahkan pesan ke dead-letter queue agar:
- antrian utama tidak tersumbat,
- tim bisa menginspeksi payload dan error,
- reprocessing bisa dilakukan secara terkontrol.
Simpan konteks minimal berikut saat mengirim ke DLQ:
- jobId,
- business key,
- idempotency key,
- attempt count,
- error message singkat,
- timestamp kegagalan terakhir.
Cache inconsistency: jebakan yang sering diremehkan
Banyak implementasi gagal karena memutuskan status job dari cache. Contoh yang berbahaya:
- consumer cek Redis:
status=NOT_PROCESSED, lalu lanjut memproses, - padahal database sebenarnya sudah menyimpan transaksi sukses, tetapi cache belum diperbarui atau key sudah hilang.
Mitigasinya:
- Database tetap sumber kebenaran untuk status bisnis final.
- Gunakan cache hanya untuk optimasi baca, bukan satu-satunya dasar keputusan irreversible.
- Jika memakai cache status, definisikan strategi invalidasi dengan jelas dan siapkan fallback ke database saat status meragukan.
Metrik dan visibilitas error yang harus ada
Kalau job masih terproses dua kali, Anda butuh bukti, bukan dugaan. Minimal pantau metrik berikut:
- jumlah pesan masuk per jenis job,
- jumlah lock acquisition success/failure,
- waktu tunggu lock bila memakai retry terukur,
- jumlah duplicate detect dari layer idempotency,
- retry count per job,
- jumlah pesan ke DLQ,
- durasi pemrosesan job,
- error rate per tipe exception.
Untuk log, sertakan field yang konsisten:
jobId,orderIdatau business key lain,idempotencyKey,lockKey,lockTokenbila aman untuk internal log,attempt,consumerInstanceId.
Dengan data ini, Anda bisa membedakan apakah duplikasi terjadi karena broker redelivery, lock expiry, bug idempotency, atau commit yang tidak atomik.
Langkah debugging saat job tetap terproses dua kali
1. Cek apakah sebenarnya ini duplicate delivery yang sah
Lihat apakah broker mengirim ulang karena ack terlambat atau worker crash. Jika ya, ini bukan bug broker; aplikasi memang harus tahan terhadap kondisi ini.
2. Verifikasi lock key
Kesalahan umum: lock memakai jobId acak, padahal dua pesan berbeda untuk entitas yang sama harus saling mengunci. Pastikan key lock dibangun dari business key yang tepat.
3. Periksa TTL lock terhadap durasi job nyata
Bandingkan durasi proses aktual dengan TTL lock. Jika job lebih lama dari TTL, kemungkinan besar worker kedua masuk setelah lock kedaluwarsa.
4. Audit mekanisme unlock
Pastikan unlock memakai verifikasi token. Jika tidak, lock milik worker baru bisa terhapus oleh worker lama.
5. Cek apakah idempotency key benar-benar stabil
Jika key berubah setiap retry, sistem tidak punya cara mengenali bahwa itu pekerjaan bisnis yang sama.
6. Periksa operasi atomik pada penyimpanan status
Jika status PROCESSING ditulis dengan pola baca-lalu-tulis biasa tanpa unique constraint atau transaksi yang tepat, dua worker bisa lolos bersamaan.
7. Tinjau urutan commit dan ack
Jika efek samping ke database atau layanan eksternal terjadi tetapi status akhir belum tersimpan saat worker mati, job akan tampak belum selesai dan diproses ulang.
8. Pastikan cache bukan sumber kebenaran
Jika keputusan duplicate check diambil dari cache yang basi, Anda bisa melihat duplikasi walaupun database sebenarnya sudah konsisten.
Checklist mitigasi untuk produksi
- Gunakan business key yang jelas untuk lock dan idempotency.
- Terapkan idempotency di storage yang andal, idealnya dengan unique constraint atau operasi atomik.
- Pakai Redis lock dengan TTL + token unik + unlock via script atomik.
- Sesuaikan TTL lock dengan durasi job aktual; pertimbangkan renewal untuk job panjang.
- Bedakan error retryable dan non-retryable.
- Terapkan backoff retry, jangan retry rapat.
- Siapkan dead-letter queue untuk kegagalan berulang.
- Jangan jadikan cache sebagai satu-satunya sumber keputusan status final.
- Tambahkan log terstruktur dan metrik lock, retry, duplicate, dan DLQ.
- Uji skenario crash: worker mati sebelum ack, worker mati sebelum update status, lock habis saat proses berjalan.
Jebakan umum yang sering menyebabkan hasil tetap ganda
- Hanya memakai lock tanpa idempotency.
- TTL lock terlalu pendek dibanding durasi proses.
- Unlock tanpa token.
- Idempotency key berubah setiap retry.
- Menggunakan cache untuk duplicate check final.
- Retry tak terbatas tanpa DLQ.
- Status gagal menimpa status sukses karena race condition saat update status.
Penutup
Untuk mencegah job ganda di Spring Boot pada arsitektur queue worker terdistribusi, Redis distributed lock membantu mencegah eksekusi paralel, tetapi idempotency tetap fondasi utama agar hasil bisnis tetap benar saat pesan dikirim ulang, worker crash, atau lock kedaluwarsa. Jika Anda hanya memasang lock, Anda belum benar-benar aman.
Pendekatan yang paling praktis di produksi adalah menggabungkan lock berbasis business key, idempotency di storage yang andal, retry dengan backoff, DLQ, serta observabilitas yang cukup untuk membuktikan sumber duplikasi. Dengan kombinasi ini, job ganda tidak selalu bisa dicegah 100%, tetapi dampaknya bisa dikendalikan dan hasil akhirnya tetap konsisten.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!