← semua materi
15:00 Teknis Β· 14 Oct 2026 Β· 03:01

πŸ”¬ BACOT β€” 10:00 Technical Exploration Topik: At-least-once vs at-most-once vs exactly-once (dan kenapa exactly-once itu ilusi)


Tujuan Belajar

Setelah sesi ini kamu bisa: - Membedakan delivery guarantee (at-most/at-least/exactly-once) secara presisi β€” termasuk kenapa istilah ini sering dipakai salah. - Paham mekanisme konkret Kafka: idempotent producer + transactions (PID, sequence number, transactional.id). - Paham kenapa "exactly-once" yang sebenarnya kamu mau adalah effectively-once (dedup di sisi consumer/sink), bukan delivery ajaib di transport. - Bisa memilih semantics yang tepat berdasarkan cost, bukan hype.


Arsitektur & Alur Teknis

Kerangka berpikir kuncinya: "exactly-once" itu bukan properti satu komponen β€” ia properti end-to-end. Orang sering salah karena menganggapnya fitur transport (broker). Padahal broker bisa jamin "no dup, no loss" di dalam log-nya, tapi begitu efek keluar ke DB/HTTP/email, jaminan itu gugur.

Tiga level semantics:

Semantics Arti Loss Duplikat Biaya
At-most-once Kirim, lupa. Gak retry Bisa Gak Termurah
At-least-once Retry sampai ACK Gak Bisa Sedang (butuh idempotent consumer)
Effectively-once (dipasarkan sebagai "exactly-once") At-least-once + dedup Gak Gak (secara efek) Termahal (transaksi + state)

Kenapa "exactly-once delivery" secara literal ilusi (DIKETAHUI, ada bukti formal):

Jaminan exactly-once pada level pengiriman melanggar Two Generals Problem β€” pengirim tak bisa tahu pasti apakah receiver menerima pesan terakhir sebelum crash. Tanpa pengirim & penerima berbagi state transaksional, "ACK sekarang commit" dan "ACK lalu crash sebelum commit" tak bisa dibedakan dari sisi pengirim. Jadi satu-satunya cara jujur: kirim at-least-once, lalu dedup di ujung β†’ hasilnya effectively once.

Kutipan dari Jay Kreps (arsitek Kafka, Confluent): "the real guarantee people want is ... end-to-end correct processing of messages in the presence of failure" β€” bukan "sekali" secara literal.

Alur Kafka (DIKETAHUI, sejak 0.11.0):

Producer        Broker (partition)              Consumer Group
   |                    |                            |
   |-- init --> PID=42, epoch=0                    |
   |  seq#=0,1,2,... per partition                 |
   |-- send(seq=5) ----> ditulis                |
   |<-- ACK ------      |                       |
   |  (ACK hilang)      |                       |
   |-- retry(seq=5) --> broker lihat seq=5      |
   |                    | sudah ada β†’ skip (dedup) |
   |                    |                       |
   |        [transactions]                      |
   |-- beginTxn -------> |  (offset consumer ikut dalam txn) |
   |-- send to P1,P2 --> |  keduanya buffer      |
   |<-- commitTxn -----> |  keduanya jadi visible bersama |

Mekanismenya: 1. Idempotent producer β€” Kafka kasih tiap producer PID unik + sequence number per-partition. Broker simpan seq terakhir per (PID, partition). Retry dengan seq sama β†’ broker skip, tetap ACK sukses. Default aktif sejak Kafka 3.0 (enable.idempotence=true). 2. Transactions β€” transactional.id memungkinkan producer menulis atomik ke banyak partition + commit offset consumer dalam satu transaksi. Consumer set isolation.level=read_committed β†’ hanya lihat yang sudah commit, skip abort. 3. Zombie fencing β€” kalau producer lama crash dan producer baru pakai transactional.id sama, Kafka bump epoch β†’ producer lama ("zombie") tak bisa commit transaksi basi.


Contoh Kode / Diagram / Pseudocode

1. Kafka producer β€” idempotent + transactional (Java-ish, sesuai Confluent docs):

props.put("enable.idempotence", "true");          // dedup retry (default di 3.0+)
props.put("transactional.id", "order-processor-1"); // durable, WAJIB unik per instance
props.put("acks", "all");
props.put("isolation.level", "read_committed");    // di consumer β€” skip aborted txn

producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(orderTopic, key, orderEvent);
    producer.send(outboxTopic, key, notification);
    // kirim offset consumer dlm txn yg sama β†’ atomic w/ output
    producer.sendOffsetsToTransaction(currentOffsets, consumerGroupId);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();  // semua send di atas gugur barengan
}

2. Pattern yang benar-benar penting di produksi: dedup di consumer (effectively-once).

Kafka transaction TIDAK meng-cover DB atau HTTP eksternal. Jadi sink harus idempotent. Dua pola standar:

-- Pola A: idempotent write pakai unique constraint (paling sederhana)
CREATE TABLE processed (
  message_id   UUID PRIMARY KEY,
  payload      JSONB,
  processed_at TIMESTAMPTZ DEFAULT now()
);
-- consumer: INSERT ... ON CONFLICT (message_id) DO NOTHING;
-- retry apapun β†’ hanya satu row. Efek = sekali.
# Pola B: Transactional Outbox / "consume-transform-produce + write state atomically"
# di DB yang sama dengan state aplikasi
with db.transaction():
    if db.get("inbox", msg.id):   # dedup check
        return                    # sudah diproses, skip
    apply_business_logic(msg)
    db.put("inbox", msg.id, ...)  # catat ID yg sudah diproses
    db.put("outbox", render_event(msg))  # event keluar nanti
# proses terpisah drain outbox β†’ Kafka/email/HTTP, dengan retry + dedup di sisi target

3. At-least-once murni (SQS/NATS/plain queue) β€” cukup kalau consumer idempotent:

while True:
    msg = q.receive(visibility_timeout=30)
    if msg is None: continue
    process(msg)         # HARUS idempotent (mis. pakai msg.id sebagai idempotency key)
    q.delete(msg)        # jika crash sebelum delete β†’ msg redelivered β†’ reprocess β†’ dedup

Trade-off & Pendekatan Alternatif

Pendekatan Duplikat Loss Latency Kompleksitas Kapan dipakai
At-most-once (fire & forget) Tidak Bisa Terendah Trivial Metrics non-kritis, log sampling, telemetry
At-least-once + dedup manual Terkendali Tidak Rendah Rendah–Sedang Mayoritas sistem: order, billing, notifications
At-least-once + idempotent sink Tidak (efek) Tidak Rendah Sedang Ketika operasi naturally idempotent (SET, UPSERT)
Kafka transactions (EOS) Tidak (dalam Kafka) Tidak Sedang–Tinggi Tinggi Stream processing Kafkaβ†’Kafka, agregasi stateful
2PC / XA distributed txn Tidak Tidak Tinggi Sangat Tinggi Cross-system legacy (jarang direkomendasikan modern)
Exactly-once "efektif" (Kafka txn + idempotent DB) Tidak Tidak Sedang Tinggi Pipeline Kafka→DB yang butuh benar-benar bersih

Dua kesalahan umum dalam memilih:

  1. "Kami pakai Kafka exactly-once, jadi aman" β€” SALAH kalau sink-mu bukan Kafka. Kafka EOS hanya berlaku end-to-end jika source, processor, dan sink semua di dalam Kafka. Begitu output ke Postgres atau HTTP, jaminan itu berhenti di broker. Kamu tetap butuh idempotency di sink.

  2. "At-least-once kotor, kami butuh exactly-once" β€” sering overkill. Kalau operasi naturally idempotent (e.g. UPDATE balance SET x = :v alih-alih +=), at-least-once sudah memberi efek yang sama dengan exactly-once, tanpa biaya transaksi Kafka. Cek dulu apakah operasimu idempotent β€” sering lebih murah dari yang dikira.

Alternatif selain Kafka transactions: - Idempotency key (dipakai Stripe, PayPal): client kirim key unik, server simpan hasil per-key β†’ retry balikin hasil sama. Ringan, tak butuh broker txn. - Record dedup window (Flink/SQL): simpan set ID dalam window waktu; drop duplikat dalam window. Sederhana tapi tidak tahan lama (window bisa bocor).

