Pola Idempotent Consumer untuk Message Queue

Foto oleh Enoch Leung from Canada via Wikimedia Commons (CC BY-SA 2.0)
Ini cara membuat consumer message-queue aman terhadap pengiriman duplikat. Anda mencatat ID setiap pesan yang diproses di tabel database dengan unique constraint, dan insert itu dilakukan dalam transaksi yang sama dengan pekerjaan sebenarnya. Jika pesan yang sama tiba lagi, insert gagal pada unique constraint, sehingga Anda melewati pekerjaan dan meng-acknowledge duplikat tanpa dampak.
Karena acknowledgement bisa hilang di perjalanan. Jika consumer menyelesaikan pekerjaan dan mengirim ack yang tidak pernah tiba, broker tidak bisa membedakan consumer yang lambat dari yang mati. Untuk menghindari kehilangan pesan, broker mengirim ulang, itulah pengiriman at-least-once. Exactly-once sejati melintasi jaringan terbukti mustahil, jadi consumer harus menangani duplikat sendiri.
Gunakan business message ID yang stabil dan ditetapkan produser, bukan offset broker atau delivery tag. Offset bisa direset dan delivery tag hanya berlaku untuk satu koneksi, jadi keduanya tidak bertahan andal melalui pengiriman ulang atau rebalancing consumer. Message ID yang disetel produser tetap konstan di setiap pengiriman ulang, persis yang dibutuhkan deduplikasi.
Transaksi database tidak bisa me-rollback panggilan HTTP yang sudah berhasil, jadi Anda mendorong idempotensi ke penyedia. Kirim idempotency key yang diturunkan secara deterministik dari message ID; sebagian besar API pembayaran dan pesan melakukan deduplikasi pada kunci itu dan mengembalikan hasil asli saat retry. IETF juga memiliki draft Standards Track aktif untuk header HTTP Idempotency-Key.
Ya jika tidak pernah dibersihkan. Anda hanya butuh baris yang lebih baru dari jendela pengiriman ulang atau retensi maksimum broker, biasanya dalam hitungan jam. Jalankan delete terjadwal yang menghapus baris lebih tua dari margin aman, dan tabel beserta indeks uniknya tetap kecil dan cepat.

