ข้ามไปยังเนื้อหา

End-to-End Delivery Guarantees

guarantee แบบ end-to-end แข็งแรงได้เท่ากับจุดที่อ่อนที่สุดของตัวเองเท่านั้น — ฝั่ง producer (acks กับ min.insync.replicas) และฝั่ง consumer (จังหวะ commit ที่พูดถึงในmodule consumers) ต่างก็มีส่วน และคุณต้องคิดถึงทั้งสองฝั่ง พร้อมกัน

delivery semantics ไม่ใช่สวิตช์ตัวเดียว data ไหลจาก producer → log → consumer และแต่ละ hop มี failure mode ของตัวเอง

  • Producer → log ด้วย acks=all และ enable.idempotence=true record ถูกเขียนแบบทนทานและ 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 จริง ๆ

ProducerConsumer commitEnd-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 producercommit 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"]
แต่ละ hop มีส่วนใน guarantee แบบ end-to-end

พิสูจน์: acks=0 ไม่ยืนยันอะไรเลย และนั่นแหละคือสาเหตุที่ทำข้อมูลหาย

หัวข้อที่มีชื่อว่า “พิสูจน์: acks=0 ไม่ยืนยันอะไรเลย และนั่นแหละคือสาเหตุที่ทำข้อมูลหาย”

acks=0 แปลว่า producer ไม่รอ response จาก broker เลย เทียบ response ที่ KafkaJS ส่งกลับมาระหว่าง acks=0 กับ acks=-1 (all) บน broker เดียวกัน

src/delivery-guarantees/acks-comparison.ts
// @run
import { 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 ไปแล้วจริง ๆ

src/delivery-guarantees/acks0-burst-loss.ts
// @run
import { 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 A
burst of 300: client-side fulfilled=0
high-watermark after restart: acks0-burst:0:0
# run B
burst of 300: client-side fulfilled=299
high-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 จริงของคอร์สนี้

scripts/delivery-guarantees/insync-check.sh
/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-check
echo "raw-cli-test" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic ${TOPIC_PREFIX}insync-check --producer-property acks=all --sync
echo "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: 0

write สำเร็จ — ไม่มี 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 (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 แบบเต็ม

ระบบส่วนใหญ่รันแบบ 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 นั้นขึ้นมา

🐛 หา bug ในโค้ดนี้

src/delivery-guarantees/payment-producer.ts
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 แทนที่จะเป็นการหาย

acks ไม่ใช่ปุ่มปรับความเร็วที่มีผลข้างเคียงเรื่องความทนทาน — มันคือ สัญญาการยืนยัน acks=0 ไม่สัญญาอะไรกับ client เลย ดังนั้น “promise resolve แล้ว” ไม่มีข้อมูลเรื่องการส่งถึงเลยแม้แต่น้อย acks=1 สัญญาว่า “leader มีแล้ว” (รอดทุกอย่างยกเว้น leader ตายก่อน replicate) acks=all สัญญาว่า “ทุก replica ที่ต้องการมีแล้ว” และ min.insync.replicas คือตัวเลขที่นิยาม “ต้องการ” อ่านทุกบั๊กที่เกี่ยวกับ acks ผ่านมุมมองนี้ คำถามไม่เคยเป็น “call สำเร็จไหม” แต่เป็น “ความสำเร็จนั้นสัญญาอะไรจริง ๆ” และ — พิสูจน์ข้างบน — คำสัญญานั้นอาจเป็นความว่างเปล่าล้วน ๆ ก็ได้

  • 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) ตารางข้างบนถึงมีไว้ — คุณต้องเช็คทั้งสองฝั่ง
การเทียบ response จริงข้างบนแสดงอะไรระหว่าง acks=0 กับ acks=-1 (all)
ใน burst proof 300 record เกิดอะไรขึ้นตอน kill broker ทันทีหลังคิว send เสร็จ
การทดสอบจริงของ min.insync.replicas=2 / replication-factor=1 แสดงผลอะไร และเทียบกับความเชื่อทั่วไปยังไง
finding เรื่อง shortfall โครงสร้างของบทนี้เกี่ยวข้องกับ NotEnoughReplicasException จริงของ Acks and Durability ยังไง
high-watermark คือเส้นแบ่งของอะไร ในแง่ของ acks กับ min.insync.replicas
payment producer (ดู SpotTheBug ข้างบน) ตั้ง acks: 0 เพื่อ "throughput สูงสุด" ความเสี่ยงจริงที่พิสูจน์แล้วคืออะไร