《Java 消息中间件·可靠消息与消费治理》4.2 分配策略与消费者倾斜

7 个分区、2 到 3 个消费者,range、round-robin、sticky、cooperative-sticky 四种策略各跑一遍:谁撤销了全部分区,谁只撤销搬走的那几个,谁多花一轮协议,谁把 8 个分区分成 4-2-2。本篇把分配结果、迁移分区数、再平衡耗时与倾斜成因放进同一组实测数据,并给出观测与选型方法。

分配策略是组协议的一部分

消费组决定「谁和谁一起分」,分配策略决定「怎么分」。两者在同一个 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. 分配尽量均衡(成员间分区数差不超过 1,或差距无法再通过迁移缩小)。
  2. 在满足第一条的前提下,尽量保留上一次的分配。

但它仍然是 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;单次运行,随调度波动,不作基准):

策略AB
range0,1,2,3(4 个)4,5,6(3 个)
round-robin0,2,4,6(4 个)1,3,5(3 个)
sticky0,1,2,3(4 个)4,5,6(3 个)
cooperative-sticky0,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 个消费者

策略ABC
range0,1,2(3)3,4(2)5,6(2)
round-robin0,3,6(3)1,4(2)2,5(2)
sticky0,1(2)4,5,6(3)2,3(2)
cooperative-sticky0,1(2)4,5,6(3)2,3(2)

7 不能被 3 整除,3-2-2 是最小倾斜。四种策略都达到了这个下界,但路径完全不同。

迁移了多少分区

把「C 加入前」和「加入后」的归属对比,统计换了主人的分区数:

策略迁移的分区数量
rangep3(A→B)、p5(B→C)、p6(B→C)3
round-robinp2(A→C)、p4(A→B)、p3(B→A)、p5(B→C)4
stickyp2、p3(A→C)2
cooperative-stickyp2、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 拿到分区耗时
range1456 ms
round-robin1983 ms
sticky1841 ms
cooperative-sticky4548 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 设计和组内同构。

怎么观测倾斜

三个层次,从粗到细:

  1. 成员与分区数:kafka-consumer-groups.sh --describe --group <g> --members --verbose,看 #PARTITIONS 和 CURRENT-ASSIGNMENT。
  2. 每组每分区的 lag:--describe 的 LAG 列,或 AdminClient 的 listConsumerGroupOffsets 与 listOffsets 相减。分区数相同但 lag 差很多,说明消息量或处理速度倾斜。
  3. 分配方案本身:用 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。

本节判据

  1. range 与 round-robin 的根本差别? 答:range 按主题分别取余分配,round-robin 把所有主题的分区排成一圈跨主题分发。
  2. sticky 与 cooperative-sticky 的差别在哪一层? 答:分配算法相同,协议不同;sticky 是 eager 全量撤销,cooperative 只撤销要搬走的分区并多走一轮。
  3. 7 个分区 3 个消费者,为什么最优是 3-2-2? 答:分区数不能被成员数整除,差 1 是数学下界。
  4. 两个 4 分区主题、3 个消费者,range 为什么是 4-2-2? 答:每个主题的余数都分给排序第一的成员,两个主题累加。
  5. 怎么判断该换分配器还是该改 key? 答:先看 lag 倾斜还是分区数倾斜;分区数均匀但 lag 倾斜,换分配器无效。

阅读导航:上一节 4.1 消费组设计:按业务还是按分区 | 下一节 4.3 扩缩容与优雅停机

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收