Kodeloka
  1. Kodeloka
  2. Kelas RabbitMQ
  3. Materi

Modul 10 · Modul 10 dari 15

Idempotensi, Urutan Pesan, dan Mitos Exactly-Once

Menerima kenyataan bahwa pesan bisa datang dua kali dan tidak berurutan, lalu merancang konsumer yang tetap benar.

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".

celah yang tidak bisa dihilangkantext
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.
🎯 Yang benar-benar bisa dicapai

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

tabel dedupsql
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';
konsumer idempotenjavascript
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 });
⚠️ Klaim dan pekerjaan harus dalam satu transaksi

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

upsertsql
-- ❌ 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 baris

3. Kunci Idempotensi ke Sistem Eksternal

idempotency keyjavascript
// 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

apa yang dijamintext
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 leader
⛔ Satu konsumer demi urutan = tembok skalabilitas

Menjamin 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

pola shardingtext
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.
deklarasijavascript
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.

penolakan berbasis versisql
UPDATE pesanan
   SET status = $2, versi = $3
 WHERE id = $1 AND versi < $3;
-- pesan lama yang tiba belakangan mengubah 0 baris → diabaikan dengan aman

Pola ini sekaligus menyelesaikan idempotensi dan urutan dalam satu mekanisme, dan sangat cocok untuk peristiwa yang menyatakan keadaan terkini sebuah entitas.