Idempotent Consumer: Cara Mencegah Pesan Diproses Dua Kali

Idempotent consumer adalah consumer yang menghasilkan efek sama walaupun menerima pesan yang sama berkali-kali. Caranya: setiap pesan membawa kunci unik, consumer mencatat kunci yang sudah diproses di database, dan pencatatan itu dilakukan dalam transaksi yang sama dengan perubahan data bisnis. Bila pesan datang lagi, consumer melihat kuncinya sudah tercatat lalu melewatinya.

Pola ini wajib dipakai di hampir semua sistem berbasis pesan, karena broker umumnya hanya menjanjikan pengiriman at-least-once, bukan tepat satu kali.

Kenapa duplikat pasti terjadi

At-least-once delivery berarti broker berjanji pesan sampai minimal sekali. Duplikat muncul dari celah antara “selesai memproses” dan “broker tahu Anda sudah selesai”. Beberapa skenario nyata:

  • Consumer sudah menyimpan pesanan ke database, lalu crash sebelum mengirim acknowledgement. Broker mengirim ulang pesan ke instance lain.
  • Koneksi jaringan putus tepat saat acknowledgement dikirim.
  • Producer tidak menerima konfirmasi dari broker karena timeout, lalu mengirim ulang pesan yang sebenarnya sudah tersimpan.
  • Operator memindahkan pesan dari dead letter queue ke queue asal, padahal sebagian sudah sempat diproses.
  • Pada Kafka, consumer group melakukan rebalancing sebelum offset sempat di-commit.

Dampaknya sering terlihat sepele di lingkungan uji, tetapi mahal di produksi: pembeli menerima dua email konfirmasi, stok tercatat minus, atau sistem membuat dua order pengiriman untuk satu paket sehingga ongkos kirim tertagih dua kali.

Jadi pertanyaannya bukan “bagaimana mencegah duplikat sampai”, tetapi “bagaimana memastikan duplikat tidak berdampak”.

Memilih kunci idempotensi

Kunci idempotensi adalah nilai yang mengidentifikasi satu maksud bisnis. Ada beberapa pilihan:

Kunci Kelebihan Kekurangan
Message ID dari broker (JMSMessageID) Tersedia otomatis Berbeda bila producer mengirim ulang isi yang sama sebagai pesan baru
ID event buatan producer (UUID di header atau body) Stabil walau dikirim ulang, asalkan disimpan sebelum dikirim Producer harus disiplin
Kunci bisnis (misalnya nomor pesanan ditambah jenis event) Bermakna dan mudah diaudit Harus dipastikan memang unik per kejadian

Pilihan yang paling andal biasanya ID event yang dibuat producer saat event lahir dan disimpan bersama datanya, misalnya di tabel outbox. Message ID broker baik sebagai cadangan, tetapi tidak melindungi dari duplikat yang berasal dari producer.

Tabel processed_messages

CREATE TABLE processed_messages (
    consumer_name VARCHAR(100) NOT NULL,
    message_key   VARCHAR(200) NOT NULL,
    processed_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (consumer_name, message_key)
);

Kolom consumer_name diperlukan karena satu event bisa diproses oleh beberapa consumer berbeda, misalnya layanan stok dan layanan email. Masing-masing perlu catatan sendiri. Primary key gabungan memastikan database yang menolak duplikat, bukan logika aplikasi yang rawan race condition.

Implementasi dengan JDBC

Inti pola ini ada pada urutan dan transaksi: sisipkan kunci dulu, dan bila berhasil, jalankan logika bisnis dalam transaksi yang sama. Contoh untuk PostgreSQL memakai ON CONFLICT DO NOTHING:

public void tangani(String messageKey, PesananDibayar event) throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.setAutoCommit(false);
        try {
            String sql = "INSERT INTO processed_messages (consumer_name, message_key) "
                       + "VALUES (?, ?) ON CONFLICT DO NOTHING";
            int inserted;
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setString(1, "layanan-stok");
                ps.setString(2, messageKey);
                inserted = ps.executeUpdate();
            }
            if (inserted == 0) {
                conn.rollback();
                return; // sudah pernah diproses, lewati
            }
            kurangiStok(conn, event);
            conn.commit();
        } catch (SQLException | RuntimeException e) {
            conn.rollback();
            throw e;
        }
    }
}

