LLM 面试备考
首页/分布式与可靠性

队列与发布订阅:投递语义、背压与任务队列(Queue, Pub/Sub & Delivery Semantics)

所属:01-fundamentals / 07-distributed-systems
面试权重:★★(Agent 平台「高并发高可用」设计题的骨架;很少被单独深挖,但答不上来会连带扣掉整道设计题的印象分)
来源:通用分布式知识,本仓库两本参考书基本未覆盖 —— 按 System Design 101(ByteByteGo)、MIT 6.5840(分布式系统)、DDIA 第 11 章「流处理」以及各消息/队列服务的公开文档整理,未做一手考证。能对回书的有两处:「到达方式决定排队时间」「抢占的两笔账」出自《深入理解 AI Infra》第 11.3.2 节(李博杰,github.com/bojieli/ai-infra-book);「连续保存位置」缺口出自第 11 章 §11.5.1,经本仓库 ../06-agent-infra/10-agent-runtime.md §6 转述
相关知识点:timeout-retry-idempotency.md(重试、幂等键、去重表怎么落地 —— 本篇只讲「为什么会重复」,退避与熔断在那边)、transaction-wal.md(事务、outbox、补偿 —— 「用数据库表做队列」为什么能消掉双写问题)、process-thread-async.md(消费者为什么会被阻塞)、rpc-http-grpc.md(同步调用与异步消息的分界)、../06-agent-infra/10-agent-runtime.md §4 / §6、../../02-interview/design/08-agent-platform.md §4.4

一句话#

队列解决的是**「一件工作该由谁做」,发布订阅解决的是「一件事发生了,谁该知道」**;而两者最容易被讲错的地方是投递语义 —— 端到端「恰好一次」在一般情况下做不到,工程上说的 exactly-once 其实是「至少一次投递 + 幂等消费 + 去重」这一整套组合拳的名字,不是消息中间件上的一个开关。

为什么重要(面试角度)#

  • 它是「Agent 平台高并发高可用」设计题的骨架。 那道题里「突发怎么削峰」「任务不能丢」「跑两遍扣两次钱」分别对应本篇的削峰、ack 与投递语义、幂等消费。缺了这块,设计题只能停在「加个队列」。
  • 投递语义是最能分开人的一道题。 说「我们用了 exactly-once 的队列所以不会重复」是错的;能说清「ack 丢失导致重投递,而这在原理上不可消除」「所以做法是至少一次 + 幂等消费 + 去重表,且去重必须和业务写在同一个事务里」,是对的。
  • 它接得上你的真实锚点。 QueWI 网关做的是模型调用层的超时 / 重试 / 熔断;这一篇讲任务层的消息怎么投、重复怎么收。同一场面试里能把这两层分开,就是加分。
  • ../06-agent-infra/10-agent-runtime.md §6 的「连续保存位置」缺口,本质就是消费位点问题。 云端只记「收到的最后一轮」而不记「从起点连续到哪一轮」,等于把消费位点提交到了还没补齐的位置 —— 换台机器恢复就会跳过缺口。同一个错误在 Kafka 里叫「乱序提交 offset」。
  • 「什么时候加消费者没用」是压力测试。 面试官常从「队列积压了怎么办」往下追;只会答「加消费者」的人,会在「瓶颈其实在下游数据库」那一层停住。

⚠️ 边界声明:你没有在生产里运维过 Kafka / RabbitMQ 集群,没做过跨机房的消息链路。本篇按「原理 + 这套账我会这么算」讲,不要说成「我们线上就是这么做的」。许可的表述是「如果我来设计,我会先从投递语义和幂等键这两件事入手」。

核心内容#

1. 队列 vs 发布订阅:一条消息给一个人,还是给一群人#

队列(点对点 / work queue) 发布订阅(pub/sub)
一条消息的消费者数 恰好一个(多个消费者是竞争关系) 每个订阅者各拿到一份
加消费者的效果 提高吞吐(分摊工作) 多一个下游动作(不提高吞吐)
语义上像什么 命令:「去做这件事」 事实:「这件事已经发生了」
失败谁来兜 队列负责重投给别的 worker 每个订阅者各自决定重试与否
典型用途 任务队列、抽帧、批量 embedding 事件通知、审计、缓存失效、多系统联动

选择判据是「加一个消费者,应该让系统更快,还是应该多做一件事」 —— 前者是队列,后者是发布订阅。反过来说,把「发通知给三个下游」写成同一个队列的三种消息,等于人为制造三个生产者和三份重复逻辑。

一个常见的统一视角:Kafka 的消费者组同时提供这两种形态 —— 组内是队列(分区被分给组内不同成员,一条消息组内只被消费一次),组间是发布订阅(不同组各自独立消费全量)。所以「用 Kafka 到底算队列还是 pub/sub」这个问题本身问错了:它取决于你怎么建组。

