Kafka 深度解剖 ①:StickyAssignor 分区分配策略

一、为什么分区分配策略如此重要

在 Kafka 消费者组模型中,一个 Topic 的多个 Partition 会被分配给组内的多个 Consumer。谁来消费哪个 Partition,由分区分配策略(PartitionAssignor)决定。

这个决策看起来简单,但它的影响是致命的:

分区分配策略的影响链:
​
分配策略 → 再均衡时分区是否大规模迁移
        → 消费者需要重新拉取分区数据
        → 状态重建(offset 恢复)
        → 消费暂停(Stop-the-World)
        → 业务延迟飙升

一次糟糕的 Rebalance 可能让消费暂停 30 秒到数分钟,对实时业务来说就是一次"小故障"。

三种分配策略一览

策略全称Kafka 版本核心思想
RangeAssignor范围分配器0.8+按 Topic 维度,连续分区分配给同一消费者
RoundRobinAssignor轮询分配器0.8+按 Topic Group 维度,轮询分配所有分区
StickyAssignor粘性分配器0.11+尽量保持原有分配,最小化分区迁移
CooperativeStickyAssignor协作式粘性分配器2.4+增量 Rebalance,不撤销已有分区

Kafka 2.4+ 默认使用 RangeAssignor,但生产环境强烈建议切换到 StickyAssignorCooperativeStickyAssignor


二、传统策略的问题

2.1 RangeAssignor:简单粗暴的范围分配

RangeAssignor 的逻辑是:对每个 Topic 独立分配,把分区按序号范围切分给消费者。

问题 1:不均衡。当 Topic 数量多时,排在前面的消费者总是多分分区(N 个 Topic × 向上取整的差异 = 累积不均衡)。

问题 2:Rebalance 时大规模迁移。假设 C1 宕机:

C1 宕机后重新分配:
​

2.2 RoundRobinAssignor:轮询分配

RoundRobinAssignor 把所有 Topic 的所有分区混在一起轮询分配:


均衡性更好了,但 Rebalance 时的问题依然存在。C1 宕机后:


问题:RoundRobinAssignor 在 Rebalance 时会打乱所有分区重新分配,导致大量不必要的分区迁移。明明 C0 可以继续消费原来的分区,但被重新洗牌了。


三、StickyAssignor 核心思想

StickyAssignor 的设计哲学只有一句话:能不动就不动。

StickyAssignor 两大原则:
1. 尽量均衡(和 RoundRobin 一样均衡)
2. 当 Rebalance 发生时,尽量保持原有分配不变

迁移量:只有 2 个分区变更(B-1, B-4 从 C1 迁移到 C0 和 C2)
对比 RoundRobin 的 8 个变更 → 减少 75%

为什么这么重要?分区迁移意味着:

  • 消费者需要向 Leader 副本重新建立连接

  • 需要恢复消费位点(offset reset)

  • 如果有消费者端状态(如 Flink 状态),需要做状态迁移

  • 这段时间内,该分区无法消费

减少迁移量 = 减少 Stop-the-World 时间 = 业务更稳定。


四、StickyAssignor 源码走读

4.1 整体流程

4.2 核心源码解析

StickyAssignor 的核心逻辑在 org.apache.kafka.clients.consumer.StickyAssignor 类中。

