Consumer Groups & Rebalancing
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”consumer group คือกลุ่มของ consumer ที่แชร์ partition ของ topic กัน — แต่ละ partition ถูกจัดการโดย consumer เพียงตัวเดียวใน group — และ rebalancing คือกระบวนการจัดสรร partition ใหม่เมื่อสมาชิกใน group เปลี่ยนไป
group แบ่งงานกันทำ
หัวข้อที่มีชื่อว่า “group แบ่งงานกันทำ”consumer ทุกตัวประกาศ groupId Kafka จะยก partition แต่ละอันให้ consumer เพียง ตัวเดียว ภายใน group ดังนั้น topic ที่มี 6 partition จะถูกประมวลผลพร้อมกันได้สูงสุด 6 consumer เพิ่ม consumer ตัวที่ 7 เข้าไปก็จะนั่งว่าง — เพดานของ parallelism คือจำนวน partition ไม่ใช่จำนวน consumer
group ต่างกันเป็นอิสระต่อกัน: analytics กับ billing ต่างได้ stream เต็ม ๆ ของตัวเอง และ track offset ของตัวเอง ส่วนภายใน group เดียวกัน partition จะถูกแบ่งกัน
คุณตรวจดู group, สมาชิก และ lag ได้จาก CLI:
# Show members, assigned partitions, and lag for a groupkafka-consumer-groups.sh --bootstrap-server localhost:9092 \ --describe --group billingflowchart TB
subgraph t["Topic orders: 3 partitions"]
p0["orders-0"]
p1["orders-1"]
p2["orders-2"]
end
subgraph g1["Group billing"]
c1["consumer A"]
c2["consumer B"]
end
p0 --> c1
p1 --> c1
p2 --> c2
t --> g2["Group analytics (own offsets, full copy)"] Rebalancing
หัวข้อที่มีชื่อว่า “Rebalancing”เมื่อ consumer เข้า, ออก หรือตาย — หรือมีการเพิ่ม partition — group ต้อง rebalance: กระจาย partition ใหม่ไปยังสมาชิกที่มีอยู่ตอนนั้น ภายใต้ protocol แบบ classic นี่คือขั้นตอน stop-the-world: สมาชิกทุกตัวคืน partition ทั้งหมดแล้วรอ assignment ใหม่ทั้งชุด แม้แต่ตัวที่ assignment สุดท้ายจะไม่เปลี่ยนเลยก็ตาม บน group ขนาดใหญ่ การหยุดแบบนั้นเจ็บมาก
แก้ไขให้ถูกต้อง: KIP-848 กับ JavaScript client
หัวข้อที่มีชื่อว่า “แก้ไขให้ถูกต้อง: KIP-848 กับ JavaScript client”Kafka 4.0 มาพร้อม next-generation consumer rebalance protocol (KIP-848) ในสถานะ generally available โดยย้าย logic การ assign ไปไว้ที่ group coordinator ฝั่ง broker และทำให้ rebalance เป็นแบบ incremental — เพิกถอนเฉพาะ partition ที่ต้องย้ายจริง ๆ ส่วนที่เหลือประมวลผลต่อได้
# section: consumer# Client opt-in: the new consumer group protocol (KIP-848), GA since Kafka 4.0.# @confluentinc/kafka-javascript takes this as a TOP-LEVEL rdkafka key, NOT nested# under kafkaJS:{} -- see "Reading AI-generated code" below for the exact error# you get if you put it in the wrong place.group.protocol=consumer# The classic (older) protocol is still available, and is still the CLIENT default:# group.protocol=classicพิสูจน์ group.protocol=consumer ด้วย client จริงและ broker จริง
หัวข้อที่มีชื่อว่า “พิสูจน์ group.protocol=consumer ด้วย client จริงและ broker จริง”รันจริงกับ apache/kafka:4.3.1 (single-broker KRaft) และ @confluentinc/[email protected]:
// @runimport pkg from '@confluentinc/kafka-javascript';const { KafkaJS } = pkg;
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}orders`;
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers } });
const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 1 }] }).catch(() => {});await admin.disconnect();
const consumer = kafka.consumer({ 'group.protocol': 'consumer', // KIP-848 opt-in: top-level key, not under kafkaJS:{} kafkaJS: { groupId: `${prefix}kip848-group`, fromBeginning: true },});await consumer.connect();await consumer.subscribe({ topic });
const producer = kafka.producer();
let done = false;const run = consumer.run({ eachMessage: async ({ message }) => { if (done) return; done = true; console.log('consumed with group.protocol=consumer:', message.value?.toString()); await consumer.disconnect(); await producer.disconnect().catch(() => {}); process.exit(0); },});
await new Promise((r) => setTimeout(r, 2000)); // let JoinGroup/assignment settle firstawait producer.connect();await producer.send({ topic, messages: [{ value: 'hello-kip848' }] });
void run;setTimeout(() => { console.error('timeout'); process.exit(1); }, 25000);ผลลัพธ์ CLI จริงแบบ trim — kafka-consumer-groups.sh --describe ไม่มีคอลัมน์ protocol แต่ --list --type มี และนี่คือวิธีที่ตรงไปตรงมาที่สุดในการดูว่า group เจรจา protocol อะไรจริง ๆ:
$ kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list --type classic,consumerGROUP TYPEkip848-group2 Consumerclassic-protocol-group Classicgroup ที่ไม่เคยตั้ง group.protocol (หรือตั้งเป็น classic) จะขึ้นเป็น Classic; group ที่ตั้ง group.protocol: 'consumer' จะขึ้นเป็น Consumer — พิสูจน์ตรง ๆ ไม่ได้เดาเอา
Classic (eager) vs KIP-848 (incremental): วัดจริง ไม่ใช่เดา
หัวข้อที่มีชื่อว่า “Classic (eager) vs KIP-848 (incremental): วัดจริง ไม่ใช่เดา”เริ่ม consumer สองตัว (A, B) ใน group บน topic 6 partition ให้ producer ส่งข้อความทุก 30ms แล้วให้ consumer ตัวที่สาม (C) เข้ากลุ่มแล้วออกจากกลุ่ม บันทึก timestamp การประมวลผลของทุกข้อความจริง บน Mac เครื่องนี้ broker local งานเล็ก ๆ:
Protocol แบบ classic (KafkaJS, round-robin, eager เสมอ): ตอนที่ C ออกจากกลุ่ม สมาชิกที่เหลือ ทั้งคู่ rejoin ใหม่ — รวมถึง B ที่ partition assignment สุดท้าย ([1,3,5]) เหมือนเดิมทุกประการ ทั้งก่อนและหลัง:
[A] REBALANCING t=...286034[A] GROUP_JOIN t=...286072 memberAssignment={"orders":[0,2,4]} duration=38[B] REBALANCING t=...286066[B] GROUP_JOIN t=...286072 memberAssignment={"orders":[1,3,5]} duration=6
# gap between the LAST message consumed before REBALANCING and the FIRST after:A: 45ms stop-the-world pauseB: 14ms stop-the-world pause (even though B's assignment never changed!)Protocol consumer ของ KIP-848 (@confluentinc/kafka-javascript, group.protocol=consumer): ตลอดการเปลี่ยนสมาชิก 2 ครั้งเดียวกัน (สมาชิกเข้า แล้วออก) partition 0 กับ 1 อยู่กับ consumer A ไม่ถูกขัดจังหวะเลยตลอดรอบรัน ~18 วินาที — ไม่มีช่องว่างที่ใหญ่กว่า jitter ปกติของ producer (~150ms ซึ่งพบตลอดทั้งรอบ ไม่ใช่แค่ตอน rebalance) — ในขณะที่ partition 2–5 ย้ายไปมาระหว่างสมาชิกอื่นทีละตัว:
A partition 0: consumed continuously from t=...464489 to t=...482256 (never revoked)A partition 1: consumed continuously from t=...464520 to t=...482286 (never revoked)A partition 5: revoked ~469171 -> picked up by C at ~470366 (~1s later, matches group.consumer.assignment.interval.ms=1000, the broker default)A partition 2: revoked ~474155 -> picked up by C at ~475518นั่นคือสิ่งที่ “incremental” ให้จริง ๆ: ภายใต้ classic สมาชิก ทุกตัว หยุดและ rejoin เมื่อสมาชิกเปลี่ยนแปลง ใด ๆ; ภายใต้ protocol consumer ของ KIP-848 สมาชิกที่ partition ไม่ได้ถูกย้าย ยังคง consume ต่อไปได้ระหว่าง rebalance
Protocol classic ของ KafkaJS แบบตรงไปตรงมา: ไม่มี cooperative-sticky
หัวข้อที่มีชื่อว่า “Protocol classic ของ KafkaJS แบบตรงไปตรงมา: ไม่มี cooperative-sticky”คงจะสะดวกถ้า “classic vs KIP-848” แปลว่า “eager vs cooperative” ภายใน client ตัวเดียว ทำให้ลด stop-the-world ของ classic ได้ด้วย cooperative-sticky assignor ก่อนแตะ group.protocol ด้วยซ้ำ แต่ KafkaJS 2.2.4 ไม่มีตัวเลือกนั้นให้ assignor registry ทั้งหมดของมันมีแค่ตัวเดียว คือ round-robin:
$ node -e "console.log(require('kafkajs').PartitionAssigners)"{ roundRobin: [Function: assigner] }kafka-consumer-groups.sh --describe และ probe ของคอร์สนี้ยืนยันตรงกันว่าทุก group ของ KafkaJS เจรจา groupProtocol: "RoundRobinAssigner" — eager, stop-the-world, ทุกสมาชิก rejoin ทั้งกลุ่มเมื่อสมาชิกเปลี่ยนแปลง จบ ถ้าคุณต้องการ rebalance แบบ incremental สไตล์ cooperative-sticky บน JavaScript client วันนี้ ทางเลือกจริงมีแค่: (a) ยอมรับ rebalance แบบ eager ของ classic (โอเคสำหรับ group เล็ก) หรือ (b) ย้ายไป @confluentinc/kafka-javascript กับ group.protocol: 'consumer' (KIP-848 ขับโดย broker เป็น incremental — พิสูจน์แล้วข้างบน) ไม่มี cooperative-sticky assignor ให้ตั้งค่าใน KafkaJS เลย การอ้างว่ามีคือรายละเอียดแบบ “มั่นใจแต่ผิด” ที่ AI generate โค้ดจาก training data เก่ามักสร้างขึ้นมาพอดี
session.timeout.ms / heartbeat.interval.ms ย้ายไปฝั่ง broker ภายใต้ KIP-848
หัวข้อที่มีชื่อว่า “session.timeout.ms / heartbeat.interval.ms ย้ายไปฝั่ง broker ภายใต้ KIP-848”ภายใต้ protocol classic sessionTimeout กับ heartbeatInterval เป็น option ฝั่ง client ของ KafkaJS consumer (default 30000 / 3000ms) — client เป็นคนตัดสินจังหวะการยืนยันว่ายังมีชีวิตอยู่เอง network partition หรือ client ที่ยุ่งอาจโกหกว่ายัง alive ได้ตลอด session timeout KIP-848 ย้ายเรื่องนี้ไปเป็น config ที่ broker ควบคุม ยืนยันตรงกับ broker 4.3.1 ที่รันอยู่จริง:
/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP \ --describe --entity-type brokers --entity-name 1 --all \ | grep -E "group\.consumer\.(session|heartbeat)" || truegroup.consumer.heartbeat.interval.ms=5000 sensitive=false synonyms={DEFAULT_CONFIG:...}group.consumer.session.timeout.ms=45000 sensitive=false synonyms={DEFAULT_CONFIG:...}group.consumer.min.session.timeout.ms=45000group.consumer.max.session.timeout.ms=60000นี่คือ ชื่อ key จริง ยืนยันกับ broker ที่รันอยู่จริง ไม่ได้เดาจาก KIP wiki: group.consumer.session.timeout.ms (default 45 วินาที ไม่ใช่ 30 วินาทีแบบ classic) และ group.consumer.heartbeat.interval.ms (default 5 วินาที ไม่ใช่ 3 วินาทีแบบ classic) client ที่ใช้ protocol consumer ยังขอ session timeout ของตัวเองได้ภายในช่วง min/max ที่ broker กำหนด แต่ broker ไม่ใช่ client คือคนที่ตัดสินว่าสมาชิกที่เงียบไปนานแค่ไหนถึงจะทนได้
protocol ไหนคือ default ใน Kafka 4.3.1?
หัวข้อที่มีชื่อว่า “protocol ไหนคือ default ใน Kafka 4.3.1?”มี default สองแบบต่างกัน ยืนยันแยกกัน และสับสนกันได้ง่าย:
- Default ฝั่ง client:
classicconsumer ที่ไม่เคยตั้งgroup.protocolจะเจรจาเป็นClassic— พิสูจน์แล้วข้างบน (default-protocol-groupขึ้นเป็นClassicใน--list --typeโดยไม่มี keygroup.protocolเลย) ตรงกับ release note ของlibrdkafkaเอง (“It defaults toclassicif not provided”) - ฝั่ง broker: ทั้งสาม protocol เปิดใช้งานเป็น default อยู่แล้ว ดังนั้น client ที่ เลือก opt in สำเร็จได้เลยโดยไม่ต้อง config broker เพิ่ม:
group.coordinator.rebalance.protocols=classic,consumer,share synonyms={STATIC_BROKER_CONFIG:...=classic,consumer,share, DEFAULT_CONFIG:...=classic,consumer,streams}อีกหนึ่งข้อค้นพบตรงไปตรงมาที่คอร์สนี้ไม่เคยมีมาก่อน: บน broker ตัวเดียวกันนี้ group.coordinator.rebalance.protocols เองขึ้น log ว่า deprecated ใน 4.3.1 —
WARN The config `group.coordinator.rebalance.protocols` is deprecated and will beremoved in Kafka 5.0. ... all protocols will always be enabled and controlled solelyby feature versions (group.version, streams.version, share.version) via kafka-features.sh.สรุป: ไม่มีอะไรต้อง config ฝั่ง broker เพื่อให้ KIP-848 ใช้งานได้ใน 4.3.1 (เปิดอยู่แล้ว) แต่ client ยังต้องขอเองอยู่ดี และโดย default มันไม่ขอ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”🐛 หา bug ในโค้ดนี้
import pkg from '@confluentinc/kafka-javascript';const { KafkaJS } = pkg;
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers: ['localhost:9092'] } });
const consumer = kafka.consumer({kafkaJS: { groupId: 'orders-group', groupProtocol: 'consumer', // "opting in" to KIP-848},});
await consumer.connect();เฉลย
error จริงที่ throw ที่ consumer.connect():
KafkaJSError: The 'groupProtocol' property is not supported. at #kafkaJSToConsumerConfig (.../kafkajs/_consumer.js:502:13) ... code: -186, type: 'ERR__INVALID_ARG'@confluentinc/kafka-javascript มี config surface สองแบบ: block kafkaJS: {...} ที่เลียนแบบชื่อ option ของ KafkaJS เอง (groupId, sessionTimeout, autoCommit, …) กับ key config ดิบของ librdkafka เป็น property ระดับบนสุดของ object เดียวกัน ('group.protocol', 'max.in.flight', …) group.protocol เป็น key แบบ librdkafka-native ที่ไม่มี kafkaJS:{} เทียบเท่า — compatibility layer ปฏิเสธตรง ๆ ถ้าคุณเอาไปซ้อนไว้ข้างใน นี่คือบั๊กสไตล์ AI-generated ที่สมจริง: assistant ที่เห็น kafkaJS: { ... } block มาเป็นร้อยครั้งจะเดา (ผิด) ว่า option ใหม่ทุกตัวต้องอยู่ตรงนั้น วิธีแก้คือย้ายบรรทัดเดียวตามที่แสดงไว้ก่อนหน้าในบทนี้: kafka.consumer({ 'group.protocol': 'consumer', kafkaJS: { groupId: '...' } })
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ให้คิดว่า rebalance ของ consumer group คือ “ใครถือ lease ของแต่ละ partition และเราต้องรบกวน lease เดิมแค่ไหนเพื่ออัปเดตมัน” Protocol classic: lease ทุกอันถูกฉีกทิ้งแล้วออกใหม่เมื่อสมาชิกเปลี่ยนแปลงใด ๆ เพราะการคำนวณ assignment ทำฝั่ง client โดยสมาชิกที่กลายเป็น group leader และวิธีที่ปลอดภัยที่สุดในการส่ง assignment ที่คำนวณฝั่ง client คือให้ทุกคนหยุดก่อน KIP-848 ย้ายการคำนวณ assignment ไปที่ group coordinator ฝั่ง broker ซึ่งรู้ membership ทั้งหมดอยู่แล้วและคำนวณ diff ได้ — จึงเพิกถอนเฉพาะสิ่งที่ต้องย้ายจริง ๆ แล้วบอกแต่ละสมาชิกเป็นรายตัว เป้าหมายเดียวกัน (หนึ่ง partition หนึ่งเจ้าของ) แต่กลไกที่ไปถึงเป้าหมายต่างกันโดยพื้นฐาน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Protocol classic — ง่าย ใช้ได้กับ client ทุกตัวที่พูด protocol นี้ (รวมถึง KafkaJS ที่ไม่มีคน maintain แล้ว) แต่ทุก rebalance คือ stop-the-world สำหรับ ทั้ง group ไม่ว่าสมาชิกจะเปลี่ยนแปลงเล็กแค่ไหน เหมาะกับ group เล็กและนิ่ง เจ็บมากกับ group ใหญ่ที่ scale/deploy บ่อย
- Protocol consumer ของ KIP-848 — incremental ขับโดย broker ช่วงเวลาที่ถูกรบกวนสั้นลงอย่างมีนัยสำคัญสำหรับ partition ที่ไม่เกี่ยวข้อง (พิสูจน์แล้วข้างบน) — แต่ต้องการ client ที่ implement มัน (KafkaJS ไม่มี), broker Kafka 4.0+ และเป็น code path ใหม่กว่าที่มีระยะทางใน production น้อยกว่า classic
- Config session ฝั่ง broker (KIP-848) vs ฝั่ง client (
classic) — ฝั่ง broker หมายถึงมีจุดเดียวในการ tune การตรวจจับว่าสมาชิกยัง alive แทนที่จะเชื่อ timer ในเครื่องของแต่ละ client แต่ก็หมายความว่าช่วงgroup.consumer.min/max.session.timeout.msของ broker จำกัดสิ่งที่ client ขอได้ด้วย
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “KIP-848 ยังใช้กับ JavaScript ไม่ได้” ผิดตั้งแต่
librdkafka2.12.0 (ต.ค. 2025) ผ่าน@confluentinc/kafka-javascript— พิสูจน์แล้วข้างบน เพียงแต่ไม่ใช่ default และ KafkaJS โดยเฉพาะไม่เคยได้รับมันเลย - “KafkaJS มี cooperative-sticky assignor ฉันแค่ต้อง config มัน” ไม่มี — ยืนยันตรงกับ package ที่ติดตั้งจริง: KafkaJS 2.2.4 มี assignor แค่ตัวเดียวคือ round-robin ไม่มีอะไรให้ config
- “consumer เยอะขึ้นใน group เท่ากับ parallelism มากขึ้นเสมอ” ไม่จริง — จำนวน partition คือเพดาน consumer ส่วนเกินเกินจำนวน partition จะนั่งว่างสนิท (ดู quiz ด้านล่าง)
- “
kafka-consumer-groups.sh --describeแสดง protocol ของ group ให้ดู” ไม่มีคอลัมน์ protocol ให้ดู — ใช้--list --type classic,consumerแทน ตามที่พิสูจน์แล้วข้างบน