解耦不是免费的。 发布订阅让生产者不再知道谁在消费,但耦合并没有消失,只是从调用关系转移到了消息契约上 —— 事件字段一旦被下游依赖,改 schema 就是一次跨团队变更。真正省掉的是「上线顺序」和「调用链长度」:生产者不需要知道消费者是否在线、是否部署完了。

2. 三种价值:解耦、削峰、背压#

价值 它到底做了什么 代价 / 前提
解耦 生产者不感知消费者数量、位置、在线与否 契约(schema / 事件语义)成为新的耦合面;链路变长,端到端排查更难
削峰 把突发摊到时间轴上,让下游按平均能力配置 队列只是延迟了失败:λ>μ\lambda>\mu 时积压只增不减,缓冲再大也是有限的
背压 下游慢时反过来约束上游,不许无界积压 前提是队列有界;无界队列(log 型)根本不产生背压

削峰和背压经常被混为一谈,但它们是两种相反的态度。 削峰是「我接受延迟变大,先垫着」;背压是「我不接受无界垫着,请你慢下来」。没有背压的削峰,等于把内存溢出的时间点往后推 —— 这也是 Kafka 这类保留了全部消息、从不主动拒绝生产者的系统的真实性质:它不给你背压,它的积压(consumer lag)只是一个你需要自己盯着并告警的数字。

3. 投递语义:三种说法,只有两种真的存在#

  • at-most-once(至多一次):投了就投了,不重试。可能丢,不会重复。适合可丢弃的指标上报。
  • at-least-once(至少一次):不确认就重投。不会丢(在持久化和重试窗口内),可能重复。绝大多数生产系统的默认档位。
  • exactly-once(恰好一次):业务效果上不重不漏。

为什么端到端的 exactly-once 做不到 —— 这条必须能自己推。 投递的收尾动作是消费者发确认(ack)。这个 ack 本身也可能丢。于是消息中间件面对的是两种无法区分的情形:

  1. 消费者没收到消息;
  2. 消费者处理完了、也发了 ack,但 ack 丢了。

这两种情形下中间件看到的都是「没有 ack」。重投 —— 情形 2 就重复执行了;不重投 —— 情形 1 就丢了。这与「两个将军问题」同源:跨不可靠信道就某个事实达成一致,需要无限轮次,而工程上必须有限次收尾。 所以投递这个动作本身只能二选一:至多一次或至少一次。

那 Kafka / Flink 说的 exactly-once 是什么? 它成立的前提要说清楚:

  • Kafka 的 EOS(幂等生产者 + 事务)保证的是**「消费 Kafka → 处理 → 写回 Kafka」这条环路内的恰好一次**:事务把「输出记录」和「消费位点提交」绑在一起原子提交,下游用 read_committed 只读已提交的数据。它不覆盖这个环路之外的副作用 —— 你的消费者如果顺手发了一封邮件、调了一次外部 API、写了一张非事务型的表,那部分仍是至少一次。
  • Flink 的端到端 exactly-once 同理,要求 sink 支持事务或幂等写入;sink 不配合的部分,保证就到不了。

所以工程上的「恰好一次」,说的是效果而不是投递:

恰好一次的效果=至少一次投递+幂等消费+去重(幂等键/唯一索引)\text{恰好一次的效果}=\text{至少一次投递}+\text{幂等消费}+\text{去重(幂等键/唯一索引)}

三个条件缺一不可,而且第三个必须和业务写在同一处:去重表的存在性判断和业务写入若不在同一个事务里,两个并发重投递会双双查到「没有」,双双写入。落地形态通常是把消息 ID(或业务幂等键)做成唯一索引,靠约束冲突来吸收重复 —— 冲突即视为「已处理过」,直接返回成功。另外要记住去重窗口是有限的:中间件的窗口去重(如 FIFO 队列的按去重 ID 去重)只在窗口内有效,超出窗口的重复仍会穿过去,主键去重才是最终防线。

4. ack / nack / visibility timeout:重复是从哪里冒出来的#

不同的中间件用不同词汇,但机制是同一套:

  • 拉取模型(Kafka / SQS):消费者取走消息 → 处理 → 提交位点 / 删除消息。没提交的会在可见性超时后重新可见,被别的消费者取走。
  • 推送模型(RabbitMQ / AMQP):消息投出后处于「未确认」态 → 消费者 ack 后删除;nack / reject(可带 requeue)则重新入队或进死信。
  • visibility timeout(可见性超时):消息被取走但未确认期间对其他消费者不可见。它同时是一个租约 —— 超时未确认,系统就假定消费者挂了,消息重新可见。

