想象你第一天走进一家热闹的食堂后厨。点单屏上滚动着几十道菜,后厨团队(消费者组)的任务是把它们全部做出来。菜单按类型分成几列:凉菜列、热菜列、炖汤列、主食列——每一列就是 Kafka 里的一个分区(Partition)。主厨定下铁律:同一列菜只能由一位厨师负责,其他人不得插手。每位厨师做几道菜就在台账上记一笔(提交 offset),每隔几秒喊一声“还在干活”(心跳)。有一天老李头晕被送走,心跳没人应,主厨立刻喊停全员,重新排班(Rebalance),把锅台分给剩下的厨师。因为食堂规定“客人没确认就不算完成”,重做的菜可能重复上桌(至少一次),所以每道菜出锅前都要贴唯一订单号,上桌前先查“已送菜清单”(幂等去重)。这就是 Kafka 消费者机制的全部轮廓:一组厨师、一列归一人、台账记位置、心跳保活命、重排要停摆、重做要防重。

岗位关联

一句话价值:弄懂消费者机制,你就能设计出高吞吐、不丢不重、可水平扩展的流式数据管道——这是实时数据平台从业者的核心基本功。

目录
  1. 可视化演示
  2. 消费者组协作
  3. 偏移量提交
  4. 重平衡机制
  5. 分区分配策略
  6. 心跳保活
  7. 重复消费防范
  8. 位移与监控
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示消费者组分区分配协作消费、Rebalance重平衡流程、Offset偏移量提交管理及Exactly-Once事务语义的完整机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Kafka消费者机制 消费者组:组内每分区仅由一个消费者负责 Rebalance:消费者加入/离开触发分区重分配 Offset:手动提交保证At-Least-Once语义 EOS:事务API+幂等生产者实现精确一次 消费者通过poll拉取消息并提交偏移量实现可靠消费

消费者组协作

小王第一天走进食堂后厨,主厨指着一块点单屏说:“以后你就在这个组里干活了。”屏幕上菜品分成四列——凉菜、热菜、炖汤、主食,每一列就是一个分区。主厨强调铁律:同一列菜只能由一位厨师负责,别的厨师手再闲也不能来抢。六个灶台面前站着三位厨师,每人分两列,各做各的,出菜又快又不乱。

Kafka 的世界里,“点单屏”叫主题(Topic),“列”叫分区(Partition),“后厨团队”叫消费者组(Consumer Group),“厨师”叫消费者(Consumer)。消费者组是 Kafka 实现并行消费的基本单元:组内消费者各自领取若干分区,互不重复地消费数据。和“每条消息广播给所有订阅者”的发布订阅模式不同,组内是完全的工作队列式分工。

分区是 Kafka 并行度的最小单位,也是顺序性保证的单位。组内同一个分区只有一个消费者持有,因此分区内的消息天然按生产顺序被处理。当需要扩容时,直接往组里加消费者即可——每个消费者负责的分区更少,单个消费者的压力随之降低。但请记住:如果消费者数量超过分区数量,多出的消费者分不到任何分区,只会空转。

小王把排班表看懂了:六列菜分给三个人,每人两列,谁跟谁都没有交集。主厨说:“这就是我们组的最大秘密——分工明确、互不抢活,才能又快又不乱。”

∑i=1Cai=P,Ai∩Aj=∅ (i≠j)\displaystyle \sum_{i=1}^{C} a_i = P, \qquad A_i \cap A_j = \varnothing \ (i \neq j)
公式说:C 个消费者分到的分区总数等于 P,且任意两人分到的分区互不重叠——这就是“一列菜只归一人”的数学表达。
  1. 分工求和所有消费者分到的分区数加起来等于总分区分区数 P,表示每个分区都有人负责,没有“漏做的菜”。
    ∑i=1Cai=P\displaystyle \sum_{i=1}^{C} a_i = P
  2. 互不重叠任意两个消费者分到的分区集合交集为空,表示同一条消息不会被两个人同时处理,没有“抢菜”发生。
    Ai∩Aj=∅ (i≠j)\displaystyle A_i \cap A_j = \varnothing \ (i \neq j)
  3. 并行上限组内并行度最大等于分区数;当消费者数超过分区数,多出来的人只能旁观,因为分区是独占的。
    max⁡(parallelism)=P\displaystyle \max(\mathrm{parallelism}) = P