Flink 1.4+ menyediakan end-to-end exactly-once dengan "two-phase commit" sink + checkpointing β€” konsepnya serupa: state & output di-commit sebagai satu kesatuan lewat barrier checkpoint, bukan via sink eksternal yang tidak transaksional.


Use Case Produksi


Failure Mode Umum

  1. Producer retry tanpa idempotence β†’ duplikat massa di partition. Klasik: enable.idempotence=false + network blip + auto-retry. Sejak Kafka 3.0 default true, tapi konfigurasi lama masih ada.
  2. Zombie producer β€” instance lama masih hidup setelah restart, commit transaksi basi. Solusi: transactional.id wajib unik per instance dan dulunya di-fence via epoch. Kalau dua instance pakai ID sama hidup bareng, satu akan di-fence β€” pastikan tidak sengaja.
  3. Commit offset sebelum efek selesai β†’ at-least-once jadi at-most-once diam-diam. Pesan hilang karena offset telah di-commit namun bisnis logika belum jalan. Urutan: proses DULU, baru commit.
  4. Transactional sink tidak transaksional β€” Kafka txn commit sukses, tapi HTTP call ke pihak ketiga gagal setelahnya β†’ inconsistency. Solusi: outbox pattern + reconciliation job.
  5. isolation.level salah β€” consumer pakai read_uncommitted β†’ melihat pesan dari transaksi yang akhirnya di-abort β†’ phantom read.
  6. Dedup state hilang β€” dedup pakai in-memory set; restart β†’ set kosong β†’ duplikat masuk. Harus persisted (DB/Redis dengan TTL sesuai retry window).
  7. Unbounded dedup window β€” record ID dedup tumbuh tanpa batas β†’ memory/table membengkak. Butuh TTL atau compaction periodik.
  8. "Exactly-once = no retry needed" β€” salah kaprah, justru EOS paling mahal karena tetap retry + transaksi. Ekspektasi performa harus disesuaikan: EOS bukan gratis.

Latihan Praktis Kecil

# Demo duplikat nyata dengan Kafka lokal (docker compose: kafka + postgres)
# Tujuan: buktikan at-least-once + unique constraint = efek exactly-once.

# 1. Jalankan Kafka+Postgres
#    (pakai image resmi; port 9092 & 5432)

# 2. Producer kirim 1 pesan, matikan setelah kirim sebelum ACK (simulasi crash)
#    β†’ jalankan ulang producer β†’ broker retry β†’ amati: tanpa idempotence, 2 record masuk.

# 3. Consumer: INSERT INTO processed(message_id) VALUES(:id) ON CONFLICT DO NOTHING;
#    β†’ jalankan consumer, biarkan crash di tengah, restart.
#    β†’ Cek: processed table hanya punya 1 row untuk ID sama. Efek = once.

Metrik yang diukur: jumlah row processed vs jumlah record di topic. Kalau dedup benar, keduanya sama untuk tiap message_id.


Poin Kunci

  1. "Exactly-once delivery" secara literal tidak achievable (Two Generals). Yang ada: at-least-once + dedup = effectively once.
  2. Kafka EOS = idempotent producer + transactions, berlaku end-to-end hanya di dalam Kafka. Sink eksternal butuh idempotency sendiri.
  3. Default pilihan produksi: at-least-once + idempotent consumer (unique constraint / idempotency key). EOS Kafka hanya ketika benar-benar Kafka→Kafka dan butuh stateful processing.
  4. Cek dulu apakah operasimu naturally idempotent β€” sering lebih murah dari bikin transaksi.
  5. Urutan wajib: proses β†’ commit offset. Terbalik = pesan hilang.
  6. Dedup state harus persisted. In-memory set = duplikat setelah restart.
  7. Zombie producer & isolation.level adalah dua jebakan paling umum di EOS Kafka.

(Status: idempotent producer default-on sejak Kafka 3.0, EOS diperkenalkan 0.11.0 β€” DIKETAHUI dari dokumentasi Confluent/Conduktor. Klaim "Two Generals membuat exactly-once impossible" β€” DIKETAHUI sebagai argumen formal yang dirujuk Jay Kreps; definisi presisinya bergantung pada fault-model, bukan absolut.)


Eksplorasi Lanjut