Exactly-Once Tidak Ada — dan Itu Bukan Masalah
Tidak ada sistem pesan terdistribusi yang bisa menjamin setiap pesan diproses tepat satu kali, karena selalu ada celah antara "pekerjaan selesai" dan "konfirmasi tercatat".
Konsumer: proses(pesan) ──✓ berhasil
← proses MATI di sini
ack(pesan) ──✗ tidak pernah terkirim
Broker tidak menerima ack → mengirim ulang → pekerjaan dilakukan dua kali.
Membalik urutannya juga tidak menolong:
ack(pesan) ──✓
← proses MATI di sini
proses(pesan) ──✗ tidak pernah dijalankan
→ pekerjaan hilang.At-least-once delivery + idempotent processing = efek exactly-once.
Pesan boleh datang lima kali; yang penting akibatnya sama seperti datang satu kali. Ini yang dikerjakan sistem produksi sungguhan — termasuk yang mengiklankan "exactly-once", yang sebenarnya melakukan deduplikasi di balik layar.
Tiga Cara Membuat Konsumer Idempoten
1. Tabel Deduplikasi — Paling Umum
CREATE TABLE pesan_diproses (
message_id TEXT PRIMARY KEY,
diproses_pada TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX pesan_diproses_waktu ON pesan_diproses (diproses_pada);
-- bersihkan berkala; simpan lebih lama dari TTL pesan terpanjang
DELETE FROM pesan_diproses WHERE diproses_pada < now() - INTERVAL '30 days';await ch.consume('pesanan.email', async (msg) => {
const id = msg.properties.messageId;
if (!id) { // kontrak dilanggar producer
ch.nack(msg, false, false); // langsung ke DLQ
return;
}
try {
await db.transaction(async (trx) => {
// klaim dulu; bila sudah ada, unique violation
await trx('pesan_diproses').insert({ message_id: id });
// pekerjaan bisnis DI DALAM transaksi yang sama
await kirimEmail(trx, JSON.parse(msg.content.toString()));
});
ch.ack(msg);
} catch (e) {
if (e.code === '23505') { // unique_violation
ch.ack(msg); // duplikat: sudah pernah, aman
return;
}
ch.nack(msg, false, false); // gagal sungguhan → DLX
}
}, { noAck: false });Bila Anda mencatat message_id di transaksi terpisah dari pekerjaannya, akan muncul celah baru: proses mati setelah klaim tercatat tetapi sebelum pekerjaan selesai. Pesan dikirim ulang, dilihat sebagai duplikat, lalu di-ack — dan pekerjaannya tidak pernah dilakukan.
2. Operasi yang Idempoten Secara Alami
-- ❌ tidak idempoten: dijalankan dua kali, stok berkurang dua kali
UPDATE produk SET stok = stok - 1 WHERE id = 'P-1';
-- ✅ idempoten: hasil akhirnya sama berapa kali pun dijalankan
UPDATE produk SET stok = 42 WHERE id = 'P-1';
-- ✅ upsert dengan status
INSERT INTO pesanan (id, status) VALUES ('P-1', 'dikonfirmasi')
ON CONFLICT (id) DO UPDATE SET status = 'dikonfirmasi';
-- ✅ transisi state bersyarat: hanya berlaku sekali
UPDATE pesanan SET status = 'dibayar'
WHERE id = 'P-1' AND status = 'menunggu_bayar';
-- eksekusi kedua mengubah 0 baris3. Kunci Idempotensi ke Sistem Eksternal
// banyak API pihak ketiga mendukung ini secara resmi
await stripe.charges.create(
{ amount: 150000, currency: 'idr', source: token },
{ idempotencyKey: msg.properties.messageId }
);
// permintaan kedua dengan kunci sama mengembalikan hasil yang pertama,
// tanpa menagih ulang.Urutan Pesan: Jaminan yang Sempit
DIJAMIN:
Pesan dari SATU channel, ke SATU queue, dikirim ke SATU konsumer
akan tiba dalam urutan publish.
TIDAK DIJAMIN, urutan bisa berubah karena:
• ada lebih dari satu konsumer pada queue yang sama
• pesan di-requeue (kembali ke antrean, bisa di posisi berbeda)
• ada beberapa channel atau beberapa producer
• retry dengan penundaan (pesan yang gagal tertinggal di belakang)
• quorum queue saat terjadi perpindahan leaderMenjamin urutan global berarti satu konsumer dengan prefetch=1. Throughput Anda menjadi terbatas pada kecepatan satu proses, dan tidak bisa ditambah dengan cara apa pun.
Yang biasanya benar-benar dibutuhkan bukan urutan global, melainkan urutan per entitas: peristiwa pesanan P-1 harus berurutan, tetapi P-1 dan P-2 boleh diproses bersamaan.
Urutan Per Entitas — Consistent Hashing
Plugin: rabbitmq_consistent_hash_exchange
publish(exchange="pesanan-hash", routing_key=<pesanan_id>)
│
└── hash(pesanan_id) mod jumlah_binding
├──> queue shard-0 ──> konsumer 0
├──> queue shard-1 ──> konsumer 1
└──> queue shard-2 ──> konsumer 2
Semua peristiwa milik satu pesanan_id selalu jatuh ke shard yang sama,
sehingga berurutan — sementara pesanan berbeda diproses paralel.await ch.assertExchange('pesanan-hash', 'x-consistent-hash', {
durable: true,
arguments: { 'hash-header': 'x-pesanan-id' } // atau pakai routing key
});
for (let i = 0; i < 3; i++) {
await ch.assertQueue('pesanan.shard-' + i, {
durable: true, arguments: { 'x-queue-type': 'quorum' }
});
// bobot binding: "1" berarti porsi yang sama
await ch.bindQueue('pesanan.shard-' + i, 'pesanan-hash', '1');
}Alternatif Tanpa Plugin: Nomor Urut dan Rekonsiliasi
Bila sharding tidak memungkinkan, sisipkan nomor urut per entitas ke dalam pesan dan biarkan konsumer menolak pesan yang datang terlambat.
UPDATE pesanan
SET status = $2, versi = $3
WHERE id = $1 AND versi < $3;
-- pesan lama yang tiba belakangan mengubah 0 baris → diabaikan dengan amanPola ini sekaligus menyelesaikan idempotensi dan urutan dalam satu mekanisme, dan sangat cocok untuk peristiwa yang menyatakan keadaan terkini sebuah entitas.