题干与适用场景
设计一个面向多个业务团队的分布式消息队列。稳态入口为每秒 100 万条、平均每条 1 KB, 流量峰值可以持续 15 分钟达到每秒 300 万条。消息默认保留 24 小时,生产者需要批量发布, 消费者以消费组拉取、提交位点并在保留期内重放。相同业务键的消息需要局部有序;不同键可以并行。 普通消息采用至少一次投递,发布确认 p99 目标小于 50 毫秒。每个分区跨三个可用区保存三个副本, 失去一个可用区时不能丢失已经确认的消息。
单条消息通常较小,但接口允许最大 100 MB 的业务载荷。大载荷不能反复穿过 Broker 的日志、 副本同步和消费者缓存;本题让它先进入对象存储,队列只保存不可变引用、大小和校验和。 题目中的吞吐、延迟、保留期、阈值和副本数都是面试假设,需要通过目标硬件压测,不代表任何产品承诺。
2026 年 5 月发布的中文系统设计文章直接讨论每天百亿条、峰值百万 QPS 的消息队列设计;PracHub 在 2026 年 6 月更新的公开题目要求覆盖 API、消费组、位点、分区、副本、大载荷与多租户隔离。 两份材料证明这是一道当前仍可核验的系统设计题。公司归属只有单一页面陈述,因此本文不设置公司名。
面试官考察点
第一,看候选人是否先定义交付契约。发布成功、Broker 持久化、消费者拿到消息、业务副作用完成和 消费位点提交是五个不同边界。把它们统称为“消息已成功”会隐藏两类故障窗口:生产者没有收到确认时 可能重复发布,消费者完成副作用但尚未提交位点时可能重复消费。
第二,看分区、顺序和扩展是否来自同一条推导。相同键进入同一分区,才能保留该键的日志顺序;一个分区 同时限制写吞吐和同组消费并行度。增加分区提高并行度,却不能拆开一个持续超载的热键,也不能提供全局顺序。
第三,看副本确认是否真的覆盖故障。强回答会说明何时可以向生产者确认、哪些副本能参与新 Leader 选举、 旧 Leader 恢复后怎样被 epoch 隔离。只说“三副本自动切换”无法证明已确认消息在故障后仍存在。
第四,看消费位点与业务结果能否闭环。先提交位点再处理可能漏业务结果;先处理再提交会在崩溃时重放。 至少一次的正确落点是后者,再由稳定消息 ID、业务幂等键、唯一约束或版本条件吸收重复。Broker 内事务 只能覆盖参与同一事务的资源,不能自动让外部支付、邮件或数据库获得 exactly-once。
最后,看队列是否能在真实压力下保持边界。候选人需要计算 24 小时存储与突发积压,处理大消息、热分区、 慢消费、毒消息、磁盘满和租户噪声,并用故障注入证明“已确认不丢、未提交不跳、旧所有者不能前进位点”。
回答前需要澄清的问题
- 要设计保留日志还是领取后删除的工作队列? 本题选择保留日志,因为需要多个消费组和重放。
若每个任务只需一个 Worker 领取且无需历史重放,带可见性超时的租约队列会更简单。
- 顺序范围是什么? 只保证同一主题、分区内的追加顺序,相同业务键固定到一个分区。不要求跨键、
跨分区或跨主题全局有序;全局有序会把吞吐压到单一串行日志。
- 发布确认代表什么? 三副本中的两个已把记录持久化,并且控制面仍认可当前 Leader epoch 后才确认。
少于两个可用副本时拒绝需要强持久性的写入。
- 消费语义是什么? 默认“处理成功后提交下一位点”的至少一次语义。外部副作用必须支持幂等或对账。
允许丢失但不能重复的低价值场景,才可以先提交再处理。
- 消费者需要任意重放吗? 可以在 24 小时保留窗口内按 offset 或时间重置消费组;越过保留边界只能
从长期归档恢复,若没有归档就明确失败。
- 毒消息能否跳过? 普通主题允许有限重试后进入死信主题。严格按键有序的主题不能无代价跳过;
应暂停该键或分区并修复,否则后续消息可能越过失败消息。
- 100 MB 载荷是否必须内联? 不需要。假设 256 KiB 以内可以内联,更大的载荷通过对象引用传递。
阈值由压测和成本决定,100 MB 是对象载荷上限。
- 跨区域要求是什么? 主方案是单区域、三可用区。跨区域灾备采用异步复制,不能同时承诺零数据丢失
和本地写延迟;若业务要求跨区域零 RPO,确认路径与延迟预算都要重做。
- 租户隔离到什么程度? 默认共享 Broker 但按租户限制入口、出口、存储、分区和连接。超大或强合规
租户可放入独立 Broker 池,控制面与协议保持一致。
30 秒回答框架
“我先把它定义为可保留、可重放的分区日志。生产者按业务键路由到分区 Leader,批量写入后由三个可用区 中的两个副本持久化再确认;同键有序,全局不保序。消费者按组独占分区,拉取后先完成业务结果,再提交 下一 offset,因此系统是至少一次,外部副作用要用消息 ID 幂等。稳态入口约 1 GB/s、每天 86.4 TB 逻辑数据,三副本约 259.2 TB;15 分钟三倍峰值相对稳态消费能力多出约 1.8 TB backlog。大于 256 KiB 的载荷放对象存储,队列只传引用和校验和。最后用配额、公平调度、lag 告警、Leader 故障、 确认响应丢失和消费者崩溃测试,证明消息不静默丢失且重复可控。”
分步骤深入解答
第一步:把 API 和不变量写在组件之前。
核心接口只暴露主题、发布、拉取、提交与重置位点:
POST /v1/topics
POST /v1/topics/{topic}/messages:publish
POST /v1/groups/{group}/messages:fetch
POST /v1/groups/{group}/offsets:commit
POST /v1/groups/{group}/offsets:reset发布请求包含 tenantid 的认证上下文、主题、可选 messagekey、稳定 message_id、载荷或对象引用, 以及生产者 epoch 和分区内序号。批次响应返回每条消息的分区、offset 和提交状态。拉取请求带消费组、 分区所有权 generation、起始 offset、最大字节和长轮询时间;提交请求写“下一条要读取的 offset”。
设计要守住四个不变量:已确认记录在一个可用区故障后仍可读取;消费者只能看到已提交前缀;同一 Leader epoch 内 offset 单调增加;过期 generation 的消费者不能提交位点或继续写结果。接口中的 messageid 用于业务去重,producerid + epoch + sequence 用于 Broker 识别同一发布重试。
第二步:分开控制面与数据面。
控制面:租户与 ACL、主题配置、分区放置、副本成员、Leader epoch、配额
数据面:
Producer -> metadata cache -> partition leader -> follower replicas
Consumer group -> group coordinator -> partition leaders -> business sink小型一致性集群保存主题和分区元数据,给每次 Leader 任期分配递增 epoch。它不承载消息正文。 Broker 负责追加日志、复制、读取和保留。客户端缓存分区 Leader,遇到旧 epoch 或非 Leader 响应后刷新元数据。 这个拆分让消息吞吐不经过单一代理,也让控制面故障期间已有分区可以在有限租约内继续服务;创建主题和迁移 分区可以暂时失败,不能用过期元数据任意选主。
第三步:用分区日志获得吞吐、重放和局部顺序。
每个分区是一组只追加的 segment,记录包含:
MessageEnvelope {
tenant_id, topic, partition, offset
message_id, message_key, producer_id, producer_epoch, sequence
created_at, headers, payload_or_ref, payload_size, checksum
}活跃 segment 顺序追加,稀疏 offset 索引定位读取位置,关闭的 segment 按时间或大小滚动。消费者按 offset 批量拉取,Broker 可以利用顺序 I/O、页缓存和批量网络发送。24 小时到期后以 segment 为单位删除;正在 被合法重放或上传到分层存储的 segment 受引用保护,避免保留任务与读取竞争。
分区键取可信租户、主题和业务键的哈希。带相同业务键的消息固定到同一分区;无键消息可以轮询或粘性批次 分配。只按租户分区会制造大租户热点,完全随机会丢掉业务键顺序。增加分区只能影响未来消息;若改变哈希 取模,相同键可能跨新旧分区。需要稳定顺序时使用虚拟分片到物理分区的映射,并在迁移时停止该虚拟分片、 记录切换 offset、排空旧所有者,再以新 epoch 恢复。
第四步:让副本确认与选主共享同一提交定义。
每个分区有三个跨可用区副本。Leader 为记录分配 offset,将批次写入本地持久日志并并行复制;任意两个 副本持久化后推进 commit_watermark,再向生产者确认。消费者只读取小于该水位的 offset。 Leader 故障时,控制面只从拥有已提交前缀的副本中选新 Leader,并递增 epoch。旧 Leader 恢复后先截断 未提交尾部、追平新 Leader,不能继续接受旧 epoch 请求。
这一选择能承受一个可用区故障。故障后只剩两个副本,写入仍需两者确认,延迟和容量会下降;其中任何一个 再次失效时,强持久主题停止确认新写入,直到补足副本。若为了可用性把落后的副本强行选主,已确认不丢的 承诺就被破坏。网络分区期间只有拥有提交多数的一侧可以写,另一侧必须被 fencing。
生产者在“已经提交但确认响应丢失”时会重试。Broker 以生产者 epoch 和单调 sequence 在分区内去重, 返回原 offset;旧 epoch 的僵尸生产者被拒绝。这个机制只去除发布重试造成的日志重复。两个独立业务请求 误用不同 ID,或消费者重复执行外部副作用,仍需要业务层处理。
第五步:把消费组所有权、位点和业务结果连起来。
同一消费组内,一个分区同时只分配给一个成员。Group coordinator 维护成员、租约、generation 和分配; 成员超时或扩缩容时产生新 generation,旧成员的 fetch 与 commit 被拒绝。渐进式再平衡可以只移动必要 分区,减少全组停顿,但消费者仍要在撤销所有权前停止取新消息并提交已完成进度。
默认处理顺序是:拉取批次,执行幂等业务写,成功后提交下一 offset。消费者若在业务提交后、offset 提交前 崩溃,接管者会重放已完成消息,因此形成至少一次。稳定 message_id 可以进入目标表唯一约束、幂等记录, 或与业务状态在同一数据库事务中提交。若输出仍写回同一消息系统,可以把输出记录与输入位点放进一个 Broker 事务;外部数据库、支付或邮件不参与该事务时,仍需幂等、状态查询或对账。
位点按 (tenant, group, topic, partition) 存入复制元数据日志,并携带 generation。监控同时显示 logendoffset - committed_offset 和最老未处理消息年龄。单看条数会误判大小差异明显的 backlog, 还要记录落后字节数与按当前净消费速度估算的追赶时间。
第六步:明确重试、死信与顺序的冲突。
瞬时网络和限流错误进入带抖动的延迟重试主题;确定无法处理的 schema、权限或业务校验错误不做盲目重试。 重试消息保留原 message_id、源主题、分区、offset、首次时间、尝试次数和错误分类。超过次数或业务期限后 进入死信主题,告警并允许受控 redrive;redrive 仍使用原 ID,防止绕过幂等。
把失败消息移走会让后续消息先完成,因此与严格的同键顺序冲突。订单状态必须严格演进时,可以暂停该键, 把该键后续消息暂存到独立有序通道,修复后从原 offset 恢复;最简单但影响更大的方案是暂停整个分区。 若业务接受最终状态按版本收敛,可以让后续消息继续,并由目标拒绝旧版本。选择必须写进主题契约,不能同时 承诺“毒消息不阻塞”和“绝不越序”。
第七步:让大消息走对象引用,并关闭垃圾回收竞态。
本题把 256 KiB 设为内联阈值。更大载荷先通过短期上传凭据写入对象存储,使用不可变对象键并记录大小、 内容哈希和加密信息;上传成功后才发布引用消息。消费者取得消息后读取对象并校验哈希。Broker 的副本日志 只复制小型 envelope,避免一条 100 MB 消息占满网络缓冲、复制批次和消费者内存。
上传成功但引用未发布会留下孤儿对象,由上传会话 TTL 清理。引用已提交后,对象保留期必须覆盖消息保留、 合法重放、死信和安全余量;删除任务先扫描仍受保护的引用,再延迟删除。对象读取失败时消息保持未提交并重试, 不能确认消费后再发现正文永久缺失。大消息另设每租户字节率、并发下载和存储配额,因为按消息条数限流会失真。
第八步:用容量反推分区、磁盘和追赶余量。
按十进制 1 KB 计算,稳态逻辑入口为:
1,000,000 messages/s × 1,000 bytes = 1 GB/s
1 GB/s × 86,400 s = 86.4 TB/day
三副本写入量下界 = 86.4 × 3 = 259.2 TB/day15 分钟内总入口为 3 GB/s × 900 = 2.7 TB。若消费者只能维持稳态 1 GB/s,新增 backlog 为:
(3 GB/s - 1 GB/s) × 900 s = 1.8 TB峰值结束后,若消费者可持续 1.5 GB/s,而新入口仍为 1 GB/s,净追赶速度为 0.5 GB/s,清空 1.8 TB 理论上需要 3,600 秒,也就是约 1 小时。实际还要计入副本恢复、批次开销、压缩率、索引、文件系统余量 和大消息对象存储,因此这些数是容量下界。
分区数同时受字节率和消息率限制。假设在三副本与目标 p99 下,压测得到单分区可持续 40 MB/s 和每秒 4 万条,那么峰值两个维度都需要至少 75 个分区;再留 50% 的故障与再平衡余量,至少约 113 个,实际可 选择 128 个。这个单分区能力是面试压测假设,换硬件、批次大小、确认策略或消息大小后必须重测,不能把 128 当通用答案。
第九步:实施背压、多租户隔离和可验证运维。
消费者采用长轮询,并用 max_bytes 与在途批次数控制自己的速率。Broker 磁盘水位升高时先阻止创建新分区、 降低低优先级租户突发额度,再拒绝超过配额的发布并返回可重试信号;无限内存队列和无限重试只会把拥塞变成 进程崩溃。生产者使用有上限的批次缓冲、截止时间和带抖动退避,不能在 Broker 故障时制造同步重试风暴。
租户身份来自认证凭据,不能相信消息体。入口按消息数和字节数限流,出口按 fetch 字节和请求 CPU 公平调度, 并限制存储、分区数、消费组数、连接、在途请求和大对象。分区放置避免同租户所有副本或所有热分区集中到 少数 Broker。超大租户迁移到独立池,小租户共享池仍保留逐租户计量和拒绝指标。
关键指标包括发布确认 p50/p95/p99、错误与未知结果,按分区的入口字节、Leader 与副本 lag、 commit_watermark、磁盘水位、热键、消费组已提交 offset、消费 lag 与最老年龄、再平衡次数、重试和 死信、对象孤儿与读取失败,以及按租户 的限流和公平性。端到端 canary 发布稳定 ID,完成幂等写后提交 offset,再核对 Broker、消费组和业务结果。
故障矩阵至少覆盖:Leader 在复制前、提交后和返回确认前崩溃;一个可用区失效;网络分区;磁盘满; 过期 Leader 恢复;热键;15 分钟三倍峰值;消费者在业务提交前后崩溃;再平衡时旧成员继续提交;毒消息; 对象上传成功但发布失败;对象读取故障和死信 redrive。验收断言是已确认消息不丢、未提交消息不越过、 同键顺序符合主题契约、旧 generation 无法推进位点,并且所有重复、拒绝和丢弃都有可归因指标。
高质量示范回答
“我会先确认这是一套需要多个消费组和 24 小时重放的保留日志服务。主题切成分区,相同业务键固定到同一 分区,所以顺序保证只到键和分区;不同分区并行。生产者从控制面取得 Leader 元数据后直接批量写入, 每个分区三个副本跨可用区放置,两个副本持久化才推进 commit_watermark 并返回确认。新 Leader 只能从 含有已提交前缀的副本中选,epoch 会拒绝旧 Leader 和旧生产者。
存储使用只追加 segment 与稀疏 offset 索引,消费者按组独占分区并长轮询。它先把业务结果幂等提交, 再提交下一 offset;崩溃窗口会重放,但不会静默跳过。生产端用 producer epoch 和 sequence 去除确认丢失 造成的发布重试,消费端用稳定 message ID、唯一约束或版本条件吸收副作用重复。我只会在输入位点和输出 都属于同一 Broker 事务时谈端到端事务,外部系统仍需幂等或对账。
容量上,稳态是 1 GB/s 和每天 86.4 TB 逻辑数据,三副本写入下界约 259.2 TB。15 分钟三倍峰值在消费 能力维持稳态时会多积压 1.8 TB;恢复后若有 0.5 GB/s 净追赶能力,理论清空约一小时。分区数取消息率和 字节率计算结果的较大值,再留故障余量,并用真实硬件压测校准。
大于 256 KiB 的正文先写对象存储,队列只保存不可变引用、大小和校验和;孤儿上传按会话 TTL 清理, 已提交引用的对象保留到消息、重放和死信窗口之后。重试保留原消息 ID。严格有序主题遇到毒消息时暂停该键 或分区,因为直接送死信会让后续消息越序。
最后我会按租户限制入口、出口、存储、分区和大对象,把热租户隔离到独立池,并监控确认延迟、未知发布、 副本 lag、磁盘水位、消费最老年龄、热键和死信。故障测试会覆盖确认响应丢失、Leader 提交前后崩溃、 可用区故障、旧成员提交位点、消费者业务写后崩溃和对象存储故障,逐项验证确认边界。”
常见错误
- 错误表现:只画 Producer、Kafka、Consumer → 失败原因:组件名没有定义确认、位点、顺序和故障边界 → 修正方法:先写交付契约与四个不变量,再让组件逐项对应。
- 错误表现:承诺全局有序并继续水平扩展 → 失败原因:全局顺序需要单一串行决定点,分区并行会失去该顺序 → 修正方法:把顺序收窄到业务键和分区,明确热键上限。
- 错误表现:Leader 本地落盘就确认 → 失败原因:Leader 所在可用区故障可能带走唯一持久副本 → 修正方法:跨可用区提交多数持久化后确认,只从拥有已提交前缀的副本选主。
- 错误表现:消费者收到消息立刻提交 offset → 失败原因:提交后崩溃会让业务结果永久缺失 → 修正方法:先幂等提交业务结果,再提交下一 offset,接受可控重放。
- 错误表现:把 Broker exactly-once 等同于外部副作用 exactly-once → 失败原因:外部系统不参与 Broker 事务,确认丢失仍有重复窗口 → 修正方法:使用业务幂等键、唯一约束、版本条件或对账。
- 错误表现:失败消息一律立即送死信 → 失败原因:后续同键消息可能越过它,破坏状态顺序 → 修正方法:按主题契约选择暂停键、暂停分区或允许版本收敛。
- 错误表现:100 MB 载荷直接写 Broker 日志 → 失败原因:少数大消息会占满复制、缓存和拉取批次 → 修正方法:对象存储保存正文,日志保存引用、大小与校验和。
- 错误表现:只按消息条数做限流和容量 → 失败原因:1 KB 与 100 MB 消息的网络、磁盘和内存成本相差巨大 → 修正方法:同时按条数、字节、在途批次和对象并发计量。
- 错误表现:增加消费者就能消除所有 lag → 失败原因:同组每个分区同时只有一个所有者,热键仍受单分区串行路径限制 → 修正方法:先定位分区与键分布,再决定扩分区、拆业务键或限流。
- 错误表现:只监控 Broker 存活 → 失败原因:活着的集群仍可能出现副本落后、磁盘耗尽、旧位点和死信增长 → 修正方法:监控分段延迟、提交前缀、最老消息、追赶时间和端到端 canary。
追问及应对
追问一:需要跨区域零数据丢失,同时保持 50 毫秒 p99 怎么办?
跨区域同步确认把广域往返时间放进发布路径,50 毫秒目标是否可达取决于区域距离和网络尾延迟。先让业务在 零 RPO 与本地低延迟之间排序。零 RPO 更重要时,写入必须等待远端提交多数,并重新制定延迟 SLO;低延迟 更重要时采用区域内同步、跨区域异步,明确故障时可能丢失尚未复制的尾部。双主还要解决相同键的归属和冲突, 通常让一个主题或键范围只有一个主区域更容易维持顺序。
追问二:一个租户的单个业务键达到每秒 20 万条,增加到 128 个分区为何没有改善?
相同键必须落到同一分区才能维持顺序,因此它仍受单分区约每秒 4 万条的压测上限限制。可选动作只有优化 单分区路径、对该租户限流,或重新定义独立顺序域,例如把一个账户拆成不会相互影响的子实体键。若业务坚持 该键全序,系统应拒绝超过串行容量的承诺;把同一键随机打散只会把容量问题换成乱序问题。
追问三:消费者已经扣款,随后在提交 offset 前崩溃,怎样避免重复扣款?
用 message_id 或业务操作 ID 作为支付幂等键。支付方若支持幂等接口,重放继续使用同一键并查询原结果; 若只控制本地数据库,在同一事务中写入业务状态和已处理消息唯一记录,再由 outbox 推进外部动作。外部系统 没有幂等或查询能力时,只能记录 UNKNOWN、对账和人工补偿,不能靠提前提交 offset 隐藏风险。
追问四:毒消息进入死信后,如何安全 redrive?
先修复消费者或数据,再冻结 redrive 范围,保留原消息 ID、源 offset、首次时间和尝试历史。影子消费验证 新版本能处理后,按租户与分区限速回放,目标幂等规则继续生效。严格有序主题还要暂停相关键的后续消息, 从失败 offset 顺序恢复;若顺序已经被允许打破,目标用业务版本拒绝旧状态。redrive 不能生成新 ID 来绕过 去重,也不能在正常流量高峰无限制灌回主队列。
追问五:保留期从 24 小时改成 30 天,架构哪里先变化?
按当前稳态,30 天逻辑数据约为 86.4 × 30 = 2.592 PB,三副本全放 Broker 本地磁盘的下界约为 7.776 PB,成本和恢复时间都会成为瓶颈。保留活跃与近期 segment 在 Broker,关闭且校验完成的 segment 上传对象存储;元数据记录对象位置和校验和,历史 fetch 通过缓存或读取代理返回。删除、重放、压实和对象 生命周期必须共享同一保留状态机,避免本地 segment 已删而远端对象尚未可读。