π¬ 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:
-
"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.
-
"At-least-once kotor, kami butuh exactly-once" β sering overkill. Kalau operasi naturally idempotent (e.g.
UPDATE balance SET x = :valih-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
- Payment / billing β idempotency key (Stripe pattern) + unique constraint di ledger. At-least-once + dedup, bukan Kafka txn, karena efek keluar adalah charge ke bank pihak ketiga yang tidak akan tunduk transaksi Kafka.
- Event streaming analytics (Kafka Streams / Flink) β EOS di sini wajar dan cocok: baca Kafka, proses stateful (windowing, aggregation), tulis balik Kafka. Inilah use case unggulan Kafka transactions.
- Data pipeline Kafka β data warehouse (Snowflake/BigQuery) β gunakan upsert/merge dengan natural key (idempotent) β effectively-once tanpa Kafka txn ke tiap sink.
- Notification/email service β at-least-once + idempotency key di tabel
sentsupaya tidak kirim email dua kali. - CDC (Debezium) β aplikasi β at-least-once + consumer dedup pada primary key.
Failure Mode Umum
- 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. - Zombie producer β instance lama masih hidup setelah restart, commit transaksi basi. Solusi:
transactional.idwajib unik per instance dan dulunya di-fencevia epoch. Kalau dua instance pakai ID sama hidup bareng, satu akan di-fence β pastikan tidak sengaja. - 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.
- Transactional sink tidak transaksional β Kafka txn commit sukses, tapi HTTP call ke pihak ketiga gagal setelahnya β inconsistency. Solusi: outbox pattern + reconciliation job.
isolation.levelsalah β consumer pakairead_uncommittedβ melihat pesan dari transaksi yang akhirnya di-abortβ phantom read.- Dedup state hilang β dedup pakai in-memory set; restart β set kosong β duplikat masuk. Harus persisted (DB/Redis dengan TTL sesuai retry window).
- Unbounded dedup window β record ID dedup tumbuh tanpa batas β memory/table membengkak. Butuh TTL atau compaction periodik.
- "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
- "Exactly-once delivery" secara literal tidak achievable (Two Generals). Yang ada: at-least-once + dedup = effectively once.
- Kafka EOS = idempotent producer + transactions, berlaku end-to-end hanya di dalam Kafka. Sink eksternal butuh idempotency sendiri.
- Default pilihan produksi: at-least-once + idempotent consumer (unique constraint / idempotency key). EOS Kafka hanya ketika benar-benar KafkaβKafka dan butuh stateful processing.
- Cek dulu apakah operasimu naturally idempotent β sering lebih murah dari bikin transaksi.
- Urutan wajib: proses β commit offset. Terbalik = pesan hilang.
- Dedup state harus persisted. In-memory set = duplikat setelah restart.
- Zombie producer &
isolation.leveladalah 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
- Baca: Jay Kreps, "Exactly-once Support in Apache Kafka" (Medium) β pembahasan paling jernih soal definisi & fault-model.
- Baca: Martin Kleppmann, Designing Data-Intensive Applications, Ch. 11 (Stream Processing) β bagian "Exactly-Once and Effectively-Once".
- Flink: "An Overview of End-to-End Exactly-Once Processing in Apache Flink" (flink.apache.org, 2018) β two-phase commit sink + checkpoint barrier.
- Hands-on berikutnya: implement Transactional Outbox + CDC (Debezium) β ini pola produksi paling realistis untuk sync DBβKafka.
- Tantangan: bandingkan EOS Kafka vs idempotency key pattern β kapan masing-masing menang? Bangun benchmark kecil dengan 1M pesan & ukur overhead transaksi.