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

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 ordered
orders-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 ทั้งหมดกลับมา:

src/partitions-and-ordering/ordering-interleave.ts
// @run
import { 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 ไม่ได้กำหนดไว้ และอย่างที่เห็นข้างต้น อาจดูไม่เหมือนเวลาจริงเลย

ภายใน 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 กำหนดเพดานว่า consumer ทำงานพร้อมกันได้กี่ตัว

เพราะ 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 เท่าไหร่:

src/partitions-and-ordering/remap-demo.ts
// @run
import { 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 1
AFTER alter (6 partitions): key=user-42 -> partition 4

key เดียวกัน โค้ด 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

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 สำหรับการพิสูจน์แบบเต็ม

partition ไม่ใช่แนวคิดนามธรรม — มันคือ directory ของไฟล์จริง log.dirs ค่าเริ่มต้นของ Kafka 4.3.1 คือ /tmp/kafka-logs และทุก partition จะได้ /tmp/kafka-logs/<topic>-<partition>/:

scripts/partitions-and-ordering/list-segments.sh
TOPIC="${TOPIC_PREFIX:-}segments-demo"
/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic "$TOPIC" --partitions 1 --if-not-exists
for i in 1 2 3; do echo "record-$i" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic "$TOPIC"; done
ls -la "/tmp/kafka-logs/${TOPIC}-0/"

ผลจริง (scratch broker ของตัวเอง หนึ่ง partition หลังผลิต 3 records):

total 20
drwxr-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 เดียวกัน

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

src/partitions-and-ordering/global-order-assumption.ts
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 topic
const 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 อย่างชัดเจน

ให้นึกถึง topic เป็น ชั้นวางสมุดโน้ตอิสระหลายเล่ม หนึ่งเล่มต่อหนึ่ง partition ไม่ใช่ม้วนกระดาษยาวม้วนเดียว แต่ละเล่มถูกเขียนจากหน้าแรกไปหน้าสุดท้ายอย่างเคร่งครัด ตลอดไป — คุณเชื่อใจ order ภายในเล่มเดียวได้เสมอ แต่ไม่มีอะไรบอกว่าหน้า 10 ของสมุดเล่ม 3 ถูกเขียนก่อนหรือหลังหน้า 10 ของสมุดเล่ม 1 — มันเป็นสมุดแยกกันที่ถูกเติมโดยคนเขียนแยกกัน (หรือคนเดียวกัน แต่จัดตารางเวลาอิสระกัน) “อ่าน topic” หมายถึงหยิบสมุดมาอ่านตามลำดับที่สะดวก ไม่ใช่อ่านหน้าตามลำดับเดียวที่รวมกันเป็นหนึ่ง

  • 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 เพิ่งเขียนจริง
Kafka รับประกัน ordering แบบไหน?
Topic มี 4 partitions และ consumer group มี consumer 6 ตัว จะเกิดอะไรขึ้น?
kafka-topics.sh --alter --partitions มีผลจริงที่พิสูจน์ได้อะไรกับ topic ที่ใช้ key ตามที่แสดงข้างต้น?
อะไรที่สามารถเรียงลำดับ record ภายใน partition เดียวใหม่ได้ (ตามแนวคิดที่พิสูจน์ข้างต้น) ทั้งที่ตัว partition เองไม่เคยเรียงลำดับสิ่งที่รับไปแล้วใหม่?
ls /tmp/kafka-logs/<topic>-0/ แสดงอะไร (พิสูจน์ข้างต้น) เกี่ยวกับสิ่งที่ partition เป็นจริง ๆ ทางกายภาพ?