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

Acks กับ Durability

ค่า acks เป็นตัวตัดสินว่า ต้องมี replica กี่ตัวยืนยันก่อน Kafka จะถือว่า write นั้น durable — เป็นการแลก latency กับการรับประกันว่า record จะรอดแม้ broker ล่ม

ทุก 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 guarantee
acks=0
# Leader only: fast, but unreplicated writes can be lost on leader failure
acks=1
# All in-sync replicas must confirm: strongest durability
acks=all

ใน KafkaJS acks ไม่ใช่ property ระดับ producer แต่เป็น option ต่อการ send แต่ละครั้ง ที่คุณส่งให้ send() โดยใช้สามค่าเดิม:

// -1 = all (the default), 1 = leader only, 0 = fire-and-forget
await producer.send({ topic: 'orders', acks: -1, messages })

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 ลำพังหมายถึง “ทุก 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:

scripts/acks-and-durability/rf1-silent-success.sh
/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=all
echo "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: 0

การบังคับใช้ ทำงานจริง — พิสูจน์บน 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:

src/acks-and-durability/rf3-not-enough-replicas.ts
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 required
retriable: 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"]
Durability ไต่จาก acks=0 ไปถึง acks=all พร้อม min.insync.replicas — และช่องว่างจริงหนึ่งจุด

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 ทำได้

durability ไม่มีของฟรี ทุกระดับ acks ที่แข็งขึ้นเพิ่ม network round trip และรอเครื่องมากขึ้น latency จึงสูงขึ้นตามความแข็งของการรับประกัน เลือกตามต้นทุนของการเสีย record: metrics และ log มักอยู่กับ acks=1 ได้ แต่ order, payment และอะไรที่สร้างใหม่ไม่ได้ควรใช้ acks=all คู่กับ min.insync.replicas ที่ตั้งไว้ และตรวจสอบแล้วว่าไม่เกิน replication factor จริงของ topic — พิสูจน์แล้วด้านบนว่า broker จะไม่จับการตั้งค่าผิดแบบนี้ให้คุณ

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

src/acks-and-durability/payments-topic-setup.ts
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' }],
}],
});

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 ที่เงื่อนไขนั้นเป็นเท็จได้เฉย ๆ

  • 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 ทั้งหมด ทั้งสองเทียบเท่ากันแค่โดยประมาณ และการแปลงจากอันหนึ่งไปอีกอันต้องคำนวณจริง
acks=all รับประกันอะไรที่ต่างจาก acks=1
ทำไม acks=all ต้องมี min.insync.replicas ถึงจะมีความหมาย
บน cluster 3-broker จริง (replication.factor=3, min.insync.replicas=3) หลังหยุด follower หนึ่งตัวจน ISR หดเหลือ 2 การ send แบบ acks=-1 ของ KafkaJS ให้ผลอะไรจริง ๆ
บน topic single-broker ที่สร้างด้วย replicationFactor: 1 และ min.insync.replicas: 2 บทนี้พิสูจน์ว่าอะไรเกิดขึ้นจริงตอน write แบบ acks=all
ความต่างเชิงปฏิบัติระหว่าง delivery.timeout.ms ของ Java/librdkafka กับ retry object ของ KafkaJS ในการจำกัดว่า send ที่ durability สำคัญจะ retry ได้นานแค่ไหนคืออะไร
tradeoff หลักเมื่อเพิ่มระดับ acks คืออะไร