End-to-End Delivery Guarantees
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”guarantee แบบ end-to-end แข็งแรงได้เท่ากับจุดที่อ่อนที่สุดของตัวเองเท่านั้น — ฝั่ง producer (acks กับ min.insync.replicas) และฝั่ง consumer (จังหวะ commit ที่พูดถึงในmodule consumers) ต่างก็มีส่วน และคุณต้องคิดถึงทั้งสองฝั่ง พร้อมกัน
guarantee เดียวที่มีสองครึ่ง
หัวข้อที่มีชื่อว่า “guarantee เดียวที่มีสองครึ่ง”delivery semantics ไม่ใช่สวิตช์ตัวเดียว data ไหลจาก producer → log → consumer และแต่ละ hop มี failure mode ของตัวเอง
- Producer → log ด้วย
acks=allและenable.idempotence=truerecord ถูกเขียนแบบทนทานและ retry ไม่สร้าง duplicate ใน partition ถ้าใช้acks=0— พิสูจน์ด้านล่าง — client จะไม่ได้รับการยืนยันใด ๆ เลย ไม่มีแม้แต่ error ดังนั้น broker ล่มจังหวะไม่ดีก็ทำให้ write หายไปเงียบ ๆ - Log → consumer commit หลัง ประมวลผลได้ at-least-once commit ก่อน ได้ at-most-once — พิสูจน์ด้วยสคริปต์ kill-แล้วนับจริงใน Delivery Semantics ตัว log เองไม่เคยทำ record ที่ commit แล้วหายภายใน retention
รวมสองอย่างเข้าด้วยกันก็ได้พฤติกรรม end-to-end จริง ๆ
| Producer | Consumer commit | End-to-end |
|---|---|---|
acks=all + idempotent | หลังประมวลผล | at-least-once (ไม่หาย อาจซ้ำ) |
acks=1 | หลังประมวลผล | at-least-once แต่ broker ล่มแบบหายากยังทำ write หายได้ |
acks=0 | ใดก็ตาม | ไม่มี guarantee — พิสูจน์ด้านล่าง: client ไม่เคยรู้ว่า write เกิดขึ้นจริงหรือไม่ |
acks=all + idempotent | ก่อนประมวลผล | at-most-once (ไม่ซ้ำ อาจหาย) |
| transactional producer | commit offset ใน transaction (txn.sendOffsets) | exactly-once ภายใน Kafka — พิสูจน์ใน Exactly-Once Processing |
flowchart LR prod["Producer: acks=all + idempotence"] -->|durable, no dupes| log["Partition log"] log -->|committed records retained| cons["Consumer"] cons -->|commit after processing| out["at-least-once end-to-end"] out -->|idempotent sink or transactions| eos["effectively exactly-once"]
พิสูจน์: acks=0 ไม่ยืนยันอะไรเลย และนั่นแหละคือสาเหตุที่ทำข้อมูลหาย
หัวข้อที่มีชื่อว่า “พิสูจน์: acks=0 ไม่ยืนยันอะไรเลย และนั่นแหละคือสาเหตุที่ทำข้อมูลหาย”acks=0 แปลว่า producer ไม่รอ response จาก broker เลย เทียบ response ที่ KafkaJS ส่งกลับมาระหว่าง acks=0 กับ acks=-1 (all) บน broker เดียวกัน
// @runimport { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}acks-comparison`;
const kafka = new Kafka({ clientId: 'acks-comparison', brokers });const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 1 }] }).catch(() => {});await admin.disconnect();
const producer = kafka.producer();await producer.connect();const withAcks0 = await producer.send({ topic, acks: 0, messages: [{ value: 'probe-acks0' }] });const withAcksAll = await producer.send({ topic, acks: -1, messages: [{ value: 'probe-acksall' }] });console.log('acks=0 response:', JSON.stringify(withAcks0));console.log('acks=all response:', JSON.stringify(withAcksAll));await producer.disconnect();process.exit(0);Real output:
acks=0 response: []acks=all response: [{"topicName":"acks-comparison","partition":0,"errorCode":0,"baseOffset":"1","logAppendTime":"-1","logStartOffset":"0"}]acks=-1 ส่ง baseOffset จริงกลับมา — หลักฐานว่า write ไปถึงแล้ว acks=0 ส่ง array ว่างเปล่า กลับมา ไม่มี partition ไม่มี offset ไม่มี error code ไม่มีอะไรเลย promise ของ client resolve ไม่ว่าจะเกิดอะไรขึ้น นี่แหละคืออันตรายทั้งหมด — โค้ดของคุณแยกไม่ออกระหว่าง “ส่งถึงแล้ว” กับ “ไม่เคยออกจากเครื่องเลย”
พิสูจน์: burst ของ acks=0 ที่ “สำเร็จ” แต่หายไปทั้งหมด
หัวข้อที่มีชื่อว่า “พิสูจน์: burst ของ acks=0 ที่ “สำเร็จ” แต่หายไปทั้งหมด”เพื่อให้เห็นการหายแบบจับต้องได้ ไม่ใช่แค่ทฤษฎี ยิง acks=0 300 ครั้งโดยไม่รอทีละตัว แล้ว kill process ของ broker ทันทีที่คิวเสร็จ แข่งกันระหว่าง docker kill กับจำนวนที่หลุดออกจากคิว batch ภายในของ client ไปแล้วจริง ๆ
// @runimport { Kafka } from 'kafkajs';import { execSync } from 'node:child_process';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}acks0-burst`;const containerName = process.env.KAFKA_PROBE_CONTAINER ?? 'kafka-deep-dive-probe-broker';
const kafka = new Kafka({ clientId: 'acks0-burst', brokers });const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 1 }] }).catch(() => {});await admin.disconnect();
const producer = kafka.producer();await producer.connect();
const N = 300;const promises: Promise<unknown>[] = [];for (let i = 0; i < N; i++) { promises.push(producer.send({ topic, acks: 0, messages: [{ value: `burst-${i}` }] }));}// Kill the broker's PROCESS (not remove the container) the instant the burst is queued.try { execSync(`docker kill ${containerName}`, { stdio: 'ignore' }); } catch {}const settled = await Promise.allSettled(promises);const fulfilled = settled.filter((s) => s.status === 'fulfilled').length;console.log(`burst of ${N}: client-side fulfilled=${fulfilled}`);
// Restart the same container (data on disk survives a process kill, only the broker// process died) and read back the real high-watermark.try { execSync(`docker start ${containerName}`, { stdio: 'ignore' }); } catch {}for (let i = 0; i < 30; i++) { try { execSync(`docker exec ${containerName} /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092`, { stdio: 'ignore' }); break; } catch { await new Promise((r) => setTimeout(r, 1000)); }}const hw = execSync(`docker exec ${containerName} /opt/kafka/bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic ${topic} --time -1`).toString().trim();console.log('high-watermark after restart:', hw);process.exit(0);Real output สองรอบแยกกันของสคริปต์เดียวกันนี้บน probe broker ของคอร์สเอง:
# run Aburst of 300: client-side fulfilled=0high-watermark after restart: acks0-burst:0:0
# run Bburst of 300: client-side fulfilled=299high-watermark after restart: acks0-burst:0:299ตัวแบ่งระหว่าง “resolve” กับ “reject” (และระหว่าง “หาย” กับ “ถึง”) ไม่ deterministic — ขึ้นกับว่า docker kill ตัด connection ไปได้ไกลแค่ไหนก่อนที่ byte ของแต่ละ send จะหลุดออกจากคิว batch ภายในของ client ความไม่ deterministic นี้เองคือหลักฐาน — acks=0 ไม่มีสัญญาณระดับ protocol ที่ผูก “send() เรียกเสร็จแล้ว” กับ “broker มี record นี้แบบทนทานแล้ว” run A แสดงว่า client เชื่อว่า 0 send สำเร็จ ขณะที่ broker มีจริง 0 เช่นกัน (บังเอิญตรงกัน) ส่วน run อื่น (เช่น B) แสดงว่าส่วนใหญ่ของ burst ทั้ง “สำเร็จ” ฝั่ง client และไปถึงจริง — แต่คุณบอกไม่ได้ว่ากำลังอยู่ใน regime ไหนจากภายใน application เอง โดยไม่มีการเช็คภายนอกแบบที่ใช้ kafka-get-offsets.sh ตรงนี้ producer ที่ใช้ acks=all ไม่มีความกำกวมแบบนี้เลย มันได้ offset จริงกลับมา หรือไม่ก็ error จริงที่ catch ได้
พิสูจน์: min.insync.replicas มากกว่า replication factor — สิ่งที่เจอจริง (ไม่ใช่สิ่งที่มักสันนิษฐานกัน)
หัวข้อที่มีชื่อว่า “พิสูจน์: min.insync.replicas มากกว่า replication factor — สิ่งที่เจอจริง (ไม่ใช่สิ่งที่มักสันนิษฐานกัน)”กฎที่พูดกันบ่อย ๆ คือ “ถ้า min.insync.replicas ตั้งสูงกว่า replication factor ทุก acks=all write จะพัง NotEnoughReplicasException” นั่นจริงก็ต่อเมื่อ shortfall เกิดบน topic ที่ replication factor อย่างน้อยเท่ากับ min.insync.replicas — พิสูจน์บน cluster 3-broker จริงด้วย live ISR shrink จริงใน Acks and Durability (module producers) ซึ่งจับข้อความ NotEnoughReplicasException จริงของ broker ได้ — “Messages are rejected since there are fewer in-sync replicas than required.” เราทดสอบกรณีที่ เป็นไปไม่ได้โดยโครงสร้าง ตรงนี้ — topic ที่สร้างด้วย replication-factor=1 และ min.insync.replicas=2 ดังนั้น ISR (ขนาด 1 คือ leader เอง) ไม่มีทางถึงขนาดที่ต้องการได้เลย — กับ broker Kafka 4.3.1 จริงของคอร์สนี้
/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic ${TOPIC_PREFIX}insync-check --partitions 1 --replication-factor 1/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP --alter --entity-type topics --entity-name ${TOPIC_PREFIX}insync-check --add-config min.insync.replicas=2/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --describe --under-min-isr-partitions | grep ${TOPIC_PREFIX}insync-checkecho "raw-cli-test" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic ${TOPIC_PREFIX}insync-check --producer-property acks=all --syncecho "console-producer exit code: $?"Real output:
Created topic insync-check.Completed updating config for topic insync-check. Topic: insync-check Partition: 0 Leader: 1 Replicas: 1 Isr: 1 Elr: LastKnownElr:console-producer exit code: 0write สำเร็จ — ไม่มี NotEnoughReplicasException ไม่มี exit code ที่ไม่ใช่ 0 — ทั้งที่ --under-min-isr-partitions ยืนยันว่า broker เองก็ flag partition นี้ว่า under-replicated (Isr: 1 ในขณะที่ต้องการ 2) เอกสาร kafka.apache.org อธิบาย exception ไว้สำหรับตอนที่ “จำนวน in-sync replica ตกลงมาต่ำกว่า threshold นี้” แต่ไม่ได้พูดถึงกรณีที่ replication factor ไม่มีทางถึง threshold ได้เลยตั้งแต่ต้นโดยเฉพาะ บน broker ตัวนี้ ในกรณีเฉพาะนี้ broker ยอมรับ write แทนที่จะบล็อกไปตลอดกาล เราทำซ้ำด้วย min.insync.replicas=5 (ห่างจาก RF=1 มากขึ้น) แล้วได้ผลเหมือนเดิม ทำไม: การบังคับใช้ min.insync.replicas อยู่ในโค้ดพาธที่รอ follower ตามให้ทันเท่านั้น — partition ที่ replication-factor เป็น 1 ไม่มี follower เลย จึงไม่มีขั้นตอน “รอ replica ให้พอ” ที่จะล้มเหลวได้ นี่คือกับดักที่ KIP-579 เสนอ InconsistentReplicationFactorException เฉพาะทางไว้แก้ — verify กับ broker Kafka 4.3.1 ตัวนี้แล้ว write ยังถูกยอมรับเงียบ ๆ อยู่จนถึงวันนี้ ยังไม่มี exception แบบนั้น ข้อสรุปเชิงปฏิบัติไม่ใช่ “broker จะป้องกันคุณจากการตั้งค่าผิดนี้” แต่คือ monitor --under-min-isr-partitions (หรือ JMX metric UnderMinIsrPartitionCount) โดยตรง
High-watermark: “committed” จริง ๆ แปลว่าอะไร
หัวข้อที่มีชื่อว่า “High-watermark: “committed” จริง ๆ แปลว่าอะไร”high-watermark (HW) คือ offset ที่ทุก ISR member replicate log ของ partition ไปถึงแล้ว — มันคือเส้นแบ่งระหว่าง “เขียนแล้ว” กับ “commit แบบปลอดภัยแล้ว” consumer (กับ write ที่ใช้ acks=all) ไม่มีวันอ่านเลยจุดนี้ไป และมันขยับได้ก็ต่อเมื่อ replica ที่ต้องการตามทันแล้วเท่านั้น acks=all + min.insync.replicas คือคันโยกฝั่ง producer ที่กำหนดว่าต้องมี replica กี่ตัวตามทันก่อนที่ HW — และการมองเห็นข้อมูล — จะขยับได้เลย กลไกการขยับของ HW (leader append → follower fetch → HW advance) เป็น internals ที่ module นี้ไม่ลงลึกซ้ำ ดู Replication & ISR สำหรับฝั่ง ISR และ module kafka-internals ที่จะมาในอนาคตสำหรับ produce-to-fetch pipeline แบบเต็ม
default ที่ควรออกแบบเผื่อไว้
หัวข้อที่มีชื่อว่า “default ที่ควรออกแบบเผื่อไว้”ระบบส่วนใหญ่รันแบบ at-least-once end-to-end คือ acks=all producer เป็น idempotent commit หลังประมวลผล แล้วทำให้ผลลัพธ์ปลายทาง idempotent เพื่อให้ duplicate ไม่เป็นอันตราย วิธีนี้เรียบง่าย ทนทาน และเร็ว และ — พิสูจน์ข้างบน — ปลอดภัยกว่า acks=0 อย่างมีนัยสำคัญ เพราะ acks=0 ไม่มีทางแม้แต่จะตรวจจับการหายที่มันทำให้เกิดขึ้นได้เลย หยิบ exactly-once เต็มรูปแบบ (transactions) มาใช้ก็ต่อเมื่อ pipeline อยู่ภายใน Kafka และ duplicate ยอมรับไม่ได้จริง ๆ เพราะเพิ่มต้นทุนการ coordinate ส่วนที่เหลือของ module นี้จะค่อย ๆ สร้างเส้นทาง exactly-once นั้นขึ้นมา
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”🐛 หา bug ในโค้ดนี้
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'payments', brokers: ['localhost:9092'] });const producer = kafka.producer();
await producer.connect();
// Fire-and-forget for maximum throughput on the payment-events stream.async function recordPaymentEvent(event: { orderId: string; amountCents: number }) {await producer.send({ topic: 'payment-events', acks: 0, messages: [{ key: event.orderId, value: JSON.stringify(event) }],});console.log('payment event recorded:', event.orderId);}เฉลย
โค้ดนี้ compile ผ่านและ “ทำงานได้” ในการทดสอบมือทุกครั้ง — promise resolve เสมอ log พิมพ์ “payment event recorded” เสมอ นั่นแหละคือกับดักที่พิสูจน์ไว้ข้างบน: response ของ acks=0 คือ array ว่างเปล่า ไม่มี offset ไม่มี error code ดังนั้น await producer.send(...) ที่ resolve บอกไม่ได้เลยว่า broker เคยเห็น record นี้หรือไม่ — พิสูจน์ข้างบน: run จริงของสคริปต์เดียวกันลงเอยตั้งแต่ 0 จนเกือบครบ โดย promise ฝั่ง client resolve สำเร็จทั้งคู่ สำหรับอะไรก็ตามที่ต้องกระทบยอดทีหลัง (payment event เข้าข่ายแน่นอน) ทางแก้คือ acks: -1 (ค่า default ของ KafkaJS — บั๊กของสนิปเป็ตนี้คือมัน opt-out ออกมาชัดเจน) คู่กับ idempotent: true เพื่อให้ retry หลัง failure ชั่วคราวไม่สร้าง duplicate แทนที่จะเป็นการหาย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”acks ไม่ใช่ปุ่มปรับความเร็วที่มีผลข้างเคียงเรื่องความทนทาน — มันคือ สัญญาการยืนยัน acks=0 ไม่สัญญาอะไรกับ client เลย ดังนั้น “promise resolve แล้ว” ไม่มีข้อมูลเรื่องการส่งถึงเลยแม้แต่น้อย acks=1 สัญญาว่า “leader มีแล้ว” (รอดทุกอย่างยกเว้น leader ตายก่อน replicate) acks=all สัญญาว่า “ทุก replica ที่ต้องการมีแล้ว” และ min.insync.replicas คือตัวเลขที่นิยาม “ต้องการ” อ่านทุกบั๊กที่เกี่ยวกับ acks ผ่านมุมมองนี้ คำถามไม่เคยเป็น “call สำเร็จไหม” แต่เป็น “ความสำเร็จนั้นสัญญาอะไรจริง ๆ” และ — พิสูจน์ข้างบน — คำสัญญานั้นอาจเป็นความว่างเปล่าล้วน ๆ ก็ได้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”acks=0— producer ที่เร็วที่สุด และพิสูจน์ข้างบนว่าไม่ให้ข้อมูลการส่งถึงเลย เหมาะกับข้อมูลที่ตัดสินใจแล้วว่าทิ้งได้ (best-effort metrics) เท่านั้น ไม่เหมาะกับอะไรก็ตามที่ต้องกระทบยอดทีหลังacks=1— จุดกึ่งกลาง: ทนทานทุกอย่างยกเว้น “leader ตายก่อน follower ไหนจะ replicate write ทัน” ช่วงเสี่ยงที่แคบแต่มีจริงacks=all+min.insync.replicas— guarantee ที่แข็งแรงที่สุด แลกกับการรอ replication ทุก write พิสูจน์ข้างบนว่าmin.insync.replicasที่ตั้งผิด (สูงกว่า replication factor จริง) ไม่ได้ป้องกันคุณเสมอไปแบบที่เอกสารสำหรับกรณี live-shrink บอกเป็นนัย ยังต้อง monitor เองโดยตรง- Exactly-once (transactions) — ลบช่องว่างขนาด crash ระหว่างการ produce กับการ commit offset ออกไปทั้งหมด (พิสูจน์ใน Exactly-Once Processing) แลกกับต้นทุนการ coordinate จริงที่ module นี้จะวัดให้เห็น
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
acks=0ก็แค่acks=1แต่เร็วกว่า มีโอกาสหายนิดหน่อย” พิสูจน์ข้างบนว่าต่างกันโดยสิ้นเชิง:acks=1/acks=allอย่างน้อยก็บอกคุณเมื่อมีอะไรผิดพลาด (error, retry, write ที่ถูกปฏิเสธ)acks=0ไม่บอกอะไรเลยไม่ว่าจะสำเร็จหรือล้มเหลว หน้าตาเหมือนกันหมดจากฝั่งผู้เรียก - “ตั้ง
min.insync.replicasสูงกว่า replication factor เป็น safety net — broker จะป้องกันฉันด้วยการปฏิเสธ write” พิสูจน์ข้างบนว่าไม่จริงบน broker จริงตัวนี้: write สำเร็จทั้งที่ partition ถูก flag ว่า under-min-isr safety net จริง ๆ คือการ monitor--under-min-isr-partitions/ JMX metric ไม่ใช่การสันนิษฐานว่ามันจะถูกปฏิเสธ - “high-watermark ก็แค่ ‘offset ล่าสุด’” มันคือเส้นแบ่งที่ consumer ซึ่งอ่านข้อมูลที่ commit แล้วไม่มีวันข้ามไปโดยเฉพาะ มันขยับได้ก็ต่อเมื่อ replica ที่
min.insync.replicas/acks=allต้องการตามทันแล้วเท่านั้น ซึ่งเป็นเรื่องเดียวกับที่acksเล่าจากอีกมุมพอดี - “guarantee แบบ end-to-end ก็แค่ตัวที่อ่อนกว่า ไม่ว่าจะ producer หรือ consumer — ดังนั้นทำให้ถูกแค่ฝั่งเดียวก็พอ” มันรวมกัน ไม่ได้ override กัน:
acks=allที่มีจังหวะ commit ฝั่ง consumer วางผิดที่ยังคงเป็น at-most-once แบบ end-to-end (ข้อมูลหายฝั่ง consumer) ตารางข้างบนถึงมีไว้ — คุณต้องเช็คทั้งสองฝั่ง