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.
Mengapa pengiriman exactly-once itu mitos
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.
Tabel processed-messages
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.
Satu transaksi adalah inti triknya
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.
Ketika efek samping keluar dari database Anda
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.
- Turunkan idempotency key secara deterministik dari message ID, sehingga pengiriman ulang menghasilkan kunci yang persis sama.
- Simpan hasil panggilan eksternal dalam transaksi Anda sebelum commit, sehingga Anda tidak pernah mengulang panggilan pada pengiriman ulang berikutnya.
- Urutkan operasi agar efek eksternal menjadi hal terakhir sebelum commit, memperkecil jendela di mana crash meninggalkan pekerjaan setengah jadi.
Pembersihan, pertumbuhan, dan detail yang menggigit
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.