重复的三个来源,按出现频率排:

  1. 处理超时 > 租约时长:任务本身跑了 5 分钟、租约只有 1 分钟,消息在跑的过程中就重新可见了,第二个消费者开始做同一件事。对策是让租约显著长于 P99 处理时间,或处理中定期续租。
  2. 处理成功但确认失败(上一节的情形 2)。
  3. 消费者崩溃 / 重平衡(rebalance):活着但卡住的消费者把分区交还给别人,已处理未提交的部分被重做。

结论:这一层要设计的是「重复一定发生」,而不是「避免重复发生」。 幂等消费是所有生产队列使用者的默认前提,不是可选项。

5. 死信队列与重试次数上限#

无限重试的系统有三个确定的坏结局:

  • 毒丸消息(poison message):一条永远处理不了的消息(格式非法、引用了已删除的实体)占着队列头反复失败 —— 严格有序的队列上,它会把后面全部消息一起堵死(队头阻塞)。
  • 资源黑洞:一条消息的重试每次都真的烧 CPU / 调模型 / 写库。重试次数是这个成本的乘数。
  • 掩盖真故障:把「下游挂了」当成「这条消息有点问题」,重试到天荒地老,而真正的修复动作(扩容、修 bug、回滚)一直没人做。

所以标准做法是「有限次重试 + 死信队列」:每次失败记尝试次数,超过上限的消息移入 DLQ 而不是删除。DLQ 不是垃圾桶,它是待人工处理的工位,配套要有:DLQ 深度告警、可单条重放的运维入口、以及区分可重试与不可重试错误(参数非法 = 重试一万次还是失败,应当直接进 DLQ;超时/限流 = 值得退避重试)。

上限怎么定? 没有普适数字,判据是**「重试带来的边际成功率」与「阻塞与资源代价」的交点**。实践上要能说出:重试次数与退避时长由业务对「最终一致所需时间」的容忍度决定,并且必须和对端是否幂等挂钩 —— 对端不幂等的写操作,重试本身就是危险动作(见 timeout-retry-idempotency.md)。

6. 顺序性:全局有序贵在哪,分区键怎么选#

全局有序的代价是吞吐的天花板。 要保证「所有消费者看到的消息顺序完全一致」,就只能有一条有序的链路 —— 单分区 / 单消费者,于是整个 topic 的吞吐被一个进程(或一个分区所在的一个 broker)锁死,而且故障转移期间必须停住(否则新消费者可能重放或者跳过)。

所以现实里给的是分区内有序 + 分区键(partition key):同一分区键的消息进同一分区,从而在该键的粒度上有序。分区键怎么选 —— 判据是「哪些操作必须串行」:

  • 按实体 ID(用户 / 订单 / 会话 / agent 任务 ID):该实体的状态变更天然需要有序,不同实体互不干扰。这是最常见的正确选择。
  • 按聚合根 / 事务边界:如果一次业务操作会写多张表,锁的是那个聚合根,那么顺序也应当按它来分,而不是按某张表的行 ID。
  • 不要按「随机」或「轮询」分:那会销毁所有顺序性,同时也说明你其实不需要顺序。
  • 警惕热点键:所有消息都带同一个大客户 ID,就会把全部流量压在一个分区上 —— 有序和均匀分布在这里是直接冲突的两个目标,需要显式取舍(拆键、或把该键单独走一条有序链路)。

还必须说清两件事:① 顺序保证是「分区内 + 单次投递」的,重投递会打破它 —— 重试的消息可能与后到的消息乱序到达,坚持严格有序就得接受队头阻塞;② 分区数决定了消费并行度的上限,消费者多于分区数的部分是闲置的(Kafka 里明确如此)。

7. 消费位点与「连续保存位置」#

位点(offset / cursor)是「消费者读到哪了」的持久化形式,它比 ack 更粗糙:ack 是逐条的,位点通常是批量提交的。提交时机直接决定语义:

提交时机 语义 后果
先提交,后处理 至多一次 处理前崩 → 这条永远不会再被处理(丢)
先处理,后提交 至少一次 提交前崩 → 重放这一段(重复,需幂等)
处理后提交,且处理是幂等的 效果上的恰好一次 生产上的默认组合

真正的坑是「位点提交到了还没补齐的位置」。 这就是 ../06-agent-infra/10-agent-runtime.md §6 那个异步上传缺口在队列里的同构形态:三轮记录本地都全,但第二轮上传失败、第三轮成功;如果云端按「收到的最后一轮」记位置,就会把连续位置从第一轮直接推到第三轮,换台机器恢复时跳过第二轮。正确做法是记「从起点连续保存到了哪一轮」(连续前缀 / low-water mark),而不是「最大已收到位点」。

