第 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()

可视化:分区与消费者的对应关系

graph TD subgraph "Topic test-group" P0["分区 P0"] P1["分区 P1"] P2["分区 P2"] end C1["消费者 C1"] C2["消费者 C2"] C3["消费者 C3"] P0 --> C1 P1 --> C2 P2 --> C3

可视化:再均衡时序

sequenceDiagram participant C1 as Consumer C1 participant C2 as Consumer C2 participant C3 as Consumer C3 participant CO as Group Coordinator Note over C1,C3: C2 下线或异常 CO->>C1: Rebalance start (revoke) CO->>C3: Rebalance start (revoke) C1-->>CO: Revoke ACK C3-->>CO: Revoke ACK CO->>C1: Assign P0, P1 CO->>C3: Assign P2 C1-->>CO: Assign ACK C3-->>CO: Assign ACK Note over C1,C3: 新分配完成,恢复拉取

三、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.mssession.timeout.ms
    • 处理耗时较长的消息时,增大 max.poll.interval.ms,避免被判定为卡死触发再均衡;
    • 调整 max.poll.records 控制批量处理大小。
  • 尽量使用 Sticky 分配策略:降低频繁再均衡对吞吐的影响。
  • 提交策略建议:
    • 需要强一致时使用同步提交;
    • 更关注吞吐可使用异步提交并配合回调与补偿;
    • 与幂等业务逻辑结合,抵御重复消息。
  • 观测与告警:
    • 关注 rebalance 频率、commit 失败率、消费延迟等关键指标;
    • 在再均衡回调中做好状态落盘与资源清理。

环境准备与运行步骤

  1. 安装依赖(建议使用虚拟环境):
python3 -m venv .venv && source .venv/bin/activate
pip install kafka-python
  1. 启动本地或远程 Kafka 集群,并确认 localhost:9092 可访问(或修改示例中的 bootstrap_servers)。

  2. 创建 Topic:

kafka-topics.sh --create \
  --topic test-group \
  --bootstrap-server localhost:9092 \
  --partitions 3 \
  --replication-factor 1
  1. 发送测试消息:
python lesson_four/producer_keyed.py
  1. 启动多个消费者实例并观察分配与再均衡:
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>

image

posted @ 2025-09-06 09:11  何双新  阅读(358)  评论(0)    收藏  举报