一、为什么分区分配策略如此重要
在 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,但生产环境强烈建议切换到StickyAssignor或CooperativeStickyAssignor。
二、传统策略的问题
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 迁移量对比
| 场景 | RangeAssignor | RoundRobinAssignor | StickyAssignor |
|---|---|---|---|
| 6→8 消费者(扩容) | 24 分区迁移 | 18 分区迁移 | 6 分区迁移 |
| 8→6 消费者(缩容) | 24 分区迁移 | 18 分区迁移 | 6 分区迁移 |
| 6→4 消费者(故障) | 24 分区迁移 | 24 分区迁移 | 8 分区迁移 |
| 新增 1 Topic | 0 迁移 | 12 迁移 | 6 迁移 |
| 删除 1 Topic | 0 迁移 | 12 迁移 | 0 迁移 |
5.3 Rebalance 耗时对比
| 场景 | RangeAssignor | RoundRobinAssignor | StickyAssignor |
|---|---|---|---|
| 6→8 扩容 | 18.2s | 14.5s | 6.3s |
| 8→6 缩容 | 22.1s | 16.8s | 7.1s |
| 1 消费者宕机 | 15.3s | 12.7s | 4.8s |
| 消费者崩溃恢复 | 28.5s | 22.3s | 9.6s |
Rebalance 耗时 = JoinGroup + SyncGroup + 分区分配 + 分区撤销/分配 + offset 恢复
5.4 消费暂停时间对比
| 场景 | RangeAssignor | RoundRobinAssignor | StickyAssignor |
|---|---|---|---|
| 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
| 维度 | StickyAssignor | CooperativeStickyAssignor |
|---|---|---|
| Rebalance 模式 | Eager(全量) | Cooperative(增量) |
| 消费暂停 | 全部分区暂停 | 仅迁移的分区暂停 |
| Kafka 版本要求 | ≥ 0.11 | ≥ 2.4 |
| 迁移量 | 最小化 | 最小化(同 Sticky) |
| 实现复杂度 | 简单 | 复杂(两轮 Rebalance) |
| 生产推荐 | 过渡方案 | 首选方案 |
选型建议: Kafka < 2.4 → StickyAssignor Kafka ≥ 2.4 → CooperativeStickyAssignor(强烈推荐) 不能停服务的场景 → CooperativeStickyAssignor(增量 Rebalance 几乎无感知)
九、总结

专栏导航:

451

被折叠的 条评论
为什么被折叠?



