Pada sistem queue dengan pola at-least-once delivery, pesan yang sama bisa diproses lebih dari sekali. Ini bukan bug broker semata, tetapi konsekuensi desain agar pesan tidak hilang saat worker crash, koneksi putus, atau ack gagal terkirim. Karena itu, worker di Spring Boot harus dirancang idempoten: jika event atau job yang sama datang dua kali, efek akhirnya tetap satu kali.

Artikel ini membahas panduan praktis membangun idempotensi worker queue untuk cegah proses duplikat di Spring Boot. Fokusnya adalah masalah operasional nyata: retry, redelivery, duplicate event, race condition antar-instance, lock timeout, dan inkonsistensi antara cache dan database. Contoh implementasi menggunakan Spring Boot, JPA, serta database atau Redis sebagai guard.

Kenapa proses duplikat tetap terjadi?

Beberapa penyebab umum job diproses lebih dari sekali:

  • Retry otomatis: worker melempar exception, broker atau framework mengirim ulang job.
  • Redelivery setelah crash: worker selesai menjalankan sebagian logika, tetapi crash sebelum ack.
  • Race condition antar-instance: dua instance membaca pesan yang sama atau menerima event duplikat hampir bersamaan.
  • Producer mengirim event ganda: timeout di sisi producer memicu publish ulang.
  • Lock timeout: guard berbasis lock dilepas terlalu cepat, lalu job yang sama masuk lagi.
  • Cache/database tidak sinkron: status di Redis bilang selesai, tetapi transaksi database rollback.

Intinya, jangan mengasumsikan queue akan memberikan exactly-once processing. Di level aplikasi, anggap setiap pesan bisa datang berkali-kali.

Prinsip dasar idempotensi worker queue

1. Gunakan idempotency key yang stabil

Setiap job perlu memiliki idempotency key yang merepresentasikan operasi bisnis yang sama. Kunci ini harus stabil lintas retry dan redelivery.

Contoh yang baik:

  • orderId + eventType untuk event yang memang seharusnya sekali per order.
  • paymentId untuk proses settlement satu pembayaran.
  • externalRequestId dari sistem upstream jika memang unik dan konsisten.

Contoh yang buruk:

  • UUID baru yang dibuat oleh consumer saat menerima pesan.
  • timestamp penerimaan pesan.
  • hash seluruh payload jika payload dapat berubah urutan field atau berisi metadata non-deterministik.

Catatan: idempotency key harus merepresentasikan operasi bisnis, bukan sekadar satu kiriman pesan.

2. Simpan status processing secara persisten

Minimal, sistem perlu mengetahui apakah suatu key sedang diproses, sudah selesai, atau gagal. Menyimpan status ini di memori proses tidak cukup karena tidak aman terhadap restart atau scale-out.

Status yang umum dipakai:

  • RECEIVED: key tercatat, tetapi belum diproses.
  • PROCESSING: worker sedang menjalankan logika utama.
  • COMPLETED: efek bisnis sudah sukses dilakukan.
  • FAILED: eksekusi gagal dan perlu diputuskan apakah boleh retry.

3. Lindungi dengan unique constraint

Unique constraint di database adalah fondasi paling kuat untuk mencegah dua transaksi membuat record idempotensi yang sama. Redis lock berguna, tetapi database tetap lebih otoritatif untuk konsistensi data bisnis.

4. Tentukan boundary transaksi dengan jelas

Kesalahan paling sering adalah memisahkan pengecekan idempotensi dan perubahan data bisnis tanpa boundary transaksi yang aman. Jika status idempotensi ditulis terpisah dari efek bisnis, Anda bisa mendapat kondisi:

  • status bilang COMPLETED, tetapi data bisnis belum tersimpan;
  • data bisnis sudah berubah, tetapi status masih PROCESSING lalu job diulang lagi.

Solusinya bergantung pada bentuk pekerjaannya, tetapi prinsipnya adalah: state idempotensi dan state bisnis harus dirancang agar konsisten saat retry.

Desain tabel idempotensi

Untuk banyak kasus, satu tabel guard di database sudah cukup.

