Partitions & Ordering
แนวคิดในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “แนวคิดในหนึ่งประโยค”partition คือหน่วยของทั้ง parallelism และ ordering ใน Kafka — ยิ่งมี partition มากยิ่งได้ throughput มากขึ้น แต่ Kafka รับประกัน order แค่ ภายใน partition เดียวเท่านั้น — ไม่เคยรับประกันข้าม topic ทั้งหมด
การรับประกันหนึ่งอย่าง ในขอบเขตเดียว
หัวข้อที่มีชื่อว่า “การรับประกันหนึ่งอย่าง ในขอบเขตเดียว”Kafka ให้การรับประกัน ordering ที่แข็งแรง แต่ขอบเขตของมันคือ partition เดียวเท่านั้น ภายใน orders-0 offset 5 จะตามหลัง offset 4 เสมอ ตลอดไป แต่ข้าม partition ไม่มี order ที่กำหนดไว้เลย: offset 5 ของ orders-0 กับ offset 5 ของ orders-1 อาจถูกเขียนมาในลำดับเวลาจริงแบบไหนก็ได้ และ consumer อาจอ่านมันในลำดับไหนก็ได้
orders-0: [a0][a1][a2][a3] ← totally orderedorders-1: [b0][b1][b2] ← totally ordered ← a2 vs b1: NO defined orderนี่ไม่ใช่ข้อจำกัดที่ต้องหาทางเลี่ยง แต่เป็นราคาที่ต้องจ่ายเพื่อขยายสเกลแนวนอน log ที่เรียงลำดับสมบูรณ์เพียงตัวเดียวไม่สามารถเขียนได้เร็วกว่าที่เครื่องเดียวจะ append ได้ การแบ่งเป็น partition ทำให้หลายเครื่อง append พร้อมกันได้ — คุณแลก global order กับ throughput
พิสูจน์: เขียนแบบ interleave จริง แต่อ่านไม่ interleave เลย
หัวข้อที่มีชื่อว่า “พิสูจน์: เขียนแบบ interleave จริง แต่อ่านไม่ interleave เลย”เพื่อทำให้ “ไม่มี order ที่กำหนดไว้ข้าม partition” เป็นรูปธรรมแทนที่จะเป็นแค่ทฤษฎี สคริปต์นี้ส่งไปสอง partition แบบ interleave ตามลำดับเวลาจริง (a0, b0, a1, b1, a2, b2, a3, b3) แล้ว consume ทั้งหมดกลับมา:
// @runimport { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}ordering-interleave`;
const kafka = new Kafka({ clientId: 'ordering-interleave', brokers });const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 3 }] }).catch(() => {});await admin.disconnect();
const producer = kafka.producer();await producer.connect();// Send order in real time: a0, b0, a1, b1, a2, b2, a3, b3 -- genuinely interleaved.for (let i = 0; i < 4; i++) { await producer.send({ topic, messages: [{ partition: 0, value: `a${i}` }] }); await producer.send({ topic, messages: [{ partition: 1, value: `b${i}` }] });}await producer.disconnect();
const consumer = kafka.consumer({ groupId: `${prefix}ordering-interleave-group` });await consumer.connect();await consumer.subscribe({ topic, fromBeginning: true });const seen: string[] = [];await new Promise<void>((resolve) => { consumer.run({ eachMessage: async ({ partition, message }) => { seen.push(`p${partition}:${message.value!.toString()}(offset ${message.offset})`); if (seen.length === 8) resolve(); }, }); setTimeout(resolve, 8000);});await consumer.disconnect();console.log('consumed order (as delivered to the app):');console.log(seen.join('\n'));process.exit(0);ผลจริงจากสคริปต์นี้เป๊ะ ๆ รันกับ broker Kafka 4.3.1:
consumed order (as delivered to the app):p1:b0(offset 0)p1:b1(offset 1)p1:b2(offset 2)p1:b3(offset 3)p0:a0(offset 0)p0:a1(offset 1)p0:a2(offset 2)p0:a3(offset 3)ภายใน p1: b0, b1, b2, b3 — เรียงตามที่เขียนจริงเป๊ะ ทุกครั้ง ภายใน p0: a0, a1, a2, a3 — เหมือนกัน แต่สอง log นี้ถูกเขียนแบบ interleave (a0, b0, a1, b1, ...) และ consumer กลับส่งมอบ p1 มาทั้งหมดก่อน p0 ทั้งหมด — ตรงข้ามกับ interleave เลย นี่ไม่ใช่บั๊ก: ไม่มีอะไรใน protocol รับประกันความสัมพันธ์ระหว่างลำดับเวลาการเขียนข้าม partition กับลำดับที่ consumer fetch/ส่งมอบ order ภายใน partition คือการรับประกันเดียวที่มี ส่วน order ข้าม partition ไม่ได้กำหนดไว้ และอย่างที่เห็นข้างต้น อาจดูไม่เหมือนเวลาจริงเลย
Partition กำหนดเพดาน parallelism ของ consumer
หัวข้อที่มีชื่อว่า “Partition กำหนดเพดาน parallelism ของ consumer”ภายใน consumer group partition หนึ่งตัวถูกอ่านโดย consumer เพียงตัวเดียวในเวลาหนึ่ง นั่นทำให้จำนวน partition เป็นเพดานว่า consumer ทำงานพร้อมกันได้กี่ตัว:
- 6 partitions, consumer 3 ตัว → แต่ละตัวอ่าน 2 partitions
- 6 partitions, consumer 6 ตัว → แต่ละตัวอ่าน 1 partition (parallelism สูงสุด)
- 6 partitions, consumer 8 ตัว → ทำงานแค่ 6 ตัว อีก 2 ตัวว่าง ไม่มีอะไรให้ทำ
ถ้าจะขยาย consumer ในอนาคต ต้องเตรียม partition ไว้ล่วงหน้าให้พอ — consumer แบ่ง partition เดียวออกเป็นส่วนย่อยไม่ได้
flowchart LR
subgraph topic["Topic: orders (4 partitions)"]
p0["orders-0"]
p1["orders-1"]
p2["orders-2"]
p3["orders-3"]
end
p0 --> c1["Consumer 1"]
p1 --> c1
p2 --> c2["Consumer 2"]
p3 --> c3["Consumer 3"]
idle["Consumer 4 (idle: no partition left)"] พิสูจน์: เพิ่ม partition ทำให้ key remap กลางทาง
หัวข้อที่มีชื่อว่า “พิสูจน์: เพิ่ม partition ทำให้ key remap กลางทาง”เพราะ partitioner ค่าเริ่มต้นคำนวณ hash(key) % numberOfPartitions การเปลี่ยนจำนวน partition จึงเปลี่ยนตัวหาร — และทำให้ key ตกไปที่ partition ต่างออกไป คำสั่ง CLI ที่เทียบเท่ากันสำหรับการขยาย topic คือ:
kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic remap-demo --partitions 6สคริปต์นี้พิสูจน์แบบเดียวกับที่ --alter --partitions ทำภายใต้ฉากหลัง (admin API’s createPartitions ซึ่ง kafka-topics.sh --alter เรียกใช้เอง) — สคริปต์เดียวต่อเนื่อง ส่ง key เดิม ก่อน และ หลัง ตามลำดับเคร่งครัด จึงไม่มีความคลุมเครือว่า write ไหนเกิดขึ้นภายใต้จำนวน partition เท่าไหร่:
// @runimport { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}remap-demo`;
const kafka = new Kafka({ clientId: 'remap-demo', brokers });const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 3 }] }).catch(() => {});
const producer = kafka.producer();await producer.connect();const [before] = await producer.send({ topic, messages: [{ key: 'user-42', value: 'before-alter' }] });console.log(`BEFORE alter (3 partitions): key=user-42 -> partition ${before.partition}`);
// Same operation kafka-topics.sh --alter --topic remap-demo --partitions 6 performs.await admin.createPartitions({ topicPartitions: [{ topic, count: 6 }] });await admin.disconnect();await producer.disconnect();
// A FRESH producer, not the same one, deliberately -- see the gotcha below.const producer2 = kafka.producer();await producer2.connect();const [after] = await producer2.send({ topic, messages: [{ key: 'user-42', value: 'after-alter' }] });console.log(`AFTER alter (6 partitions): key=user-42 -> partition ${after.partition}`);await producer2.disconnect();process.exit(0);ผลจริงจากสคริปต์นี้เป๊ะ ๆ รันกับ broker Kafka 4.3.1:
BEFORE alter (3 partitions): key=user-42 -> partition 1AFTER alter (6 partitions): key=user-42 -> partition 4key เดียวกัน โค้ด producer เดียวกัน แต่ได้ partition ต่างกัน — เพราะ hash("user-42") % 3 = 1 แต่ hash("user-42") % 6 = 4 ทุก record ของ user-42 ที่เขียนก่อน alter จะอยู่ที่ partition 1 ตลอดไป ทุก record ที่เขียนหลัง alter จะตกที่ partition 4 ถ้ามีอะไรพึ่งพาว่า event ทั้งหมดของ user-42 อยู่ใน partition เดียว (ordering หรือ state store ของ Kafka Streams ที่ key ด้วย partition) การรับประกันนั้นเพิ่งพังไปแบบเงียบ ๆ — ไม่มี error ทั้งจาก alter หรือจาก send() ครั้งถัดไป บท keys-and-partitioning พูดเรื่องนี้ลึกกว่านี้ รวมถึงทำไมต้องเตรียมจำนวน partition ไว้ล่วงหน้าสำหรับ topic ที่ใช้ key
Producer-side reordering: max.in.flight กับ retries
หัวข้อที่มีชื่อว่า “Producer-side reordering: max.in.flight กับ retries”order ของ partition ก็พังได้ ก่อน ที่อะไรจะไปถึง partition ด้วย — ที่ producer ถ้า maxInFlightRequests มากกว่า 1 และ batch หนึ่งล้มเหลวแล้วถูก retry batch ทีหลัง ที่สำเร็จไปแล้วอาจจบด้วยการถูกเขียนก่อน batch ก่อนหน้า ที่ใช้เวลา retry นานกว่า — ตัว partition เองไม่เคยเรียงลำดับใหม่สิ่งที่มันรับไปแล้ว แต่สอง request ของ producer ที่แยกกันอาจมาถึงไม่ตรงลำดับเดิม การตั้ง maxInFlightRequests: 1 (KafkaJS) ป้องกันสิ่งนี้ได้ แลกกับต้นทุน throughput ส่วน idempotent producer (idempotent: true) แก้ปัญหาเดียวกันด้วย sequence number แทน โดยไม่เสีย ordering เลย กลไกนี้ พิสูจน์ด้วย sequence number จริงจาก kafka-dump-log.sh เป็นเรื่องของโมดูล producers — ดู idempotent-producer สำหรับการพิสูจน์แบบเต็ม
พิสูจน์: segment files มีอยู่จริงบนดิสก์
หัวข้อที่มีชื่อว่า “พิสูจน์: segment files มีอยู่จริงบนดิสก์”partition ไม่ใช่แนวคิดนามธรรม — มันคือ directory ของไฟล์จริง log.dirs ค่าเริ่มต้นของ Kafka 4.3.1 คือ /tmp/kafka-logs และทุก partition จะได้ /tmp/kafka-logs/<topic>-<partition>/:
TOPIC="${TOPIC_PREFIX:-}segments-demo"/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic "$TOPIC" --partitions 1 --if-not-existsfor i in 1 2 3; do echo "record-$i" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic "$TOPIC"; donels -la "/tmp/kafka-logs/${TOPIC}-0/"ผลจริง (scratch broker ของตัวเอง หนึ่ง partition หลังผลิต 3 records):
total 20drwxr-xr-x 2 appuser appuser 4096 Sep 28 22:57 .drwxr-xr-x 59 appuser appuser 4096 Sep 28 22:58 ..-rw-r--r-- 1 appuser appuser 10485760 Sep 28 22:57 00000000000000000000.index-rw-r--r-- 1 appuser appuser 428 Sep 28 22:57 00000000000000000000.log-rw-r--r-- 1 appuser appuser 10485756 Sep 28 22:57 00000000000000000000.timeindex-rw-r--r-- 1 appuser appuser 8 Sep 28 22:57 leader-epoch-checkpoint-rw-r--r-- 1 appuser appuser 43 Sep 28 22:57 partition.metadataสามไฟล์ต่อหนึ่ง segment (log เริ่มด้วย segment เดียว 00000000000000000000.* ตั้งชื่อตาม base offset ของมัน): .log เก็บ record จริง (byte จริง โตขึ้นทุกครั้งที่ append — นั่นคือเหตุผลที่มันมีแค่ 428 byte ที่นี่ ทั้งที่ไฟล์ .index/.timeindex ถูก pre-allocate ไว้ที่ 10 MiB ซึ่งเป็นเพดานจาก index.max.bytes ไม่ใช่ขนาดจริงที่ใช้); .index map offset ไปยังตำแหน่ง byte ในไฟล์ .log เพื่อ seek เร็ว; .timeindex ทำแบบเดียวกันสำหรับ timestamp (ใช้ขับเคลื่อน --offsets-for-times และการ seek ตามเวลา) leader-epoch-checkpoint ติดตามขอบเขต leader-epoch (ใช้ fence follower ที่ค้างอยู่หลัง leader เปลี่ยน); partition.metadata เก็บ topic ID ของ partition partition ที่โตขึ้นเรื่อย ๆ หมายถึง segment มากขึ้น ไม่ใช่ไฟล์เดียวที่โตไม่หยุด — บท retention-and-compaction พิสูจน์การ roll และลบ segment ตรง ๆ บนไฟล์ชุดเดียวกันนี้
จุดแลกเปลี่ยนในการออกแบบ
หัวข้อที่มีชื่อว่า “จุดแลกเปลี่ยนในการออกแบบ”การเลือกจำนวน partition คือการเลือกจุดบนเส้นโค้ง:
- partition มากขึ้น → throughput และ consumer parallelism สูงขึ้น แต่ open file มากขึ้น metadata มากขึ้น และ rebalance นานขึ้น
- partition น้อยลง → ง่ายกว่าและถูกกว่า แต่เพดาน throughput ต่ำกว่า
- ความต้องการ ordering → ถ้ามีชุด event ที่ต้องเรียงลำดับกันเข้มงวด ต้องตกที่ partition เดียวกัน — พิสูจน์ข้างต้นแล้วว่าต้องวางแผนล่วงหน้า เพราะการ alter จำนวน partition ทำให้ key remap
กฎที่ใช้ได้จริง: กำหนด partition ตาม throughput เป้าหมายบวก headroom และให้แน่ใจว่า event ที่เกี่ยวข้องกันซึ่งต้อง order อยู่ใน partition เดียวกัน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”🐛 หา bug ในโค้ดนี้
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'order-service', brokers: ['localhost:9092'] });const consumer = kafka.consumer({ groupId: 'order-timeline' });
await consumer.connect();await consumer.subscribe({ topic: 'order-events', fromBeginning: true });
// Build a single global timeline of every order event across the whole topicconst timeline: string[] = [];
await consumer.run({eachMessage: async ({ message }) => { // Assumes offsets increase in the same order events actually happened, // across the WHOLE topic, so this array is "the" order of events. timeline.push(message.value!.toString());},});เฉลย
โค้ดนี้ compile และรันได้โดยไม่มี error และถ้า topic มี partition เดียว มันจะถูกต้องด้วยซ้ำ บั๊กอยู่ที่สมมติฐานในคอมเมนต์: “offsets increase in the same order events actually happened, across the whole topic” Kafka รับประกัน order แค่ ภายใน partition เท่านั้น (พิสูจน์ข้างต้นด้วยการทดสอบเขียนแบบ interleave — record 4 ตัวของ p1 มาถึงก่อน p0 ทั้งที่ถูกเขียนแบบ interleave ตามเวลาจริง) ถ้า order-events มีมากกว่าหนึ่ง partition timeline ไม่ใช่ “ลำดับที่สิ่งต่าง ๆ เกิดขึ้น” — มันคือการ interleave แบบสุ่มของ log ที่เรียงลำดับอิสระ N ตัว ซึ่งขึ้นกับจังหวะ fetch ไม่ใช่เวลาที่ event เกิดขึ้นจริง วิธีแก้คือให้ topic มี partition เดียว (ฆ่า throughput และ parallelism) หรือติดตาม order ต่อ key (เช่นต่อ orderId) โดยใช้ partition + offset ของ record เอง แทนที่จะใช้ array ร่วมกัน และไม่สมมติว่า timeline ข้าม partition มีความหมายโดยไม่มีขั้นตอน reconciliation ตาม timestamp อย่างชัดเจน
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ให้นึกถึง topic เป็น ชั้นวางสมุดโน้ตอิสระหลายเล่ม หนึ่งเล่มต่อหนึ่ง partition ไม่ใช่ม้วนกระดาษยาวม้วนเดียว แต่ละเล่มถูกเขียนจากหน้าแรกไปหน้าสุดท้ายอย่างเคร่งครัด ตลอดไป — คุณเชื่อใจ order ภายในเล่มเดียวได้เสมอ แต่ไม่มีอะไรบอกว่าหน้า 10 ของสมุดเล่ม 3 ถูกเขียนก่อนหรือหลังหน้า 10 ของสมุดเล่ม 1 — มันเป็นสมุดแยกกันที่ถูกเติมโดยคนเขียนแยกกัน (หรือคนเดียวกัน แต่จัดตารางเวลาอิสระกัน) “อ่าน topic” หมายถึงหยิบสมุดมาอ่านตามลำดับที่สะดวก ไม่ใช่อ่านหน้าตามลำดับเดียวที่รวมกันเป็นหนึ่ง
จุดแลกเปลี่ยน (Trade-offs)
หัวข้อที่มีชื่อว่า “จุดแลกเปลี่ยน (Trade-offs)”- Partition เดียว — global order สมบูรณ์แบบ แต่ write parallelism เป็นศูนย์ (เครื่องเดียว ดิสก์เดียว append stream เดียวคือเพดาน) เหมาะกับ topic throughput ต่ำที่ต้องการ total order จริง ๆ
- หลาย partition — write และ read parallelism สูง แต่ ordering รอดแค่ภายในแต่ละ partition สิ่งที่ต้อง order ต้องถูกบังคับให้ไปที่ partition เดียวกันผ่าน key (บทถัดไป) และการบังคับนั้นพังได้ถ้าจำนวน partition เปลี่ยน (พิสูจน์ข้างต้น)
maxInFlightRequests: 1— คืน order การส่งต่อ partition ที่เข้มงวดแม้มี retry แลกกับ in-flight request ทีละหนึ่ง (producer throughput ต่ำลง) idempotent producer ได้การรับประกันเดียวกันโดยไม่เสียต้นทุนนี้ — ดูโมดูล producers- เพิ่ม partition ทีหลัง — operation ที่ดูถูกและย้อนกลับได้ (
--alter --partitions) ไม่ได้ฟรี — พิสูจน์ข้างต้นแล้วว่ามัน remap ปลายทางของทุก key แบบเงียบ ๆ ไปข้างหน้า ซึ่งเป็นการเปลี่ยนแปลง correctness ที่แฝงมาในรูป capacity
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Kafka รับประกัน order ทั้ง topic” ไม่จริง — พิสูจน์ข้างต้น: การเขียน interleave ตามเวลาจริงข้ามสอง partition กลับมาเรียงใหม่หมด (partition หนึ่งมาทั้งหมดก่อน อีก partition ตามมาทั้งหมด) order มีแค่ต่อ partition เท่านั้น
- “การเพิ่ม partition เป็นการขยายสเกลที่ปลอดภัยแบบ additive ล้วน ๆ” ไม่จริง — พิสูจน์ข้างต้น: key เดียวกันย้ายจาก partition 1 ไป partition 4 ทันทีที่ topic เปลี่ยนจาก 3 เป็น 6 partitions เพราะ hash ของ partitioner คือ
% numberOfPartitions - “ยิ่ง partition เยอะยิ่งประสิทธิภาพดีเสมอ” ไม่จริงเสมอไป — partition ที่มากขึ้นหมายถึง open file handle ต่อ broker มากขึ้น traffic การ replicate มากขึ้น metadata มากขึ้น และ rebalance นานขึ้น มันยกเพดาน throughput ขึ้น ไม่ได้ลด latency ด้วยตัวมันเอง
- “partition เป็นแค่แนวคิดเชิงตรรกะ ไม่ใช่ไฟล์จริงที่ไปดูได้” มันคือไฟล์จริง — พิสูจน์ข้างต้นด้วยการ
lsจริงที่/tmp/kafka-logs/<topic>-0/แสดงไฟล์ segment.log/.index/.timeindexที่ Kafka เพิ่งเขียนจริง