这条规则在并发消费下更容易破。 多个消费者/多线程并行处理同一分区或同一任务的不同分片时,完成顺序是乱的:第 5 条先于第 3 条完成。若把「已提交的最大位点」当成「可以安全继续的位置」,第 3、4 条就被静默跳过。要么串行处理,要么维护连续完成的低水位 —— 后者在乱序完成场景下是唯一正确的判据。

8. 背压与扩容:什么时候加消费者没用#

先说定理。Little 定律把「在系统中的平均数量」「到达率」「平均停留时间」三者钉在一起(书里 §11.1.2 用它求环境数量):

N‾=λT‾\overline N=\lambda\overline T

放到队列上:队列的平均长度 = 到达率 × 平均排队+处理时间。推论:如果消费者侧的吞吐能力 μ\mu 已经打满(λ>μ\lambda>\mu),排队时间就会线性增长,队列长度随时间无界膨胀 —— 加缓冲只能推迟溢出,不能让积压消失。

加消费者什么时候有用:瓶颈就在消费这一环本身(CPU 密集的解码、串行的模型调用配额、单进程的处理能力),且该环节可以并行。

加消费者什么时候没用,甚至有害:

  • 瓶颈在下游共享资源。数据库连接池、单条主键的行锁、外部的限流配额(比如模型 API 的 QPS / token 配额)、同一块磁盘。此时加消费者只会把队列搬进下游 —— 表现为连接超时、锁等待上升、429 变多。判据:看下游的饱和度指标,而不是看队列长度。这也解释了 Agent 平台里一个常见现象:任务队列不积压了,但工具沙箱的创建速率、模型并发槽、环境内存其中之一开始积压(见 ../06-agent-infra/10-agent-runtime.md §1)—— 排队不会消失,只会换一层。
  • 任务本身要求串行(分区内有序、单用户加锁)。加消费者不但没用,还会破坏正确性。
  • 任务里有「重试风暴」:下游抖动时,所有消费者同时重试,把抖动放大成雪崩。这时该做的是退避 + 熔断 + 限并发,而不是加消费者。

背压的手段,从弱到强:① 有界队列 + 生产者阻塞或拒绝(最直接,但要求上游能承受阻塞);② 拉取式消费(消费者按自己节奏拉,天然背压 —— Kafka / SQS 是这个模型);③ 信用 / 预取额度(RabbitMQ 的 prefetch 是典型:未确认的消息数达上限就不再投);④ 丢车保帅(采样、只保最新一条的合并、降级到同步拒绝),在实时性优先的场景里是正确选择而不是认输。

9. 选型:五种做法,各自的定位与代价#

做法 定位 关键机制 主要代价
Redis List 最小可用的任务队列 LPUSH / BRPOP;要可靠投递得配「取走时同时放进 processing 列表、处理完再删」 没有原生消费者组与 ack;持久化策略与内存上限直接决定会丢多少
Redis Stream 带消费者组与 ack 的轻量队列 XADD / XREADGROUP / XACK,未确认消息留在 PEL(待处理列表) 数据主要活在内存里;PEL 会持续增长需要清理;不是为长期保留设计的
RabbitMQ 路由复杂的任务队列 exchange / routing key / 每消息 ack / prefetch / 原生 DLX / 延迟与优先级插件 队列是「消费完即删」的模型,不能重放历史;消息堆积时对内存/磁盘压力敏感
Kafka 可重放的事件日志 追加式分区日志 + 消费者组 + 位点外置;按时间/大小保留而非按消费删除 单分区有序换取聚合吞吐;没有原生延迟/优先级;运维(分区、rebalance、lag)成本高
云队列(SQS / Pub/Sub 等) 免运维、按量付费 可见性超时模型 + 内置 DLQ;部分变体提供有序与窗口去重 单条成本与每请求限额;顺序与优先级的表达力受限;厂商语义(超时、批量、延展性)需逐个确认
数据库表做队列 被低估的默认选项 INSERT 入队;消费者 SELECT ... FOR UPDATE SKIP LOCKED LIMIT n 认领,处理完改状态 轮询开销与行锁竞争(高吞吐下明显);表膨胀与清理;无推送(除非用 LISTEN/NOTIFY)

关于「用数据库表做队列」,有一条理由比性能重要得多:它能和业务写处在同一个事务里。 「先写业务表、再发消息」是经典的双写问题 —— 两次写之间崩溃就会出现「业务成功但消息没发」或反之。把消息行插进同一个库、和业务更新一起提交,双写问题就消失了(进一步的做法是 outbox 模式,见 transaction-wal.md)。所以对账顺序是:先问「积压速率和下游瓶颈在哪」,再问「需不需要跨系统扇出」,最后才问性能。 速率不高、又不想引入第二个存储系统的场景,一张表往往就是最简单且正确的答案。注意别凭印象报数字:这里不给「每秒多少条以上就必须换 Kafka」的数,那个交点取决于行锁竞争、批量大小、存储与延迟要求,要算就得代入自己的量。

