组是职责边界,不是进程集合
Kafka 的消费组(consumer group)同时承担三件事:
- 订阅边界:组内成员共同订阅哪些主题。
- 位移命名空间:每个组在每个分区上有一份独立的已提交位移,存在
__consumer_offsets里。 - 再平衡单位:成员增减、订阅变化、分区数变化,都以组为单位重新分配。
这三件事绑在一起,决定了消费组设计的第一条原则:一个组只做一件事。组不是「同一份代码起了几个实例」的同义词,而是「同一份职责、同一套位移、同一个失败域」。
判断方法很简单:两个消费者处理同一条消息的副作用是否相同。相同,才应该放在同一个组里分摊分区;不同,就必须拆成两个组,各自收到完整的一份。
libraryd 里 library.loan.created 有两个下游:库存服务要扣减可借数量,通知服务要给读者发确认。它们看着都在「处理借阅创建」,但副作用完全不同。如果放进同一个组,6 个分区被两个服务各拿 3 个,结果是:库存服务只收到一半借阅事件,通知服务只收到另一半。两边都不会报错,只是数据悄悄少了一半。
一个组一个职责
「职责」的粒度按失败影响面划,不按代码仓库划。三条实用规则:
- 不同副作用 → 不同组。 扣库存、发通知、写审计日志,三个组。
- 不同 SLA → 不同组。 实时告警要求秒级,报表落库可以分钟级,不要共享一个组。
- 不同团队维护 → 不同组。 组的位移、lag、重启节奏都是运维对象,跨团队共享组等于共享故障。
反模式是「为了省消费者,一个组订阅所有主题」。省下的是进程数,付出的是耦合:任何一个主题增加分区,整个组再平衡;任何一条毒消息卡住分区,组内所有主题的消费都停;任何一次下游变慢,整组 lag 一起上涨。
同组消费者的处理能力必须一致
分配器只按分区数量分,不按处理速度分。同一个组里放一个快消费者和一个慢消费者,分区数可以是 3 比 3,但完成时间不会是 3 比 3。
实验设置
主题 v2c4.uneven3,6 个分区,写入 600 条带随机 key 的记录,各分区条数为:
p0=86 p1=118 p2=98 p3=116 p4=94 p5=88
两个消费者在同一个组 v2c4-uneven3,都用默认 RangeAssignor:
fast:每条消息处理 5 ms。slow:每条消息处理 100 ms。
两者 max.poll.records=10,最多每秒提交一次,跑 20 秒。
分配结果
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;单次运行,随调度波动,不作基准):
group=v2c4-uneven3 state=Stable protocol=range members=2
drain-fast partitions=3 [v2c4.uneven3-0, v2c4.uneven3-1, v2c4.uneven3-2]
drain-slow partitions=3 [v2c4.uneven3-3, v2c4.uneven3-4, v2c4.uneven3-5]
消费结果
fast total=302 perPartition={0=86, 1=118, 2=98}
slow total=140 perPartition={3=116, 4=24}
20 秒后抓这个组的位移:
group=v2c4-uneven3 topic=v2c4.uneven3
p0 committed=86 end=86 lag=0
p1 committed=118 end=118 lag=0
p2 committed=98 end=98 lag=0
p3 committed=116 end=116 lag=0
p4 committed=24 end=94 lag=70
p5 committed=0 end=88 lag=88
sumLag=158
三个数字放在一起看:
fast按 5 ms/条计算,302 条约 1.5 秒处理完,剩下 18 秒在空转。slow20 秒只处理了 140 条,p4、p5还积压 158 条。- 整组的完成时间由最慢的那个成员决定,而不是由平均速度决定。
分配器没有做错任何事。它看到的是「3 个分区对 3 个分区」,看不到分区背后的消息量和处理耗时。这就是为什么同一个组的消费者必须同镜像、同配置、同下游。如果确实需要异构处理能力,正确做法是拆组,而不是调大慢消费者的分区数。
多组订阅同一主题的代价
拆组解决了职责隔离,代价是同一批消息被复制多份。这个代价可以直接量出来。
实验设置
主题 v2c4.fanout,6 个分区,写入 120 条记录。三个消费者分别属于三个组 v2c4-fanout2-gA、v2c4-fanout2-gB、v2c4-fanout2-gC,订阅同一个主题,跑 12 秒。
实测结果
gA total=120 perPartition={0=14, 1=22, 2=13, 3=19, 4=23, 5=29}
metrics.records-consumed-total=120.0 metrics.bytes-consumed-total=2277.0
gB total=120 perPartition={0=14, 1=22, 2=13, 3=19, 4=23, 5=29}
metrics.records-consumed-total=120.0 metrics.bytes-consumed-total=2277.0
gC total=120 perPartition={0=14, 1=22, 2=13, 3=19, 4=23, 5=29}
metrics.records-consumed-total=120.0 metrics.bytes-consumed-total=2277.0
三个组看到的记录集合、分区分布完全一致,每个组都消费了全部 120 条。对 broker 来说,这不是「广播一次」,而是三份独立的 fetch:同一批 120 条记录被投递了 360 次。三个组的 lag 随后都是 0,因为每个组都追上了自己的位移。
代价清单:
| 代价 | 表现 | 量级 |
|---|---|---|
| 网络与 broker 读放大 | 每个组各自 fetch | 组数 × 消息字节 |
| 位移存储 | 每组每分区一条提交记录 | 组数 × 分区数 |
| 再平衡次数 | 每组独立再平衡 | 组数 × 成员变化 |
| 监控对象 | 每组一份 lag、消费速率 | 组数 × 指标 |
| 契约兼容 | 每个消费者都要能解析新 schema | 组数 × 版本 |
这些代价在大多数业务里是值得的,因为它换来的是故障隔离和独立扩缩容。不值得的情况只有一种:多个「组」其实在做同一件事,只是被拆成了不同服务名。那就是用复制成本换来了假的隔离。
位移是每组独立的
三个组消费完之后,分别查位移,都是 sumLag=0,但三份位移互不影响:
group=v2c4-fanout2-gA p0 committed=14 end=14 lag=0
group=v2c4-fanout2-gB p0 committed=14 end=14 lag=0
group=v2c4-fanout2-gC p0 committed=14 end=14 lag=0
这条性质有两个直接后果。第一,任何一组出错都不会影响其他组:gA 的消费者崩了,gB、gC 照常消费。第二,任何一组的位移都不能代表其他组:排查「消息丢了吗」时,必须按组分别看,不能拿一个组的 committed 去推断全局。
位移不是永久保存的。本机 broker 默认值:
offsets.retention.minutes=10080
10080 分钟是 7 天。一个组如果连续 7 天没有任何成员、也没有提交,协调器会删除它的位移。之后再用同一个 group.id 启动,且 auto.offset.reset=earliest,就会从最早的位置重新消费——这不是 Kafka 丢消息,是组已经不存在了。长期停用的组要么保留一个空转成员,要么接受重置,并在启动前显式指定位点。
三种分组方案对照
| 方案 | 组边界 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 按业务域分组 | 一个业务域一个组,订阅该域全部主题 | 消费者少,部署简单 | 主题间耦合;任一主题再平衡影响全组;毒消息阻塞面大 | 主题少、团队单一、流量低的内部系统 |
| 按处理职责分组 | 一个下游职责一个组,多个组订阅同一主题 | 隔离彻底;独立扩缩容;故障不扩散 | 组数 × 复制与位移成本;需要统一 schema 治理 | 绝大多数生产场景 |
| 按分区手工分配 | 不依赖组协调,assign() 指定分区 | 分配完全可控;无再平衡;可做分区级灰度 | 成员故障要自己发现;扩容要手工改配置;lag 要自己记 | 固定分区、强顺序、专用消费(如索引构建) |
手工分配的骨架如下,注意它不参与组协调,group.id 只用于位移提交:
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
List<TopicPartition> owned = List.of(
new TopicPartition("library.loan.created", 0),
new TopicPartition("library.loan.created", 1));
consumer.assign(owned);
consumer.seekToBeginning(owned); // 或者从外部存储恢复位点
手工分配的坑在于「谁负责发现成员死亡」。组协调器帮你做的事——心跳、踢出、重分配——现在都要自己实现。没有把握时不要用它省事。
分区是并行单位,不是组织单位
标题问「按业务还是按分区」,答案取决于这两个词指什么。
分区是并行单位:一个分区的消息只能被同组的一个消费者处理,所以 分区数 是组内有效并行度的上限。6 个分区最多养活 6 个消费者,第 7 个只能分到空集合,加入后还会触发一次无意义的再平衡。反过来说,7 个分区分给 3 个消费者,必然是 3-2-2,倾斜从分配那一刻就注定了。
分区也是顺序单位:同一个 key 的消息落在同一个分区,才保证按写入顺序被消费。如果业务要求「同一本书的借还事件有序」,key 必须用 bookId,分区数一旦改变,key 到分区的映射也会变(10.3 会实测)。这意味着分区数不是可以随手调整的部署参数,它同时是顺序契约的一部分。
但分区不是组织单位。不要按「p0 一个服务、p1 一个服务」去拆消费者,那等于用手工分配替代组协调,把故障发现、扩缩容、位移管理全部变成人工操作。正确的顺序是:
- 先按职责划分组;
- 再按吞吐估算分区数与消费者数;
- 最后用 key 设计保证需要有序的消息落在同一分区。
把这三步倒过来——先按分区拆服务,再让多个服务共享一个组——就会出现「消费者数量看似够,实际每个分区只有一个固定处理者」的伪并行:扩缩容不生效,成员故障也没人接管。
组的状态与空组回收
协调器里的组有五个状态,运维时能直接看到:
| 状态 | 含义 | 这时能做什么 |
|---|---|---|
Stable | 成员与分配都已就绪 | 正常消费;此时扩容会触发新一轮再平衡 |
Empty | 没有成员,但位移还在 | 可以安全地重新加入;位移按保留期计时 |
PreparingRebalance | 正在等成员重新加入 | 新成员可以加入;老成员在 poll() 里等待 |
CompletingRebalance | 分配方案下发中 | 不要在这时重启,容易叠加多轮 |
Dead | 组已删除,位移被清理 | 再启动等同新组,按 auto.offset.reset 决定起点 |
滚动发布时,如果所有成员同时退出,组会短暂进入 Empty 再重新组建;如果逐个退出,则是一连串 PreparingRebalance。后者对 lag 更友好,但发布更慢。选择哪一种,取决于这个组能容忍多长的消费中断,而不是取决于发布脚本方便。
组 ID 是可运维资产
group.id 会出现在位移主题、监控面板、告警规则和值班手册里,命名要按可检索的结构来:
<domain>.<service>.<env>.<version>
例:libraryd.inventory.prod.v2
两个细节:
- 迁移时不要复用旧组 ID。 新版本逻辑上线,用新组 ID 从
earliest或指定时间点重放,旧组保留到确认无需回滚。复用组 ID 会让新代码直接接上旧位移,跳过历史消息。 - 静态成员用
group.instance.id。 滚动重启时,带实例 ID 的成员在session.timeout.ms内回来可以拿回原分区,避免整个组连续再平衡。实例 ID 必须全局唯一且在重启间保持不变。
本机 4.3.0 客户端配置日志里可以看到协议选择:
group.protocol = classic
group.remote.assignor = null
group.protocol=classic 是默认值,分配在客户端算;改成 consumer 会走 KIP-848 的服务端分配。两者不能混用,partition.assignment.strategy 只对 classic 生效,这一点在 4.2 展开。
libraryd 的分组落地
把上面的原则套到贯穿项目上,主题与组的对应关系如下(这是本书构造的示例,不是线上现状):
| 主题 | 消费组 | 职责 | 组内消费者数上限 |
|---|---|---|---|
library.loan.created | libraryd.inventory.prod.v2 | 扣减可借库存 | 6(等于分区数) |
library.loan.created | libraryd.notify.prod.v2 | 发送借阅确认 | 6 |
library.loan.returned | libraryd.inventory.prod.v2 | 归还入库 | 6 |
library.loan.overdue | libraryd.fee.prod.v2 | 生成逾期费用 | 3 |
library.hold.placed | libraryd.notify.prod.v2 | 预约到书通知 | 6 |
这里假设 loan.created、loan.returned、hold.placed 都是 6 分区,loan.overdue 是 3 分区。
两点值得注意。第一,libraryd.inventory.prod.v2 同时订阅 created 与 returned,这是同一职责的两个事件,放在一个组合理,因为它们共同维护同一份库存状态;但代价是两个主题的分区数必须一起规划,任一个扩容都会让整组再平衡。第二,组内消费者数不要超过该组订阅主题的最小分区数,多出来的成员只会拿到空分配,还会触发无意义的再平衡。
选择清单
设计一个新消费组时,按顺序回答:
- 这条消息有几个不同的副作用?每个副作用一个组。
- 组内成员的镜像、配置、下游是否完全一致?不一致就拆。
- 这个组能不能独立扩缩容?如果需要和别的职责一起扩,说明组边界划错了。
- 组 ID 是否带环境与版本?迁移时能否并存新旧两个组?
- 多组复制的成本是否可接受?用「组数 × 消息字节」估算 broker 出口流量。
这张清单可以在每次新建组时当作评审模板,五项都答完再上线。
本节判据
- 消费组的三重含义是什么? 答:订阅边界、位移命名空间、再平衡单位。
- 两个消费者什么时候该在同一个组? 答:处理同一职责、副作用相同、可以互相接管对方的分区时。
- 同组消费者能力不一致会发生什么? 答:分区数可能均分,但完成时间由最慢成员决定;实测快消费者 302 条、慢消费者 140 条,20 秒后仍有 158 条 lag。
- 多个组订阅同一主题的代价怎么估? 答:每个组各自 fetch 一份,网络与位移成本按组数线性增长;实测 3 个组各消费 120 条,共 360 次投递。
- 什么时候适合手工
assign()? 答:分区固定、需要完全控制分配或做分区级灰度,并且愿意自己承担成员故障发现与位移管理时。
阅读导航:上一节 3.3 事务的性能与运维代价 | 下一节 4.2 分配策略与消费者倾斜
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。