π Topik: Backpressure & Queue Semantics β At-Least-Once vs Exactly-Once
d-backpressure Β· Distributed Systems Β· depth 3
π― Tujuan Belajar
Setelah sesi ini, keputusan berikut jadi lebih baik:
- Kapan pakai queue dan kapan queue justru memindahkan masalah (dan memperburuknya).
- Memilih semantik delivery yang benar per hop β dan tahu bahwa "exactly-once" di level end-to-end itu tidak ada; yang ada effectively-once lewat idempotensi.
- Mendesain idempotency key dan dedup window yang tidak bocor di tepi.
- Mengenali metastable failure: sistem yang sudah pulih kapasitasnya tapi tidak pulih throughput-nya.
π§ Penjelasan Inti
Dua masalah berbeda, sering dicampur
| Backpressure | Queue semantics | |
|---|---|---|
| Pertanyaan | "Apa yang terjadi kalau producer > consumer?" | "Kalau pesan diproses 2x, apa dampaknya?" |
| Sifat | Kontrol aliran (flow control) | Jaminan pengiriman (delivery guarantee) |
| Kegagalan khas | OOM, latency explosion, retry storm | Duplikat transaksi, double charge, drift saldo |
Keduanya ketemu di satu titik: queue adalah buffer, dan buffer tanpa batas = bug yang tertunda.
Kenapa backpressure harus ada
Sistem punya kapasitas layanan (ΞΌ). Kalau arrival rate Ξ» > ΞΌ, antrean tumbuh tanpa batas. Matematikanya tidak negotiable:
Little's Law: L = Ξ» Γ W
- Ξ» = 100 rps, W = 200 ms β L = 20 request in-flight. Ini angka yang harus ditampung pool/thread/connection.
- Balik: kalau concurrency dibatasi 20 dan latency 200 ms, throughput plafon = 100 rps. Concurrency limit adalah throughput limit.
M/M/1: L = Ο/(1-Ο), W = 1/(ΞΌ(1-Ο))
| Utilisasi Ο | Panjang antrean L | Latency vs service time |
|---|---|---|
| 0.5 | 1.0 | 2Γ |
| 0.8 | 4.0 | 5Γ |
| 0.9 | 9.0 | 10Γ |
| 0.95 | 19.0 | 20Γ |
| 0.99 | 99.0 | 100Γ |
Konsekuensi arsitektural: latency bukan fungsi linear dari load. Di 90% utilisasi, sedikit lonjakan = latency 2Γ. Karena itu target utilisasi produksi biasanya 60β70%, bukan 90%. (Ini konsekuensi model antrean, bukan angka benchmark vendor β angka aktual tergantung distribusi arrival; arrival yang bursty/bukan Poisson lebih buruk lagi.)
Tiga strategi menghadapi overload β pilih salah satu, jangan diam
- Buffer (antrean) β tunda kerja. Menangani spike, kalah pada sustained overload. Buffer wajib bounded.
- Shed (drop/reject) β tolak lebih awal, murah.
429+Retry-After, atau drop pesan non-kritis. Butuh definisi prioritas. - Degrade β kerjakan lebih murah: cache stale, skip enrichment, sampling, jawaban parsial.
Yang tidak boleh: buffer unbounded (OOM) atau retry tanpa batas (retry storm β amplifikasi beban 3β10Γ saat downstream sudah sekarat).
Mekanisme backpressure yang nyata
- Bounded queue + blocking/slowdown producer. Node stream:
write()returnfalseβ berhenti sampai'drain'.highWaterMark= ambang buffer. - Credit-based / pull-based. Consumer minta N item; producer tidak boleh kirim lebih. AMQP
basic.qos(prefetch), Kafkamax.poll.records+pause()/resume(), gRPC flow control (HTTP/2 window). - Semaphore / bulkhead. Batasi concurrency per dependency. Beda dari rate limit: rate limit = laju/waktu, semaphore = in-flight.
- Adaptive concurrency limit. Ukur latency (mis. p95) dan sesuaikan limit dengan AIMD atau gradient β lebih tahan terhadap downstream yang melambat dibanding limit statis. (Pendekatan yang dipopulerkan Netflix
concurrency-limits; ini pola, bukan spesifikasi.) - End-to-end signal. Backpressure harus merambat ke ujung. Kalau hop tengah punya queue, sinyal berhenti di situ dan producer hulu tidak pernah tahu. Ini sumber utama "sistem terlihat sehat, downstream sekarat".
- Load shedding di edge (gateway/LB) dengan admission control: tolak di pintu, bukan setelah 3 hop.
Semantik delivery β yang sebenarnya dijamin
| Semantik | Arti operasional | Cara dapat | Risiko |
|---|---|---|---|
| At-most-once | commit offset sebelum proses | fire-and-forget | pesan hilang saat crash |
| At-least-once | commit offset sesudah proses | ack setelah sukses | duplikat saat crash/retry/rebalance |
| Effectively-once | at-least-once + dedup/idempotensi | idempotency key, upsert, transaksi | butuh state dedup + window |
Kenapa exactly-once delivery mustahil secara umum: ack dan proses adalah dua operasi pada dua sistem berbeda tanpa commit protokol bersama. Kalau consumer crash setelah proses tapi sebelum ack, sistem tidak bisa membedakan "sudah diproses" dari "belum" β harus pilih salah satu: kirim ulang (duplikat) atau jangan (hilang). Ini varian Two Generals; tidak ada jumlah handshake yang menyelesaikannya. Jadi semua "exactly-once" industri adalah exactly-once processing effect, bukan exactly-once delivery.
Kafka, spesifik (fakta, bukan klaim benchmark):
- Idempotent producer (enable.idempotence=true, default aktif di client modern): broker menyimpan (producerId, partition, sequenceNumber) dan membuang duplikat retry dalam sesi producer. Ini menutup duplikat akibat retry internal producer β bukan duplikat akibat consumer crash.
- Transaksi (transactional.id + sendOffsetsToTransaction): atomicitas tulis-ke-banyak-partisi plus commit offset consumer. Offset jadi bagian transaksi β hilangnya duplikat di pola read-process-write selama output juga Kafka dan consumer baca dengan isolation.level=read_committed.
- Di luar batas itu (sink ke Postgres, HTTP call ke payment gateway), jaminan berhenti. Butuh idempotensi di sink.
Batas dedup yang harus eksplisit:
- Dedup butuh state: store key + TTL. TTL = dedup window. Pesan yang di-retry setelah window lewat = diproses dua kali. Retry policy dan dedup window harus kompatibel (max retry delay Γ max attempts < window).
- Dedup by key tidak menyelesaikan ordering. Butuh partisi per key agar urutan terjaga (Kafka: key β partition; konsekuensinya hot partition).
- Natural idempotency > tabel dedup: UPDATE ... WHERE version = $expected, INSERT ... ON CONFLICT DO NOTHING, state machine dengan transisi legal (PENDING β PAID idempoten, PAID β PAID no-op).
βοΈ Cara Kerja Internal
Alur at-least-once yang benar (poll loop)
loop:
batch = consumer.poll(max.poll.records=N, max.poll.interval.ms=M)
try:
result = process(batch) # idempoten by design
producer.send(outputs) # atau tulis ke DB
commit(offsets) # SETELAH sukses
except Transient:
retry with backoff (bounded) # duplikat mungkin terjadi β aman karena idempoten
except Poison:
send to DLQ, commit offset # jangan block partisi
Dua timer yang sering dilupakan:
- max.poll.interval.ms β consumer dianggap mati kalau tidak poll dalam window ini β rebalance β partisi pindah β duplikat. Batch processing yang lambat memicu ini. Mitigasi: kurangi max.poll.records, proses async dengan batas, atau naikkan interval (trade-off: deteksi mati lebih lambat).
- Rebalance = titik duplikat paling sering di Kafka. Cooperative rebalancing mengurangi stop-the-world, tidak menghapus duplikat.
Offset commit bukan ACK
Commit offset menyatakan "semua sebelum ini sudah diproses" β high-water mark, bukan per-pesan. Commit setelah N dari 100 pesan batch sukses = 100 pesan dianggap selesai. Kalau butuh granularitas, commit per-pesan (mahal) atau idempotensi per-pesan (murah).
Outbox pattern (menutup gap DBβbroker)
Masalah: tulis DB sukses, publish ke broker gagal β event hilang. Atau publish sukses, DB rollback β event hantu.
BEGIN
INSERT INTO orders (...)
INSERT INTO outbox (id, topic, payload, created_at)
COMMIT
-- relay terpisah (polling atau CDC/WAL) publish outbox β broker, tandai sent
Satu transaksi lokal = satu sumber kebenaran. Relay at-least-once β consumer wajib idempoten. Harga: latency tambahan + tabel outbox perlu di-prune.
Backpressure propagasi (rantai)
client β [LB: rate limit + shed 429] β [svc A: semaphore per dep] β [queue bounded] β [svc B: pull-based, prefetch=K]
Aturan: setiap hop punya batas, dan batas itu terlihat (metrik queue depth + in-flight + lag). Queue tanpa alarm = bug yang menunggu.
ποΈ Diagram: di mana backpressure mati
Producer βββΊ [queue TANPA BATAS] βββΊ Consumer (lambat)
β
βββ producer tidak pernah merasa lambat
queue tumbuh β memory naik β GC pressure β latency naik
β consumer makin lambat β queue makin tumbuh β
vs.
Producer βββΊ [queue bounded, size=N] βββΊ Consumer
β
ββ penuh β producer block / 429 / drop-oldest
ββ sinyal balik ke hulu β hulu melambat atau shed
Perbedaan satu properti: batas. Bounded queue mengubah "kehilangan data diam-diam + OOM" menjadi "penolakan eksplisit" β yang bisa diobservasi dan diberi kebijakan.
βοΈ Trade-off & Kapan JANGAN Dipakai
| Keputusan | Pakai kalau | JANGAN pakai kalau |
|---|---|---|
| Queue async | spike sementara, kerja bisa ditunda, butuh decoupling temporal | butuh hasil sinkron (user menunggu), ordering ketat lintas entitas, kerja tidak boleh hilang sama sekali tanpa DLQ |
| Bounded queue kecil | ingin latency rendah & shedding cepat | burst legit besar (batch import) β pakai admission control di depan, bukan buffer besar |
| At-least-once + idempotensi | default sehat untuk hampir semua sistem | β (ini pilihan default) |
| Exactly-once transaksional (Kafka) | read-process-write seluruhnya di Kafka | sink eksternal non-transaksional; tim belum paham fencing/epoch β risiko lebih besar dari manfaat |
| At-most-once | telemetry, metrics, log sampling β nilai hilang < biaya duplikat | apa pun yang menyentuh uang, stok, hak akses |
| Retry agresif | error transient terverifikasi, backoff + jitter, circuit breaker | downstream overload (retry = amplifikasi); error non-transient (retry = sia-sia) |
| Autoscaling sebagai solusi overload | beban naik bertahap, cold start cepat | spike instan, stateful consumer, DB sebagai bottleneck (scale app tidak menambah kapasitas DB) |
Yang paling sering salah: menambah queue untuk "menyerap" overload. Queue menyerap spike, bukan defisit kapasitas. Kalau Ξ» > ΞΌ secara persisten, queue hanya mengubah kapan kegagalan terjadi, bukan apakah.
Kapan JANGAN pakai exactly-once transaksional: kalau efek sampingnya keluar dari boundary transaksi (email, payment gateway, third-party API). Transaksi Kafka tidak bisa rollback HTTP call. Di situ idempotency key di sisi penerima adalah satu-satunya jawaban jujur.
π Contoh Produksi & Failure Mode
| Failure mode | Mekanisme | Gejala | Mitigasi |
|---|---|---|---|
| Retry storm | 3 layer retry (client, gateway, service) Γ 3 attempt = 27Γ load | downstream down total padahal kapasitas cukup | retry hanya di satu layer, backoff + jitter, retry budget (mis. β€10% traffic), circuit breaker |
| Metastable failure | overload β queue penuh β timeout β retry β overload | sistem tidak pulih walau load turun; butuh restart/shed manual | load shedding agresif, admission control, circuit breaker, "fail fast" bukan "queue forever" |
| Rebalance storm | consumer lambat > max.poll.interval.ms β rebalance loop |
lag naik terus, duplikat masif, throughput jatuh | kurangi batch, naikkan interval, static membership, cooperative sticky assignor |
| Unbounded queue OOM | buffer tanpa batas, producer cepat | pod OOMKilled / GC death spiral | bounded queue + metrik depth + shed policy |
| Poison message | satu pesan selalu gagal, retry selamanya | partisi berhenti (head-of-line blocking), lag tak turun | DLQ setelah N attempt, commit offset, alert |
| Duplicate side effect | at-least-once ke payment gateway tanpa idempotency key | double charge, tiket ganda | idempotency key end-to-end + dedup di penerima |
| Hot partition | key ordering per user β 1 partisi jenuh | satu consumer 100%, sisanya idle | key salting / composite key / partition lebih granular β tapi urutan per entitas harus dipertahankan |
| Thundering herd saat scale-up | cache miss massal / reconnect massal setelah deploy | lonjakan ke DB tepat saat sistem rapuh | jitter, warmup, staggered rollout, request coalescing |
Pola yang terbukti: backpressure yang benar terlihat di metrik, bukan di kode. Kalau kamu tidak bisa menjawab "berapa queue depth sekarang, berapa lag, berapa in-flight per dependency, berapa yang di-shed" β kamu belum punya backpressure, kamu punya harapan.
β οΈ Miskonsepsi Umum
- "Kafka exactly-once, jadi sistem saya exactly-once." Jaminan berhenti di boundary Kafka. Sink non-Kafka = at-least-once + idempotensi milikmu.
- "Idempotent producer = consumer aman dari duplikat." Tidak. Itu menutup duplikat retry producer. Duplikat consumer (crash setelah proses, sebelum commit) tetap terjadi.
- "Tambah queue = tambah resilience." Queue tanpa batas = penundaan kegagalan. Queue tanpa consumer yang di-scale = akumulasi utang.
- "At-least-once itu jelek, kita butuh exactly-once." At-least-once + idempotensi = effectively-once, lebih murah, lebih sederhana, dan bekerja lintas sistem. Exactly-once transaksional mahal dan rapuh di tepi.
- "Retry bikin sistem lebih tahan gagal." Retry tanpa batas + tanpa jitter = amplifier kegagalan. Retry adalah kredit, bukan hak.
- "Autoscaling menyelesaikan overload." Kalau bottleneck di DB atau di dependency bersama, menambah app instance hanya menambah tekanan.
- "Latency tinggi berarti butuh lebih banyak worker." Kalau Ξ» sudah > ΞΌ, lebih banyak worker membantu; kalau Ξ» < ΞΌ tapi latency tinggi, masalahnya di antrean/blocking/contention β tambah worker bisa memperburuk (context switch, lock contention, connection pool exhaustion).
β Cek Pemahaman
- Consumer Kafka memproses 500 pesan per batch, commit setelah batch selesai. Di pesan ke-300 prosesnya lambat (60s),
max.poll.interval.ms=300000. Crash terjadi di pesan ke-450. Berapa pesan yang diproses ulang, dan perubahan konfigurasi mana yang mengurangi jumlah duplikat tanpa mengorbankan throughput secara berlebihan? - Sistem punya target 200 rps, latency p95 50 ms. Berapa concurrency minimum yang dibutuhkan (Little's Law)? Kalau utilisasi naik ke 90%, apa yang terjadi pada p95 dan berapa concurrency efektif yang perlu disiapkan?
- Kamu menulis event ke Postgres lalu publish ke Kafka. Publish gagal setelah commit DB. Sebutkan dua solusi dan trade-off masing-masing β dan jelaskan mengapa "publish dulu, lalu commit DB" bukan solusi.
π Poin Kunci
- Bounded everywhere. Setiap buffer harus punya batas dan alarm. Tanpa batas, backpressure tidak ada β hanya penundaan.
- Little's Law mengikat concurrency, throughput, dan latency. Ketiganya tidak bisa diinginkan sekaligus.
- Latency meledak non-linear mendekati saturasi. Target utilisasi 60β70%, bukan 90%.
- Exactly-once delivery mustahil; effectively-once processing dicapai lewat idempotensi. Itu default yang benar.
- Idempotensi lebih murah daripada koordinasi. Pilih operasi yang idempoten secara natural (upsert, conditional write, state machine) sebelum membangun tabel dedup.
- Dedup window harus cocok dengan retry policy β kalau tidak, jaminannya bocor diam-diam.
- Backpressure harus merambat end-to-end. Queue di tengah memutus sinyal.
- Retry adalah anggaran terbatas, bukan refleks. Backoff + jitter + retry budget + circuit breaker.
- Metastable failure nyata: pemulihan butuh shedding, bukan kesabaran.
- Observability dulu: queue depth, consumer lag, in-flight, shed rate, dedup hit rate. Tanpa itu, semua di atas teori.
β‘οΈ Eksplorasi Lanjut
- Outbox + CDC β bandingkan polling relay vs Debezium/WAL tailing: latency, beban DB, kompleksitas operasional.
- Saga vs 2PC β kenapa saga menukar atomicity dengan kompensasi, dan kapan kompensasi tidak mungkin (efek eksternal ireversibel).
- Idempotency key di HTTP API β desain header, scope key, retry semantics (
Idempotency-Key+ cached response), dan interaksi dengan 409/422. - CoDel & adaptive concurrency β load shedding berbasis sojourn time, bukan queue length; kenapa lebih tahan terhadap bufferbloat.
- Backpressure di runtime β Go channel buffer +
errgroup.SetLimit, Node streamhighWaterMark/pipeline, Rusttokiobounded mpsc, actor mailbox bounded. Bandingkan default-nya: mana yang default-nya unbounded dan kenapa itu jebakan. - Ordering vs parallelism β partitioning per key, hot partition, dan kapan harus menyerah pada global ordering.