偏移量提交

每列灶台前都放着一本台账。小王做一道热菜,就在“热菜”那页写上“已做到第 5 道”。主厨纠正他:“你写的是'下一道要做第 6 道',不是'第 5 道做完了'。”这样哪怕小王中途被叫走,回来翻开台账就能无缝接着做,不会把第 5 道再做一遍。

台账上的位置就是 Kafka 里的 offset(偏移量)。消费者从分区拉取消息时,offset 是游标,指明“下一条待读取消息的位置”。消费者每处理完一批消息,需要把当前游标提交(commit)给 Kafka 持久化保存。这样消费者进程重启、或分区被重新分配给别人后,新任消费者可以从提交的位置继续消费,而不是从头读起。

提交分两种:自动提交和手动提交。默认配置 enable.auto.commit=true 时,消费者每 5 秒自动提交一次当前拉取位置,代码简单但“不精确”——5 秒窗口内若进程崩溃,重启后会从上次提交点重新消费,产生重复。手动提交用 commitSync(同步,阻塞直到成功)或 commitAsync(异步,不阻塞)把控制权交到业务手里:通常先处理消息、成功后再提交,这正是“至少一次”语义的经典实现。提交时机决定了“崩溃后重放多少”。

主厨给小王看了一条事故记录:有位厨师把台账写成了“第 5 道已做完”,结果两天后系统回放,第 5 道菜被重复做了一遍,客人桌上出现两份红烧肉。小王默默记住了这句口诀:“台账记的是下一次,不是这一次。”

offsetcommitted=offsetlastProcessed+1\displaystyle \mathrm{offset}_{committed} = \mathrm{offset}_{lastProcessed} + 1
公式说:提交的 offset 不是最后一条已处理消息的位置,而是它的下一个位置——台账记的是“下一次”,不是“这一次”。
  1. 已处理位置offset_lastProcessed 表示消费者成功处理完的最后一条消息在分区中的序号,它是业务进度的真实记录。
    offsetlastProcessed\displaystyle \mathrm{offset}_{lastProcessed}
  2. 指向下一条加 1 是因为 Kafka 的 offset 语义是“游标”,代表下一条待读取的位置,而不是最后一条已完成的位置。
    +1\displaystyle +1
  3. 提交结果提交后的 offset_committed 被持久化保存,消费者重启或分区易主后,新消费者从这里继续拉取,避免从头或重复消费。
    offsetcommitted=offsetlastProcessed+1\displaystyle \mathrm{offset}_{committed} = \mathrm{offset}_{lastProcessed} + 1

重平衡机制

下午三点,炖汤列的老李突然头晕,被同事架回家休息。过了三分钟,主厨发现老李的心跳没了,立刻招呼大家:“老李的汤我来分,大家停一下手里的活,重新排班!”短短几秒后,小王的排班表上就多了“炖汤列”三个字。

老李的“突然消失”在 Kafka 里叫消费者失效,重新排班的过程叫 Rebalance(再平衡)。当一个消费者组发生成员变化(有人加入、有人主动离开、有人心跳超时被判定下线)、或者订阅的主题新增了分区、或者订阅关系发生变化时,协调者(Group Coordinator)就会召集组内所有消费者重新分配分区。协调者由 Kafka 在某个 Broker 上的组件担任,负责管理这个消费者组的全部状态。