10. 延迟任务、定时任务、优先级与抢占#

延迟任务有三种实现,按延迟长度选:

  • 短延迟(秒级到分钟级):放进支持延迟投递的队列(延迟队列 / 可见性延后),或 Redis ZSET —— score 存到期时间戳,轮询取出 score ≤ now 的成员。简单、可控、延迟精度取决于轮询间隔。
  • 中等延迟:TTL + 死信转发(消息到期后被转发到目标队列)。
  • 长延迟(小时 / 天 / 周):不要把消息一直压在队列里。改为「一张带 next_run_at 的任务表 + 定时扫描(cron)」,到点才生成真正的任务消息。原因:单条延迟消息的可见时间一般有上限(各服务不同,以文档为准),且长期驻留的消息是纯成本。

优先级:先想清楚「公平性目标是什么」。 队列层面的常见做法是「按等级分队列 + 加权消费」或「消息带优先级字段」。要提醒两点:① 严格优先级 + 高优到达率不设上限 = 低优饿死,所以需要配额或老化(aging);② 队列里的优先级只在队列这一层成立 —— 真正执行时硬件上不一定分得开。书里 §11.3.2 的实测很能说明问题:把尽力而为任务放进 SCHED_IDLE,开了超线程后延迟敏感任务每步仍慢约 45%,加 core scheduling 才降到约 17%。所以「我设了优先级」不等于「它真的被优先执行了」。

抢占的账要算两笔(同样出自 §11.3.2):① 交还延迟 —— 离线任务保存状态可能要 30 秒,而在线任务最多等 2 秒,所以光设优先级不够,必须另行预留能在 2 秒内给出的资源;② 未保存进度 —— 被抢占后要重做多少,取决于上一个 checkpoint 之后积累了多少工作。这两笔账在设计题里就是 design/08 §4.4 讲的内容;运行时层的环境暂停与重建取舍见 ../06-agent-infra/10-agent-runtime.md §3。

突发是怎么被摊开的:难点是调度器看到的负载视图天然过时(定期汇总),若每次都挑「当前最空闲的节点」,同一批请求会一起挤到同一台。三项措施:随机抽几个候选选最闲的(power-of-k-choices)、把刚放下还没汇总的负载补记到本地视图、最终是否接收由节点自查本地容量决定。全局视图可以过时,不超容量由节点保证。

常见面试问法#

Q:队列和发布订阅有什么区别?什么时候用哪个? A:判据是一句话:「加一个消费者,应该让系统更快,还是应该多做一件事」。 队列(点对点)里一条消息恰好被一个消费者处理,加消费者是分摊工作、提高吞吐;发布订阅里每个订阅者各拿一份,加订阅者是增加一个下游动作,不提高吞吐。语义上队列传的是命令(去做这件事),pub/sub 传的是事实(这件事已经发生了),所以 pub/sub 天然适合审计、缓存失效、多系统联动。再补一句统一视角:Kafka 的消费者组同时给你两种形态 —— 组内是队列,组间是 pub/sub,所以「Kafka 算哪种」取决于怎么建组。

Q:消息中间件保证了 at-least-once,怎么做到不重复处理? A:先纠正问题本身:重复是不可能在投递层消除的。 消费者发 ack 后 ack 可能丢,中间件无法区分「消息没送到」和「消费者处理完了但 ack 丢了」 —— 这与两个将军问题同源。所以「恰好一次」只能是效果:至少一次投递 + 幂等消费 + 去重。去重的落地形态通常是把消息 ID / 业务幂等键做成唯一索引/主键,靠约束冲突吸收重复;关键是这个去重写入必须和业务写在同一事务里,否则两个并发重投递会双双查到「没有」、双双写入。还要知道去重窗口是有限的(窗口去重只在窗口内有效),主键去重才是最终防线。

Q:Kafka 不是支持 exactly-once 吗? A:支持,但有明确的边界。 它保证的是**「消费 Kafka → 处理 → 写回 Kafka」这条环路之内**的恰好一次:幂等生产者 + 事务把「输出记录」和「消费位点提交」原子绑在一起,下游用 read_committed 只读已提交数据。它不覆盖环路之外的副作用 —— 消费者顺手发邮件、调外部 API、写非事务型的库,那部分仍是至少一次。Flink 的端到端 exactly-once 同理,前提是 sink 支持事务或幂等写入。所以正确的说法不是「用了 EOS 就没有重复」,而是「EOS 把事务域内的那一段收干净了,域外还得自己幂等」。

