Acks กับ Durability
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ค่า acks เป็นตัวตัดสินว่า ต้องมี replica กี่ตัวยืนยันก่อน Kafka จะถือว่า write นั้น durable — เป็นการแลก latency กับการรับประกันว่า record จะรอดแม้ broker ล่ม
สามระดับของ acks
หัวข้อที่มีชื่อว่า “สามระดับของ acks”ทุก partition มี replica ที่เป็น leader หนึ่งตัว และ follower อีกศูนย์ตัวขึ้นไป ค่า acks ควบคุมว่าต้องมีกี่ตัว acknowledge การ write ก่อน producer จะถือว่า send() สำเร็จ สามค่านี้ — 0, 1 และ all — คือชื่อมาตรฐานที่ใช้ทั่วทุก client ของ Kafka และใน official docs
acks=0— fire-and-forget producer ไม่รอ acknowledge ใด ๆ ถือว่าส่งแล้วทันทีที่ record ออกจาก client latency ต่ำสุด แต่ record หายเงียบ ๆ ได้ถ้า leader ล่มacks=1— leader เท่านั้น leader เขียน record ลง log ของตัวเองแล้วตอบกลับ เร็ว แต่ถ้า leader crash ก่อน ที่ follower จะ replicate record นั้น record จะหายacks=all(มีค่าเท่ากับacks=-1) — ทุก in-sync replica ต้องยืนยัน record ปลอดภัยตราบใดที่ยังมี in-sync replica เหลืออยู่หนึ่งตัว durability สูงสุด latency สูงสุด
# Fire-and-forget: fastest, weakest guaranteeacks=0
# Leader only: fast, but unreplicated writes can be lost on leader failureacks=1
# All in-sync replicas must confirm: strongest durabilityacks=allใน KafkaJS acks ไม่ใช่ property ระดับ producer แต่เป็น option ต่อการ send แต่ละครั้ง ที่คุณส่งให้ send() โดยใช้สามค่าเดิม:
// -1 = all (the default), 1 = leader only, 0 = fire-and-forgetawait producer.send({ topic: 'orders', acks: -1, messages })“committed” หมายถึงอะไรจริง ๆ: high watermark
หัวข้อที่มีชื่อว่า ““committed” หมายถึงอะไรจริง ๆ: high watermark”acks=all ที่รอ “ทุก in-sync replica” จริง ๆ แล้วคือการรอให้ high watermark (HW) ของ partition เลื่อนผ่าน offset ของ record คุณ HW คือ offset ที่ ทุก in-sync replica replicate ไปถึงแล้ว — เป็นเส้นแบ่งระหว่าง “เขียนแล้ว” กับ “committed”: consumer ที่อ่านด้วย isolation level แบบ default จะเห็นแค่ record ที่ไม่เกิน HW เท่านั้น และมีแค่ record ที่ offset ไม่เกิน HW เท่านั้นที่รอดผ่านการเลือก leader ใหม่โดยไม่เปลี่ยนแปลง Replication and ISR ครอบคลุมว่า HW เลื่อนอย่างไรและ ISR set ที่หนุนมันหดและขยายยังไงแบบเต็ม ๆ บทนี้ต้องการแค่เส้นแบ่ง: acks=all คือคำสัญญาเกี่ยวกับ HW และ min.insync.replicas คือสิ่งที่ทำให้คำสัญญานั้นบังคับใช้ได้จริง ไม่ใช่แค่ความหวัง — สองหัวข้อถัดไปพิสูจน์ทั้งการบังคับใช้และช่องว่างจริงในนั้น
acks=all ต้องมี min.insync.replicas ถึงจะมีความหมาย
หัวข้อที่มีชื่อว่า “acks=all ต้องมี min.insync.replicas ถึงจะมีความหมาย”acks=all ลำพังหมายถึง “ทุก replica ที่ in-sync อยู่ตอนนั้น” ถ้าตอนนั้นมีแค่ leader ที่ in-sync คำว่า “all” ก็คือ leader ตัวเดียว — เท่ากับกลับไปเป็น acks=1 ค่าฝั่ง broker min.insync.replicas ปิดช่องนี้ โดยตั้ง จำนวนขั้นต่ำ ของ in-sync replica ที่ต้องมีอยู่ถึงจะรับ write ได้เลย
const producer = kafka.producer({ idempotent: true }) // implies acks: -1 internally
await producer.send({ topic: 'orders', // a topic configured with min.insync.replicas=2 (broker/topic setting) messages: [{ key: 'order-42', value: JSON.stringify({ amount: 19.99 }) }],})Edge case ที่จริงและน่าประหลาดใจ: min.insync.replicas > replication factor ไม่ทำให้ write ถูกปฏิเสธ
หัวข้อที่มีชื่อว่า “Edge case ที่จริงและน่าประหลาดใจ: min.insync.replicas > replication factor ไม่ทำให้ write ถูกปฏิเสธ”คำกล่าวอ้างตามตำราคือ “min.insync.replicas ที่มากกว่าที่มีอยู่จริงจะทำให้ broker ปฏิเสธการ write” นั่นเป็นจริงเมื่อการขาดแคลนเกิดบน topic ที่ replication factor อย่างน้อยเท่ากับ min.insync.replicas (พิสูจน์ในหัวข้อถัดไป บน cluster 3 broker จริง) แต่ ไม่จริง — ยืนยันตรง ๆ ซ้ำแล้วซ้ำเล่ากับ broker จริง — เมื่อ replication factor ของ topic เอง ต่ำกว่า min.insync.replicas อยู่แล้ว เช่นบน cluster single-broker:
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" --create --if-not-exists \ --topic orders-durable --partitions 1 --replication-factor 1 --config min.insync.replicas=2/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" --describe --topic orders-durable
echo "=== under-min-isr-partitions (the broker DOES flag it) ==="/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" --describe --under-min-isr-partitions
echo "=== produce with acks=all anyway ==="echo "test-message" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server "$BOOTSTRAP" \ --topic orders-durable --producer-property acks=allecho "console-producer exit code: $?"ผลลัพธ์จริง ไม่ได้แก้ไขอะไรเลย — ReplicationFactor: 1, Isr: 1 (ขนาด 1 ต่ำกว่า min.insync.replicas=2), partition นี้ ถูก แสดงใน --under-min-isr-partitions (broker รู้) และ write ก็ยังสำเร็จ:
Topic: orders-durable TopicId: hXfSTO0GQQuNwexIyVVZCg PartitionCount: 1 ReplicationFactor: 1 Configs: min.insync.replicas=2 Topic: orders-durable Partition: 0 Leader: 1 Replicas: 1 Isr: 1 Elr: LastKnownElr:
=== under-min-isr-partitions (the broker DOES flag it) === Topic: orders-durable Partition: 0 Leader: 1 Replicas: 1 Isr: 1 Elr: LastKnownElr:
=== produce with acks=all anyway ===console-producer exit code: 0NotEnoughReplicasException จริง — พิสูจน์บน ISR ที่หดตัวจริง
หัวข้อที่มีชื่อว่า “NotEnoughReplicasException จริง — พิสูจน์บน ISR ที่หดตัวจริง”การบังคับใช้ ทำงานจริง — พิสูจน์บน cluster 3-broker จริง (replication.factor=3, min.insync.replicas=3, compose 3-broker ชั่วคราวของคอร์สนี้เอง ที่ถูกปิดทันทีหลังพิสูจน์เสร็จ) write หนึ่งครั้งสำเร็จตอนทั้งสาม replica in-sync ครบ จากนั้น follower ตัวหนึ่งถูกหยุด (docker stop) ทำให้ ISR หดเหลือ 2 (ต่ำกว่า min.insync.replicas=3) ในขณะที่ controller quorum เสียงข้างมาก 2-ใน-3 ยังแข็งแรงอยู่:
# kafka-topics.sh --describe --topic orders-rf3, after stopping one follower:Topic: orders-rf3 PartitionCount: 1 ReplicationFactor: 3 Configs: min.insync.replicas=3 Topic: orders-rf3 Partition: 0 Leader: 2 Replicas: 2,3,1 Isr: 2,3 Elr: 1 LastKnownElr:จากนั้น KafkaJS producer ส่งไปยัง partition นั้นด้วย acks: -1:
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'acks-rf3-probe', brokers: ['localhost:9092'], requestTimeout: 8000 });
async function main() { const producer = kafka.producer({ retry: { retries: 2, initialRetryTime: 300, maxRetryTime: 1000 } }); await producer.connect(); try { const res = await producer.send({ topic: 'orders-rf3', acks: -1, timeout: 5000, messages: [{ key: 'k', value: 'after-kill-kafkajs' }], }); console.log('unexpected success:', JSON.stringify(res)); } catch (err: any) { console.log('send() threw:', err.name, '-', err.message); console.log('retriable:', err.retriable); } await producer.disconnect();}
main();ผลลัพธ์จริง ไม่ได้แก้ไขอะไรเลย — ข้อความ error จริงจาก broker, exception ที่ KafkaJS ห่อไว้ และ retriable: false หลังงบ retry หมด:
{"level":"ERROR", ..., "error":"Messages are rejected since there are fewer in-sync replicas than required", "correlationId":2, ...}{"level":"ERROR","message":"[Producer] Failed to send messages: Messages are rejected since there are fewer in-sync replicas than required","retryCount":0,"retryTime":307}{"level":"ERROR","message":"[Producer] Failed to send messages: Messages are rejected since there are fewer in-sync replicas than required","retryCount":1,"retryTime":520}{"level":"ERROR","message":"[Producer] Failed to send messages: Messages are rejected since there are fewer in-sync replicas than required","retryCount":2,"retryTime":1000}send() threw: KafkaJSNumberOfRetriesExceeded - Messages are rejected since there are fewer in-sync replicas than requiredretriable: false“Messages are rejected since there are fewer in-sync replicas than required” คือข้อความ NotEnoughReplicasException ตัวจริงของ broker เอง ตรงตัวเป๊ะ KafkaJS retry ไป 3 ครั้ง (แต่ละครั้งเจอ error เดิมเป๊ะ เพราะ follower ยังไม่กลับมา) แล้วยอมแพ้ด้วย KafkaJSNumberOfRetriesExceeded, retriable: false — นี่คือความล้มเหลวจากการตั้งค่าจริง ๆ ไม่ใช่แค่ชั่วคราว และ flag retriable ของ KafkaJS เองก็บอกถูกต้องแบบนั้นเมื่องบ retry หมดแล้ว
flowchart LR
p["Producer send()"] --> a0["acks=0: no wait, lowest latency"]
p --> a1["acks=1: leader writes and replies"]
p --> aa["acks=all: all in-sync replicas confirm"]
aa --> misr{"'min.insync.replicas' met?"}
misr -- "yes, RF >= minIsr" --> safe["Durable: survives a broker failure"]
misr -- "ISR short, RF >= minIsr" --> reject["Real NotEnoughReplicasException"]
misr -- "RF < minIsr (e.g. single broker)" --> trap["Silent trap: write still succeeds, proven above"] Retry ของ producer และเส้นตายของ durability: delivery.timeout.ms เทียบกับ retry object ของ KafkaJS
หัวข้อที่มีชื่อว่า “Retry ของ producer และเส้นตายของ durability: delivery.timeout.ms เทียบกับ retry object ของ KafkaJS”ความล้มเหลวแบบ NotEnoughReplicasException คือสิ่งที่งบ retry มีไว้เพื่อทนผ่านพอดี — follower สะดุดสั้น ๆ, broker restart ระหว่าง rolling upgrade Java client (และ native librdkafka surface ของ @confluentinc/kafka-javascript ซึ่งใช้ config เดียวกันนี้) คุมทั้งหน้าต่าง retry ด้วยเส้นตายเดียว delivery.timeout.ms (ค่า default 120000 ที่บทก่อนครอบคลุมไปแล้ว): พอเลยเส้นตายนั้น การ send จะ fail ถาวร ไม่ว่าจะ retriable หรือไม่
KafkaJS ไม่มี property เส้นตายเดี่ยว ๆ แต่ object retry ระดับ client คุม retry ด้วย จำนวนครั้งและ backoff แทนที่จะเป็นเส้นตายตามนาฬิกา:
const kafka = new Kafka({ clientId: 'orders-app', brokers: ['localhost:9092'], retry: { retries: 5, // give up after 5 attempts (not after N milliseconds) initialRetryTime: 300, maxRetryTime: 30000, },});การพิสูจน์ด้านบนใช้แบบนี้เป๊ะ: retries: 2 หมายถึงสามครั้งรวม (หนึ่งครั้งบวกสอง retry) กับ cluster ที่เสียถาวร แต่ละครั้งถูก log เป็น retryCount: 0, 1, 2 พร้อม retryTime ที่โตขึ้นตาม backoff ของ KafkaJS เอง — แล้วจบด้วย KafkaJSNumberOfRetriesExceeded ไม่ใช่ timeout ผลลัพธ์เชิงปฏิบัติ: การตั้ง retry policy ของ KafkaJS สำหรับ durability คือการเลือกจำนวน retries และ maxRetryTime ที่รวมกัน ประมาณ เวลารวมที่คุณยอมรอ แทนที่จะตั้งตัวเลขเดียวแบบที่ delivery.timeout.ms ทำได้
Tradeoff แบบตรงไปตรงมา
หัวข้อที่มีชื่อว่า “Tradeoff แบบตรงไปตรงมา”durability ไม่มีของฟรี ทุกระดับ acks ที่แข็งขึ้นเพิ่ม network round trip และรอเครื่องมากขึ้น latency จึงสูงขึ้นตามความแข็งของการรับประกัน เลือกตามต้นทุนของการเสีย record: metrics และ log มักอยู่กับ acks=1 ได้ แต่ order, payment และอะไรที่สร้างใหม่ไม่ได้ควรใช้ acks=all คู่กับ min.insync.replicas ที่ตั้งไว้ และตรวจสอบแล้วว่าไม่เกิน replication factor จริงของ topic — พิสูจน์แล้วด้านบนว่า broker จะไม่จับการตั้งค่าผิดแบบนี้ให้คุณ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”🐛 หา bug ในโค้ดนี้
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'ops-tool', brokers: ['localhost:9092'] });
// AI comment: "payments need the strongest durability — belt and suspenders"async function createPaymentsTopic() {const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic: 'payments', numPartitions: 1, replicationFactor: 1, // "single broker for now, will scale replicas later" configEntries: [{ name: 'min.insync.replicas', value: '2' }], // "require 2 replicas to confirm" }],});await admin.disconnect();}เฉลย
comment อ่านดูเหมือนโค้ด infrastructure ที่ระมัดระวังเรื่องความปลอดภัย — durability แบบ “belt and suspenders” ตั้ง min.insync.replicas ชัดเจน บั๊กจริง พิสูจน์แล้วก่อนหน้านี้ในบทนี้บน shape การตั้งค่าผิดแบบเดียวกันเป๊ะ: replicationFactor: 1 กับ min.insync.replicas: 2 ไม่ได้เพิ่ม durability เลย — มันสร้าง partition ที่ under-replicated ถาวร (เห็นได้ตลอดไปใน kafka-topics.sh --describe --under-min-isr-partitions) ในขณะที่ทุก write แบบ acks=all สำเร็จเงียบ ๆ อยู่ดี เพราะไม่มี follower ให้ min-ISR check รอเลย topic payments ที่ตั้งแบบนี้มี durability เท่ากับ replicationFactor: 1 เป๊ะ — broker ล่มตัวเดียวก็เสียข้อมูลที่ยังไม่ flush — ในขณะที่ config อ่านเหมือนกำลังเรียกร้องการยืนยันจากสอง replica วิธีแก้คือทำให้ replication factor กับ min.insync.replicas สอดคล้องกัน และเพิ่ม broker จริงตามที่ comment ผัดไว้:
await admin.createTopics({ topics: [{ topic: 'payments', numPartitions: 1, replicationFactor: 3, // <-- must actually be >= min.insync.replicas to mean anything configEntries: [{ name: 'min.insync.replicas', value: '2' }], }],});Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”acks กับ min.insync.replicas คือคำสัญญาสองอันที่แยกจากกัน จะรวมกันเป็น durability ได้ก็ต่อเมื่อถูกเช็กกับ ตัวเลขเดียวกัน: acks=all สัญญาว่า “ฉันจะรอไม่ว่า in-sync set ตอนนั้นจะเป็นอะไรก็ตาม” และ min.insync.replicas สัญญาว่า “broker จะไม่ยอมให้ set นั้นเล็กเกินไป” — แต่คำสัญญาที่สองบังคับใช้ได้แค่เท่าที่ replica set โตได้จริงในทางกายภาพ ซึ่งก็คือ replication.factor มองว่า min.insync.replicas เป็นข้อเรียกร้องที่มีความหมายก็ต่อเมื่อคุณเช็กด้วยว่า replication.factor >= min.insync.replicas สำหรับ topic นั้นเป๊ะ ๆ แล้วเท่านั้น — broker ยินดีให้คุณตั้งค่า (และรันจริงในโปรดักชันแบบเงียบ ๆ) topic ที่เงื่อนไขนั้นเป็นเท็จได้เฉย ๆ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”acks=0— latency ต่ำสุด ไม่มีการรับประกัน durability เลย เหมาะกับ metrics/telemetry ที่ยอมเสียได้acks=1— latency หนึ่ง network hop durable กับทุกอย่างยกเว้น “leader ตายพร้อม record ที่ยังไม่ replicate” — หน้าต่างเสี่ยงจริง แม้จะแคบacks=all+min.insync.replicas <= replication.factor— combination เดียวที่พิสูจน์แล้วด้านบนว่าปฏิเสธ write ที่ไม่ปลอดภัยได้จริง latency สูงสุด การรับประกันจริงแข็งที่สุดacks=all+min.insync.replicas > replication.factor— อ่านเหมือนค่าที่แข็งที่สุด แต่พิสูจน์แล้วด้านบนว่า ไม่แข็งไปกว่า replicationFactor เฉย ๆ เลยแบบเงียบ ๆ โดยมีแค่ flag monitoring ถาวรที่พลาดง่ายเป็นอาการเดียว- retry policy แบบนับจำนวน/backoff ของ KafkaJS — ให้เหตุผลง่ายกว่าต่อครั้ง แต่ไม่มีปุ่ม “งบเวลารวม” เดียวแบบที่
delivery.timeout.msให้ การตั้งให้ตรงกับ SLA จริงต้องคำนวณนิดหน่อย (retriesคูณการโตของretryTimeเฉลี่ย)
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ตั้ง
min.insync.replicasสูงขึ้นทำให้ topic durable ขึ้นเสมอ” ไม่ — พิสูจน์ตรง ๆ ด้านบนแล้ว: บน topic ที่replication.factorต่ำกว่าตัวเลขนั้นอยู่แล้ว มันไม่ทำอะไรเลยตอน write มีแค่--under-min-isr-partitionsเท่านั้นที่จะโชว์มัน - “การตั้งค่าผิดแบบ
NotEnoughReplicasExceptionจะ throw ตอน produce เสมอ” ไม่ — ขึ้นอยู่ทั้งหมดว่าการขาดแคลนนั้นเป็น ISR หดตัวชั่วคราว บน topic ที่ replicate เพียงพออยู่แล้ว (throw จริง พิสูจน์แล้วด้านบน) หรือเป็นการขาดแคลนเชิงโครงสร้างที่ replication factor เองไม่มีทางตอบสนองmin.insync.replicasได้เลย (สำเร็จเงียบ ๆ พิสูจน์แล้วด้านบน) - “
acks=allคือคำสัญญาว่า replica N ตัวตาม replication factor ที่ตั้งไว้ยืนยัน write แล้ว” ไม่ — มันคือคำสัญญาว่าอะไรก็ตามที่ อยู่ใน ISR ตอนนั้น ยืนยันแล้วmin.insync.replicasคือสิ่งเดียวที่ตั้งพื้นให้ว่า set ตอนนั้นเล็กได้แค่ไหน - “
retry.retriesของ KafkaJS กับdelivery.timeout.msของ Java client เป็นแค่ชื่อสองชื่อของ setting เดียวกัน” ไม่ — อันหนึ่งคือ จำนวนครั้ง ที่มี backoff schedule ของตัวเอง อีกอันคือ เส้นตายตามนาฬิกาเดียว ที่ครอบคลุมวงจรชีวิตการ send ทั้งหมด ทั้งสองเทียบเท่ากันแค่โดยประมาณ และการแปลงจากอันหนึ่งไปอีกอันต้องคำนวณจริง