传统 Rebalance 流程分三步:revoke(组内所有消费者交出手上的分区)、rejoin(重新向协调者发起加入请求)、assign(协调者根据分配策略把分区重新发给各消费者)。整个过程组内没有人能拉取消息,像“全员静止”,所以 Rebalance 常被称为 stop-the-world(惊群)。为了避免“僵尸消费者”干扰,协议引入了“代数”概念:每次 rebalance 完成,组代数加一,旧代成员再来提交数据会被拒绝。

排班表右下角印着一个小数字:版本号。每次重新排班,版本号就加一。老李第二天回来说“我还是按昨天的表干活”,主厨把新表拍在他面前:“你的旧表已经作废了,按新表来。”

gnew=gold+1\displaystyle g_{new} = g_{old} + 1
公式说:每完成一次再平衡,组代数就加一,用代数来识别和隔离“带着旧排班表回来”的成员。
  1. 旧代数g_old 是上次 rebalance 完成后消费者组携带的代数标识,相当于排班表的版本号,每位成员都带着它。
    gold\displaystyle g_{old}
  2. 代数递增每次 rebalance 结束,协调者把组代数加一,所有新成员带上新的代数,旧成员身份随之失效。
    +1\displaystyle +1
  3. 隔离僵尸旧代成员再来提交 offset,协调者发现代数不匹配直接拒绝,防止迟到的请求污染新组的状态。
    gnew=gold+1\displaystyle g_{new} = g_{old} + 1

分区分配策略

重新排班前,主厨会选一种“分活套路”。老主厨喜欢“切段法”:把菜列按顺序切成连续几段,一段段分给人,简单粗暴;年轻厨师喜欢“发牌法”:六列菜像扑克牌一样一张张轮流发给每个人,最公平;还有一位“保守派”主张“粘性法”:尽量不动大家原有的灶台,只把老李那列塞给最闲的人。

Kafka 内置了多种分区分配策略(PartitionAssignor),在 Rebalance 阶段由协调者统一执行,决定“哪个消费者负责哪些分区”。常用四种:RangeAssignor(范围分配)、RoundRobinAssignor(轮询分配)、StickyAssignor(粘性分配)、CooperativeStickyAssignor(合作式粘性分配)。它们的差异直接决定负载是否均衡,以及 Rebalance 时分区迁移量的多少。

Range 策略以“主题”为单位:把单个主题的分区按序号连续切段,按消费者顺序从前到后分配。若 P 个分区分给 C 个消费者,则前 P mod C 个消费者各拿 ceil(P/C) 个,其余拿 floor(P/C) 个。当组内订阅多个主题时,每个主题都如此切段,容易造成排在前面的消费者承担过多分区。RoundRobin 则把组内所有主题的全部分区放在一起轮询发牌,整体上更均衡。Sticky 在保证均衡的前提下尽量保留上次分配,减少分区转移;CooperativeSticky 进一步支持增量式分配,允许部分消费者在 Rebalance 期间继续工作。

小王觉得“发牌法”最讲道理,但主厨摇头:“发牌法每次都要把所有人手里的灶台打乱重来,代价太大。粘性法大部分人不换岗,只有老李那列动了动,大家干活几乎没停顿。你看,'尽量不动'有时比'绝对公平'更聪明。”

n=⌈PC⌉\displaystyle n = \left\lceil \frac{P}{C} \right\rceil
公式说:Range 策略下每个消费者分到的分区数大约是 P 除以 C,除不尽时排前面的消费者多拿一个。
  1. 向上取整ceil(P/C) 算出每个消费者理论上最多分到几个分区,这是 Range 策略的“每个人拿到的上限”。
    ⌈PC⌉\displaystyle \left\lceil \frac{P}{C} \right\rceil
  2. 余数分配P mod C 表示多出来的分区个数,Range 会把它们优先分给排在前面的消费者,所以前面的人可能多拿一个。
    P mod C\displaystyle P \bmod C
  3. 不均衡代价当 P 不能被 C 整除时,消费者之间最多相差一个分区;组内订阅多个主题时,这种偏差会累积放大。
    ⌈PC⌉≠PC\displaystyle \left\lceil \frac{P}{C} \right\rceil \neq \frac{P}{C}