Jika dua instance menerima pesan yang sama secara bersamaan, salah satunya akan tertahan pada baris yang sama sampai yang lain selesai. Bila yang pertama commit, yang kedua mendapat inserted == 0 dan melewati pesan. Bila yang pertama gagal dan rollback, kunci ikut hilang sehingga pesan bisa diproses ulang dengan benar. Di MySQL, padanannya adalah INSERT IGNORE atau menangkap error duplicate key; prinsipnya sama.

Acknowledge ke broker dilakukan setelah commit() database berhasil. Jika aplikasi mati di antara keduanya, pesan akan datang lagi, dan kali ini tabel processed_messages yang menyelamatkan Anda.

Bila efeknya ada di luar database

Tabel deduplikasi hanya melindungi perubahan yang ikut dalam transaksi database yang sama. Mengirim email, memanggil API kurir, atau menagih kartu terjadi di luar transaksi itu. Beberapa cara menanganinya:

  • Teruskan kunci idempotensi ke API pihak ketiga. Banyak API pembayaran dan logistik menerima header kunci idempotensi sehingga permintaan ulang dengan kunci sama tidak membuat transaksi baru. Cek dokumentasi penyedia yang Anda pakai.
  • Catat status sebelum dan sesudah. Simpan “pembuatan label dimulai” dan “label berhasil dibuat beserta nomornya”. Saat pesan datang lagi, periksa status ini sebelum memanggil API.
  • Buat operasinya idempoten secara alami. “Set status pesanan menjadi DIKIRIM” aman diulang; “tambah saldo 10.000” tidak. Bila bisa, ubah perintah relatif menjadi perintah absolut dengan nomor versi.

Memanggil API pihak ketiga juga butuh strategi percobaan ulang yang tidak memperburuk keadaan; caranya dibahas di retry dan exponential backoff dengan jitter.

Perawatan tabel

Tabel processed_messages akan terus tumbuh. Hapus baris yang lebih tua dari jendela waktu yang realistis untuk duplikat, misalnya beberapa hari lebih panjang dari lama penyimpanan dead letter queue Anda. Jalankan pembersihan terjadwal dalam batch kecil agar tidak mengunci tabel terlalu lama. Jika Anda menghapus terlalu cepat, pesan yang diproses ulang dari DLQ minggu lalu bisa lolos sebagai pesan baru.

Menguji bahwa consumer benar-benar idempoten

Pola ini mudah rusak saat refactor, misalnya ketika seseorang memindahkan pemanggilan API ke luar transaksi atau mengubah cara kunci dibentuk. Tulis tes integrasi yang mengirim pesan yang sama dua kali, lalu periksa bahwa stok hanya berkurang sekali dan baris di processed_messages hanya satu. Tambahkan juga skenario dua consumer menerima pesan yang sama hampir bersamaan. Tes seperti ini paling meyakinkan bila dijalankan terhadap database dan broker sungguhan, karena perilaku penguncian baris dan transaksi bergantung pada database yang Anda pakai.

FAQ singkat

Bukankah Kafka punya exactly-once?

Fitur transaksi dan idempotent producer di Kafka memberi jaminan exactly-once untuk alur baca-proses-tulis di dalam Kafka itu sendiri. Begitu efeknya keluar ke database lain atau API eksternal, Anda tetap butuh consumer idempoten.

Bisakah memakai Redis sebagai pengganti tabel?

Bisa untuk mencegah kerja ganda yang tidak kritis, misalnya notifikasi. Namun karena Redis berada di luar transaksi database bisnis, selalu ada celah ketika salah satu berhasil dan yang lain gagal. Untuk data uang dan stok, simpan kunci di database yang sama dengan datanya.

Hendra Kurniawan

Oleh Hendra Kurniawan

Pengembang backend Java yang lama menangani integrasi sistem dan message broker. Menulis catatan praktis tentang JMS, API, dan arsitektur aplikasi.