qinghuan
← 消息队列 / 全部笔记

单元 84 / 消息队列

P1 · 重点提升L2 · 进阶

Kafka 分区与消费组:并行能力从哪里来

用分区和消费组看懂实例怎样分工,再检查扩容是否增加并行,以及同一业务的顺序是否仍能保证。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能推算传统组的分区分配与闲置实例,并解释热 key 带来的不均衡。

学习理由、准备要求与知识点

为什么学它

分区决定排序与并行边界,传统消费组的实例数不能无限提升同一主题的消费并发。

先读这些内容

知识点清单

  • 分区日志
  • 分区内顺序
  • 消息 key
  • 传统消费组
  • partition assignment
  • 再均衡
  • poll
  • 消费滞后

运行前先确认Kafka 4.1 传统消费组与 KafkaConsumer.subscribe 模型;源码固定 4.1.0 的 RoundRobinAssignor。Share Groups 与新协议服务端分配是不同机制,不以本文策略推断它们的全部行为。独立算法 Java 17。

01先弄懂它是什么

Kafka 可以先看成分成多段、不断追加的持久日志。Topic 是逻辑分类,Partition 是有序存储和分工的单位,offset 只表示本分区里的位置。不同分区的 offset=100 不代表同一时间或同一进度,比较时必须带上 Topic 与分区。消息被读过后,通常仍按保留或压缩规则管理,不是读取一条就立即删除一条。

消费组让多个实例共同完成一份订阅职责。在传统 KafkaConsumer 自动分配模型的一次稳定分配中,同组一个分区由一个消费者负责,一个消费者可以负责多个分区。审计、索引和计费若各自都要处理同一日志,应使用独立组和各自进度。增加组内实例是在分担工作,新建组则是增加一份独立处理。

分区数是一项架构选择。更多分区能提供更多分配单位,却也增加元数据、日志、连接及恢复管理成本。需要同一订单保持局部顺序时,应让相同业务键稳定进入同一分区,并确保消费者按要求完成处理。修改分区数可能改变键的后续落点,旧事件与新事件不再在同一日志序列中;顺序设计不能仅靠“我一直传同一个 key”来证明。

文中这几个词,先认一认

Partition
一段有序追加的日志;offset、复制和消费顺序都要结合具体分区讨论。
Consumer group
一起承担同一份消费职责的一组实例,按分配结果协作处理。
Rebalance
成员或订阅资源发生变化后,重新安排哪些消费者负责哪些分区。
消费滞后
消费位置与日志末尾差多远;必须先说明量的是读取、提交还是业务完成的位置。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

生产者根据消息键、分区策略和当前元数据选择分区,由该分区的服务节点接收并按配置复制。顺序是分区内的事实,而不是整个 Topic 的全局时间线。即使分区内读取有序,消费者把记录交给线程池异步执行,也会产生完成次序差异;提交进度还必须避开尚未完成的记录,这会在下一篇用独立算法推导。

组协议维护成员与分区分配。经典客户端策略可能按范围、轮询或粘性目标计算结果;不同订阅集合会约束某个消费者能否接收某个 Topic 的分区。轮询在订阅完全相同时较容易均衡,但订阅集合不同会不断跳过不合格成员,不能保证每个人数量仍然接近。选择策略要说明目标是数量平衡、迁移较少还是按订阅约束合法。

重平衡是一段交接过程,不能当作图里瞬间换一根线。旧消费者有正在处理的业务,新消费者可能从已提交位置继续;旧任务随后回来的完成回调必须有代次或所有权检查,避免更新新分配的状态。长时间不 poll、成员失败和扩容都可能改变归属。KafkaConsumer 不支持被任意多个线程并发操作,常见设计是一个线程管理 poll、分配和提交,工作线程只报告业务完成。

正在加载流程图…

同组按分区协作,独立组拥有独立进度;图是传统消费组的一种合法分配,不是固定策略的保证。
查看流程图源码(Mermaid)
flowchart TD
 T["orders Topic"] --> P0["P0:独立 offset"]
 T --> P1["P1:独立 offset"]
 T --> P2["P2:独立 offset"]
 T --> P3["P3:独立 offset"]
 P0 --> A["计费组:消费者 A"]
 P1 --> B["计费组:消费者 B"]
 P2 --> A
 P3 --> B
 P0 --> C["审计组:消费者 C"]
 P1 --> C
 P2 --> C
 P3 --> C