Q:队列积压了,加消费者有用吗? A:先找瓶颈在哪一环,再决定加不加。 用 Little 定律 N‾=λT‾\overline N=\lambda\overline T:只要消费侧吞吐 μ\mu 打满(λ>μ\lambda>\mu),队列长度就随时间无界增长,加缓冲只是推迟溢出。 加消费者有用的前提是瓶颈就在消费这一环、且这一环可以并行。没用甚至有害的三种情况:① 瓶颈在下游共享资源(数据库连接池、行锁、外部 API 的 QPS/token 配额)—— 加消费者只是把队列搬进下游,表现为超时、锁等待和 429 上升,判据是看下游饱和度而不是看队列长度;② 任务本身要求串行(分区内有序、单用户加锁);③ 下游抖动时的重试风暴 —— 这时要做的是退避、熔断、限并发。一句话总结:排队不会消失,只会换一层。

Q:消息的顺序怎么保证? A:先问「需要哪个范围内的有序」,因为全局有序的代价是吞吐天花板 —— 只能单分区单消费者,整个 topic 的吞吐被一条链路锁死,故障转移还要停住。现实给的是分区内有序,分区键按「哪些操作必须串行」选:最常见是实体 ID(用户 / 订单 / 会话 / agent 任务),跨表业务操作则按聚合根分。要注意两个反面:随机/轮询分键等于放弃顺序(说明根本不需要);热点键会把全部流量压在一个分区上 —— 有序和均匀分布在这里是直接冲突的目标。还要知道重投递会打破有序(重试的消息可能后到),而分区数决定了消费并行度的上限。

Q:你怎么保证「任务不会丢」? A:把「不丢」拆成三段分别说。 ① 投递段:至少一次投递(拉取模型下是「处理完再提交位点」,推送模型下是「处理完再 ack」),代价是必然重复。② 位点段:记「从起点连续保存到哪」而不是「收到的最大位点」 —— 这正是 ../06-agent-infra/10-agent-runtime.md §6 那个缺口:第二轮上传失败、第三轮成功,若按最后一轮记位置,换机器恢复就会跳过第二轮;并发消费下完成顺序是乱的,必须维护连续完成的低水位。③ 业务段:幂等消费 + 失败重试 + 超过上限进 DLQ。最后诚实补一句:以上三段都在「已持久化」的前提下成立,中间件本身的持久化配置(副本数、刷盘策略)直接决定这三段能扛哪种故障。

Q:为什么不能用无限重试? A:三个确定的坏结局。 ① 毒丸消息:一条永远处理不了的消息反复失败,在严格有序的队列上还会把后面全部消息堵死(队头阻塞);② 资源黑洞:每次重试都真的烧 CPU / 调模型 / 写库,重试次数是这个成本的乘数;③ 掩盖真故障:把「下游挂了」当成「这条消息有问题」,重试到天荒地老,而真正的修复动作没人做。所以标准做法是有限次重试 + 死信队列,并且 DLQ 要当待人工处理的工位来运营(深度告警、单条重放入口),还要区分可重试错误与不可重试错误(参数非法重试一万次还是失败 → 直接进 DLQ;超时/限流 → 值得退避重试)。

易错点#

易错说法 问题 应该说
「用了 exactly-once 的队列就不会重复」 投递层做不到 ack 丢失导致无法区分「没送到」与「确认丢了」;正确的是至少一次 + 幂等消费 + 去重表
「Kafka 有 EOS,端到端就恰好一次了」 忘了事务域边界 EOS 只覆盖**「消费 Kafka → 处理 → 写回 Kafka」**这一段;域外副作用仍是至少一次
「队列能削峰,所以不怕突发」 队列只是延迟失败 λ>μ\lambda>\mu 时积压无界增长;削峰要有界队列才成立,否则只是推迟溢出
「积压了加消费者就行」 没找瓶颈 瓶颈在下游(DB / 行锁 / 外部配额)时加消费者只是把队列搬进下游;判据看下游饱和度
「死信队列就是垃圾桶」 运营方式错 DLQ 是待人工处理的工位:深度告警 + 单条重放 + 区分可重试/不可重试错误
「重试越多越可靠」 忽略毒丸与成本 毒丸消息会堵死有序队列;重试次数是资源成本的乘数,且不幂等的对端重试本身就是危险动作
「全局有序最稳妥」 忽略吞吐代价 全局有序 = 单链路,吞吐被锁死且故障转移要停;按实体/聚合根分区才是常规解
「顺序保证和分区键随便选」 两个目标会冲突 热点键会让有序与均匀分布直接冲突,必须显式取舍
「提交位点 = 处理到哪了」 混淆最大位点与连续位置 并发/乱序完成下必须维护连续完成的低水位;../06-agent-infra/10-agent-runtime.md §6 的上传缺口是同一问题
「加消费者总能提高吞吐」 忽略分区数与串行要求 消费者多于分区数的部分闲置;要求串行的任务加消费者会破坏正确性
「表做队列性能不行」 只算性能不算事务收益 它能让入队与业务写同事务提交,消掉双写问题;没有普适的速率交点,要代入自己的量算
「设了优先级就会被优先执行」 硬件上不一定分得开 书里 §11.3.2:开超线程时 LS 每步仍慢约 45%,加 core scheduling 才降到约 17%
把「加个队列」当成高可用设计题的完整答案 停在架构名词层 要落到投递语义 / 幂等键 / DLQ / 位点连续性 / 背压归属这五件事上
讲成「我们线上就是这么搭的」 触碰仓库硬规矩 你没运维过消息集群;用「这套账我会这么算」的口径