CREATE TABLE processed_jobs (
    id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    idempotency_key VARCHAR(255) NOT NULL,
    consumer_name VARCHAR(100) NOT NULL,
    status VARCHAR(20) NOT NULL,
    locked_until TIMESTAMP NULL,
    result_hash VARCHAR(128) NULL,
    error_message TEXT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    CONSTRAINT uq_processed_jobs UNIQUE (consumer_name, idempotency_key)
);

Penjelasan field penting:

  • consumer_name: penting jika satu event dipakai banyak consumer dengan efek bisnis berbeda.
  • idempotency_key: kunci utama operasi bisnis.
  • status: status processing.
  • locked_until: membantu recovery jika worker mati saat status masih PROCESSING.
  • result_hash: opsional, berguna untuk validasi bahwa duplicate request membawa makna yang sama.
  • error_message: memudahkan debug operasional.

Jika proses bisnis menghasilkan record domain yang bisa dibuat unik, pertimbangkan juga unique constraint langsung pada tabel domain. Contoh: transaksi pembayaran dengan external_payment_id unik. Ini memberi lapisan perlindungan tambahan selain tabel idempotensi.

Alur consumer yang aman

Berikut alur yang umum dan praktis untuk worker queue:

  1. Terima pesan dan ekstrak idempotencyKey.
  2. Coba buat atau klaim record idempotensi.
  3. Jika status sudah COMPLETED, anggap duplicate dan hentikan dengan aman.
  4. Jika status PROCESSING dan locked_until masih valid, jangan proses lagi sekarang.
  5. Jika status PROCESSING tapi lock sudah kedaluwarsa, worker lain boleh mengambil alih dengan hati-hati.
  6. Jalankan logika bisnis.
  7. Tandai status COMPLETED setelah efek bisnis benar-benar selesai.
  8. Jika gagal, simpan error dan biarkan mekanisme retry bekerja sesuai kebijakan.

Pseudo-sequence

Consumer menerima message
  - ambil idempotencyKey
  - INSERT processed_jobs(status=PROCESSING, locked_until=now+ttl)
    - jika sukses: lanjut proses
    - jika duplicate key:
        - SELECT row
        - jika status=COMPLETED: skip/ack
        - jika status=PROCESSING dan locked_until > now: skip/requeue pendek
        - jika status=PROCESSING dan locked_until <= now: coba claim ulang
  - jalankan transaksi bisnis
  - UPDATE processed_jobs SET status=COMPLETED, locked_until=NULL
  - ack message

Urutan ini bekerja karena duplicate tidak lagi hanya ditangani oleh logika aplikasi, tetapi juga dijaga oleh constraint dan status persisten.

Implementasi Spring Boot + JPA dengan database sebagai guard

Entity idempotensi

@Entity
@Table(
    name = "processed_jobs",
    uniqueConstraints = @UniqueConstraint(columnNames = {"consumerName", "idempotencyKey"})
)
public class ProcessedJob {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String consumerName;
    private String idempotencyKey;

    @Enumerated(EnumType.STRING)
    private JobStatus status;

    private Instant lockedUntil;
    private String resultHash;

    @Column(length = 4000)
    private String errorMessage;

    private Instant createdAt;
    private Instant updatedAt;

    @PrePersist
    void prePersist() {
        createdAt = Instant.now();
        updatedAt = createdAt;
    }

    @PreUpdate
    void preUpdate() {
        updatedAt = Instant.now();
    }

    // getter/setter
}
public enum JobStatus {
    RECEIVED,
    PROCESSING,
    COMPLETED,
    FAILED
}

Repository

public interface ProcessedJobRepository extends JpaRepository<ProcessedJob, Long> {
    Optional<ProcessedJob> findByConsumerNameAndIdempotencyKey(String consumerName, String idempotencyKey);
}

Service untuk klaim job

Di sini, unique constraint dipakai untuk memastikan hanya satu instance yang berhasil mendaftarkan key baru.

@Service
public class IdempotencyService {

    private final ProcessedJobRepository repository;

