Lesson 0017 · System Design · Modul Asinkron · ±20 menit

At-most-once, at-least-once, exactly-once; idempotency key, DLQ, ordering

Pesan bisa hilang atau datang dua kali. Pilih mana yang Anda terima, lalu buat consumer yang aman terhadap duplikat

Sebelumnya, lesson 0016: queue dan pub/sub memisahkan producer dari consumer, dan Kafka menyimpan posisi consumer sebagai offset. Lesson ini menjawab pertanyaan lanjutan yang hampir selalu muncul di interview: "Bagaimana kalau consumer crash di tengah jalan?"

Target lesson ini: Anda bisa menjawab "Queue Anda at-least-once. Bagaimana Anda memastikan pembayaran tidak terjadi dua kali, dan apa yang Anda lakukan dengan pesan yang terus gagal?" tanpa catatan, dalam 60 detik: tiga delivery semantics, kenapa default yang realistis adalah at-least-once, consumer idempotent dengan idempotency key, batas retry + dead letter queue, dan ordering per key.

1. Tiga delivery semantics

Kafka docs mendefinisikan ketiganya dengan ringkas [1]:

SemantikDefinisi (Kafka docs)Artinya untuk Anda
at-most-once"Messages may be lost but are never redelivered."Boleh hilang: metrik, log debug, notifikasi "sedang mengetik"
at-least-once"Messages are never lost but may be redelivered."Tidak boleh hilang, tapi consumer harus tahan duplikat
exactly-once"Each message is processed once and only once."Paling mahal, dan biasanya hanya berlaku di dalam satu sistem

Semantik mana yang Anda dapat ditentukan oleh urutan dua langkah di consumer: memproses pesan dan menyimpan posisi (offset atau ack).

baca pesan→ simpan posisi→ proses→ crash sebelum selesai = pesan terlewat (at-most-once)
baca pesan→ proses→ crash sebelum simpan posisi→ consumer pengganti memproses ulang (at-least-once)

Kafka docs menjelaskan kedua urutan ini persis seperti di atas, lalu menyimpulkan bahwa Kafka "guarantees at-least-once delivery by default". At-most-once didapat dengan mematikan retry di producer dan commit offset sebelum memproses. Exactly-once tersedia untuk alur baca-proses-tulis di dalam Kafka (Kafka Streams, transactional producer), tetapi "exactly-once delivery for other destination systems generally requires cooperation with such systems." [1]

RabbitMQ sama: dengan manual ack, pesan yang belum di-ack "is automatically requeued" saat koneksi consumer putus, sehingga "consumers must be prepared to handle redeliveries and otherwise be implemented with idempotence in mind." [2] Amazon SQS standard queue juga terus terang: Anda "might get that message copy again", jadi "design your applications to be idempotent." [3]

Kalimat yang aman di interview: "Saya asumsikan at-least-once, lalu membuat efeknya exactly-once dengan consumer yang idempotent." Ini lebih jujur daripada "broker kami exactly-once".

2. Idempotency: duplikat tidak boleh berefek dua kali

Idempotent berarti menjalankan operasi yang sama berkali-kali menghasilkan efek yang sama dengan menjalankannya sekali. Kafka docs memberi contoh termurah: kalau pesan punya primary key, update-nya sudah idempotent, karena "receiving the same message twice just overwrites a record with another copy of itself." [1] Masalahnya ada pada operasi yang menambah sesuatu: tagih kartu, kirim email, kurangi stok.

Idempotency key di API

Untuk request HTTP yang di-retry (timeout, koneksi putus), Stripe memakai idempotency key: client membuat "a unique key that the server uses to recognize subsequent retries of the same request", disarankan V4 UUID, maksimal 255 karakter. Server menyimpan status code dan body hasil pertama, dan "retries with the same key return the saved response, including 500 errors." Server juga membandingkan parameter request; kalau berbeda untuk key yang sama, request ditolak. Key boleh dibuang setelah minimal 24 jam. [4]

POST /v1/payments
Idempotency-Key: 6f1c2a3e-9b7d-4e21-a0c4-5d8e7f9a1b2c
{"order_id": 42, "amount": 150000}

→ 201 Created  {"payment_id": "pay_901"}      ← request pertama, hasil disimpan per key
(timeout di sisi client, client mengulang dengan key yang sama)
→ 201 Created  {"payment_id": "pay_901"}      ← hasil tersimpan, kartu tidak ditagih lagi

Consumer idempotent

Di sisi consumer queue, polanya sama: setiap pesan membawa ID unik, dan consumer mencatat ID yang sudah diproses dalam transaksi yang sama dengan efeknya.

// Dibaca, bukan dijalankan. processed_messages.message_id punya UNIQUE constraint.
async function handle(msg: { id: string; orderId: number; amount: number }) {
  await db.transaction(async tx => {
    const inserted = await tx.query(
      "INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING",
      [msg.id]);
    if (inserted.rowCount === 0) return;          // duplikat: sudah pernah diproses, lewati
    await tx.query("INSERT INTO payments (order_id, amount) VALUES ($1, $2)", [msg.orderId, msg.amount]);
  });                                             // ack/commit offset SETELAH transaksi commit
}

Kalau efeknya ada di sistem luar (gateway pembayaran), teruskan ID yang sama sebagai idempotency key ke sistem itu. Itulah "cooperation with such systems" yang dimaksud Kafka docs.

Latihan 1 — Idempotency key dan consumer idempotent

Jawab tanpa scroll ke atas. Soal yang sama akan muncul lagi, diacak, di review 0026.

Kuis di lesson ini adalah feedback cepat, bukan bukti penguasaan; yang dicatat hanya hasil review 0026.

