题干与适用场景
一个 Kafka Topic 有 24 个分区,峰值写入 12 万条/秒。某租户产生 45% 的流量,生产者按 tenantid 分区,结果一个分区持续积压,其他消费者大多空闲。单个消费者对当前处理逻辑可稳定处理 8000 条/秒。业务真正要求的是同一 orderid 内有序,不要求整个租户的事件彼此有序;事故期间不能增加分区数。
请说明你会怎样证明问题来自键倾斜而非消费者、Broker 或下游故障,怎样在当天降低积压增长,怎样重做分区键和迁移路径,以及异步并行处理时如何安全提交 offset。最后给出能够证明修复有效的指标和故障测试。
这是一道数据工程与流式平台排障题。当前公开的 Kafka 面试材料直接使用“一个分区很热、负责它的消费者持续落后、其他消费者空闲、暂时不能改变分区数”的场景,要求候选人同时处理生产者分区策略、消费者并行度和顺序约束。Apache Kafka 文档说明,带 key 的记录默认按 key 的哈希选择分区;语义分区能保留局部顺序和数据局部性,但也会把同一个 key 的负载集中到同一分区。华为云的 Kafka 实践还明确指出,一个分区同一时刻只能由一个消费者消费,临时新增分区也难以快速消化已有分区的积压。
题目中的吞吐、占比、消费者能力和顺序范围是用于推导的面试假设,不代表任何特定公司的生产数据。
面试官考察点
第一项是能否从总量指标下钻到分区指标。总 Topic lag、消费者平均 CPU 或消费者数量都可能掩盖一个热分区。强回答会同时检查每个分区的写入速率、消费速率、lag 斜率、leader 所在 Broker、消息 key 分布和下游耗时,再用时间轴判断是生产端突然倾斜、单消费者变慢、频繁重平衡,还是 Broker 资源异常。
第二项是能否先算出问题规模。热租户写入约为:
120,000 × 45% = 54,000 条/秒若单个分区对应的消费者只能处理 8000 条/秒,热分区的积压增长约为:
54,000 - 8,000 = 46,000 条/秒
46,000 × 600 = 27,600,000 条/10 分钟这说明“再加几个普通消费者”不会改变热分区的消费上限,也说明只调整告警阈值无法止损。
第三项是能否识别真正的顺序不变量。当前 key 把顺序范围扩大到了租户,但业务只要求订单内有序。把 key 改为 order_id 可以提高基数并分散新订单,不过必须先回答:同一订单会不会在迁移时跨两个分区或两个 Topic?若会,简单改 key 会破坏顺序。
第四项是 offset 正确性。把一个分区的数据交给线程池后,完成顺序可能与 offset 顺序不同。若 offset 105 已完成而 104 尚未完成,提交 105 会在进程崩溃后跳过 104。强回答会维护“最大连续完成 offset”,只提交所有更小 offset 都完成的位置,并让下游写入具备幂等性以承受重放。
第五项是方案边界。更多分区只能增加可用并行槽,不能拆开一个仍被固定 key 绑定的超大实体;更多消费者也不能让传统消费组内两个消费者同时消费同一分区。如果单个订单本身长期超过单分区安全能力,又必须严格有序,就要优化串行路径、限流,或重新定义可独立处理的业务序列,而不是继续横向扩容。
回答前需要澄清的问题
- 顺序究竟按租户、订单还是单个事件流保证? 若必须租户内全序,不能直接把一个租户拆到多个分区;若只需订单内有序,
order_id才是更合适的分区边界。 - 45% 是事件数量、字节量还是处理成本? 一条大消息或一次昂贵下游写入可能让事件数均匀但处理成本倾斜。诊断必须同时看 records、bytes 和处理时间。
- 积压来自生产突增还是消费能力下降? 若热分区输入一直是 5.4 万条/秒,根因是键倾斜与容量;若输入不变但消费突然从 8000 降到 2000,先排查下游、GC、网络、磁盘和重平衡。
- 消费者写到哪里? Kafka 到 Kafka 可以考虑事务性地提交输出与输入 offset;写数据库、对象存储或外部 API 时,通常按至少一次处理,并用业务幂等键或
topic-partition-offset去重。 - 订单生命周期有多长? 若订单能在短时间内关闭,可让旧订单留在旧路由、新订单进入新路由;若订单长期存在,需要显式迁移屏障、序列号或路由状态。
- 事故期间允许限流或降级吗? 若可以对热租户启用配额、延迟非关键事件或合并可压缩更新,止损速度会快于代码和分区迁移。
- 现有消费者是否频繁超过
max.poll.interval.ms? 若 poll 线程被重处理阻塞,重平衡会进一步扩大 lag;此时必须先拆分 poll 与处理,并约束在途任务。
30 秒回答框架
“我先把总 lag 拆成分区级写入、消费和 lag 斜率,再对照 key 频率、消息字节、处理耗时、重平衡日志以及分区 leader 的 Broker 指标,证明是 tenantid 倾斜。按题目数据,热分区每秒进入 5.4 万条、只能处理 8000 条,积压每秒增加 4.6 万条,增加普通消费者不会帮助同一分区。当天我会先对热租户限流或降级,把热分区隔离到专用实例,并在只需订单内有序的前提下,按订单串行、跨订单并行;offset 只提交到最大连续完成位置,下游保持幂等。长期把新订单迁到按 orderid 分区的新 Topic,旧订单留在旧路由直至结束,避免一个订单跨路由乱序。最后用倾斜压测验证每分区吞吐、lag 斜率、端到端 p99、重复和乱序,并做消费者崩溃与重平衡测试。”
分步骤深入解答
第一步:先证明是哪一层形成热点
把观察窗口固定在同一段峰值时间,按以下顺序对齐指标:
- 每个分区的生产 records/s、bytes/s 和最大 offset 增长;
- 每个分区的消费 records/s、当前 offset 和 lag 斜率;
- key 的频率、字节量和估算处理成本分布;
- 热分区 leader 所在 Broker 的 CPU、网络、磁盘等待和请求延迟;
- 对应消费者的 poll 间隔、批次大小、处理耗时、GC、错误与重平衡日志;
- 下游数据库、存储或 API 的分区级耗时和限流。
判断规则很直接:若热分区输入约 5.4 万条/秒、其他 23 个分区合计约 6.6 万条/秒,那么其他分区在均匀假设下平均只有约 2870 条/秒。单消费者 8000 条/秒足以处理普通分区,却远低于热分区输入,键倾斜已经能解释“一个落后、其余空闲”。若热分区的输入没有异常,消费速率却与下游延迟同步恶化,则不能把问题误诊为分区键。
同时检查 Broker 热点。一个分区的 leader 集中到已经过载的 Broker 时,生产和拉取都可能变慢。移动 leader 或重新平衡副本可以消除 Broker 放置问题,但不会改变同一个 tenant_id 仍然落到一个分区的事实。
第二步:先降低积压斜率,再讨论清空积压
事故处理的第一个目标是让:
热分区输入速率 ≤ 热分区安全处理速率最快的杠杆通常在入口。对热租户启用有上限的配额;延迟可重建的分析事件;对只保留最新状态的更新进行合并;把非关键工作写入单独的降级路径。每种降级都要标出丢失、延迟或重放语义,不能把静默丢消息当作限流。
消费者侧可以在受控维护窗口停止原消费组,再用互斥且不重叠的显式分配方案,让一台资源充足的实例只负责热分区,其余实例负责其他分区。不能直接启动第二个普通消费组来“帮助”,因为它会独立读取整份数据并产生重复业务效果。隔离能避免热分区拖累同实例上的普通分区,但单线程处理能力仍然只有 8000 条/秒。
若业务只要求订单内有序,可在热分区消费者内部按 order_id 分发:同一订单进入同一串行队列,不同订单由有界工作池并行处理。工作池必须有在途上限;达到上限时暂停拉取或降低批次,避免把 Kafka 积压变成进程内存积压。poll 循环不能被长任务阻塞,否则超过 max.poll.interval.ms 会触发重平衡并制造更大的停顿。
第三步:用连续完成水位提交 offset
分区内并行会打乱“完成顺序”,但不能打乱“可提交顺序”。可以为每个分区维护以下状态:
nextCommitOffset = 最小尚未完成的 offset
completed = 已完成但前方仍有缺口的 offset 集合
任务完成(offset):
将 offset 加入 completed
当 completed 包含 nextCommitOffset:
从 completed 删除 nextCommitOffset
nextCommitOffset 加 1
提交 nextCommitOffsetKafka 提交的是下一条要读取的位置,因此只有 104、105、106 都完成后,才能把提交位置推进到 107。若 105 先完成、104 仍在重试,提交位置必须停在 104。进程在提交前崩溃会重放已经完成但尚未提交的消息,所以数据库写入应使用 event_id 或业务唯一键做幂等 upsert;缺少业务键时,可以用 topic-partition-offset 作为去重标识。
不要把 offset 提交与外部副作用说成天然原子。Kafka 到 Kafka 的处理可以把输出记录和消费 offset 放进同一 Kafka 事务;写外部数据库时,更常见的边界是至少一次消费加幂等写入,或把写入与去重记录放在同一个数据库事务中。
第四步:把分区边界缩小到真实顺序域
当前 24 个分区的平均容量并不一定不足。若每分区安全能力为 8000 条/秒,并把目标利用率限制在 70%,每分区规划能力约为 5600 条/秒:
120,000 ÷ 5,600 ≈ 21.4平均分布时 24 个分区可以覆盖题目中的峰值,并保留少量余量。失败来自 45% 的流量被一个低基数 key 固定到同一分区,而不是分区总数本身。长期 key 应满足三个条件:对应最小必要顺序域、基数足够高、在业务峰值下分布可预测。这里的自然候选是 orderid,或在确有租户局部性需求时使用 (tenantid, order_id) 的稳定编码。
受控加盐只适用于原 key 内可以乱序或能够在下游重排的场景。若必须保证订单内顺序,不应对同一订单随机加盐;否则同一订单会跨分区并发到达。若一个单独订单就超过单分区能力,增加 key 基数也无效,只能优化该订单的串行路径、限制其速率,或把业务协议拆成带明确依赖的独立序列。
第五步:通过版本化路由安全迁移
直接在原 Topic 上增加分区并立刻更换 key 有两个风险:默认哈希映射会随分区数改变,已有 key 可能移动;同一订单在切换点前后的事件可能进入不同分区,Kafka 无法提供跨分区顺序。
更稳妥的方案是建立按 order_id 分区的新 Topic,并给生产路由加版本:
- 切换点之后创建的新订单全部进入新 Topic;
- 切换前已经存在的订单继续使用旧 Topic 和旧 key,直到订单关闭;
- 所有生产者依据同一份订单路由状态发送,不能各自用本地时间猜测;
- 消费端同时处理两条路由,但每个订单在任一时刻只属于其中一条;
- 旧订单清空并达到保留期后,再关闭旧 Topic。
若订单不能自然结束,就为单个订单建立迁移屏障:暂停该订单的新事件,等待旧路由消费到记录的最后序列号或 offset,更新路由版本,再恢复发送。需要零停顿时,可以携带单调序列号并在下游合并,但这会引入缓存、超时和缺口恢复,只有业务确实需要才值得采用。
第六步:用倾斜流量和故障验证方案
验证不能只看总吞吐。至少覆盖:
- 45% 流量来自单租户、订单分布接近真实峰值的压测;
- 每分区输入、消费、lag 斜率和最大值;
- 端到端 p50、p95、p99,以及积压清空预计时间;
- 工作池在途数、最老任务年龄、重试和死信数量;
- 重复写入率、同订单乱序数和幂等冲突数;
- 消费者在 offset 缺口存在时崩溃并重启;
- 处理时间超过预期时是否触发重平衡;
- 新旧 Topic 路由交界处同一订单是否只出现一条路径。
通过条件应包含稳定性,而不只是短时峰值:热分区 lag 斜率在持续峰值下不再为正;消费者崩溃后允许重放但不丢业务效果;同一订单没有乱序;新订单均匀分布;旧订单能按计划排空。若修复后总吞吐提高但一个大订单仍反复形成热点,说明顺序域或业务限流问题尚未解决。
高质量示范回答
“我不会先加消费者,因为题目已经给出一个分区落后而其他消费者空闲。我要先用分区级 records/s、bytes/s、lag 斜率和 key 频率证明倾斜,同时排除热分区 leader 所在 Broker、消费者重平衡和下游耗时异常。
按题目数据,热租户每秒产生 5.4 万条。单分区消费者只能处理 8000 条,所以 lag 每秒增加约 4.6 万条,十分钟就是 2760 万条。剩余 55% 流量若大致分散在其他 23 个分区,平均每个分区约 2870 条/秒,正好解释为什么其他消费者有余量。传统消费组里一个分区同一时刻只给一个消费者,再增加普通实例不会提升这一个分区的吞吐。
当天我会先降低输入斜率:对热租户启用明确配额,把可延迟或可合并的非关键事件移到降级路径。然后在受控窗口把热分区隔离给专用实例,避免它拖累普通分区。因为业务只要求订单内有序,我会让消费者按 orderid 分发到有界工作池:同一订单串行、不同订单并行。offset 不按最快完成的任务提交,而是维护最大连续完成水位;任何前方缺口未完成时都不越过它。崩溃会重放未提交消息,所以下游用 eventid 或业务唯一键做幂等写入。
长期我不会把‘加分区’当成唯一修复。以 70% 目标利用率计算,单分区规划能力约 5600 条/秒,24 个分区平均上可以覆盖 12 万条/秒;真正的问题是 tenantid 把 45% 流量锁在一个分区。我会新建按 orderid 分区的 Topic。切换后创建的新订单进入新 Topic,已有订单继续走旧路由直到结束,路由版本由共享状态决定,保证一个订单不会跨两个 Topic。
上线前我会用同样的 45% 租户倾斜压测,观察每分区吞吐、lag 斜率、端到端 p99、重复和乱序。再故意让消费者在多个 offset 乱序完成时崩溃,确认重启后只发生幂等重放、不丢数据;触发重平衡并检查恢复时间;最后验证新旧路由边界上的订单只走一条路径。如果单个订单本身超过单分区能力,我会明确告诉面试官,继续加消费者或分区都解决不了,必须优化串行处理、限流,或重新定义可独立排序的业务单元。”
常见错误
- 只看 Topic 总 lag → 平均值掩盖单分区输入和消费斜率 → 同时画出每分区 records、bytes、lag 与 key 分布。
- 看到 lag 就增加消费者 → 一个分区在传统消费组内同一时刻只能由一个消费者处理 → 先判断分区数、分配和单分区能力,再决定扩容。
- 立刻增加分区 → 旧积压不会自动搬走,默认 key 映射还可能改变 → 先止损,再用版本化 Topic 和明确迁移边界。
- 随机给热 key 加盐 → 同一订单可能跨分区并乱序 → 只在允许乱序时加盐;本题按
order_id保留真实顺序域。 - 线程池任务完成就提交对应 offset → 较小 offset 尚未完成时会在崩溃后被跳过 → 只提交最大连续完成水位。
- 声称提交 offset 后就是恰好一次 → 外部数据库写入与 Kafka offset 通常不在同一事务 → 说明至少一次、幂等键和事务边界。
- 用第二个普通消费组帮助热分区 → 第二组会独立消费完整 Topic,造成重复业务效果 → 使用互斥的显式分配或受控的新处理架构。
- 只测均匀流量 → 均值通过不能证明热 key 已消失 → 重放真实或合成的倾斜分布,并检查最大分区而非平均分区。
- 忽略单实体上限 → 严格有序的单个实体无法由多个分区并行处理 → 明确顺序、限流和串行路径之间的硬边界。
追问及应对
追问一:为什么不直接把分区数从 24 增加到 48?
新增分区能增加未来的并行槽,却不会把旧分区的现有积压自动拆开,也不会让同一个 tenant_id 同时落到多个分区。默认 key 哈希在分区数变化后还可能把已有 key 映射到新分区,从而让同一订单在切换前后跨分区。先修正顺序域和迁移路径,再依据压测决定是否需要更多分区。
追问二:分区内并行后,如何避免内存无限增长?
为每个分区设置最大在途记录数和最大未提交 offset 窗口。达到上限时暂停该分区的拉取或减少每次交给工作池的记录,同时让 poll 与处理解耦,保持必要的消费组活性。监控最老未完成 offset 的年龄;单个订单持续阻塞时,将其隔离、重试或转入人工处理,不能让后续所有 offset 永久卡住。
追问三:如果下游数据库不支持幂等 upsert 怎么办?
在同一个数据库事务中先插入去重表,唯一键使用业务 event_id 或 topic-partition-offset,插入成功后再执行副作用;唯一键冲突表示该事件已经处理。若副作用在外部不可事务化 API 中,使用带幂等键的接口、outbox 或可查询的操作状态。无法建立幂等边界时,就不能承诺崩溃重放不会产生重复效果。
追问四:如果业务后来要求同一租户内严格有序呢?
那么 tenant_id 就是不可拆分的顺序域,按订单并行不再合法。单租户输入超过单分区能力时只能提升该串行处理路径、限制租户速率,或与业务方重新定义哪些事件可以独立排序。可以建立带全局序列号的分片和下游合并器,但它把排序等待、缺口恢复和可用性成本转移到了消费端,不能当作免费扩容。
追问五:如何估算清空 2760 万条积压需要多久?
先让输入低于处理能力。若限流后热分区输入降到 3000 条/秒,而优化后的处理能力达到 1.2 万条/秒,净清空速度是 9000 条/秒:
27,600,000 ÷ 9,000 ≈ 3,067 秒 ≈ 51 分钟这仍是基于稳定速率的估算。实际计划还要加入重试、下游限流、消息大小差异和安全余量,并持续用 lag 斜率更新预计完成时间。
追问六:迁移期间如何证明没有订单跨新旧 Topic?
给每个订单保存唯一的 partitioning_version,所有生产者发送前读取或缓存带版本的路由记录;版本只有在迁移屏障满足后才能改变。消费端记录订单首次出现的 Topic 与版本,发现同一订单同时出现在两条活动路径时立即告警并停止自动推进。压测和灰度阶段还要核对生产日志、两侧 offset 和下游订单序列,不能只看总消息数相等。