按顺序看,每一步做了什么

  1. 定义业务键、顺序范围与分区规划。
  2. 配置 group.id 及订阅,确认采用的组协议与分配器。
  3. 客户端 poll 获取归属分区的记录,建立在途处理状态。
  4. 业务完成后按分区计算可提交位置,避免跨越未完成记录。
  5. 重平衡时交接在途工作,隔离过期回调并验证重复处理。

03对照实现代码

RoundRobinAssignor 如何跳过未订阅成员 (新标签页)↗

Apache Kafka 4.1.0 · RoundRobinAssignor#assign 与 allPartitionsSorted

原创局部 Java 模型,抽出轮询与订阅资格检查。真实源码使用订阅与 TopicPartition 类型,并由经典分配协议调用;该模型不负责成员共识、故障检测与交接。

核心机制代码 · java
import java.util.*;
final class EligibleMemberModel {
    static int choose(List<Set<String>> subscriptions, int start, String topic) {
        if (subscriptions.isEmpty()) throw new IllegalArgumentException("members");
        for (int tried = 0; tried < subscriptions.size(); tried++) {
            int index = (int) (((long) start + tried) % subscriptions.size());
            if (subscriptions.get(index).contains(topic))
                return index; // 从当前轮询位置开始选第一个合格成员。
        }
        return -1; // 本模型显式报告没有订阅者,避免无限循环。
    }
}

真实实现先准备消费者的分配结果,再按排序后的成员和 Topic 分区清单依次分配。排序能让相同输入得到相同结果,方便测试和核对;但加入一个新成员后,后续很多轮询位置仍可能改变。前者叫确定性,后者涉及“粘性”,也就是成员变化时尽量少搬动已有分区。两者解决的是不同问题。

对每个分区,策略检查轮询指针指向的消费者是否订阅对应 Topic,不符合就向后跳。只有合法订阅者才能获得分区,说明分配并非无条件平均分配整数。示例中一个实例订阅 A 和 B,另一个只订阅 A,B 的全部分区都只能交给前者,合法性本身就限制了均衡程度。

源码枚举的是订阅涉及的 Topic,通常存在至少一个合格成员。教学模型则接受更宽的输入,显式限制最多检查成员数次并返回 -1。公开工具若直接复制“不断跳到合格成员”的循环却没有确保存在合格成员,可能形成死循环;算法的前置条件必须随代码一起保留。

分配器返回结果不等于消费已迁移完成。运行时还需要协调当前成员代次、撤销回调、记录位置与工作停止。生产排障应同时查看分配结果、重平衡时间线和业务完成位置;仅截取 assign 返回的 Map,无法证明网络故障时只有一份业务作用。这也是源码局部机制与端到端可靠性的区别。

代码先调用谁,再调用谁

  1. 组协议收集成员订阅与 Topic 元数据
  2. 经典分配器 RoundRobinAssignor.assign 接收输入
  3. allPartitionsSorted 生成稳定分区序列
  4. 轮询成员并跳过未订阅当前 Topic 的实例
  5. 分配结果交还组协议,客户端按回调和状态进行交接

04动手写一个例子

带订阅约束的确定性轮询分配

输入 Topic 到分区数量的映射,以及消费者到订阅 Topic 集合的映射。按 Topic 名、分区号排序,轮询选择已订阅的消费者;无人订阅的 Topic 不分配。零分区仅作为算法空输入,真实 Kafka Topic 创建规则另行校验。

输入 1
a=3,b=2;A订阅a,b;B订阅a
输出 1
A=[a:0,a:2,b:0,b:1];B=[a:1]
输入 2
a=1;没有消费者
输出 2
{}

先固定输入顺序,把消费者名称和 Topic 名排序,避免 HashMap 的遍历顺序影响输出。维护一个轮询游标,分配成功后从下一个成员继续。每个分区最多检查所有成员一圈:若某人订阅当前 Topic,就分配并停止;若没有人订阅,就显式跳过,程序仍然会终止。

合法性来自分配前的资格检查,唯一性来自每个分区只在外层循环中处理一次且找到成员后立即 break。覆盖性需要条件限定:每个至少有一位订阅者的分区都会在完整的一圈搜索内被找到,而完全没有订阅者的分区按接口约定不在结果中。不能把后者悄悄算成分配算法遗漏。