3. Dead letter queue: pesan yang terus gagal

Pesan dengan format rusak (sering disebut poison message) akan gagal setiap kali diproses. Kalau consumer terus me-requeue, RabbitMQ docs memperingatkan terjadinya "a requeue/redelivery loop". [2] Obatnya adalah batas percobaan, lalu pindahkan ke dead letter queue (DLQ):

DLQ bukan tempat sampah: pasang alarm pada jumlah pesannya, selidiki penyebabnya, perbaiki, lalu kirim ulang (redrive).

4. Ordering: hanya per key, dan ada harganya

Urutan global di sistem terdistribusi itu mahal. Yang realistis adalah ordering per key: di Kafka, event dengan key sama (mis. account_id) masuk ke partition yang sama dan dibaca berurutan (lesson 0016). Di Amazon SQS, FIFO (first-in-first-out) queue menjaga urutan dan menghindari duplikat saat producer mengulang SendMessage "within the 5-minute deduplication interval". [7]

Dua jebakan: (1) menambah consumer paralel tanpa key merusak urutan; (2) DLQ juga bisa merusak urutan. SQS docs: "Don't use a dead-letter queue with a FIFO queue if you don't want to break the exact order of messages or operations." [5] Jadi tanyakan dulu ke interviewer: urutan apa yang benar-benar penting, per akun, per pesanan, atau tidak sama sekali?

Latihan 2 — Semantik, DLQ, ordering

Jawab tanpa scroll ke atas. Soal yang sama akan muncul lagi, diacak, di review 0026.

5. Kapan tidak perlu

Sinyal yang membenarkan: exactly-once "sungguhan" (transaksi Kafka, FIFO queue) layak dibayar kalau duplikat berarti uang hilang dan efeknya tidak bisa dibuat idempotent dengan murah. DLQ layak kalau ada pesan yang bisa gagal permanen dan Anda tidak boleh membuangnya diam-diam. Ordering ketat layak kalau urutan mengubah hasil (debit sebelum kredit), bukan hanya "lebih rapi".

Biaya dan risiko: jaminan yang lebih kuat dibayar dengan latency dan throughput (contoh kecil: producer Kafka yang menunggu pesan ter-commit ke semua replica in-sync bisa butuh "on the order of 10 ms" per pengiriman [1]); ordering per key membatasi paralelisme pada jumlah key aktif; tabel processed_messages tumbuh dan butuh pembersihan; DLQ tanpa alarm hanya menunda masalah.

Alternatif lebih murah:

Latihan lisan — Ucapkan dengan suara keras

Pasang timer dan jawab dengan suara keras, seolah sedang interview. Jangan buka jawaban contoh sebelum selesai.

"Queue Anda at-least-once. Bagaimana Anda memastikan pembayaran tidak terjadi dua kali, dan apa yang Anda lakukan dengan pesan yang terus gagal?" (60 detik)
Bandingkan dengan jawaban contoh dan checklist-nya

"At-least-once berarti pesan tidak hilang tapi bisa datang dua kali, misalnya kalau consumer crash setelah memproses tapi sebelum ack atau commit offset. Exactly-once end-to-end ke sistem eksternal tidak saya andalkan dari broker, jadi consumer saya buat idempotent. Setiap pesan membawa ID unik; dari sisi API, client mengirim Idempotency-Key, misalnya UUID. Consumer mencatat ID yang sudah diproses di tabel dengan unique constraint, dalam transaksi yang sama dengan efek pembayarannya, jadi duplikat dilewati dan mengembalikan hasil lama. Pesan yang terus gagal saya batasi, misalnya lima percobaan dengan backoff, lalu dipindah ke dead letter queue dan diberi alarm, supaya tidak memblokir pesan lain. Kalau volumenya kecil dan semua dalam satu database, cukup transaksi biasa tanpa queue sama sekali."

Checklist: apakah Anda menyebut…

  • At-least-once: pesan bisa duplikat, dengan contoh penyebabnya (crash sebelum ack/commit offset)
  • Tidak mengandalkan exactly-once dari broker untuk sistem eksternal
  • Idempotency key / ID pesan unik, dicatat bersama efeknya (unique constraint atau transaksi)
  • Duplikat mengembalikan hasil tersimpan, bukan menagih lagi
  • Batas retry lalu dead letter queue, dengan monitoring/alarm
  • Membenarkan komponen dengan angka, atau menyebut kapan cukup transaksi database biasa

Lulus jika minimal 4 dari 6.

Bacaan lanjutan (opsional, tidak dihitung dalam durasi) Apache Kafka 4.4 docs: Message Delivery Semantics, ±10 menit. Sumber paling jelas tentang kenapa urutan "simpan posisi" dan "proses" menentukan semantik.

Berikutnya, lesson 0018: event-driven, outbox pattern untuk menghindari dual write, dan back-pressure saat consumer tertinggal.

Istilah: GLOSSARY.md (bagian "Asinkron") · Indeks: semua lesson System Design

Ada yang belum jelas? Tanyakan ke saya (agent) di terminal, misalnya: "Kenapa ack setelah commit transaksi, bukan sebelumnya?", "Berapa maxReceiveCount yang masuk akal untuk job yang memanggil API pihak ketiga?"

Sumber: [1] Apache Kafka 4.4 docs: Design (Message Delivery Semantics) · [2] RabbitMQ: Consumer Acknowledgements and Publisher Confirms · [3] Amazon SQS: At-least-once delivery · [4] Stripe API Reference: Idempotent requests · [5] Amazon SQS: Using dead-letter queues · [6] RabbitMQ: Dead Letter Exchanges · [7] Amazon SQS: Exactly-once processing (FIFO)