主题
第 22 周 · Day 3:消息队列与异步任务设计——把慢活儿搬出请求线程
对应手册任务:学习「消息队列 + 异步任务设计」,动手「设计“文档上传 → 异步向量化 → 通知”的队列方案」,当日产出「队列设计」。本篇只解决一个问题:知识库文档从上传到可检索要经过解析、切块、向量化、入库四步慢活儿,塞进上传接口同步做就是定时炸弹;今天用队列把慢活儿搬出请求线程,并把队列面试三连问(为什么用、怎么不丢、重复了怎么办)串成一套话术。
今日目标
- 队列面试三连问每问两个版本:30 秒版名词准确,2 分钟版有故障剧本、有方案、有取舍
- 说得清向量化为什么不能同步做:embedding 延迟乘文档大小的不确定性,会沿着请求线程烧到在线链路
- 独立写出「文档上传 → 异步向量化 → 通知」的队列设计文档:秒回接口、job 状态机、失败处理、优先级隔离四段齐全,每个参数落数字
概念讲解:为什么向量化不能同步做
接着 Day 1 的架构往下走。知识库是 AI 客服平台的地基,运营今天传一份 5 页 FAQ,明天传一份 300 页产品手册。最直觉的实现:上传接口里一气呵成,解析 PDF、切块、调 embedding、写 pgvector,全做完再返回 200。
拿数字算这笔账。300 页手册按每块 500 token 切,约 300 块;embedding 单次调用 200 毫秒,串下来就是 60 秒起步,碰上限流排队,几分钟也正常。用户端的表现是上传按钮转圈三分钟,网关 30 秒就超时断开;用户以为失败再点一次,服务器又从头处理一遍。更糟的是在线问答跑在同一批机器上:上传高峰期接口 worker 全在埋头切文档,用户的提问挤不进来,主链路被文档处理绑架。
病根不在代码写得差,在于把「延迟不确定的批量计算」塞进了「要求快速返回的请求线程」。embedding 的耗时取决于文档大小、切块数量、供应商限流三个你控制不了的变量,而 HTTP 请求的隐含契约是快速结束,两者天生冲突。
队列的解法是承认冲突,然后把两件事拆开:接口只负责收文件、建任务、丢进队列,50 毫秒返回「已受理」;真正干活的 worker 在后台慢慢做,做完通知用户。这一个拆分同时兑现三个收益——异步化(接口秒回)、削峰(一口气传 500 份文档,队列先存着,worker 按自己的节奏消化,embedding 配额不被打爆)、解耦(上传服务和处理服务互不认识,各自扩缩容)。这三个词就是面试第一问的答案,接下来逐问展开。
核心知识
本节没有能跑的代码——队列的 API 各家不同,背了就过时;要写的是三连答法和管线方案,全部落在平台场景。
1. 三连问之一:为什么用队列
30 秒版三个词各跟一句:异步化,把慢操作移出请求线程,接口秒回;削峰,洪峰先入队排队,下游按固定速率消化;解耦,生产方只管投递,不关心谁消费、怎么消费。面试官要的不是名词,是每个词背后「没有队列会怎样」的故障剧本:批量上传打爆 embedding 限流是削峰的剧本,上传服务和处理服务一起发版互相拖累是解耦的剧本,用户盯转圈三分钟是异步化的剧本。
加分的一手是反向答案:什么时候不该用队列。调用快而确定(几十毫秒的 DB 写)、结果必须同步给用户(登录换 token)的场景,加队列只多出一个会丢消息、会堆积的故障点,收益为零。主动说边界,比背全三大好处更能证明你用过。
2. 三连问之二:消息怎么保证不丢
用了队列,可靠性追问必然跟上。一条消息从生产到处理完成要过三道关,每道关都能丢,所以答案固定三段,合称可靠性三段。
生产端,生产确认:发出去不等于送到了,网络会断、broker 会重启。RabbitMQ 开 publisher confirm,Kafka 等 acks,拿到确认才算发完,超时就重发。再讲究一点用本地消息表:业务数据和待发消息写进同一个事务,由后台任务保证投出,彻底消灭「库改了、消息没发」。
Broker,持久化:队列声明 durable,消息标记 persistent,落盘再回确认,集群部署防单机宕机。默认配置常常内存优先,性能最好也丢得最干脆,这里必须显式改。
消费端,手动 ack:关掉自动确认,worker 把活儿真正干完(包括落完 checkpoint)再 ack;中途崩溃,消息未确认,会被重新投给别的 worker。反例剧本:autoAck 开着,消费者领到 300 页文档的消息,解析到一半进程挂了,消息已被确认、人间蒸发,这份文档从此永远「处理中」。
一句话串起来:生产确认保证「发出且送达」,持久化保证「存下且不死」,手动 ack 保证「做完才划账」。三段是木桶,缺一段,另外两段白做。
3. 三连问之三:重复消费怎么办
先答为什么必然重复。队列的默认契约是至少一次投递:生产端确认超时会重发,消费端处理完但 ack 未送达时崩溃,broker 会重投。不丢的代价就是可能重。重复不可消除,只能免疫,免疫的手段叫幂等,做在消费端。
平台的幂等落点很自然:worker 的每一步以 (job_id, step) 为唯一键,开工前先查 checkpoint,该步已完成就直接跳到下一步;入库用 upsert,同一个块重复处理,结果还是那一份;兜底再加一层 Redis SET NX 处理锁,防止同一 job 被两个 worker 同时领走。这和第 6 周写过的防重提交是同一个思想:重复请求一定还会来,让第二次执行的副作用为零。
面试话术:「队列保证至少一次,不保证恰好一次;恰好一次在分布式里做不到,业界都是至少一次加消费端幂等。」这句说出口,第三问就从名词题变成了你踩过坑的证明。
4. 三连问合流:RAG 文档处理管线全景
三问答完,把整条管线拼出来,这就是今天的实战设计。
上传接口收到文件,落对象存储,写一条 job 记录,往队列发消息(job_id、tenant_id、document_id、优先级、入队时间),立刻返回 202 加 job_id——秒回的接口只承诺「受理」,不承诺「完成」。
worker 领到 job,跑四步流水线:解析(PDF 转文本)→ 切块 → 向量化(调 embedding)→ 入库(写 pgvector)。每步完成落一个 checkpoint,job 状态机在 pending、processing、done、failed 之间流转。checkpoint 是断点续跑的关键:第 3 步失败重试时,从第 3 步的起点重来,前两步的成果直接复用——300 页文档不会因为入库挂了就重新 embed 一遍,那可是按 token 计费的真金白银。
失败处理三层:单步失败就地重试,指数退避(1 秒、4 秒、16 秒),最多 3 次;3 次不过,job 标记 failed,消息转入死信队列,不再堵塞正常流转;管理后台留人工重试入口,修好根因(比如坏 PDF)后一键重投。完成或最终失败都通知用户:在线的走 WebSocket 或 SSE 推给前端,不在线的落站内信,两个通道各管一半。
优先级与隔离两条规矩:付费租户的 job 走高优先级队列,免费租户走普通队列,worker 先喂付费的;超过阈值的大文件(200 页以上)单独一条慢队列,配独立 worker 组——否则一份 300 页手册在 worker 上跑十分钟,后面几百份小文档全部饿死。注意分工:优先级队列解决「谁先」,独立队列解决「别堵死」,两个问题一个方案解不完,只靠一个 priority 字段排序,大文件照样占着 worker 让高优先级干等。
动手任务:队列设计文档一步一步
手册任务:设计「文档上传 → 异步向量化 → 通知」的队列方案。拆成 5 步,全程约 40 分钟,产出 queue-design.md。骨架直接复用 Day 2 的 cache-design.md 那套五段式——昨天说过,文档的结构比内容更可迁移。
第 1 步:建文件,搭五段骨架。 沿用昨天的五段:场景与链路、可靠性与幂等、状态机与断点续跑、失败处理、优先级与隔离,外加监控指标收尾。今天剩下的时间就是把空节填满。
第 2 步:写场景与链路。 先画链路图:上传接口 → 对象存储 + jobs 表 → 队列 → worker 流水线 → checkpoint 表 → pgvector → 通知。再定两份契约:接口返回(202 + job_id + 轮询地址)和消息体字段(job_id、tenant_id、document_id、priority、enqueued_at)。字段写不全,后面幂等和隔离都无从谈起。
第 3 步:写状态机与 checkpoint。 画出 pending → processing → done/failed 的流转图,标清每次流转的触发条件。checkpoint 表结构写出来:job_id、step、status、updated_at,主键 (job_id, step)。再自设一问:worker 崩在切块一半,消息重新投递后从哪续跑?答案要精确到「查 checkpoint,从最后一个完成步的下一步开始」。
第 4 步:写失败处理与通知。 三层各落数字:重试 3 次、退避 1/4/16 秒;死信堆积超过 10 条触发告警;人工重试入口只限管理员。通知写双通道:WebSocket/SSE 在线推,站内信兜底,前端断线时用 job_id 轮询补位。
第 5 步:写优先级、隔离与监控,再自测。 两条队列的划分标准(付费/免费;200 页以上进慢队列)和 worker 配比(比如 2:1)。监控指标至少五个:队列深度、job 年龄 P99、单步失败率、重试次数分布、死信堆积数。写完把三连问当考卷自问自答,答不上的回本篇对应小节补课。
常见踩坑
坑 1:把队列当 RPC 用。 消息丢进队列就开个通道死等结果,绕了一圈,同步的本质一点没变,还多背了队列的故障面。队列的定位是异步受理,同步语义留给 HTTP。
坑 2:可靠性只做一段。 开了持久化就以为万无一失,结果消费端 autoAck 没关,前两段白做。三段是一个木桶,面试官随手挑一段追问,三段都得在场。
坑 3:幂等做在生产端。 试图「保证只发一次」来消灭重复——至少一次投递下做不到,重发恰恰是可靠性的组成部分。幂等只能做在消费端:重复尽管来,第二次没有副作用。
坑 4:状态机没有 checkpoint。 job 只有 processing 一个笼统状态,失败重跑就从解析第一步重来。入库挂一次,300 页文档重新 embed 一遍,队列换来的可靠性全变成双倍账单。checkpoint 才是断点续跑的实体,状态机只是它的脸面。
坑 5:一个 priority 字段管所有隔离。 付费优先、大文件也塞同一队列,排序看着公平,300 页手册照样把 worker 占满十分钟,高优先级小文档排队干等。排序改变不了「一个 job 卡住一批 job」,物理分队列才有隔离。
自测问题
先自己答,再展开对照。答不上来的,回对应小节看一遍。
- 30 秒内说出为什么用队列,各配一个平台的故障剧本;再说说什么时候不该用。
参考答案
异步化让接口秒回(否则用户盯转圈三分钟);削峰让洪峰排队、下游按固定速率消化(否则批量上传打爆 embedding 限流);解耦让生产方与消费方互不依赖(否则一起发版互相拖累)。不该用的场景:调用快而确定、结果必须同步返回,比如登录换 token,加队列只有故障面没有收益。
- 一条消息在哪三个环节丢?各怎么防?
参考答案
生产端发送时丢(网络断、broker 重启)——开生产确认,拿到确认才算发完,可配本地消息表兜底;broker 存储时丢(内存队列、单机宕机)——durable 队列加 persistent 消息落盘,集群部署;消费端处理时丢(autoAck 开着,领到消息没做完就崩)——手动 ack,做完再确认,崩了会被重新投递。
- 为什么重复消费不可避免?幂等做在哪一端、怎么做?
参考答案
队列契约是至少一次投递:确认超时重发、ack 丢失重投都会造出重复,而重发重投正是不丢消息的代价。恰好一次分布式做不到,所以幂等做在消费端:以 (job_id, step) 为唯一键查 checkpoint,已完成直接跳过;入库用 upsert;加 Redis SET NX 锁防并发领取。目标是第二次执行的副作用为零。
- checkpoint 解决什么问题?没有它会付出什么代价?
参考答案
解决断点续跑:流水线每步完成就落 checkpoint,失败重试从最后完成步的下一步继续,前面的成果复用。没有它,入库挂一次,300 页文档就从解析重新来,embedding 重新调一遍,重试次数越多 token 账单烧得越多,队列的可靠性全变成双倍成本。
- 优先级和隔离分别解决什么问题?为什么一个 priority 字段不够?
参考答案
优先级解决「谁先被处理」——付费租户的 job 排在前面;隔离解决「别被一件事堵死」——大文件进独立慢队列、配独立 worker 组。只靠 priority 排序,300 页手册照样把单个 worker 占住十分钟,排在后面的小文档全部干等。排序改变不了「一个 job 卡住一批 job」,必须物理分队列。
延伸阅读
- RabbitMQ 官方 Reliability Guide,可靠性三段的原始出处,producer confirm、持久化、手动 ack 的细节都在,面试前通读一遍
- BullMQ 文档,跑在 Redis 上的 Node 任务队列,第 6 周 Redis 的直接延伸,重点看 retry 与 backoff、priority、独立队列的配置写法
- Designing Data-Intensive Applications 第 11 章,从日志到流再到投递语义的系统讨论,三连答完还想再深一层读它
今天的产出 queue-design.md 和昨天的 cache-design.md 放在一起留好——明天 Day 4 用 STAR 法则重写三个项目,这两份文档就是 Action 与 Result 的第一手素材。