    public IdempotencyService(ProcessedJobRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public ClaimResult claim(String consumerName, String key, Duration lockTtl) {
        try {
            ProcessedJob job = new ProcessedJob();
            job.setConsumerName(consumerName);
            job.setIdempotencyKey(key);
            job.setStatus(JobStatus.PROCESSING);
            job.setLockedUntil(Instant.now().plus(lockTtl));
            repository.saveAndFlush(job);
            return ClaimResult.acquired(job);
        } catch (DataIntegrityViolationException e) {
            ProcessedJob existing = repository
                .findByConsumerNameAndIdempotencyKey(consumerName, key)
                .orElseThrow();

            if (existing.getStatus() == JobStatus.COMPLETED) {
                return ClaimResult.alreadyCompleted(existing);
            }

            if (existing.getStatus() == JobStatus.PROCESSING
                && existing.getLockedUntil() != null
                && existing.getLockedUntil().isAfter(Instant.now())) {
                return ClaimResult.inProgress(existing);
            }

            existing.setStatus(JobStatus.PROCESSING);
            existing.setLockedUntil(Instant.now().plus(lockTtl));
            existing.setErrorMessage(null);
            repository.save(existing);
            return ClaimResult.acquired(existing);
        }
    }

    @Transactional
    public void markCompleted(Long id) {
        ProcessedJob job = repository.findById(id).orElseThrow();
        job.setStatus(JobStatus.COMPLETED);
        job.setLockedUntil(null);
        repository.save(job);
    }

    @Transactional
    public void markFailed(Long id, String error) {
        ProcessedJob job = repository.findById(id).orElseThrow();
        job.setStatus(JobStatus.FAILED);
        job.setErrorMessage(error);
        job.setLockedUntil(null);
        repository.save(job);
    }
}

Contoh di atas cukup baik sebagai dasar, tetapi ada satu trade-off penting: dua worker yang sama-sama melihat lock kedaluwarsa dapat mencoba mengambil alih bersamaan. Untuk skenario konkurensi tinggi, gunakan conditional update atau pessimistic lock saat merebut job kedaluwarsa.

Kapan perlu lock database?

Anda perlu lock tambahan jika:

  • job bernilai tinggi, misalnya pembayaran atau pengiriman dana;
  • ada peluang dua instance meng-claim ulang row yang sama setelah lock timeout;
  • proses domain membaca lalu mengubah state yang sensitif terhadap race condition.

Pilihan yang umum:

  • Pessimistic lock (SELECT ... FOR UPDATE): lebih aman, tetapi bisa menambah blocking.
  • Optimistic lock dengan version column: cocok jika konflik jarang dan Anda siap retry.
  • Conditional update: misalnya update hanya jika status != COMPLETED dan locked_until <= now().

Jika memakai JPA, hindari lock yang terlalu luas atau transaksi yang terlalu lama karena dapat menurunkan throughput worker.

Menyatukan idempotensi dengan transaksi bisnis

Bagian ini paling penting. Idempotensi tidak selesai hanya dengan menulis row guard. Anda harus memastikan efek bisnis tidak terjadi dua kali.

Pola aman yang umum

  1. Klaim job lebih dulu.
  2. Jalankan transaksi bisnis yang juga memiliki proteksi unik di level domain jika memungkinkan.
  3. Setelah commit bisnis berhasil, tandai row idempotensi menjadi COMPLETED.

Contoh kasus: membuat invoice dari event OrderPaid. Selain tabel processed_jobs, tabel invoice bisa punya unique constraint pada order_id. Dengan begitu, jika duplicate lolos karena crash di waktu yang buruk, database domain tetap menolak invoice kedua.

Prinsip praktis: untuk operasi yang punya dampak finansial atau perubahan state penting, gunakan double protection: guard idempotensi + unique constraint domain.

Masalah crash di tengah proses

Skenario klasik:

  1. worker memproses pembayaran;
  2. data bisnis sudah commit;
  3. sebelum status idempotensi diubah ke COMPLETED, worker crash;
  4. broker mengirim ulang job.

Jika hanya mengandalkan status di tabel idempotensi, duplicate kedua bisa tampak seperti belum selesai. Karena itu, domain harus bisa mendeteksi bahwa efek bisnis sudah pernah terjadi, misalnya lewat unique key pada payment_reference, order_id, atau external_transaction_id.

Redis sebagai guard cepat: kapan cocok, kapan berisiko

Redis sering dipakai untuk guard karena operasi seperti SET key value NX EX ttl sangat cepat. Ini cocok untuk menahan duplicate jangka pendek dan mengurangi beban database.

Kapan Redis cocok

  • throughput tinggi dan duplicate sering terjadi dalam rentang singkat;
  • proses idempotensi lebih berupa throttle atau dedup sementara;
  • database ingin dilindungi dari lonjakan duplicate event.

Kapan Redis saja tidak cukup