心跳保活

主厨立了一条规矩:每位厨师每隔 3 秒必须喊一声“还在干活!”要是连续 45 秒没听到某个人的声音,主厨就认为他出事了,立刻找人来顶班。小王觉得吵,但主厨说:“后厨这么大,我只能靠这个确认每个人还醒着。”

这条“喊一声”的机制就是心跳(Heartbeat)。消费者在后台有一个专门的心跳线程,以 heartbeat.interval.ms 为间隔,定期向协调者发送心跳请求,告诉协调者“我还活着”。协调者用 session.timeout.ms 作为容忍上限:超过这个时间没收到心跳,就判定该消费者下线,触发 Rebalance 并把它的分区转移出去。要注意:心跳只负责“活着”,不负责“干得快”。

Kafka 2.3 之前,心跳与拉取在同一个线程里,如果消费者处理一条消息耗时太长(比如调用外部 API),就会同时阻塞心跳,容易被误判下线。新版本把心跳线程独立出来,又引入 max.poll.interval.ms:消费者两次 poll() 之间的最大时间间隔,超过它说明“处理停滞”,即使心跳正常也会被移出组。此外 Kafka 消费采用 pull(拉)模型,消费者自己控制拉取的时机和批量(max.poll.records 限制单次拉取条数),避免 broker 用 push 把消费者压垮。

小王理解了“活着”和“在干活”是两码事:你还能喊“我在”,但十分钟端不出一盘菜,主厨一样会换人。喊话的频率和容忍时限得搭配好——喊话太密浪费嗓子,容忍太短又容易误伤。

session.timeout.ms≥3×heartbeat.interval.ms\displaystyle \mathrm{session.timeout.ms} \ge 3 \times \mathrm{heartbeat.interval.ms}
公式说:会话超时至少要是心跳间隔的 3 倍,给网络抖动留缓冲,别因为一次心跳迟到就误杀好人。
  1. 心跳间隔heartbeat.interval.ms 是心跳线程发送心跳的频率,默认 3000ms,相当于每 3 秒喊一声“我还在”。
    heartbeat.interval.ms=3000\displaystyle \mathrm{heartbeat.interval.ms} = 3000
  2. 会话超时session.timeout.ms 是协调者容忍静默的时间上限,默认 45000ms,超过就判定成员下线。
    session.timeout.ms=45000\displaystyle \mathrm{session.timeout.ms} = 45000
  3. 倍数关系官方建议会话超时至少是心跳间隔的 3 倍,给网络抖动留出缓冲,避免误杀正常消费者。
    session.timeout.ms≥3×heartbeat.interval.ms\displaystyle \mathrm{session.timeout.ms} \ge 3 \times \mathrm{heartbeat.interval.ms}

重复消费防范

食堂有条硬规矩:客人没确认收到菜,这道菜就不算完成。但传菜员偶尔会把回执弄丢,厨师以为菜没送到,就重新做了一份。菜确实送到了(至少一次),但客人桌上可能出现两份红烧肉。小王问:“两份菜两份钱,客人不闹吗?”

这是 at-least-once(至少一次)语义的典型场景。Kafka 默认保证:只要生产者按规范配置,消息不会丢失,但可能重复投递。重复来自消费者侧:消息处理完、offset 还没来得及提交、进程崩溃,重启后从旧 offset 重放,之前处理过的消息被再处理一遍。Kafka 本身不会自动去重,“不重复”要靠消费端自己做。

最常用的手段是幂等消费:让重复执行和一次执行产生完全相同的效果。工程上通常给每条消息定义业务唯一键(如订单号、事件 ID),处理前先查“去重表”——Redis 的 SETNX、数据库唯一索引、或一张已处理表——键已存在就跳过。也可以把“消费进度”与“业务写入”放进同一个数据库事务,原子提交。注意幂等不是免费午餐:它要求业务写入本身可以被幂等化(如累加变覆盖、插入变更新),否则单靠去重标记也无济于事。