自测#

1. 面试官说:“我们用的是支持 exactly-once 的消息队列,所以消费端不用做幂等。”你怎么回应?

答案

消费完成的标志是消费者发出的 ack,而 ack 自己也可能丢 —— 中间件于是面对两种无法区分的情形:(1) 消费者没收到消息;(2) 消费者处理完了也发了 ack,但 ack 丢了。重投则 (2) 重复执行,不重投则 (1) 丢消息,只能二选一,这与两个将军问题同源。所以「恰好一次」在投递层不可能,它只能是效果:

恰好一次的效果=至少一次投递+幂等消费+去重(幂等键 / 唯一索引)\text{恰好一次的效果}=\text{至少一次投递}+\text{幂等消费}+\text{去重(幂等键 / 唯一索引)}

三件事的落地要点:① 至少一次 —— 先处理、后提交位点 / ack;② 幂等消费 —— 同一操作标识重复执行,业务效果与执行一次相同;③ 去重 —— 把消息 ID 或业务幂等键做成唯一索引,靠约束冲突吸收重复,且这次写入必须与业务写在同一事务里,否则两个并发重投递会双双查到「没有」、双双写入。去重窗口是有限的(窗口去重只在窗口内有效),主键去重才是最终防线。

边界:若对方说的是 Kafka 的 EOS,要指出它保证的只是「消费 Kafka → 处理 → 写回 Kafka」这条环路之内的恰好一次(事务把输出记录与位点提交原子绑定,下游 read_committed);环路之外的副作用(发邮件、调外部 API、写非事务型库)仍是至少一次。Flink 的端到端 exactly-once 同理,前提是 sink 支持事务或幂等写入。

2. 一条消息处理失败了,你决定重试 3 次后进死信队列。这个「3」是怎么定的?无限重试有什么问题?

答案

无限重试有三个确定的坏结局:① 毒丸消息(poison message) —— 一条永远处理不了的消息(格式非法、引用了已删除的实体)反复失败,在严格有序的队列上还占着队头,把后面全部消息一起堵死(队头阻塞);② 资源黑洞 —— 每次重试都真的烧 CPU / 模型调用 / 数据库写入,重试次数是这个成本的乘数;③ 掩盖真故障 —— 把「下游整体挂了」误判成「这条消息有点问题」,一直重试,而扩容、修 bug、回滚这些真正的修复动作没有人做。

「3」没有普适数字,判据三条:① 边际成功率与代价的交点 —— 退避重试能救回来的是瞬时故障(抖动、限流、短暂的锁等待),几次之后成功率就趋平;② 业务对「最终一致所需时间」的容忍度 —— 重试次数 × 退避时长 ≈ 这条消息最坏要多久才定性,这个上限应当由业务定,不是由运维随手填;③ 对端是否幂等 —— 对端不幂等的写操作,重试本身就是危险动作,这时应先查「上次到底做成功没有」。

必须配套两件事:区分可重试与不可重试的错误(参数非法 / 结构不合法 = 重试一万次还是失败 → 直接进 DLQ;超时 / 限流 / 连接被拒 = 值得退避重试),把两者混在一起,这个参数就没有意义;把 DLQ 当工位运营(深度告警、可单条重放的运维入口、过期清理策略),只进不出等于把故障藏起来。

3. 一个任务处理平台,队列积压从几千涨到几十万,加了 3 倍消费者后积压没有明显下降。你会怎么排查?

答案

先算账再动手,别继续加消费者。 用 Little 定律 N‾=λT‾\overline N=\lambda\overline T(平均在系统内数量 = 到达率 × 平均停留时间):只要求解侧吞吐 μ\mu 已打满(λ>μ\lambda>\mu),队列长度就是时间的线性函数,加缓冲、加队列容量都只能推迟溢出,改变不了趋势。