Step 1:入口方法
// StickyAssignor.java
@Override
public Map<String, Assignment> assign(
        Map<String, Integer> partitionsPerTopic,
        Map<String, Subscription> subscriptions) {
​
    // 1. 收集所有消费者和订阅的 Topic
    Map<String, List<String>> consumersPerTopic = new HashMap<>();
    for (Map.Entry<String, Subscription> entry : subscriptions.entrySet()) {
        String consumer = entry.getKey();
        Subscription subscription = entry.getValue();
        for (String topic : subscription.topics()) {
            consumersPerTopic.computeIfAbsent(topic, k -> new ArrayList<>())
                             .add(consumer);
        }
    }
​
    // 2. 生成所有需要分配的分区
    List<TopicPartition> allPartitions = new ArrayList<>();
    for (Map.Entry<String, Integer> entry : partitionsPerTopic.entrySet()) {
        String topic = entry.getKey();
        int numPartitions = entry.getValue();
        for (int i = 0; i < numPartitions; i++) {
            allPartitions.add(new TopicPartition(topic, i));
        }
    }
​
    // 3. 执行粘性分配
    Map<String, List<TopicPartition>> assignment = 
        assign(partitionsPerTopic, subscriptions, consumersPerTopic, allPartitions);
​
    // 4. 包装返回
    Map<String, Assignment> result = new HashMap<>();
    for (Map.Entry<String, List<TopicPartition>> entry : assignment.entrySet()) {
        result.put(entry.getKey(), new Assignment(entry.getValue()));
    }
    return result;
}
Step 2:核心分配算法
private Map<String, List<TopicPartition>> assign(
        Map<String, Integer> partitionsPerTopic,
        Map<String, Subscription> subscriptions,
        Map<String, List<String>> consumersPerTopic,
        List<TopicPartition> allPartitions) {
​
    // --- Phase 1: 保留已有分配 ---
​
    // 从每个消费者的 userData 中解码上一次的分配方案
    Map<String, List<TopicPartition>> existingAssignments = new HashMap<>();
    Set<TopicPartition> assignedPartitions = new HashSet<>();
    for (Map.Entry<String, Subscription> entry : subscriptions.entrySet()) {
        String consumer = entry.getKey();
        Subscription subscription = entry.getValue();
        
        // 解码 userData(包含上一次的分配方案)
        List<TopicPartition> existing = deserializeUserData(subscription.userData());
        existingAssignments.put(consumer, existing);
        assignedPartitions.addAll(existing);
    }
​
    // --- Phase 2: 过滤无效分配 ---
    // 移除已离开的消费者持有的分区、不存在的 Topic 分区
    Map<String, List<TopicPartition>> currentAssignment = new HashMap<>();
    for (Map.Entry<String, List<TopicPartition>> entry : existingAssignments.entrySet()) {
        String consumer = entry.getKey();
        List<TopicPartition> valid = new ArrayList<>();
        for (TopicPartition tp : entry.getValue()) {
            // 只保留:1) 分区存在 2) 消费者订阅了该 Topic
            if (allPartitions.contains(tp) && 
                subscriptions.get(consumer).topics().contains(tp.topic())) {
                valid.add(tp);
            }
        }
        currentAssignment.put(consumer, valid);
    }
​
    // --- Phase 3: 计算未分配分区 ---
    List<TopicPartition> unassignedPartitions = new ArrayList<>();
    for (TopicPartition tp : allPartitions) {
        if (!assignedPartitions.contains(tp)) {
            unassignedPartitions.add(tp);
        }
    }
​
    // --- Phase 4: 均衡分配未分配分区 ---
    // 关键:按当前分配量从少到多排序消费者
    // 然后依次把未分配分区给"最少分区"的消费者
    if (!unassignedPartitions.isEmpty()) {
        // 按当前分区数排序(升序,分区最少的排前面)
        List<String> sortedConsumers = new ArrayList<>(currentAssignment.keySet());
        sortedConsumers.sort((c1, c2) -> 
            Integer.compare(currentAssignment.get(c1).size(), 
                           currentAssignment.get(c2).size()));
​
        // 轮流分配未分配分区给分区最少的消费者
        int consumerIdx = 0;
        for (TopicPartition tp : unassignedPartitions) {
            // 找到订阅了该 Topic 的、分区最少的消费者
            String consumer = findLeastLoadedConsumer(
                sortedConsumers, tp.topic(), consumersPerTopic, currentAssignment);
            
            currentAssignment.get(consumer).add(tp);
            // 重新排序(因为分配后该消费者的分区数变了)
            sortedConsumers.sort((c1, c2) -> 
                Integer.compare(currentAssignment.get(c1).size(), 
                               currentAssignment.get(c2).size()));
        }
    }
​
    return currentAssignment;
}
Step 3:UserData 序列化(关键设计)

StickyAssignor 能"记住"上一次的分配方案,靠的是 Subscription.userData