小王想出一个主意:每道菜出锅就在盘底贴一张订单号贴纸,上菜前先翻“已送菜名单”,发现有名字就不上。厨师还是可能多做一遍,但客人桌上永远只有一份红烧肉。这就是“厨房多做可以,上桌只能一次”。

f(f(x))=f(x)\displaystyle f(f(x)) = f(x)
公式说:对同一条消息处理两次,结果要和处理一次完全相同——重复的副作用被“吸收”掉了。
  1. 一次处理结果f(x) 表示对业务消息 x 执行一次处理,例如把订单写入账务库。
    f(x)\displaystyle f(x)
  2. 重复处理结果f(f(x)) 表示同一消息被重复处理后的结果,例如重复写库、重复扣款。
    f(f(x))\displaystyle f(f(x))
  3. 幂等条件两者相等意味着第二次处理被“吸收”掉了——要么被去重表拦住,要么更新操作天然可覆盖。
    f(f(x))=f(x)\displaystyle f(f(x)) = f(x)

位移与监控

每天收工前,主厨会巡视一遍灶台,嘴里念念有词:“灶台做到第几道了?台账记到哪了?”两个数字一对,就知道哪列菜积压最严重——这就是后厨的“健康体检”。

Kafka 里这两个数字叫 LogEndOffset(分区最新写入位置)和 CurrentOffset(消费者当前位置),它们之间的差就是 consumer lag(消费滞后)。lag 是衡量消费者健康度最核心的指标:如果 lag 持续上涨,说明消费速度跟不上生产速度,消费者正在变成瓶颈。那消费者提交的位移存在哪里?答案是一个名为 __consumer_offsets 的内部主题(默认 50 个分区,按 group.id 哈希分布),由协调者负责读写维护。

常用的运维操作有两个方向:查 lag 和重置位移。命令行工具 kafka-consumer-groups.sh --describe 可以查看每个消费组在不同分区的 lag;配合 Prometheus 与 Kafka Exporter 等监控体系,可以设置 lag 阈值告警。如果积压严重需要“丢旧追新”,可以用 --reset-offsets 把位移重置到最新(--to-latest);如果要重新处理历史数据,就重置到最早(--to-earliest)。位移重置是运维大杀器,操作前务必确认消费组已停止运行。

主厨的体检秘诀,小王也学会了:lag 为 0 说明后厨从容;lag 一直涨,就得跟主厨申请加人——用消费者组的水平扩容,把积压消化掉。这就是“看表做事”的厨房管理哲学。

lag=LogEndOffset−CurrentOffset\displaystyle \mathrm{lag} = \mathrm{LogEndOffset} - \mathrm{CurrentOffset}
公式说:分区的最新位置减去消费者已读位置,就是还没处理完的积压消息条数;lag 为 0 说明追上了。
  1. 分区最新位置LogEndOffset 是分区最后一条消息之后的位置,代表“灶台当前做到第几道菜”。
    LogEndOffset\displaystyle \mathrm{LogEndOffset}
  2. 消费当前位置CurrentOffset 是消费者正在读取的位置,代表“台账记到哪了”。
    CurrentOffset\displaystyle \mathrm{CurrentOffset}
  3. 滞后量两者相减就是积压待处理的消息条数;lag 持续上升说明需要扩容,lag 为 0 说明游刃有余。
    lag=LogEndOffset−CurrentOffset\displaystyle \mathrm{lag} = \mathrm{LogEndOffset} - \mathrm{CurrentOffset}

AI 应用场景

日志与埋点实时管道

阶段:数据接入与预处理

解决的问题:传统日志采集直接落盘,高峰期单机处理不过来,且无法水平扩展。用 Kafka 消费者组把海量日志按分区并行消费,加机器即可扩容——就像一个后厨忙不过来时,主厨直接往组里加厨师,前提是灶台(分区)够分。

