第 4 篇:消费者(Consumer)与消费者组(Consumer Group)实战指南
面向实战的 Kafka 消费者与消费者组详解:从消费模式、分区再均衡、位移提交策略,到顺序保证与幂等处理。文末提供完整的 Python 代码案例、运行步骤与可视化图示,助你快速建立系统化理解并落地到生产代码。
目录
- 一、Kafka 消费模式
- 二、消费者组与分区再均衡
- 2.1 分配策略:Range / RoundRobin / Sticky
- 2.2 何时发生 Rebalance(再均衡)
- 2.3 监听再均衡回调(实战)
- 三、Offset 提交策略
- 3.1 自动提交
- 3.2 手动提交(同步 / 异步)
- 四、消息重复消费与顺序保证
- 五、动手实战:3 个消费者组成一个组
- 5.1 创建 Topic
- 5.2 生产者(按 Key 分区)
- 5.3 消费者(组内 3 个实例)
- 5.4 验证分区分配与再均衡
- 六、常见问题与优化建议
- 环境准备与运行步骤
一、Kafka 消费模式
Kafka 的消费方式主要分为两种:
- 点对点(Point-to-Point)
- 一个消息只能被一个消费者消费。
- 典型场景:任务队列,避免重复处理。
- 发布订阅(Publish-Subscribe)
- 一个消息可以被多个消费者消费。
- 不同的消费者组可以独立读取同一个 Topic 的所有数据。
Kafka 的 Consumer Group(消费者组)是上述两种模式的结合:
- 同组内的消费者共享 Topic 分区,避免重复消费。
- 不同组之间相互独立,可以重复消费同一消息。
二、消费者组与分区再均衡
- 消费者组(Consumer Group)
- 每个组都有唯一的
group.id。 - 组内消费者协同消费 Topic 的分区,一个分区同一时刻只能被组内的一个消费者消费。
- 每个组都有唯一的
2.1 分配策略
- RangeAssignor(范围分配):按照分区号范围为每个消费者分配一段连续分区。
- RoundRobinAssignor(轮询分配):轮流分配,尽可能均匀。
- StickyAssignor(粘性分配):在尽量均衡的同时,优先保持已有分配不变,减少 Rebalance 震荡。
2.2 何时发生 Rebalance(再均衡)
- 新消费者加入或已有消费者下线。
- 订阅的 Topic 分区数变化。
- 通过 API 主动请求再均衡(例如分配策略或订阅变更)。
再均衡期间,组内消费者会短暂停止拉取,直到重新分配完成。
2.3 监听再均衡回调(实战)
我们可以在消费者订阅时挂载监听器,观察分区被撤回(revoked)与分配(assigned)的生命周期,便于在再均衡前后做资源清理与恢复。
# lesson_four/consumer_rebalance_demo.py
from kafka import KafkaConsumer
from kafka.structs import TopicPartition
GROUP_ID = "group-demo"
TOPIC = "test-group"
class RebalanceListener:
def on_partitions_revoked(self, revoked):
# revoked: set[TopicPartition]
parts = ", ".join([f"{tp.topic}-{tp.partition}" for tp in revoked])
print(f"[Rebalance] Partitions revoked: {parts}")
def on_partitions_assigned(self, assigned):
# assigned: set[TopicPartition]
parts = ", ".join([f"{tp.topic}-{tp.partition}" for tp in assigned])
print(f"[Rebalance] Partitions assigned: {parts}")
if __name__ == "__main__":
consumer = KafkaConsumer(
TOPIC,
bootstrap_servers="localhost:9092",
group_id=GROUP_ID,
enable_auto_commit=False,
auto_offset_reset="earliest",
)
# 注册再均衡监听器
consumer.subscribe(topics=[TOPIC], listener=RebalanceListener())
print("消费者启动,等待消息……")
for message in consumer:
print(
f"Got message: partition={message.partition}, offset={message.offset}, value={message.value.decode('utf-8', 'ignore')}"
)
consumer.commit()
可视化:分区与消费者的对应关系
可视化:再均衡时序
三、Offset 提交策略
消费者需要维护位移(Offset)来记录消费进度。
3.1 自动提交(enable.auto.commit=true)
- 优点:简单。
- 缺点:可能出现重复消费或未处理成功但已提交。
# lesson_four/consumer_auto_commit.py
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"test-group",
bootstrap_servers="localhost:9092",
group_id="group-demo",
enable_auto_commit=True, # 自动提交
auto_offset_reset="earliest",
)
for msg in consumer:
print(f"AUTO: partition={msg.partition}, offset={msg.offset}, value={msg.value.decode('utf-8', 'ignore')}")
3.2 手动提交
- 同步提交(commit):处理完成后提交,准确性高但稍降吞吐。
- 异步提交(commit_async):性能更高,可注册回调,但最后一次提交可能丢失。
# lesson_four/consumer_manual_commit_sync.py
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"test-group",
bootstrap_servers="localhost:9092",
group_id="group-demo",
enable_auto_commit=False, # 手动提交
auto_offset_reset="earliest",
)
for msg in consumer:
# 处理业务
print(f"SYNC: partition={msg.partition}, offset={msg.offset}, value={msg.value.decode('utf-8', 'ignore')}")
consumer.commit() # 同步提交
# lesson_four/consumer_manual_commit_async.py
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"test-group",
bootstrap_servers="localhost:9092",
group_id="group-demo",
enable_auto_commit=False,
auto_offset_reset="earliest",
)
def on_commit(offsets, response):
# offsets: dict[TopicPartition, OffsetAndMetadata](kafka-python 内部结构)
# response: None 或异常
if response is None:
print("ASYNC commit success")
else:
print(f"ASYNC commit error: {response}")
for msg in consumer:
print(f"ASYNC: partition={msg.partition}, offset={msg.offset}, value={msg.value.decode('utf-8', 'ignore')}")
consumer.commit_async(callback=on_commit)
四、消息重复消费与顺序保证
- 重复消费的常见原因:
- 自动提交 + 程序在处理后提交前异常;
- 手动提交但在提交前崩溃或 Rebalance;
- 网络抖动导致提交结果不一致。
- 业务侧建议:
- 通过幂等逻辑(如基于业务唯一键去重)确保重复消息不影响结果;
- 将相关消息使用相同 Key 发送,确保进入同一分区,获得分区内顺序保证;
- 分区内 Kafka 保证顺序,跨分区无法严格保证,需要通过 Key 设计或上层协调。
五、动手实战:3 个消费者组成一个组
5.1 创建 Topic(3 分区)
kafka-topics.sh --create \
--topic test-group \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
5.2 生产者(按 Key 分区)
通过 Key 将同类消息路由到同一分区,便于观察顺序与分配。
# lesson_four/producer_keyed.py
from kafka import KafkaProducer
import json
producer = KafkaProducer(
bootstrap_servers="localhost:9092",
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8"),
)
topic = "test-group"
for i in range(30):
key = f"user-{i % 3}" # 三类 key,将路由到不同分区(具体取决于分区器与分区数)
value = {"index": i, "key": key}
producer.send(topic, key=key, value=value)
producer.flush()
print("Produced 30 keyed messages.")
5.3 消费者(组内 3 个实例)
# lesson_four/consumer_group_demo.py
from kafka import KafkaConsumer
import sys
GROUP_ID = "group-demo"
TOPIC = "test-group"
consumer_id = sys.argv[1] if len(sys.argv) > 1 else "consumer-1"
consumer = KafkaConsumer(
TOPIC,
bootstrap_servers="localhost:9092",
group_id=GROUP_ID,
enable_auto_commit=False, # 手动提交,便于观察
auto_offset_reset="earliest",
)
print(f"启动消费者: {consumer_id}")
for message in consumer:
print(
f"{consumer_id} 收到: 分区={message.partition}, 偏移量={message.offset}, 值={message.value.decode('utf-8', 'ignore')}"
)
consumer.commit()
在不同终端启动 3 个消费者(同一个 group.id):
python lesson_four/consumer_group_demo.py c1
python lesson_four/consumer_group_demo.py c2
python lesson_four/consumer_group_demo.py c3
5.4 验证分区分配与再均衡
- 若 Topic 有 3 个分区,3 个消费者通常会各自消费一个分区;
- 停掉其中一个消费者(如
c2),会触发再均衡,剩余消费者会重新分配分区; - 结合上面的 Rebalance 监听器示例,可直观看到撤回与分配事件。
六、常见问题与优化建议
- 合理设置
max.poll.interval.ms与session.timeout.ms:- 处理耗时较长的消息时,增大
max.poll.interval.ms,避免被判定为卡死触发再均衡; - 调整
max.poll.records控制批量处理大小。
- 处理耗时较长的消息时,增大
- 尽量使用 Sticky 分配策略:降低频繁再均衡对吞吐的影响。
- 提交策略建议:
- 需要强一致时使用同步提交;
- 更关注吞吐可使用异步提交并配合回调与补偿;
- 与幂等业务逻辑结合,抵御重复消息。
- 观测与告警:
- 关注
rebalance频率、commit失败率、消费延迟等关键指标; - 在再均衡回调中做好状态落盘与资源清理。
- 关注
环境准备与运行步骤
- 安装依赖(建议使用虚拟环境):
python3 -m venv .venv && source .venv/bin/activate
pip install kafka-python
-
启动本地或远程 Kafka 集群,并确认
localhost:9092可访问(或修改示例中的bootstrap_servers)。 -
创建 Topic:
kafka-topics.sh --create \
--topic test-group \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
- 发送测试消息:
python lesson_four/producer_keyed.py
- 启动多个消费者实例并观察分配与再均衡:
python lesson_four/consumer_group_demo.py c1
python lesson_four/consumer_group_demo.py c2
python lesson_four/consumer_group_demo.py c3
# 关闭其中一个终端(如 c2)以触发再均衡
到这里,你已经掌握了 Kafka 消费者组的核心概念与最佳实践,并具备将其应用到真实业务的代码模板与操作步骤。如果需要,我可以继续补充:可控反压策略、死信队列(DLQ)设计、恰好一次(EOS)语义在 Python 客户端中的取舍与实现思路等进阶内容。
从基础到高级的项目案例
本节提供从入门到进阶的可运行项目案例,覆盖基础生产/消费、按 Key 保序与幂等处理、重试与死信队列(DLQ)、以及批量与背压控制。示例均基于 kafka-python,确保本地 Kafka 在 localhost:9092 可用。
1) 基础案例:最简单的生产者与消费者
- 生产者:
lesson_four/producer_basic.py - 消费者:
lesson_four/consumer_basic.py
运行步骤:
# 创建 Topic
kafka-topics.sh --create \
--topic demo-basic \
--bootstrap-server localhost:9092 \
--partitions 1 \
--replication-factor 1
# 启动消费者(先启动便于看到早期消息)
python lesson_four/consumer_basic.py
# 另一个终端发送测试消息
python lesson_four/producer_basic.py
观察到消费者打印 BASIC: partition=..., offset=..., value=hello-i。
2) 中级案例:按 Key 保序 + 幂等消费(订单场景)
- 生产者(按 Key 路由,每用户顺序):
lesson_four/producer_ordered.py - 消费者(幂等处理 + 同步提交):
lesson_four/consumer_idempotent.py
运行步骤:
kafka-topics.sh --create \
--topic demo-order \
--bootstrap-server localhost:9092 \
--partitions 4 \
--replication-factor 1
python lesson_four/consumer_idempotent.py
# 另起终端
python lesson_four/producer_ordered.py
要点:
- 相同
key的消息路由到同一分区,从而获得分区内顺序; - 消费端以内存集合去重(生产替换为 Redis/DB),处理完成后同步提交 offset,保证进度与处理对齐。
3) 进阶案例:失败重试 + 死信队列(DLQ)
- 消费者(失败重试,携带 header 计数,到达上限入 DLQ):
lesson_four/consumer_retry_with_dlq.py - 可选:DLQ 专用生产工具:
lesson_four/producer_retry_dlx.py
运行步骤:
kafka-topics.sh --create \
--topic demo-retry \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
kafka-topics.sh --create \
--topic demo-dlq \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
python lesson_four/consumer_retry_with_dlq.py
# 另起终端:模拟正常与异常消息
python - <<'PY'
from kafka import KafkaProducer
p = KafkaProducer(bootstrap_servers='localhost:9092', value_serializer=lambda v: v.encode('utf-8'))
for i in range(10):
v = f"ok-{i}" if i % 4 else f"bad-{i}"
p.send('demo-retry', value=v)
p.flush()
print('sent test messages')
PY
观察:
- 含有
bad的消息会被连续重试,超过阈值进入 DLQ; - 其余消息正常消费并提交 offset。
4) 批量与背压:轮询批量并 Pause/Resume
- 生产者:
lesson_four/producer_batch.py - 消费者(批量模拟 + 周期性 Pause/Resume):
lesson_four/consumer_batch_pause_resume.py
运行步骤:
kafka-topics.sh --create \
--topic demo-batch \
--bootstrap-server localhost:9092 \
--partitions 3 \
--replication-factor 1
python lesson_four/consumer_batch_pause_resume.py
# 另起终端
python lesson_four/producer_batch.py
观察:
- 消费者每处理一段数量会
pause分区,提交一次进度,sleep冷却后resume,模拟下游限流/背压; - 可调节
max_poll_records与冷却时间,观察吞吐与延迟的权衡。
可视化演示:打开
lesson_four/visualization.html查看“分区分配与再均衡”交互页面(含 Mermaid 图)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kafka 消费者组可视化演示</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--muted: #9ca3af;
--text: #e5e7eb;
--accent: #60a5fa;
--good: #34d399;
--warn: #f59e0b;
--bad: #ef4444;
--border: #374151;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
h1, h2, h3 {
font-weight: 600;
letter-spacing: 0.2px;
}
h1 { font-size: 24px; margin: 0 0 12px; }
h2 { font-size: 18px; margin: 24px 0 12px; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
label { color: var(--muted); }
input[type="number"], select {
background: #0b1220;
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
min-width: 100px;
}
button {
background: #1f2937;
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
}
button:hover { border-color: var(--accent); }
.pill {
display: inline-block;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: #0b1220;
margin-right: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 980px) {
.grid { grid-template-columns: 1.1fr 0.9fr; }
}
.diagram {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
overflow: auto;
}
.logs {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
height: 220px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.legend span { margin-right: 12px; font-size: 12px; color: var(--muted); }
.legend .add { color: var(--good); }
.legend .revoke { color: var(--bad); }
.legend .assign { color: var(--accent); }
</style>
</head>
<body>
<div class="container">
<h1>Kafka 消费者组可视化演示</h1>
<div class="panel">
<div class="row">
<label>分区数量</label>
<input id="partitions" type="number" min="1" max="32" value="3" />
<label>策略</label>
<select id="strategy">
<option value="range">Range</option>
<option value="roundrobin">RoundRobin</option>
<option value="sticky">Sticky (近似)</option>
</select>
<button id="add-consumer">添加消费者</button>
<button id="remove-consumer">移除消费者</button>
<button id="rebalance">触发再均衡</button>
</div>
<div style="margin-top:10px" class="legend">
<span class="add">● 新增消费者</span>
<span class="revoke">● 分区撤回</span>
<span class="assign">● 分配完成</span>
</div>
<div style="margin-top:10px">
<div id="consumers"></div>
</div>
</div>
<div class="grid">
<div class="panel">
<h2>当前分区分配</h2>
<div id="assignment" class="diagram"></div>
</div>
<div class="panel">
<h2>再均衡时序</h2>
<div id="sequence" class="diagram"></div>
</div>
</div>
<div class="panel">
<h2>事件日志</h2>
<div id="logs" class="logs"></div>
</div>
</div>
<script>
// Dynamically ensure Mermaid is loaded (fallback to unpkg if jsDelivr fails)
(function ensureMermaid(cb){
function ok(){ try{ mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' }); cb(); }catch(e){ console.error(e); } }
if (window.mermaid) return ok();
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
s.onload = ok;
s.onerror = function(){
const f = document.createElement('script');
f.src = 'https://unpkg.com/mermaid@10/dist/mermaid.min.js';
f.onload = ok;
f.onerror = function(){
const logs = document.getElementById('logs');
if (logs) logs.innerText += '[error] Mermaid 加载失败,请检查网络或换源\n';
};
document.head.appendChild(f);
};
document.head.appendChild(s);
})(function(){
// ready
});
const partitionsInput = document.getElementById('partitions');
const strategySelect = document.getElementById('strategy');
const addBtn = document.getElementById('add-consumer');
const removeBtn = document.getElementById('remove-consumer');
const rebalanceBtn = document.getElementById('rebalance');
const consumersDiv = document.getElementById('consumers');
const assignmentDiv = document.getElementById('assignment');
const sequenceDiv = document.getElementById('sequence');
const logsDiv = document.getElementById('logs');
let numPartitions = 3;
let consumers = ['C1', 'C2', 'C3'];
let strategy = 'range';
/** @type {Record<number,string>} */
let previousAssignment = {};
function log(line) {
const time = new Date().toLocaleTimeString();
logsDiv.innerText += `[${time}] ${line}\n`;
logsDiv.scrollTop = logsDiv.scrollHeight;
}
function renderConsumers() {
consumersDiv.innerHTML = consumers.map(c => `<span class="pill">${c}</span>`).join('');
}
function computeRange(consumers, partitions) {
const nC = consumers.length;
const base = Math.floor(partitions / nC);
const extra = partitions % nC;
const result = {};
let p = 0;
consumers.forEach((c, i) => {
const count = base + (i < extra ? 1 : 0);
for (let k = 0; k < count; k++) {
result[p] = c;
p += 1;
}
});
return result;
}
function computeRoundRobin(consumers, partitions) {
const result = {};
for (let p = 0; p < partitions; p++) {
result[p] = consumers[p % consumers.length];
}
return result;
}
function computeSticky(consumers, partitions, prev) {
// 近似 Sticky:尽量复用 prev 分配,剩余按最小负载分配
const result = {};
const load = Object.fromEntries(consumers.map(c => [c, 0]));
// 先尝试保留
for (let p = 0; p < partitions; p++) {
const prevC = prev[p];
if (prevC && consumers.includes(prevC)) {
result[p] = prevC;
load[prevC] += 1;
}
}
// 对未分配分区进行填充
const unassigned = [];
for (let p = 0; p < partitions; p++) {
if (!result[p]) unassigned.push(p);
}
for (const p of unassigned) {
let target = consumers[0];
for (const c of consumers) {
if (load[c] < load[target]) target = c;
}
result[p] = target;
load[target] += 1;
}
return result;
}
function assign(consumers, partitions, strategy, prev) {
if (consumers.length === 0) return {};
if (strategy === 'range') return computeRange(consumers, partitions);
if (strategy === 'roundrobin') return computeRoundRobin(consumers, partitions);
return computeSticky(consumers, partitions, prev);
}
function buildAssignmentMermaid(assignMap) {
const header = `graph TD\n subgraph "Topic test-group"\n`;
let parts = '';
for (let p = 0; p < numPartitions; p++) {
parts += ` P${p}["分区 P${p}"]\n`;
}
const endSub = ' end\n';
let nodes = '';
for (const c of consumers) {
nodes += ` ${c}["消费者 ${c}"]\n`;
}
let edges = '';
for (let p = 0; p < numPartitions; p++) {
const c = assignMap[p];
if (c) edges += ` P${p} --> ${c}\n`;
}
return header + parts + endSub + nodes + edges;
}
function groupAssignments(assignMap) {
const groups = {};
for (let p = 0; p < numPartitions; p++) {
const c = assignMap[p];
if (!c) continue;
groups[c] = groups[c] || [];
groups[c].push(`P${p}`);
}
return groups;
}
function buildSequenceMermaid(prev, next) {
const header = `sequenceDiagram\n participant CO as Group Coordinator\n`;
let participants = '';
for (const c of consumers) {
participants += ` participant ${c} as ${c}\n`;
}
let body = '';
// 撤回与分配
const revokedByConsumer = {};
const assignedByConsumer = groupAssignments(next);
for (let p = 0; p < numPartitions; p++) {
const prevC = prev[p];
const nextC = next[p];
if (prevC && prevC !== nextC) {
if (!revokedByConsumer[prevC]) revokedByConsumer[prevC] = [];
revokedByConsumer[prevC].push(`P${p}`);
}
}
const anyChange = Object.keys(revokedByConsumer).length > 0 || JSON.stringify(prev) !== JSON.stringify(next);
if (anyChange) {
body += ` Note over CO,${consumers[consumers.length - 1]}: 触发再均衡\n`;
} else {
body += ` Note over CO,${consumers[consumers.length - 1]}: 分配未变化\n`;
}
for (const c of Object.keys(revokedByConsumer)) {
body += ` CO->>${c}: Rebalance start (revoke)\n`;
body += ` ${c}-->>CO: Revoke ACK (${revokedByConsumer[c].join(', ')})\n`;
}
for (const c of consumers) {
const parts = assignedByConsumer[c] || [];
body += ` CO->>${c}: Assign ${parts.join(', ') || '—'}\n`;
body += ` ${c}-->>CO: Assign ACK\n`;
}
return header + participants + body;
}
async function renderMermaid(targetEl, def) {
try {
const id = `m-${Math.random().toString(36).slice(2)}`;
const out = await mermaid.render(id, def);
targetEl.innerHTML = out.svg || '';
} catch (e) {
targetEl.innerHTML = `<pre style="white-space:pre-wrap;color:#fca5a5">渲染失败: ${String(e)}</pre>`;
console.error('Mermaid render error', e, def);
}
}
function doRebalance(reason) {
const next = assign(consumers, numPartitions, strategy, previousAssignment);
const seq = buildSequenceMermaid(previousAssignment, next);
const graph = buildAssignmentMermaid(next);
renderMermaid(assignmentDiv, graph);
renderMermaid(sequenceDiv, seq);
// 日志
if (reason) log(reason);
// 撤回日志
for (let p = 0; p < numPartitions; p++) {
const prevC = previousAssignment[p];
const nextC = next[p];
if (prevC && prevC !== nextC) log(`撤回 P${p} 从 ${prevC}`);
}
// 分配日志
const grouped = groupAssignments(next);
for (const c of consumers) {
const parts = (grouped[c] || []).join(', ');
log(`分配给 ${c}: ${parts || '—'}`);
}
previousAssignment = next;
}
// Handlers
partitionsInput.addEventListener('change', () => {
const v = parseInt(partitionsInput.value, 10);
numPartitions = Math.max(1, Math.min(32, isNaN(v) ? numPartitions : v));
partitionsInput.value = String(numPartitions);
doRebalance('更新分区数量');
});
strategySelect.addEventListener('change', () => {
strategy = strategySelect.value;
doRebalance(`切换策略: ${strategy}`);
});
addBtn.addEventListener('click', () => {
const nextIndex = consumers.length + 1;
consumers.push(`C${nextIndex}`);
renderConsumers();
doRebalance(`新增消费者 C${nextIndex}`);
});
removeBtn.addEventListener('click', () => {
if (consumers.length <= 1) return;
const removed = consumers.pop();
renderConsumers();
doRebalance(`移除消费者 ${removed}`);
});
rebalanceBtn.addEventListener('click', () => {
doRebalance('手动触发再均衡');
});
// Initial render
renderConsumers();
setTimeout(function(){ doRebalance('初始化分配'); }, 50);
</script>
</body>
</html>


浙公网安备 33010602011771号