Foto oleh Enoch Leung from Canada via Wikimedia Commons (CC BY-SA 2.0)
Ringkasan Utama
Message broker menjamin pengiriman setidaknya sekali, jadi pesan yang sama pada akhirnya akan tiba dua kali. Pola idempotent consumer mengatasinya: catat setiap ID pesan yang diproses di tabel database dengan unique constraint, dan jalankan insert itu plus efek sampingnya dalam satu transaksi. Duplikat menabrak constraint, rollback, dan tidak mengubah apa pun.
Setiap message queue yang pernah saya jalankan di produksi, dari RabbitMQ ke Kafka ke SQS, membuat janji yang sama: pengiriman setidaknya sekali. Bukan exactly-once. Kata itu penting. Artinya broker akan mengirim ulang pesan jika tidak yakin Anda selesai memprosesnya, dan sering kali memang tidak yakin. Consumer crash setelah menyelesaikan pekerjaan tetapi sebelum acknowledge. Gangguan jaringan menjatuhkan ack. Visibility timeout habis saat Anda masih sibuk. Dalam setiap kasus, broker melakukan hal yang aman dan menyerahkan pesan itu lagi.
Jadi duplikat bukan kasus tepi yang mungkin Anda alami. Itu kepastian yang harus Anda antisipasi dalam desain. Jika consumer Anda menagih kartu, mengirim email, atau mengurangi stok, memproses pesan yang sama dua kali adalah bug nyata dengan uang nyata di dalamnya. Pola idempotent consumer adalah jawaban standar, membosankan, dan benar, dan hampir seluruhnya berada di database Anda.
Pengiriman exactly-once melintasi jaringan mustahil dijamin, karena acknowledgement itu sendiri bisa hilang. Jika consumer melakukan ack dan ack itu lenyap, broker harus memilih: kirim ulang dan berisiko duplikat, atau buang dan berisiko kehilangan pesan selamanya. At-least-once memilih yang pertama, satu-satunya pilihan aman. Yang dipasarkan vendor sebagai exactly-once sebenarnya adalah pengiriman at-least-once plus pemrosesan idempotent di sisi consumer. Semantik exactly-once Kafka bekerja di dalam batas Kafka; begitu Anda menulis ke Postgres atau memanggil Stripe, Anda kembali membutuhkan idempotensi Anda sendiri.
Idempotent berarti menjalankan operasi N kali meninggalkan sistem dalam keadaan yang sama seperti menjalankannya sekali. Itulah properti yang Anda rekayasa, bukan nol duplikat. Anda tidak bisa mencegah duplikat tiba; Anda bisa membuatnya tidak berbahaya.
Seluruh pola bertumpu pada satu tabel. Setiap baris adalah sidik jari pesan yang sudah Anda tangani. Kolom message ID membawa unique constraint, yang merupakan elemen penopang seluruh desain. Ketika duplikat tiba, insert melanggar constraint itu dan gagal dengan jelas, dan kegagalan itulah sinyal yang Anda inginkan.
CREATE TABLE processed_messages (
message_id TEXT NOT NULL,
consumer TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, consumer)
);
-- The composite key lets different consumers each
-- process the same message once, independently.Dua catatan desain. Pertama, gunakan business message ID sebagai kunci, bukan offset broker atau delivery tag. Offset direset, tag bersifat per-koneksi; ID stabil yang ditetapkan produser bertahan melalui pengiriman ulang dan rebalancing. Kedua, sertakan nama consumer dalam kunci ketika beberapa consumer berbeda berlangganan topik yang sama, sehingga masing-masing mendapat jaminan sekali-saja sendiri.
Kesalahan halusnya adalah mengecek tabel, lalu memproses, lalu insert. Itu menyisakan celah: dua pengiriman duplikat bisa sama-sama membaca kosong, sama-sama memproses, sama-sama insert. Bentuk yang benar membungkus insert dedup dan efek samping bisnis dalam satu transaksi database. Jika insert berhasil, kerjakan pekerjaan dan commit. Jika insert menabrak unique constraint, pesan itu duplikat, jadi rollback dan acknowledge tanpa melakukan apa pun. Atomisitas itulah yang membuatnya aman di bawah konkurensi.
// NestJS consumer, TypeORM data source
async function handle(msg: IncomingMessage): Promise<void> {
await dataSource.transaction(async (tx) => {
try {
await tx.insert(ProcessedMessage, {
messageId: msg.id,
consumer: 'billing-service',
});
} catch (err) {
if (isUniqueViolation(err)) {
// Already processed. Roll back, ack, move on.
return;
}
throw err; // real error -> nack -> redelivery
}
// Same transaction as the dedup row:
await tx.update(Order, msg.orderId, { status: 'PAID' });
});
}
// Postgres unique-violation SQLSTATE
function isUniqueViolation(e: unknown): boolean {
return (e as { code?: string })?.code === '23505';
}Ini hanya berlaku ketika efek samping dan baris dedup berbagi satu penyimpanan transaksional. Jika efek samping Anda adalah panggilan API eksternal, transaksi database tidak bisa me-rollback-nya. Itu masalah berbeda, dibahas di bawah.
Pembaruan baris lokal me-rollback dengan bersih. Menagih kartu lewat Stripe tidak; begitu panggilan HTTP itu berhasil, uang sudah berpindah, dan tidak ada rollback transaksi yang membatalkan penagihannya. Di sini Anda mendorong idempotensi ke pihak ketiga. Sebagian besar API pembayaran dan pesan yang serius menerima idempotency key, dan IETF memiliki draft Standards Track aktif untuk header HTTP Idempotency-Key, revisi 07 per akhir 2025. Kirim kunci stabil yang diturunkan dari message ID; penyedia melakukan deduplikasi di sisi mereka dan mengembalikan hasil asli saat retry.
Tabel processed-messages tumbuh selamanya jika Anda biarkan. Anda tidak butuh baris yang lebih tua dari jendela pengiriman ulang maksimum broker Anda, biasanya jam, bukan bulan. Pangkas secara terjadwal dan tabel tetap kecil serta indeks uniknya tetap cepat.
-- Nightly cron; keep a generous safety margin over
-- the broker's redelivery / retention window.
DELETE FROM processed_messages
WHERE processed_at < now() - INTERVAL '7 days';| Keputusan | Cara salah | Cara benar |
|---|---|---|
| Kunci dedup | Offset broker atau delivery tag | Business message ID dari produser |
| Cek vs insert | SELECT, lalu proses, lalu INSERT | INSERT dulu, biarkan unique constraint yang menentukan |
| Lingkup transaksi | Baris dedup dan efek samping di commit terpisah | Keduanya dalam satu transaksi atomik |
| Panggilan eksternal | Anggap transaksi DB melindunginya | Kirim idempotency key ke penyedia |
| Pertumbuhan tabel | Simpan setiap baris selamanya | Pangkas melewati jendela pengiriman ulang |
Sebagian besar operasi yang secara alami idempotent tidak butuh tabel sama sekali. Menyetel status ke PAID atau meng-upsert record berdasarkan kunci sudah aman diulang. Gunakan tabel processed-messages ketika operasi bersifat akumulatif, seperti menambah saldo atau menambahkan entri ledger.
Digabungkan, polanya tidak glamor dan itulah intinya. Asumsikan pengiriman at-least-once, gunakan message ID yang stabil sebagai kunci, insert ke tabel dengan unique constraint dalam transaksi yang sama dengan efek samping Anda, dorong idempotensi ke sistem eksternal mana pun, dan pangkas baris lama. Begitulah Anda membangun efek exactly-once di atas broker yang hanya menjanjikan at-least-once, dan bentuknya sama baik Anda menjalankan Kafka, RabbitMQ, maupun SQS.