具体案例:某电商平台大促期间每秒数万条埋点日志进入 Kafka。大数据团队在消费组里加机器,把 24 个分区分给 8 个消费者并行清洗入库,通过监控 lag 保持在个位数,保证实时报表不积压。

订单支付事件的幂等入账

阶段:数据可靠性保障

解决的问题:支付回调事件在 at-least-once 语义下可能重复投递,直接入账会重复扣款。用“订单号”做业务唯一键 + 数据库唯一索引(相当于去重表),重复消息到达时发现已处理,直接跳过——厨房多做了一遍,但上桌只有一次。

具体案例:某支付平台消费者处理“支付成功”事件时,先执行 Redis SETNX(orderId, 1),只有返回 1 才继续写账单表;消息重放时 SETNX 返回 0 直接确认,账单恰好只记一次。

实时监控大屏的消费滞后告警

阶段:可观测性与告警

解决的问题:消费滞后靠人工盯不现实。采集每个消费组的 lag 指标,超过阈值自动告警,并根据 lag 趋势判断是否需要扩容——就像主厨每天巡查“灶台和台账差多远”。

具体案例:某公司监控平台每分钟拉取 __consumer_offsets 与各分区 LogEndOffset,计算 lag 绘制趋势图,lag 超过 5 万自动触发扩容流程,把积压消息在 10 分钟内消化回零。

Python 演示

python88 行
class Partition:    def __init__(self, pid, messages):        self.pid = pid        self.messages = messages        self.next_offset = 0        self.log_end_offset = len(messages)  class Consumer:    def __init__(self, cid):        self.cid = cid        self.assigned = []        self.processed_count = 0     def pull(self, batch_size=4):        batch = []        for p in self.assigned:            while p.next_offset < p.log_end_offset and len(batch) < batch_size:                batch.append((p, p.next_offset, p.messages[p.next_offset]))                p.next_offset += 1        return batch  class ConsumerGroup:    def __init__(self, partitions):        self.partitions = partitions        self.consumers = []     def add(self, cid):        self.consumers.append(Consumer(cid))        self.rebalance()     def remove(self, cid):        self.consumers = [c for c in self.consumers if c.cid != cid]        self.rebalance()     def rebalance(self):        for c in self.consumers:            c.assigned = []        for i, p in enumerate(self.partitions):            if self.consumers:                self.consumers[i % len(self.consumers)].assigned.append(p)  def drain(group, seen):    for c in list(group.consumers):        while True:            batch = c.pull()            if not batch:                break            for p, offset, msg in batch:                key = (p.pid, offset)                if key in seen:                    print("%s skip duplicate %s" % (c.cid, msg))                    continue                seen.add(key)                c.processed_count += 1                print("%s process %s at offset %d" % (c.cid, msg, offset))  def main():    partitions = [Partition(i, ["msg-%d-%d" % (i, j) for j in range(4)])                  for i in range(4)]    group = ConsumerGroup(partitions)    group.add("chef-A")    group.add("chef-B")    seen = set()     print("== 初始: A、B 各管 2 个分区 ==")    drain(group, seen)     print("== B 崩溃: 位移回退产生重复消息, 幂等去重生效 ==")    partitions[3].next_offset = 1    group.remove("chef-B")    drain(group, seen)     print("== C 加入: 再次再平衡, 新消息被 C 接管 ==")    partitions[3].messages.append("msg-3-late")    partitions[3].log_end_offset += 1    group.add("chef-C")    drain(group, seen)     for c in group.consumers:        print("%s processed %d messages" % (c.cid, c.processed_count))  if __name__ == "__main__":    main()
目的:用 Python 模拟一个消费者组:两个厨师(消费者)并行消费四个分区,模拟成员崩溃触发再平衡、位移回退产生重复消息,并用去重集合实现幂等消费。
  1. self.next_offset = 0分区维护“下一条待读”游标,对应后厨台账的位置,崩溃后新厨师从这里继续。
  2. self.consumers[i % len(self.consumers)]轮询分配分区:第 i 个分区派给第 i % n 位厨师,像发牌一样公平。
  3. partitions[3].next_offset = 1模拟 B 崩溃前已处理但未提交,位移回退,造成 at-least-once 下的重复消息。
  4. key = (p.pid, offset)用“分区号 + 偏移量”构造唯一键,等价于订单号贴纸,用于识别同一条消息。
  5. if key in seen:幂等去重核心:发现已处理直接跳过,重复处理不产生任何副作用。
  6. group.remove("chef-B")成员离开自动触发 rebalance,剩余消费者接管其分区,进度无缝接力。

