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

Consumer Groups & Rebalancing

consumer group คือกลุ่มของ consumer ที่แชร์ partition ของ topic กัน — แต่ละ partition ถูกจัดการโดย consumer เพียงตัวเดียวใน group — และ rebalancing คือกระบวนการจัดสรร partition ใหม่เมื่อสมาชิกใน 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:

Terminal window
# Show members, assigned partitions, and lag for a group
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group billing
flowchart 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)"]
กลุ่มหนึ่งแบ่ง partition กันเอง; อีกกลุ่มอ่าน topic เดียวกันแบบอิสระ

เมื่อ consumer เข้า, ออก หรือตาย — หรือมีการเพิ่ม partition — group ต้อง rebalance: กระจาย partition ใหม่ไปยังสมาชิกที่มีอยู่ตอนนั้น ภายใต้ protocol แบบ classic นี่คือขั้นตอน stop-the-world: สมาชิกทุกตัวคืน partition ทั้งหมดแล้วรอ assignment ใหม่ทั้งชุด แม้แต่ตัวที่ assignment สุดท้ายจะไม่เปลี่ยนเลยก็ตาม บน group ขนาดใหญ่ การหยุดแบบนั้นเจ็บมาก

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

รันจริงกับ apache/kafka:4.3.1 (single-broker KRaft) และ @confluentinc/[email protected]:

src/consumer-groups-and-rebalancing/kip848-consumer.ts
// @run
import 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 first
await 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,consumer
GROUP TYPE
kip848-group2 Consumer
classic-protocol-group Classic

group ที่ไม่เคยตั้ง group.protocol (หรือตั้งเป็น classic) จะขึ้นเป็น Classic; group ที่ตั้ง group.protocol: 'consumer' จะขึ้นเป็น Consumer — พิสูจน์ตรง ๆ ไม่ได้เดาเอา

เริ่ม 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 pause
B: 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

คงจะสะดวกถ้า “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 เก่ามักสร้างขึ้นมาพอดี

ภายใต้ 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 ที่รันอยู่จริง:

scripts/consumer-groups-and-rebalancing/kip848-broker-config.sh
/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP \
--describe --entity-type brokers --entity-name 1 --all \
| grep -E "group\.consumer\.(session|heartbeat)" || true
group.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=45000
group.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 คือคนที่ตัดสินว่าสมาชิกที่เงียบไปนานแค่ไหนถึงจะทนได้

มี default สองแบบต่างกัน ยืนยันแยกกัน และสับสนกันได้ง่าย:

  • Default ฝั่ง client: classic consumer ที่ไม่เคยตั้ง group.protocol จะเจรจาเป็น Classic — พิสูจน์แล้วข้างบน (default-protocol-group ขึ้นเป็น Classic ใน --list --type โดยไม่มี key group.protocol เลย) ตรงกับ release note ของ librdkafka เอง (“It defaults to classic if 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 be
removed in Kafka 5.0. ... all protocols will always be enabled and controlled solely
by feature versions (group.version, streams.version, share.version) via kafka-features.sh.

สรุป: ไม่มีอะไรต้อง config ฝั่ง broker เพื่อให้ KIP-848 ใช้งานได้ใน 4.3.1 (เปิดอยู่แล้ว) แต่ client ยังต้องขอเองอยู่ดี และโดย default มันไม่ขอ

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

src/consumer-groups-and-rebalancing/opt-in-kip848.ts
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: '...' } })

ให้คิดว่า rebalance ของ consumer group คือ “ใครถือ lease ของแต่ละ partition และเราต้องรบกวน lease เดิมแค่ไหนเพื่ออัปเดตมัน” Protocol classic: lease ทุกอันถูกฉีกทิ้งแล้วออกใหม่เมื่อสมาชิกเปลี่ยนแปลงใด ๆ เพราะการคำนวณ assignment ทำฝั่ง client โดยสมาชิกที่กลายเป็น group leader และวิธีที่ปลอดภัยที่สุดในการส่ง assignment ที่คำนวณฝั่ง client คือให้ทุกคนหยุดก่อน KIP-848 ย้ายการคำนวณ assignment ไปที่ group coordinator ฝั่ง broker ซึ่งรู้ membership ทั้งหมดอยู่แล้วและคำนวณ diff ได้ — จึงเพิกถอนเฉพาะสิ่งที่ต้องย้ายจริง ๆ แล้วบอกแต่ละสมาชิกเป็นรายตัว เป้าหมายเดียวกัน (หนึ่ง partition หนึ่งเจ้าของ) แต่กลไกที่ไปถึงเป้าหมายต่างกันโดยพื้นฐาน

  • 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 ขอได้ด้วย
  • “KIP-848 ยังใช้กับ JavaScript ไม่ได้” ผิดตั้งแต่ librdkafka 2.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 แทน ตามที่พิสูจน์แล้วข้างบน
ภายใน consumer group เดียว มี consumer กี่ตัวที่อ่าน partition หนึ่ง ๆ?
topic มี 4 partition แล้วคุณสตาร์ท 6 consumer ใน group เดียว จะเกิดอะไรขึ้น?
จะ opt in consumer ของ @confluentinc/kafka-javascript เข้า protocol KIP-848 ให้ถูกต้องได้อย่างไร?
group มีสมาชิก 3 ตัวภายใต้ protocol classic สมาชิก C ออกจาก group อย่าง graceful จะเกิดอะไรกับสมาชิก B ที่ partition assignment สุดท้ายกลับไม่เปลี่ยนเลย?
ภายใต้ protocol consumer ของ KIP-848 เกิดอะไรขึ้นกับ partition 0 และ 1 ของ consumer A ระหว่างที่มีการเปลี่ยนสมาชิกอีก 2 ครั้งในรอบรันเดียวกัน?
บน broker Kafka 4.3.1 ของคอร์สนี้ ค่า default ของ group.protocol ที่ client เจรจาถ้าไม่เคยตั้ง option นี้คืออะไร?