  • operasi bisnis harus benar-benar konsisten dan tahan crash;
  • hasil akhir disimpan di database transaksional;
  • risiko kehilangan key akibat eviction atau expiry tidak bisa diterima.

Masalah utama Redis adalah boundary transaksi terpisah dari database bisnis. Misalnya, Redis lock berhasil dibuat tetapi transaksi database gagal; atau database sukses commit tetapi key Redis kedaluwarsa terlalu cepat. Ini bisa memunculkan inkonsistensi.

Pola yang lebih aman dengan Redis

Gunakan Redis sebagai front guard, tetapi tetap gunakan database sebagai source of truth.

if redis SETNX(idempotencyKey, ttl) gagal:
    kemungkinan duplicate, cek database processed_jobs
else:
    lanjut claim di database
    proses bisnis
    tandai completed di database

Dengan pola ini, Redis membantu performa, sementara database tetap memegang keputusan final apakah operasi sudah selesai atau belum.

Contoh alur consumer di Spring Boot

Contoh berikut bersifat generik, karena mekanisme listener berbeda tergantung broker yang dipakai. Fokusnya pada urutan logika, bukan anotasi listener tertentu.

@Service
public class PaymentJobConsumer {

    private static final String CONSUMER = "payment-settlement-consumer";

    private final IdempotencyService idempotencyService;
    private final PaymentService paymentService;

    public PaymentJobConsumer(IdempotencyService idempotencyService,
                              PaymentService paymentService) {
        this.idempotencyService = idempotencyService;
        this.paymentService = paymentService;
    }

    public void onMessage(PaymentMessage message) {
        String key = message.getPaymentId();

        ClaimResult claim = idempotencyService.claim(CONSUMER, key, Duration.ofMinutes(5));

        if (claim.isAlreadyCompleted()) {
            return;
        }

        if (claim.isInProgress()) {
            throw new RetryLaterException("Job sedang diproses instance lain");
        }

        try {
            paymentService.settlePayment(message);
            idempotencyService.markCompleted(claim.getJobId());
        } catch (Exception e) {
            idempotencyService.markFailed(claim.getJobId(), e.getMessage());
            throw e;
        }
    }
}

Hal penting pada contoh di atas:

  • Duplicate completed tidak dianggap error.
  • In progress sebaiknya tidak langsung diproses ulang secara agresif; lebih aman requeue dengan delay pendek atau biarkan retry policy menangani.
  • settlePayment sendiri tetap perlu proteksi domain-level.

Strategi status PROCESSING dan lock timeout

Status PROCESSING berguna, tetapi juga rawan menimbulkan job tersangkut jika worker mati. Karena itu, gunakan locked_until atau semacam lease.

Cara menentukan lock timeout

Jangan terlalu pendek dan jangan terlalu panjang.

  • Terlalu pendek: job lama belum selesai, tetapi lock sudah habis lalu instance lain ikut memproses.
  • Terlalu panjang: job gagal bisa tertahan lama sebelum boleh diambil alih.

Pilih nilai berdasarkan durasi normal proses plus buffer yang realistis. Untuk job yang durasinya bervariasi, pertimbangkan heartbeat atau perpanjangan lease berkala saat job masih aktif.

Kapan heartbeat perlu

  • proses bisa berjalan beberapa menit atau lebih;
  • ada pemanggilan API eksternal yang waktunya tidak stabil;
  • timeout retry broker lebih pendek dari durasi kerja aktual.

Pada pola heartbeat, worker memperbarui locked_until selama job masih hidup. Jika worker crash, lease berhenti diperbarui dan instance lain bisa mengambil alih setelah timeout.

Kesalahan umum yang sering terjadi

  • Idempotency key tidak stabil, sehingga retry dianggap job baru.
  • Hanya mengandalkan cache tanpa proteksi database.
  • Menandai COMPLETED terlalu dini sebelum transaksi bisnis benar-benar selesai.
  • Tidak punya unique constraint domain untuk operasi penting.
  • Menganggap duplicate sebagai exception fatal, padahal duplicate completed justru kondisi normal.
  • Lock timeout tidak dipantau, sehingga takeover terlalu sering atau job macet terlalu lama.
  • Mencampur consumer berbeda dalam key yang sama tanpa menyertakan consumer_name.

Observability: metrik, log, dan alert yang wajib ada

Worker queue yang idempoten bukan hanya soal kode, tetapi juga soal kemampuan observasi saat insiden terjadi.

Metrik yang sebaiknya dikumpulkan

