分配策略是组协议的一部分
消费组决定「谁和谁一起分」,分配策略决定「怎么分」。两者在同一个 JoinGroup 协议里协商:每个成员上报自己支持的策略列表,协调器选出所有成员都支持的第一个,作为本轮组的协议。
这带来两个容易踩的坑:
- 策略不是纯本地配置。只有全组都支持某个策略,它才会生效;混配时可能退回另一个策略,甚至协商失败。
- 策略名会出现在组的元数据里。排查分配问题时,先看组当前用的到底是哪一个,而不是看某个消费者的配置文件。
本篇用同一套程序和同一组分区,把四种策略的分配结果、撤销范围、迁移分区数和再平衡耗时可量化地摆在一起。
四种策略各算什么
range:按主题分段
RangeAssignor 对每个主题单独排序成员和分区,然后按区间切:
每个消费者至少拿 n / c 个分区;
余数 r 个分区,分给排序后的前 r 个消费者。
4.3.0 的源码里这段算术在 TopicAssignmentState:
// org/apache/kafka/clients/consumer/RangeAssignor.java
numPartitionsPerConsumer = consumers.isEmpty() ? 0 : partitionInfos.size() / consumers.size();
remainingConsumersWithExtraPartition = consumers.isEmpty() ? 0 : partitionInfos.size() % consumers.size();
源码里还有一条 rack-aware 分支,会先按机架匹配再分配剩余分区。没有配置 client.rack 时这条分支不生效,但读源码时不要按旧版那个简单 for 循环去理解它。
「按主题单独算」是 range 的关键特征。单主题时它很整齐;多主题时,每个主题的余数都落在同一批消费者身上,倾斜会累加。
round-robin:所有分区排成一圈
RoundRobinAssignor 把所有订阅主题的分区按 topic-partition 排序,成员也排序,然后像发牌一样依次分发,遇到不订阅该主题的成员就跳过。它跨主题看总量,因此多主题场景通常比 range 均匀。
sticky:先均衡,再尽量不动
StickyAssignor 的目标有两个,按优先级:
- 分配尽量均衡(成员间分区数差不超过 1,或差距无法再通过迁移缩小)。
- 在满足第一条的前提下,尽量保留上一次的分配。
但它仍然是 eager 协议:再平衡时成员先撤销全部分区,再拿回新方案。保留的是「方案」,不是「消费不中断」。
cooperative-sticky:同样的方案,增量撤销
CooperativeStickyAssignor 继承 AbstractStickyAssignor,分配算法与 sticky 相同,但声明支持 COOPERATIVE 协议:
// org/apache/kafka/clients/consumer/CooperativeStickyAssignor.java
@Override
public List<RebalanceProtocol> supportedProtocols() {
return Arrays.asList(RebalanceProtocol.COOPERATIVE, RebalanceProtocol.EAGER);
}
协作协议下,成员只撤销「新方案里不再属于自己」的分区,其余分区继续消费。代价是协议要多走一轮:第一轮撤销,第二轮才把腾出来的分区交给新成员。
注意:cooperative-sticky 是经典组协议下的一种分配器,和 KIP-848 的 group.protocol=consumer 是两件事。后者把分配搬到 broker 端,partition.assignment.strategy 不再生效。
默认值不是单一策略
本机 4.3.0 的 ConsumerConfig 默认值是一个列表:
// org/apache/kafka/clients/consumer/ConsumerConfig.java
.define(PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
Type.LIST,
List.of(RangeAssignor.class, CooperativeStickyAssignor.class),
...
协调器选「所有成员都支持的第一个」。默认配置下,两个消费者都支持 range 和 cooperative-sticky,协商结果是 range:
group.protocol = classic
Successfully joined group with generation Generation{generationId=1, ..., protocol='range'}
所以「默认已经是 cooperative」是误解。默认列表里虽然有 cooperative-sticky,但排在第一的是 range,实际生效的是 range。要让组走协作协议,必须把 partition.assignment.strategy 显式设成只有 CooperativeStickyAssignor,并且组内所有成员都这么配。
实验:7 个分区,2 个消费者,再加第 3 个
设置
- 主题
v2c4.assign,7 个分区,单副本。 - 每种策略一个独立组:
v2c4-assign-range、-roundrobin、-sticky、-cooperative。 - 消费者 A 启动后跑 40 秒;第 6 秒启动 B;第 16 秒启动 C。
session.timeout.ms=10000、heartbeat.interval.ms=3000、max.poll.interval.ms=60000。- 每次成员变化后用 AdminClient 打印成员与分区归属。
观察程序的核心配置只有三行:
p.put(ConsumerConfig.GROUP_ID_CONFIG, "v2c4-assign-" + strategy);
p.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, assignorClass);
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
首次分配
A 单独启动时,四种策略都拿到全部 7 个分区,但耗时约 3.5 秒。原因不是分配慢,而是 group.initial.rebalance.delay.ms=3000:组第一次组建时协调器多等 3 秒,给其他成员加入的机会。本机实测 A 在 3523–3976 ms 之间完成首次分配。
B 加入后:2 个消费者
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;单次运行,随调度波动,不作基准):
| 策略 | A | B |
|---|---|---|
| range | 0,1,2,3(4 个) | 4,5,6(3 个) |
| round-robin | 0,2,4,6(4 个) | 1,3,5(3 个) |
| sticky | 0,1,2,3(4 个) | 4,5,6(3 个) |
| cooperative-sticky | 0,1,2,3(4 个) | 4,5,6(3 个) |
7 不能被 2 整除,4 比 3 是四种策略都无法避免的最小倾斜。range 与 round-robin 的区别在这里就能看出来:range 给 A 连续区间 0-3,round-robin 给 A 隔一个拿一个 0,2,4,6。
sticky 和 cooperative 在这一步都保留了 A 原来的 0-3,只把 4-6 给 B,迁移量最小。
C 加入后:3 个消费者
| 策略 | A | B | C |
|---|---|---|---|
| range | 0,1,2(3) | 3,4(2) | 5,6(2) |
| round-robin | 0,3,6(3) | 1,4(2) | 2,5(2) |
| sticky | 0,1(2) | 4,5,6(3) | 2,3(2) |
| cooperative-sticky | 0,1(2) | 4,5,6(3) | 2,3(2) |
7 不能被 3 整除,3-2-2 是最小倾斜。四种策略都达到了这个下界,但路径完全不同。
迁移了多少分区
把「C 加入前」和「加入后」的归属对比,统计换了主人的分区数:
| 策略 | 迁移的分区 | 数量 |
|---|---|---|
| range | p3(A→B)、p5(B→C)、p6(B→C) | 3 |
| round-robin | p2(A→C)、p4(A→B)、p3(B→A)、p5(B→C) | 4 |
| sticky | p2、p3(A→C) | 2 |
| cooperative-sticky | p2、p3(A→C) | 2 |
sticky 家族明显更稳:B 的 4,5,6 一个没动,只从 A 拿走两个分区就凑够了 C 的份额。
撤销了什么
这是 eager 与 cooperative 最本质的差别。C 加入时,各成员的撤销回调:
range: A REVOKED [p0,p1,p2,p3] B REVOKED [p4,p5,p6]
round-robin: A REVOKED [p0,p2,p4,p6] B REVOKED [p1,p3,p5]
sticky: A REVOKED [p0,p1,p2,p3] B REVOKED [p4,p5,p6]
cooperative: A REVOKED [p2,p3] B 没有撤销
前三种是 eager:即使 B 最终仍然持有 4,5,6,它也要先把这三个分区全部撤销再拿回来。cooperative 只让 A 撤销真正要搬走的 p2,p3,B 的分区完全不受影响。
多花的一轮
cooperative 的代价可以量出来。从 C 进程启动算起,到它真正持有分区:
| 策略 | C 拿到分区耗时 |
|---|---|
| range | 1456 ms |
| round-robin | 1983 ms |
| sticky | 1841 ms |
| cooperative-sticky | 4548 ms |
cooperative 的日志把两轮协议暴露得很清楚:
[ 1542 ms] C ASSIGNED []
[ 4555 ms] C ASSIGNED [v2c4.assign-2, v2c4.assign-3]
第一轮拿到空分配,第二轮才拿到分区,中间约 3 秒。同时 A 的日志是:
[ 18687 ms] A REVOKED [v2c4.assign-2, v2c4.assign-3]
[ 18688 ms] A ASSIGNED []
[ 21699 ms] A ASSIGNED []
多次 ASSIGNED [] 是协作协议的正常现象,不是错误。监控如果把它当成「分配失败」会误报。
倾斜的四种成因
整除余数
分区数不能被消费者数整除时,差 1 是数学下界,任何策略都消不掉。7 个分区 3 个消费者就是 3-2-2。要彻底均匀,只能让消费者数整除分区数,或者接受差 1。
多主题的 per-topic 余数
range 按主题单独取余,多个主题的余数会落在同一批成员上。用两个主题、各 4 个分区、3 个消费者做对照(同一环境,单次运行,不作基准):
range:
multi-A partitions=4 [v2c4.ta-0, v2c4.ta-1, v2c4.tb-0, v2c4.tb-1]
multi-B partitions=2 [v2c4.ta-2, v2c4.tb-2]
multi-C partitions=2 [v2c4.ta-3, v2c4.tb-3]
round-robin:
multi-A partitions=3 [v2c4.ta-0, v2c4.ta-3, v2c4.tb-2]
multi-B partitions=3 [v2c4.ta-1, v2c4.tb-0, v2c4.tb-3]
multi-C partitions=2 [v2c4.ta-2, v2c4.tb-1]
8 个分区、3 个消费者,理想是 3-3-2。round-robin 做到了;range 是 4-2-2,因为每个主题的「多出来的那 1 个」都给了排序第一的 A。主题越多,这种累加越明显。
sticky 的保留偏好
sticky 优先保留旧分配,这会把历史倾斜固化下来。如果上一次分配因为成员数不同而倾斜,新成员加入后它只做「够用就好」的迁移,不一定立刻回到全局最优。sticky 那一行里 B 保持 3 个、A 降到 2 个就是这种偏好的结果:总量仍均衡,但 A 和 B 的份额不是按加入顺序重排的。
处理能力与 key 分布
分配器只看分区数,不看分区里的消息量,也不看消费者的处理速度。两种常见放大:
- 某个 key 特别热,所在分区消息量是其他分区的数倍(第 10.2 节会展开);
- 同组消费者处理能力不同,分区数一样但完成时间差几倍(4.1 节实测:302 条对 140 条)。
这两种倾斜都不是换分配器能解决的,要回到 key 设计和组内同构。
怎么观测倾斜
三个层次,从粗到细:
- 成员与分区数:
kafka-consumer-groups.sh --describe --group <g> --members --verbose,看#PARTITIONS和CURRENT-ASSIGNMENT。 - 每组每分区的 lag:
--describe的LAG列,或 AdminClient 的listConsumerGroupOffsets与listOffsets相减。分区数相同但 lag 差很多,说明消息量或处理速度倾斜。 - 分配方案本身:用 AdminClient 直接读成员分配,适合写进巡检脚本:
ConsumerGroupDescription d = admin.describeConsumerGroups(List.of(group))
.all().get().get(group);
for (MemberDescription m : d.members()) {
System.out.println(m.clientId() + " -> " + m.assignment().topicPartitions());
}
判断顺序很重要:先看 lag 是否倾斜,再看分区数是否倾斜。如果 lag 倾斜但分区数均匀,换分配器没有用;如果分区数本身倾斜,才轮到策略和整除问题。
选型表
| 场景 | 建议 | 理由 |
|---|---|---|
| 单主题,分区数能被消费者数整除 | range | 最简单,分配稳定可预测 |
| 多主题,分区数各不相同 | round-robin | 跨主题看总量,避免 per-topic 余数累加 |
| 频繁扩缩容,单分区流量大 | cooperative-sticky | 只撤销搬走的分区,停顿范围最小 |
| 消费者版本混杂、无法统一配置 | range | 默认协商结果,升级阻力最小 |
使用 KIP-848(group.protocol=consumer) | 不配 partition.assignment.strategy | 分配在 broker 端,客户端策略不生效 |
一个迁移注意:从 eager 切到 cooperative,必须全组同时改配置并重启。滚动改会经历一段混配期,协商可能退回 eager,甚至因协议不兼容反复再平衡。稳妥做法是先把全组停掉,改配置,再一起拉起。
常见误解
- 「sticky 就不会再平衡」:错。sticky 只是少搬分区,协议仍然是 eager,成员变化照样触发全量撤销。
- 「cooperative 一定更快」:错。它停顿范围小,但多一轮协议,成员从启动到持有分区的总时长更长(实测 4.5 秒对 1.8 秒)。
- 「分区数一样就不倾斜」:错。消息量、key 分布、处理速度都会让同样的分区数产生不同负载。
- 「默认列表里有 cooperative 就是 cooperative」:错。列表按顺序协商,默认生效的是 range。
本节判据
- range 与 round-robin 的根本差别? 答:range 按主题分别取余分配,round-robin 把所有主题的分区排成一圈跨主题分发。
- sticky 与 cooperative-sticky 的差别在哪一层? 答:分配算法相同,协议不同;sticky 是 eager 全量撤销,cooperative 只撤销要搬走的分区并多走一轮。
- 7 个分区 3 个消费者,为什么最优是 3-2-2? 答:分区数不能被成员数整除,差 1 是数学下界。
- 两个 4 分区主题、3 个消费者,range 为什么是 4-2-2? 答:每个主题的余数都分给排序第一的成员,两个主题累加。
- 怎么判断该换分配器还是该改 key? 答:先看 lag 倾斜还是分区数倾斜;分区数均匀但 lag 倾斜,换分配器无效。
阅读导航:上一节 4.1 消费组设计:按业务还是按分区 | 下一节 4.3 扩缩容与优雅停机
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。