再判断瓶颈是不是真在「消费这一环」:① 看消费者自己的指标(CPU、工具执行时长)—— 没跑满就说明瓶颈不在它;② 看下游共享资源 —— 数据库连接池等待、行锁等待、磁盘 IO、外部 API 的 QPS / token 配额与 429 比例,这是最常见的一种,加消费者只是把队列搬进下游(消费者全卡在下游,队列长度反而降不下来);③ 看任务是否要求串行(分区内有序、单用户加锁)—— 加消费者不但没用还会破坏正确性;④ 看是否重试风暴 —— 下游抖动让所有消费者同时重试、把抖动放大成雪崩,对策是退避 + 熔断 + 限并发。

对症:瓶颈在下游 → 扩容或限流下游,同时给消费者设并发上限(让积压停在队列里,而不是打崩下游),这正是「排队不会消失,只会换一层」;瓶颈在消费侧且可并行 → 这时加消费者才对,顺带检查分区数上限(消费者多于分区数的部分闲置);有个别分区/键特别热 → 处理热点键;有一批毒丸消息反复失败 → 检视重试上限与 DLQ 策略。最后给一个可监控的判据:把积压(lag / 队列深度 / 最老消息年龄)做成告警指标,并区分「积压量」与「等待时间」 —— 同样的积压,处理快慢对用户意味着完全不同的等待。

4. 你的消费链路里,第 3 条消息失败被重投递,第 4、5 条已经处理成功。现在位点该怎么提交?

答案

位点记录的是「可以安全地从哪里继续」,不是「最大到达了哪里」。 位点一旦提交到 5,重启后从 6 继续,第 3 条就被永久跳过了 —— 这不是重复问题,是丢消息,比重复严重得多。提交位点的语义前提是「位点之前的全部消息都已处理完成」,乱序完成时这个前提不成立。

两种正确做法:串行处理 —— 天然满足「处理完成的是一条连续前缀」,位点直接跟着走,代价是并行度受限;并发 / 乱序处理 —— 维护「连续完成的低水位」(记录已完成的最小连续前缀,位点只能提交到那里),上例中最多提交到 2,等第 3 条重投递成功后再一并推进到 5,落地形态是「已完成标记 + 扫描连续前缀」,或让分区内保持串行、用多分区换并行度。

这就是 ../06-agent-infra/10-agent-runtime.md §6 上传缺口的同构形态:云端只记「收到的最后一轮」时,第二轮上传失败、第三轮成功,就会把连续位置从第一轮直接推到第三轮,换台机器恢复就跳过第二轮 —— 所以要记的是「从起点连续保存到了哪一轮」。另外两点:重投递会打破有序(第 3 条可能在第 5 条之后才成功),所以「严格有序」和「重试」在同一分区上是矛盾的,要接受队头阻塞或接受乱序;提交频率本身是权衡 —— 逐条提交持久化开销大、攒批提交崩溃后重做多(§6 有算例),判断时要同时看正常路径开销与故障后最多重做多少。

5. 设计一个 Agent 平台的异步任务链路:用户提交一个长任务,中间要调模型和工具。你会怎么选消息系统,怎么保证「不丢」和「不重复扣费」?

答案

先澄清三件事再选型(这一步不能省):① 积压速率与峰值形态(均匀到达还是集中爆发,决定要不要削峰);② 是否需要跨系统扇出(只有本系统消费 → 队列;多个下游各自反应 → pub/sub);③ 延迟与顺序要求(同一用户 / 同一会话的任务是否要串行)。

选型的对账顺序:若入队和业务写必须在同一个事务(如「创建任务记录」与「投递任务」不能有一个成功一个失败)→ 数据库表做队列 + FOR UPDATE SKIP LOCKED 认领是最简单且正确的答案,配 outbox 模式可彻底消掉双写(详见 transaction-wal.md);若要长期保留、可重放、按时间回滚位点 → Kafka 这类日志;若要复杂路由、每消息 ack、延迟与优先级 → RabbitMQ 这类 broker;规模小、想免运维 → 云队列(注意它的可见性超时模型和每请求限额)。

「不丢」分三段保证:① 投递段 —— 至少一次(处理完再提交 / ack);② 位点段 —— 记「从起点连续保存到哪」,而不是「收到的最大位点」,并发完成时维护连续完成的低水位;③ 业务段 —— 失败重试(退避 + 上限)+ 死信队列,并把它当工位运营。

「不重复扣费」只能靠幂等,不能靠投递:把业务幂等键(任务 ID + 步骤序号,或每次调用的 operation id)做成唯一索引,去重写入与业务写同事务提交;模型调用与工具调用都要带上这个标识,对端支持幂等键的用幂等键,不支持的用「先查询上次结果、确认没有才重试」。最后划清边界:外部服务既不幂等、又没有查询接口时,那一侧的「恰好一次」是无法保证的,正确做法是业务上允许补偿(对账 + 退款),而不是假装问题不存在;并且要说明这是设计推演,不是生产实践 —— 我没有运维过这类链路。