  • jumlah job diterima;
  • jumlah duplicate skipped karena COMPLETED;
  • jumlah in-progress conflict;
  • jumlah lock timeout takeover;
  • jumlah sukses, gagal, dan retry;
  • durasi proses per jenis job;
  • umur job dalam status PROCESSING;
  • jumlah constraint violation pada tabel idempotensi atau tabel domain.

Log yang berguna

Setidaknya log berikut harus punya field terstruktur:

  • idempotencyKey
  • consumerName
  • messageId dari broker jika ada
  • jobStatusBefore dan jobStatusAfter
  • lockUntil
  • instanceId atau hostname
  • correlationId untuk menelusuri request lintas service

Alert yang berguna

  • lonjakan duplicate rate di atas baseline;
  • banyak job tersangkut di PROCESSING melewati SLA;
  • jumlah takeover setelah lock timeout meningkat tajam;
  • constraint violation domain meningkat, terutama pada tabel finansial;
  • retry bertumpuk pada satu jenis consumer.

Langkah debug saat job diproses lebih dari sekali

  1. Periksa apakah ini duplicate normal akibat at-least-once delivery, atau bug logika consumer.
  2. Cari berdasarkan idempotency key di log dan tabel processed_jobs.
  3. Lihat urutan timestamp: kapan claim dibuat, kapan lock habis, kapan worker lain masuk.
  4. Periksa status domain: apakah efek bisnis sebenarnya sudah pernah commit.
  5. Periksa unique constraint di tabel domain, apakah ada duplicate row atau constraint violation yang tertangkap lalu diabaikan.
  6. Periksa timeout broker dan durasi job: bisa jadi lock terlalu pendek atau ack terlalu lambat.
  7. Periksa sinkronisasi Redis dan database jika memakai dua lapisan guard.
  8. Periksa retry policy: apakah exception yang seharusnya terminal justru terus di-retry.

Kapan memilih database saja, Redis + database, atau lock tambahan

Database saja

Pilih ini jika:

  • konsistensi lebih penting daripada latensi minimum;
  • throughput masih masuk akal untuk database;
  • operasi bisnis bersifat penting dan perlu audit jelas.

Redis + database

Pilih ini jika:

  • duplicate event sangat sering;
  • beban baca/tulis guard di database mulai tinggi;
  • Anda paham bahwa Redis hanya lapisan optimasi, bukan sumber kebenaran utama.

Lock tambahan

Pilih ini jika:

  • ada takeover yang sering karena proses lama;
  • operasi sangat sensitif terhadap race condition;
  • Anda butuh memastikan hanya satu instance yang mengubah state tertentu pada saat yang sama.

Checklist implementasi

  • Idempotency key stabil dan ditentukan dari operasi bisnis.
  • Ada tabel atau store persisten untuk status processing.
  • Ada unique constraint pada (consumer_name, idempotency_key).
  • Status minimal: PROCESSING, COMPLETED, FAILED.
  • Ada locked_until atau mekanisme lease.
  • Boundary transaksi bisnis dipahami dengan jelas.
  • Tabel domain penting punya unique constraint tambahan bila memungkinkan.
  • Duplicate completed diperlakukan sebagai kondisi normal.
  • Retry policy dibedakan untuk error transient vs terminal.
  • Metrik, log terstruktur, dan alert sudah tersedia.

Penutup

Membangun Spring Boot worker queue yang idempoten berarti menerima kenyataan bahwa retry dan redelivery akan terjadi, lalu mendesain sistem agar efek bisnis tetap satu kali. Fondasinya adalah idempotency key yang benar, status processing yang persisten, unique constraint, dan boundary transaksi yang aman.

Jika harus memilih titik awal yang paling praktis, mulailah dengan database sebagai guard utama, tambahkan proteksi unik di tabel domain, lalu pakai Redis hanya bila ada kebutuhan performa yang jelas. Dengan pendekatan ini, duplicate event, crash, dan konkurensi antar-instance bisa ditangani tanpa menghasilkan proses bisnis ganda.