复杂度不是无条件线性于分区数。最坏情况下,每个分区只被游标最后遇到的成员订阅,需要扫描 c 人,所以为 O(Pc)。代码保留这个直观版本作为基线。若规模需要优化,可先为每个 Topic 建立合格成员索引,但必须定义清楚优化后的游标语义是否仍与全局轮询一致。

为什么这样写是对的已经遍历的每个有订阅者的分区恰好出现在一个合法消费者列表中;游标指向上一次成功分配成员的后继,未遍历分区尚未产生结果。

时间 O(C log(C+1) + T log(T+1) + P×(C+1))额外空间 O(C + T + P) 输出与辅助索引

C 是成员数,T 是 Topic 数,P 是总分区数。排序名称需相应对数成本,每个分区自身至少访问一次,并最多扫描 C 个订阅集合。允许 C=0,此时仍遍历 P 个分区,因此不能省略 O(P)。集合 contains 按通常散列均摊常数分析;名称比较的字符成本在长字符串模型下还应计入。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 49 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    static Map<String, List<String>> assign(Map<String, Integer> topics,
                                           Map<String, Set<String>> subscriptions) {
        Objects.requireNonNull(topics); Objects.requireNonNull(subscriptions);
        List<String> members = new ArrayList<>(subscriptions.keySet());
        Collections.sort(members); // 固定成员顺序,不依赖 HashMap 遍历。
        Map<String, List<String>> result = new LinkedHashMap<>();
        for (String member : members) {
            Objects.requireNonNull(subscriptions.get(member));
            result.put(member, new ArrayList<>());
        }
        int cursor = 0;
        for (var entry : new TreeMap<>(topics).entrySet()) {
            String topic = entry.getKey();
            int count = entry.getValue();
            if (count < 0) throw new IllegalArgumentException("partitions");
            for (int p = 0; p < count; p++) {
                for (int tried = 0; tried < members.size(); tried++) {
                    String member = members.get(cursor);
                    cursor = (cursor + 1) % members.size(); // 每次考察后推进。
                    if (subscriptions.get(member).contains(topic)) {
                        result.get(member).add(topic + ":" + p);
                        break; // 每个分区最多分配一次。
                    }
                } // 没有成员时循环自然跳过,不做除零运算。
            }
        }
        return result;
    }
    static void eq(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        var subscriptions = Map.of("A", Set.of("a","b"), "B", Set.of("a"));
        var result = assign(Map.of("a",3,"b",2), subscriptions);
        eq(List.of("a:0","a:2","b:0","b:1"), result.get("A"));
        eq(List.of("a:1"), result.get("B"));
        eq(Map.of(), assign(Map.of("a",1), Map.of()));
        eq(List.of(), assign(Map.of("x",2), Map.of("A",Set.of("a"))).get("A"));
        eq(List.of(), assign(Map.of("a",0), Map.of("A",Set.of("a"))).get("A"));
        boolean bad = false;
        try { assign(Map.of("a",-1), subscriptions); }
        catch (IllegalArgumentException e) { bad = true; }
        if (!bad) throw new AssertionError("negative partitions");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 空成员列表不得除零或无限寻找订阅者。
  • 不同订阅集合可能导致合法但数量不均衡的结果。
  • 零分区只是算法边界,不代表可据此创建真实 Topic。
  • 成员加入会改变映射,不能把确定性等价为最少迁移。
进阶选读 · 保留旧分配的最小迁移与最小费用流

场景扩容时希望既满足订阅资格和每成员容量,又尽量保留旧分区归属。

做法建立源点到分区节点的容量一边,分区到合法成员节点的容量一边,保留原归属费用零、迁移费用一,成员到汇点设置容量上限。求满足全部可分配分区的最小费用流,得到固定容量约束下的最小迁移方案;容量不合理时应报告无可行解。

复杂度图边数最坏 O(PC)。使用带势的逐次最短增广路时,单位分区流量上界 P,常见二叉堆实现约 O(P×E log V),还需初始化势的适用条件。

好处与代价精确优化成本较高,实际分配器通常结合启发式与协议约束。迁移最少、数量平衡和负载平衡不一定能同时达到,各自应明确目标函数。

05项目里怎么用

分区扩容前检查键路由算法、历史事件顺序和外部存储分片。增加分区通常不会自动把旧日志均匀迁移到新分区,同一键的新记录却可能选择新位置。对严格状态机,可以安排迁移窗口、加入业务版本或使用明确路由表。不能只观察新分区有流量就认定既有顺序要求仍然成立。

处理消息时,也要保证消费者能按要求继续调用 poll。一次同步处理太多记录,会推迟下一次 poll;交给工作线程后,则要用 pause/resume 控制拉取,限制已经取出但尚未完成的任务数,并跟踪从起点开始连续完成到了哪里,防止无限排队或跳过未完成记录提交位置。

重平衡撤销分区时,要停止接收旧分区的新任务,并明确已有任务如何结束、哪些记录之后会重放。wakeup 等专用控制方法也要按 API 约定使用。

监控要区分读取位置、已提交位置和业务完成位置。提交领先业务可能让重启跳过未完成记录,提交落后业务则可能重放;二者对应不同风险。关注最老未完成事件年龄及失败分类,结合分区分配判断是热分区、消费者阻塞还是下游限流。组内总吞吐数字会掩盖个别分区长期不前进,必须保留分区维度诊断入口。

容易踩的坑

  • 跨分区比较 offset 作为全局业务时间。
  • 消费者多于分区时继续期待传统组线性提速。
  • 将 KafkaConsumer 随意共享给多个工作线程调用。
  • 异步处理后提交整个 poll 位置,越过未完成记录。
  • 增加分区却不验证键映射与顺序变化。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
增加分区提供更多日志与分配单位。元数据、恢复及键路由迁移成本。现有瓶颈确实受分区并行度限制。
增加同组消费者在可分配范围内提升处理能力。受分区、热点及下游容量限制。有空余分区工作且处理逻辑可横向扩展。
单消费者加工作池解耦读取与较慢业务处理。需要在途上限、顺序与安全提交协议。已能正确处理撤销、失败及重复的复杂流水线。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01P0 的 offset=8 与 P1 的 offset=9 能比较业务先后吗?

展开参考答案与推导

不能。偏移量属于各自分区,跨分区业务因果需要时间、版本或业务协议,而不是比较数字大小。

02两个独立组是否互相消耗对方的进度?

展开参考答案与推导

各组维护独立进度;它们共享日志数据但不是同一个逻辑订阅的分工。

03轮询为什么在不同订阅集合下可能不均衡?

展开参考答案与推导

不合格成员会被跳过,某些 Topic 只有少数消费者可接收,合法性约束使数量平均不一定可行。

换个情况,还会处理吗

01四分区扩到五个同组实例,为什么有一个空闲?

展开参考答案与推导

传统自动分配中每分区给同组一个消费者,只有四个工作单位;需先确认实际协议模型,再排查是否应增加分区或优化单分区处理。

02撤销分区后旧任务回调仍回来,如何避免污染新进度?

展开参考答案与推导

回调携带分配代次与分区,只有当前所有权仍匹配时才能更新提交跟踪器;旧业务副作用仍通过幂等或 fencing 约束。

03整体 lag 很低但一个商户延迟很高,可能为何?

展开参考答案与推导

热业务键集中到一个分区或顺序组,整体平均掩盖尾部;按分区、键分布和最老在途事件定位,不先盲目加实例。

写代码试一试

01给轮询分配增加合法性与唯一性校验。

输入 / 条件
a=4,A与B都订阅a
输出 / 预期
四个分区各出现一次且都属于合法订阅者

提示 用 Set 收集已出现分区。

展开参考答案与推导

遍历所有结果检查 Topic 在订阅集合中并拒绝重复,最后与存在订阅者的预期分区集合相等。

02比较两个分配方案的迁移数量。

输入 / 条件
旧 A={0,1},B={2,3};新 A={0,2},B={1,3}
输出 / 预期
2

提示 以分区为键比较拥有者。

展开参考答案与推导

建立旧归属 Map,逐个新分区查旧值,拥有者变化计一;新增或消失分区应按另一个口径统计。

03扩展无人订阅的返回结果。

输入 / 条件
topics={x:2},A订阅a
输出 / 预期
unassigned=[x:0,x:1]

提示 不要把无人处理静默当成功。

展开参考答案与推导

增加未分配列表,完整一圈没有成功分配时写入;调用者根据它决定配置错误或暂时无人订阅。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能推算传统组的分区分配与闲置实例,并解释热 key 带来的不均衡。

本篇重点加练

再均衡与 poll 的配合决定所有权变化时在途工作如何处理,单看分配结果无法判断安全。

  • 再均衡
  • poll

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    订单创建与物流事件分属两个分区,监控分别显示 offset 120 与 9。定义一份事件日志,说明哪些先后关系能够由日志位置证明。

    提交内容
    日志示意与关系判断表
    怎样算完成
    • 区分分区内顺序和业务因果
    • 注明追加记录的身份

    练到的知识点分区日志 · 分区内顺序

  2. 作业 02设计评审

    消费者需要顺序处理每位商户的账目,消息 key 目前为随机请求号。评审分区键与消费组设计,并讨论头部商户占流量七成的情况。

    提交内容
    分区方案与负载估算
    怎样算完成
    • 明确顺序需求的范围
    • 包含独立核算组

    练到的知识点消息 key · 传统消费组

综合应用

  1. 作业 03代码推演

    消费者 C1、C2 分担三个分区,C2 退出后又重入。自定两轮合法分配,逐步记录任务所有权、在途记录和需要重新核验的进度。

    提交内容
    再均衡事件表
    怎样算完成
    • 所有权标明代次
    • 区分撤销任务和已产生的业务效果

    练到的知识点partition assignment · 再均衡

  2. 作业 04面试场景

    poll 批次包含 200 条记录,每条处理时间波动较大。设计区分拉取停顿、处理慢和单分区倾斜的指标,并说明各指标的采样时点。

    提交内容
    诊断指标字典
    怎样算完成
    • 包含按分区的积压年龄
    • 不只使用全组总 lag

    练到的知识点poll · 消费滞后

  3. 作业 05编程实践

    编写本地 Java 分区消费演示器,支持不同 key 分布、处理时长与实例退出;对每条记录跟踪分配代次和处理延迟。

    提交内容
    程序、固定种子数据与报告
    怎样算完成
    • 同时测均匀和倾斜负载
    • 注明不模拟真实 Kafka 网络协调协议

    练到的知识点分区日志 · 消息 key · partition assignment · 再均衡 · poll · 消费滞后

  4. 作业 06代码推演

    消费者撤销分区后,旧任务仍排在本地队列中,新消费者已经开始处理。逐个标明任务何时取得权限以及后续回调属于哪次分配。

    提交内容
    任务所有权时间线
    怎样算完成
    • 包含未开始和已执行任务
    • 区分业务完成与仍有提交资格

    练到的知识点再均衡

  5. 作业 07面试场景

    poll 很频繁但处理队列持续增长,另一实现 poll 间隔很长却队列很短。制定能区分两者风险的观测表,禁止仅用拉取频率评估健康。

    提交内容
    运行证据与风险表
    怎样算完成
    • 记录拉取、排队和完成时刻
    • 说明选用客户端的相关约束

    练到的知识点poll

  6. 作业 08编程实践

    实现本地拉取与工作队列模拟器,支持分区暂停、恢复和所有权撤销,检查撤销后旧批次对当前进度的影响。

    提交内容
    源码和事件轨迹
    怎样算完成
    • 每批次绑定分配代次
    • 声明模拟器不覆盖真实组协调协议

    练到的知识点再均衡 · poll

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    订单主题拟从四个分区扩为八个,业务要求同订单消息持续有序。审查扩容前后 key 路由假设并提出迁移验收用例。

    提交内容
    迁移风险表与验证计划
    怎样算完成
    • 覆盖扩容时在途消息
    • 声明消费者侧顺序判据

    练到的知识点分区内顺序 · 消息 key · 传统消费组 · partition assignment · 再均衡

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    将同一模型迁移到爬虫任务租赁,网页抓取可耗时数分钟且不可即时取消。如何重新定义“拥有任务”和“允许提交结果”?

    提交内容
    跨域状态契约
    怎样算完成
    • 包含租赁到期和晚到结果
    • 区分执行资源与结果接收权限

    练到的知识点再均衡 · poll

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