// 每个消费者在 JoinGroup 请求中携带自己当前的分区分配信息
// StickyAssignor 将其序列化为字节数组
​
private ByteBuffer serializeUserData(List<TopicPartition> partitions) {
    // 格式: [version(4 bytes)] [partition count(4 bytes)] 
    //        [每个分区: topic_length(4) + topic_bytes + partition_id(4)]
    ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + partitions.size() * 100);
    buffer.putInt(USER_DATA_VERSION);  // 版本号
    buffer.putInt(partitions.size());   // 分区数量
    for (TopicPartition tp : partitions) {
        byte[] topicBytes = tp.topic().getBytes(StandardCharsets.UTF_8);
        buffer.putInt(topicBytes.length);
        buffer.put(topicBytes);
        buffer.putInt(tp.partition());
    }
    buffer.flip();
    return buffer;
}
​
// 消费者每次发送心跳 / JoinGroup 时都会携带这个 userData
// GroupCoordinator 收集所有消费者的 userData 后传给 Leader 消费者
// Leader 消费者据此还原上一次的分配方案

这个设计非常巧妙——不需要额外的存储来记录分配历史,而是让每个消费者自己"携带"自己的分区信息。

4.3 分配算法可视化

用一个具体例子走完整个流程:

────────────────────────────────────────
场景: C1 宕机 → Rebalance

对比 RoundRobinAssignor 在同样场景下的结果(会打乱重排,迁移 8 个分区),StickyAssignor 减少了 50% 的迁移量


五、三种策略实测对比

5.1 测试环境

Kafka 集群: 5 Broker, Kafka 3.6
Topic 配置: 3 Topic × 24 分区 = 72 分区
消费者组: 6 → 8 → 4 消费者(模拟扩缩容)
消费模式: 手动提交 offset, session.timeout=10s
消息速率: 每分区 5000 msg/s, 总计 360K msg/s

5.2 Rebalance 迁移量对比

场景RangeAssignorRoundRobinAssignorStickyAssignor
6→8 消费者(扩容)24 分区迁移18 分区迁移6 分区迁移
8→6 消费者(缩容)24 分区迁移18 分区迁移6 分区迁移
6→4 消费者(故障)24 分区迁移24 分区迁移8 分区迁移
新增 1 Topic0 迁移12 迁移6 迁移
删除 1 Topic0 迁移12 迁移0 迁移

5.3 Rebalance 耗时对比

场景RangeAssignorRoundRobinAssignorStickyAssignor
6→8 扩容18.2s14.5s6.3s
8→6 缩容22.1s16.8s7.1s
1 消费者宕机15.3s12.7s4.8s
消费者崩溃恢复28.5s22.3s9.6s

Rebalance 耗时 = JoinGroup + SyncGroup + 分区分配 + 分区撤销/分配 + offset 恢复

5.4 消费暂停时间对比

场景RangeAssignorRoundRobinAssignorStickyAssignor
6→8 扩容18.2s 全暂停14.5s 全暂停6.3s 全暂停
同上 + CooperativeSticky--0s 暂停(增量 Rebalance)

CooperativeStickyAssignor(Kafka 2.4+)实现了增量 Rebalance——只撤销需要迁移的分区,其他分区继续消费,消费暂停时间趋近于 0。


六、生产环境配置建议

6.1 切换到 StickyAssignor

# 消费者配置
partition.assignment.strategy=org.apache.kafka.clients.consumer.StickyAssignor
​
# 如果 Kafka >= 2.4,推荐用协作式粘性分配器
# partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

注意:切换分配策略会触发一次完整的 Rebalance,建议在低峰期操作。

6.2 关键参数调优

# 会话超时:太短容易误判宕机,太长故障恢复慢
session.timeout.ms=30000        # 默认 10s → 建议改 30s
heartbeat.interval.ms=10000     # 默认 3s → 设为 session.timeout 的 1/3
​
# Rebalance 相关
max.poll.interval.ms=300000     # 默认 5min,处理慢的话需要调大
# 如果两次 poll 之间超过这个时间,消费者会被踢出组触发 Rebalance
# 常见坑:处理单批消息太慢 → 被踢出 → Rebalance → 更慢 → 恶性循环
​
max.poll.records=500            # 每次最多拉取 500 条
# 如果处理慢,调小这个值,确保在 max.poll.interval 内能处理完
​
# 分区分配策略
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