常见误区

误区消费者组里加机器,消费速度一定线性提升,加到 20 台机器就比 10 台快一倍。

正解并行度上限是分区总数。消费者数超过分区数后,多出的消费者分不到任何分区,只能空转。就好比后厨只有六个灶台,加再多厨师也没地方站。扩容前先看分区数,必要时先给主题增加分区。

为何常见:人们习惯用“工人越多产量越高”的直觉推断分布式系统,忽略了分区才是并行单位这个前提。

误区处理完一批消息提交 offset 时,记录最后一条消费完的消息序号,比如处理到第 5 条就提交 5。

正解Kafka 的 offset 是游标,指向“下一条待读取消息”的位置。处理到第 5 条,应该提交 6。如果提交 5,重启后会从 5 重新读取,最后一条被重复消费——相当于台账多写了一次,把第 5 道菜又做了一遍。

为何常见:数组下标习惯是“最后访问的位置”,而 Kafka 语义是“下一个要访问的位置”,一字之差造成重复消费。

误区重新排班嘛,新老交替应该无缝切换,旧消费者继续干活,新消费者从旁边开始接手。

正解传统 Rebalance 是 stop-the-world:先 revoke 回收所有人的分区,再 rejoin、再 assign,期间组内没有任何消费者能拉取消息。全员暂停造成的“惊群”正是它被诟病最多的地方,所以后来才有了 CooperativeSticky 的增量式改进。

为何常见:人们用“平滑扩容”的直觉看待成员变化,没意识到协调者需要先收回旧状态才能安全下发新状态。

误区把 enable.auto.commit 设为 true,Kafka 就会自动帮我记住位置,消息保证只处理一次。

正解自动提交默认每 5 秒提交一次,这 5 秒窗口内崩溃重启,就会从上次提交点重新消费,重复窗口可达 5 秒。更稳妥的是手动提交:处理完业务逻辑后再 commitSync,把重复窗口压缩到几乎为零。

为何常见:名字里的“自动”让人误以为是“可靠”,忽略了它只是“定时提交”,安全性和实时性是两个维度的东西。

面试题

Q1. 消费者组中,同一个分区最多能被几个消费者同时消费?

单选题 · easy

  • A.没有任何消费者
  • B.恰好一个消费者← 正确答案
  • C.最多两个及以上
  • D.理论上不限制

答案:B。分区是并行最小单元,组内同一分区只能由一个消费者负责,否则分区内的消息顺序就无法保证。好比同一列菜只允许一位厨师做,两个人同时动手必然乱套。

Q2. 消费者提交的 offset 表示什么?

单选题 · easy

  • A.最后一条已消费的位置
  • B.下一条待读取消息位置← 正确答案
  • C.分区最新写入消息位置
  • D.消息在文件中的字节大小

答案:B。offset 是游标,指向下一条要拉取的消息位置。若已处理到第 N 条,应提交 N+1;提交 N 会导致最后一条被重复消费,就像台账把“下一次”错记成“这一次”。

Q3. 以下哪种情况通常不会触发消费者组再平衡?

单选题 · mid

  • A.新消费者加入消费者组
  • B.消费者心跳超时被判死
  • C.订阅的主题新增了分区
  • D.单次 GC 停顿二十毫秒← 正确答案

