《Java 消息中间件·可靠消息与消费治理》4.1 消费组设计:按业务还是按分区

消费组不是一个进程列表,而是一份职责加一套位移。本篇用 600 条消息、两个快慢不同的消费者和三个订阅同一主题的组,量出「同组能力必须一致」与「多组订阅的复制成本」,再给出按业务域、按处理职责、按分区手工分配三种方案的对照,以及组 ID 命名、空组回收与选择清单。

组是职责边界,不是进程集合

Kafka 的消费组(consumer group)同时承担三件事:

  1. 订阅边界:组内成员共同订阅哪些主题。
  2. 位移命名空间:每个组在每个分区上有一份独立的已提交位移,存在 __consumer_offsets 里。
  3. 再平衡单位:成员增减、订阅变化、分区数变化,都以组为单位重新分配。

这三件事绑在一起,决定了消费组设计的第一条原则:一个组只做一件事。组不是「同一份代码起了几个实例」的同义词,而是「同一份职责、同一套位移、同一个失败域」。

判断方法很简单:两个消费者处理同一条消息的副作用是否相同。相同,才应该放在同一个组里分摊分区;不同,就必须拆成两个组,各自收到完整的一份。

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 秒在空转。
  • slow 20 秒只处理了 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 一个服务」去拆消费者,那等于用手工分配替代组协调,把故障发现、扩缩容、位移管理全部变成人工操作。正确的顺序是:

  1. 先按职责划分组;
  2. 再按吞吐估算分区数与消费者数;
  3. 最后用 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.createdlibraryd.inventory.prod.v2扣减可借库存6(等于分区数)
library.loan.createdlibraryd.notify.prod.v2发送借阅确认6
library.loan.returnedlibraryd.inventory.prod.v2归还入库6
library.loan.overduelibraryd.fee.prod.v2生成逾期费用3
library.hold.placedlibraryd.notify.prod.v2预约到书通知6

这里假设 loan.created、loan.returned、hold.placed 都是 6 分区,loan.overdue 是 3 分区。

两点值得注意。第一,libraryd.inventory.prod.v2 同时订阅 created 与 returned,这是同一职责的两个事件,放在一个组合理,因为它们共同维护同一份库存状态;但代价是两个主题的分区数必须一起规划,任一个扩容都会让整组再平衡。第二,组内消费者数不要超过该组订阅主题的最小分区数,多出来的成员只会拿到空分配,还会触发无意义的再平衡。

选择清单

设计一个新消费组时,按顺序回答:

  1. 这条消息有几个不同的副作用?每个副作用一个组。
  2. 组内成员的镜像、配置、下游是否完全一致?不一致就拆。
  3. 这个组能不能独立扩缩容?如果需要和别的职责一起扩,说明组边界划错了。
  4. 组 ID 是否带环境与版本?迁移时能否并存新旧两个组?
  5. 多组复制的成本是否可接受?用「组数 × 消息字节」估算 broker 出口流量。

这张清单可以在每次新建组时当作评审模板,五项都答完再上线。

本节判据

  1. 消费组的三重含义是什么? 答:订阅边界、位移命名空间、再平衡单位。
  2. 两个消费者什么时候该在同一个组? 答:处理同一职责、副作用相同、可以互相接管对方的分区时。
  3. 同组消费者能力不一致会发生什么? 答:分区数可能均分,但完成时间由最慢成员决定;实测快消费者 302 条、慢消费者 140 条,20 秒后仍有 158 条 lag。
  4. 多个组订阅同一主题的代价怎么估? 答:每个组各自 fetch 一份,网络与位移成本按组数线性增长;实测 3 个组各消费 120 条,共 360 次投递。
  5. 什么时候适合手工 assign()? 答:分区固定、需要完全控制分配或做分区级灰度,并且愿意自己承担成员故障发现与位移管理时。

阅读导航:上一节 3.3 事务的性能与运维代价 | 下一节 4.2 分配策略与消费者倾斜

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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