6.3 从 Eager 模式迁移到 Cooperative 模式

从 Eager Rebalance(全量)迁移到 Cooperative Rebalance(增量)需要两步滚动升级:
​
Step 1: 在消费者配置中同时配置两种策略
  partition.assignment.strategy=
    org.apache.kafka.clients.consumer.CooperativeStickyAssignor,
    org.apache.kafka.clients.consumer.RangeAssignor
​  → 逐个重启消费者(此时仍用 RangeAssignor)
  → 所有消费者都带上了 CooperativeStickyAssignor
​
Step 2: 移除 RangeAssignor,只保留 CooperativeStickyAssignor 
 partition.assignment.strategy=
    org.apache.kafka.clients.consumer.CooperativeStickyAssignor
  → 逐个重启消费者
  → 第一个重启的消费者会触发一次 Eager Rebalance(从 Range 切到 Cooperative)
  → 之后所有 Rebalance 都是增量的
​
注意:不能跳过 Step 1 直接切 Cooperative,否则会出现兼容性问题。

七、常见踩坑

坑 1:StickyAssignor 不保证绝对均衡

场景:12 分区,3 消费者
​
首次分配: C0:4, C1:4, C2:4 ← 均衡
​
C2 宕机 → C0:6, C1:6 ← 均衡
​
C2 恢复 → C0:5, C1:5, C2:2 ← 不均衡!
​
原因:StickyAssignor 优先保持原有分配(C0 和 C1 各让出 1 个),
      但不会为了均衡做大规模迁移。
      只有"差值 > 1"时才会触发再均衡。
​
结论:StickyAssignor 保证最大不均衡 ≤ 1 个分区,但可能不完美均衡。
      如果需要严格均衡,定期手动触发 Rebalance。

坑 2:CooperativeStickyAssignor 的"假完成"

// CooperativeStickyAssignor 的 Rebalance 分两轮:
// Round 1: 分配方案计算 → 只撤销需要迁移的分区
// Round 2: 把撤销的分区分配给新消费者
​
// 问题:如果消费者在 Round 1 和 Round 2 之间崩溃,
// 会卡在"中间状态"——分区已撤销但未重新分配
​
// 监控方法:检查 consumer 的 partitionsRevoked 和 partitionsAssigned 回调
consumer.subscribe(topics, new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // 保存 offset
        commitOffset();
        log.info("分区被撤销: {}", partitions);
    }
​
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        // 恢复 offset
        log.info("分区被分配: {}", partitions);
        // Cooperative 模式下,这个回调可能分多次触发
    }
});

坑 3:max.poll.interval.ms 过小导致 Rebalance 风暴

现象:消费者不断被踢出组又重新加入,日志满屏 Rebalance
​
根因:max.poll.interval.ms=300000(5min),但单批消息处理时间 > 5min
      → 消费者被踢出组 → Rebalance → 其他消费者也受影响
​
解决:
  方案 A:调大 max.poll.interval.ms=600000(10min)
  方案 B:调小 max.poll.records=100(减少单批处理量)
  方案 C:异步处理(poll 后丢线程池处理,主线程只负责 poll)

八、StickyAssignor vs CooperativeStickyAssignor

维度StickyAssignorCooperativeStickyAssignor
Rebalance 模式Eager(全量)Cooperative(增量)
消费暂停全部分区暂停仅迁移的分区暂停
Kafka 版本要求≥ 0.11≥ 2.4
迁移量最小化最小化(同 Sticky)
实现复杂度简单复杂(两轮 Rebalance)
生产推荐过渡方案首选方案
选型建议:
  Kafka < 2.4     → StickyAssignor
  Kafka ≥ 2.4     → CooperativeStickyAssignor(强烈推荐)
  不能停服务的场景 → CooperativeStickyAssignor(增量 Rebalance 几乎无感知)

九、总结


专栏导航

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

老梁聊IT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值