答案:D。GC 停顿 20ms 远小于 session.timeout(默认 45 秒)和 max.poll.interval(默认 5 分钟),不足以触发判死。成员变化、订阅变化、分区数变化才是 rebalance 的三大触发条件。

Q4. RangeAssignor 策略的核心特征是什么?

单选题 · mid

  • A.所有消费者分区分得一样多
  • B.分区切段顺序分配给消费者← 正确答案
  • C.尽量保持上一次分配不变
  • D.跨全部主题轮询式发牌分配

答案:B。Range 把每个主题的分区按连续段顺序分给消费者,P 不能被 C 整除时排前面的多拿一个;RoundRobin 才是跨主题轮询,Sticky 才保持旧分配。

Q5. 新版本 Kafka 中,心跳与 poll 拉取解耦后,哪个参数专门负责发现“处理卡死”的消费者?

单选题 · mid

  • A.会话超时时间配置项
  • B.心跳发送间隔配置项
  • C.拉取最大间隔配置项← 正确答案
  • D.请求超时时间配置项

答案:C。心跳只证明“人还活着”,max.poll.interval.ms 限制两次 poll 的最大间隔,超过说明处理停滞,会被移出组并触发再平衡。好比厨师一直喊“我在”,但十分钟端不出一盘菜,主厨照样换人。

Q6. 消费者组协调者(Group Coordinator)由哪个 Broker 担任?

单选题 · hard

  • A.集群随机挑选一个节点
  • B.负载最低的节点来担任
  • C.内部主题分区 Leader 决定← 正确答案
  • D.主题分区数最多的节点

答案:C。协调者由内部主题 __consumer_offsets 决定:对 group.id 做哈希取模得到 0~49 的分区号,该分区 Leader 所在的 Broker 即为协调者。这是 Kafka 去中心化设计的关键,协调者不是选举出来的。

Q7. at-least-once 语义下,消息可能出现什么现象?

单选题 · easy

  • A.消息一定不会丢失
  • B.消息可能重复投递← 正确答案
  • C.消息只被处理一次
  • D.消息一定不会重复

答案:B。at-least-once 保证“至少投递一次,绝不丢失”,但处理完未提交 offset 就崩溃,重启会重放旧消息,造成重复处理。好比菜一定送到,但客人桌上可能出现两份红烧肉。

Q8. 以下哪些做法可用于实现幂等消费、防止重复处理?

多选题 · mid

  • A.业务唯一键配合去重表判断← 正确答案
  • B.消费进度与业务写入同一事务← 正确答案
  • C.加大单次拉取字节数上限值
  • D.处理前查 Redis 的 SETNX 标记← 正确答案

答案:ABD。幂等消费的核心是让重复处理不产生额外副作用:唯一键去重表、SETNX 标记、事务内原子提交 offset 都是常见手段。增大拉取量与去重无关,只影响吞吐。

Q9. 关于 Kafka Rebalance,下列说法正确的有?

多选题 · hard

  • A.再平衡期间消费者仍可拉消息
  • B.新成员加入使组代数加一← 正确答案
  • C.旧代成员提交请求会被拒绝← 正确答案
  • D.粘性策略能减少分区迁移← 正确答案

答案:BCD。传统 rebalance 是 stop-the-world,全员暂停消费,A 错。每次再平衡完成代数 +1,旧代提交被隔离,防止僵尸成员干扰;Sticky/CooperativeSticky 通过保持旧分配减少迁移开销。

Q10. 消费者 lag 持续上升,合理的排查方向有哪些?

多选题 · mid

  • A.增加消费者但不超过分区总数← 正确答案
  • B.优化单条消息的处理耗时← 正确答案
  • C.调大单批拉取的最大条数← 正确答案
  • D.调低生产端消息的写入速率

答案:ABC。lag 上升说明消费速度跟不上生产。扩容(上限为分区数)、优化处理逻辑、加大批量都有效;调低生产速率治标不治本,且生产端通常不可控。真正的解法是让消费者跑得更快或更多。