04 任务队列与任务全生命周期深挖问答(简历 bullet ②)
这是本套材料的深挖主战场——简历第二条"任务全生命周期管理"完全对应这里,而且这些代码主要是你写的(
taskRecovery.ts14/33、videoWorker.ts10/19、credit_holds迁移 1/1)。 阅读顺序建议:先看第一节的时序总览,再按 Q1→Q22 顺序读。 每题统一四段式:【考点】(面试官在测什么)→ 【口述答案】(背这段)→ 【备注讲解】(不要背出声,是理解用的)→ 【代码依据】(IDE 里能跳过去验证)。
〇、一页时序总览(先在纸上画出来再开口)
┌─ 前端 ── POST /api/video/generate ────────────────────────────────────┐
│ │
│ ① 限流(按用户名 + 路径) → ② 参数/模型开关校验(assertModelEnabled)│
│ ③ 冻结积分:事务内 SELECT credits FOR UPDATE │
│ ├─ UPDATE users 余额(拆分 gift/paid 双余额) │
│ ├─ INSERT credit_holds (status='held') ← 部分唯一索引防重 │
│ └─ INSERT user_credit_transactions (direction='debit') │
│ ④ 调供应商 createTask → 拿到 externalTaskId │
│ ⑤ rebindCreditHoldTaskId(平台临时号 → 供应商任务号) │
│ ⑥ INSERT video_tasks(status='pending') + video_history('生成中') │
└───────────────────────────────┬───────────────────────────────────────┘
│ enqueue(jobId = taskId)
▼
┌────────────── video-generate 队列(concurrency 2,attempts 1)──┐
│ Worker 只做三件事: │
│ 1) 幂等守卫:done/failed 直接 return;video_history 已完成则纠正 │
│ 2) status='processing', phase='submitting' │
│ 3) submitToProvider → 落 external_task_id → phase='polling' │
│ 然后【立刻】投一条 poll job,自己释放槽位 │
└───────────────────────────────┬──────────────────────────────────┘
│ enqueue(jobId = poll-<taskId>-<n>, delay)
▼
┌────────────── video-poll 队列(concurrency 10,attempts 3)─────┐
│ 每一轮: │
│ queryStatus → done / failed / processing / not_found │
│ processing → scheduleNextPoll(间隔表 10,10,10,15,20,30,30,30s)│
│ done → finalizeStandardTask(含 Redis SET NX 抢占) │
│ failed → 再问一次拿原文 → handleStandardFailed → 退款 │
│ not_found → 标失败 → 退款 │
│ 超过 2 小时(由间隔表推导的轮询次数)→ handleStandardTimeout │
└───────────────────────────────┬──────────────────────────────────┘
▼
finalize:抽取视频 URL → (补黑尾还原)→ 音轨校验 → 抢占收尾锁
→ 画质增强 480P→720P/1080P → 转存自有存储 → 封面
→ UPDATE video_history('已完成') + video_tasks('done')
→ settleCredits(held → settled) ← 顺序不可颠倒三条铁律(口述时一定要说出来)
- 提交与轮询分离:Worker 只负责"提交",拿到外部任务号立刻转轮询,好把并发槽位让出来。
- 权威状态在服务端:任务的收尾/退款由服务端对照供应商真实状态决定,不依赖用户浏览器还在不在轮询。
- 先写业务终态、再动钱:
video_history/video_tasks先落终态,最后才settle/refund——反过来的话,一旦退款成功而写库失败,冻结记录已refunded、巡检不再扫描它,用户就永久看不到成片也没人补记录。
Q1. 为什么要引入消息队列?不用队列做不出来吗?
【考点】 你是不是"为了用而用",能不能讲清异步化的真正动机。
【口述答案】 动机有三个,而且第一个是硬约束。 第一,外部供应商生成一条视频要几分钟,HTTP 请求不可能挂在那儿等——浏览器超时、nginx 超时、用户以为卡死。所以必须"下单即返回",任务在后台跑。这是硬约束。 第二,这条链路必须能扛住进程重启和部分失败。我之前用的是进程内的 setInterval 轮询,服务一重启,内存里的定时器全没了,用户的任务就永远停在"生成中",钱还冻结着。队列的持久化 + 可重新投递,正好把"任务不会丢"这件事交给基础设施来做。 第三,并发要可控。上游供应商有并发额度和钱的问题,我们需要一个地方能统一控制"同时在跑几个"。队列的 concurrency 就是那个闸门。 具体选 BullMQ,是因为它跟 Redis 天生一体(我们本来就用 Redis 做缓存、限流、分布式锁),延迟队列、固定 jobId 去重、stalled 检测、重试退避这些我们要用的能力都是开箱的。
【备注讲解】 这里最容易被追问的是"为什么不用 Kafka / RabbitMQ"。标准答法是量级与语义:我们的任务量是"每天几千到几万条长耗时任务",不是百万 QPS 的日志流;而且我们要的是延迟任务(2 秒后、30 秒后再轮询一次)和固定 jobId 去重,这两件事在 BullMQ 里是一等公民,在 Kafka 里要靠时间轮自建、在 RabbitMQ 里要靠 TTL + 死信队列拼。用 Kafka 是拿最重的基础设施解决最轻的问题。
【代码依据】 backend/services/queue/videoQueue.ts:1-28(Queue 定义与连接参数)、backend/services/redis.ts(ioredis 单例,Redis 挂了业务降级)。
Q2. 系统里有哪几条队列?各自职责和并发度是多少?
【考点】 你对系统的全局掌握程度。答不上来说明你只懂自己写的那一小块。
【口述答案】 一共 9 条业务队列,按并发度分三类: 提交/轮询类(高并发、短任务)
video-generate:提交任务给供应商,concurrency 2,lockDuration 60s;video-poll:轮询供应商结果,concurrency 10,lockDuration 30s,attempts 3;payment-poll:支付订单状态轮询兜底,concurrency 3;image:图片生成(Qwen-Image 2.0),concurrency 默认 3,lockDuration 按"最坏任务预算 + 30s"算出来的; CPU/IO 密集类(低并发、长任务)transcode:转码,concurrency 1,CPU 密集,一次一个;retouch:精修/重绘,concurrency 1,同样理由是 CPU/IO 密集;scriptWriter:脚本创作(要调 LLM),concurrency 3,lockDuration 600s;production:脚本库一键生成,concurrency 3,lockDuration 600s——这里 concurrency 是"上游出片吞吐"的总闸;video-edit:视频编辑编排,concurrency 默认 1。 共性设计:所有 Worker 的 Redis 连接都开了keepAlive: 10000,因为我们的 Redis 是公网链路,NAT 会掐空闲长连接;maxRetriesPerRequest: null是 BullMQ 的硬要求(Worker 需要阻塞读)。
【备注讲解】 "并发度怎么定的"是个陷阱问题。真实答案是三类依据:① 任务是不是长耗时(长耗时不能给高并发,否则槽位全被占住)② 下游有没有配额(供应商并发额度)③ 任务是不是 CPU 密集(转码给 1,因为跟 Node 主线程抢资源)。答出这三类依据比背数字重要得多。
【代码依据】 逐个文件:videoQueue.ts:19-24、videoPollQueue.ts:34-39、videoPollWorker.ts:67-68、paymentPollWorker.ts:35-36、imageWorker.ts:63-70、transcodeWorker.ts:47-50、retouchWorker.ts:49-52、scriptWriterWorker.ts:174-177、productionWorker.ts:502-505、videoEditWorker.ts:62-66。
Q3. 为什么把"提交任务"和"轮询结果"拆成两条不同的队列?
【考点】 这是设计判断题。答得出说明你理解队列的本质是资源调度,不只是"异步执行"。
【口述答案】 因为这两件事的时间尺度差了两个数量级,混在一起会让槽位被长期占住。 提交任务只是调一次供应商的 HTTP 接口,几秒钟就返回;但结果要几分钟才出。如果放在同一个 Worker 里"提交完就一直轮询到出片",那么 concurrency = 2 意味着全站同时只能有 2 个视频在生成——第 3 个用户必须排队,这是不能接受的。 拆开之后,video-generate 的槽位只被占用几秒(提交完立刻投一条 poll job 然后返回),轮询则交给 video-poll,它可以给到 concurrency 10,因为轮询本身只是一次轻量的状态查询。 所以拆分的本质是:让占用槽位的时长与任务的真实耗时解耦。
【备注讲解】 这个答案可以再往上一层抽象,讲成通用原则:"队列的并发度应该匹配它里面任务的资源占用模型。同一个队列里既有毫秒级任务又有分钟级任务,一定是设计有问题。"面试官很吃这种能推广的判断。
【代码依据】 backend/services/queue/videoWorker.ts:1-2(注释直接写明"拿到 externalTaskId 后入 video-poll 队列,立即释放 slot")、videoWorker.ts:188-194。
Q4. BullMQ 的 jobId 去重你是怎么用的?踩过什么坑?
【考点】 这是"你是不是真的用过"的照妖镜。只背文档的人答不出坑。
【口述答案】 我用 jobId 做两件事:任务级去重和轮询轮次去重。
video-generate用jobId = taskId,保证同一个任务不会被投两次。video-poll用jobId = poll-${taskId}-${pollAttempt}。这里必须把轮次带上,否则会出现一个非常隐蔽的 bug:BullMQ 在 completed job 的 key 还在的时候,add一个同名 job 会被静默丢弃,于是轮询链在某一次"还在跑"之后就断掉了,任务永远收不了尾。
我踩过三个具体的坑: 坑一:jobId 里不能有冒号。 BullMQ 对自定义 jobId 有硬约束,含 : 直接抛 Custom Id cannot contain :。而我们的任务 id 里真的有冒号——字幕擦除的火山 runId 是 lb:820d66…,老的 Seedance 是 s25:…。不洗的话,凡是延迟入队(也就是每一次续排轮询)都会抛错,轮询链在第一次"还在跑"的时候就断了。所以加了一个 safeJobIdPart(),把冒号替换成下划线;对不含冒号的 id 是恒等变换,去重语义一个字节都没变。 坑二:delay = 0 时不能带固定 jobId。 恢复场景要"立刻轮询一次",如果带固定 jobId 撞上已存在的 delayed job,新的入队会被静默丢弃。所以 delayMs > 0 才加 jobId,delay = 0 时让它作为独立 job 立刻执行。 坑三:重入队要先 remove。 恢复时要覆盖一个已存在的 job,得先 getJob() 看它的状态——如果它正处于 active 且不是启动场景,必须跳过,否则会把当前进程正在跑的 job 的 lock 上下文破坏掉。
【备注讲解】 这三个坑的共同教训值得单独讲一句:"用框架的去重/幂等能力时,一定要搞清楚它的失败模式是"抛错"还是"静默丢弃"。BullMQ 这两处都是静默丢弃,比抛错危险得多——抛错你会立刻发现,静默丢弃要等到用户投诉。"这句话说出来是明显加分的。
【代码依据】 backend/services/queue/videoPollQueue.ts:6-21(冒号注释)、videoPollQueue.ts:44-57(delay=0 的处理)、videoQueue.ts:53-89(forceEnqueue 与 active 判断)。
Q5. 为什么 video-generate 的 attempts 是 1,而 video-poll 是 3?
【考点】 判断题:重试该由框架做还是业务做。
【口述答案】 因为这两类失败的性质完全不同。 video-generate 里做的是"提交给供应商"。如果提交这一步失败,绝大多数情况不是网络抖动,而是参数不对、素材不合规、额度不够——这类错误重试 100 次也是一样的结果,重试只会重复消费日志、甚至重复计费。所以我把 BullMQ 的重试关掉(attempts: 1),改成业务自己判断哪些错误值得重投:代码注释写的是"BullMQ retries handled by our own retry logic"。而且这个队列有 taskRecovery 兜底,真丢了自己会被巡检重新捞起来。 video-poll 不一样,它就是查一次状态,失败原因基本是网络/上游抖动,重试成本极低、收益明确,所以给 attempts: 3 + 指数退避 5 秒。
【备注讲解】 通用原则可以这么说:"重试的前提是幂等 + 失败是瞬时的。两个条件缺一个,就不该让框架自动重试,而应该由业务显式决定。"在视频生成这种"每次调用都烧钱"的场景里,这条尤其重要:框架的自动重试是不认识钱的。
【代码依据】 videoQueue.ts:19-24(attempts 1 + 注释)、videoPollQueue.ts:34-39(attempts 3 + backoff)。
Q6. 任务状态机是怎么定义的?phase 字段为什么存在?
【考点】 状态建模能力。
【口述答案】 枚举定义在数据库层(PostgreSQL 原生 enum task_status): pending(已建单未入队)→ queued(已入队待提交)→ processing(处理中)→ done / failed,另有 cancelled。 另外 processing 阶段有一个 phase 文本字段细分:submitting(正在提交给供应商)→ polling(已提交、等结果)。 phase 解决两个问题:一是前端展示需要区分"提交中"和"生成中";二是恢复逻辑要按阶段分支——一个停在 phase='submitting' 的任务和一个停在 phase='polling' 的任务,恢复动作完全不同(前者可能还没拿到外部任务号,要重投;后者要去问供应商)。 第三层状态在用户可见侧:video_history.status 用的是中文枚举 生成中 / 已完成 / 失败。它有独立存在的理由——它是前端的权威数据源,而且它的终态判断会直接影响前端的轮询行为(见 Q17)。
【备注讲解】 一定要主动说清"为什么同一件事有两张表",因为面试官会问"冗余不冗余"。答案:video_tasks 是机器视角的生命周期(给重试、恢复、结算用),video_history 是用户视角的业务记录(给展示、筛选、导出用),两者的字段与生命周期都不同,而且 video_history.task_id 对 video_tasks.id 有外键 + 唯一约束,所以每一条历史都必须有一个任务行——这也是为什么有些"内存自管"的链路会去建一行占位行(见 Q12)。
【代码依据】 backend/migrations/001_enums_and_trigger.up.sql(task_status 枚举)、003_video_tasks.up.sql(表结构 + 索引 + updated_at 触发器)、backend/services/queue/videoWorker.ts:138-141(phase='submitting')、videoWorker.ts:189-192(phase='polling')。
Q7. 并发下怎么保证状态不被写坏?你们用了乐观锁吗?
【考点】 并发控制的基本功。
【口述答案】 没用版本号乐观锁,用的是条件更新的原子性——也就是把状态判断写在 UPDATE ... WHERE 里,靠数据库的行级原子性 + 影响行数来判断"我有没有抢到这次状态迁移"。 典型写法是:
UPDATE video_tasks SET status = 'failed', error_message = $1, completed_at = now()
WHERE id = $2 AND status IN ('pending', 'queued', 'processing')
RETURNING id这里 WHERE status IN (...) 就是状态机的守卫:只有当前处于非终态,这次迁移才生效;返回行数为 0 说明别人已经把它推到终态了,我就什么都不做。taskRecovery 里的长视频孤儿处理、单次复刻超时处理、videoWorker 里的历史纠正,用的都是这个模式。 之所以不用乐观锁版本号,是因为这里的并发冲突不是"两个人都想改同一个字段",而是"多个巡检/Worker 同时想终结同一个任务"——只允许一个人赢,条件更新恰好就是这个语义,而且不需要额外字段。
【备注讲解】 可以补一句风险认知:"这个模式的前提是状态迁移是"从非终态到终态"的单向流。如果将来出现需要回退的迁移(比如失败后重跑),条件更新就不够了,得换成显式版本号或者状态机的 CAS。"——主动指出自己方案的适用边界。
【代码依据】 backend/services/taskRecovery.ts:47-53、taskRecovery.ts:158-165、taskRecovery.ts:223-226、backend/services/queue/videoWorker.ts:131-135。
Q8. 一个任务被重复投递(残留 job / 巡检重排)时,怎么保证不会被重复执行?
【考点】 幂等设计,这是异步系统的核心。
【口述答案】videoWorker.executeTask 里有三层守卫,从上到下依次收紧: 第一层,看自己这张表:查出 status 和 external_task_id,如果已经是 done 或 failed 就直接 return——终态任务不再发起任何供应商调用。 第二层,看权威历史表:查 video_history.status,如果是"已完成",就把 video_tasks 纠正成 done 然后 return。这一层是为了处理"历史已完成但任务表没跟上"的不一致。 第三层,看外部任务号:如果已有 external_task_id,就跳过 submitToProvider,只做状态推进。所以哪怕一个任务被投了三次,供应商侧也只会被调一次。 再往上还有一层队列层的去重:jobId = taskId。 最后还有一个业务兜底:video-poll 里有个"兜底扣费"函数 deductCreditsIfNeeded,它的幂等判断必须同时看流水表和冻结表——只看流水会把"先扣费、后建任务"的场景误判成没扣过,从而重复扣一笔。这个 bug 生产上真实发生过:2026-08-14 起 37 单、14 个用户、多扣了 370 分。
【备注讲解】 "多层守卫"是异步系统的标准形态,值得点出来:"幂等不能只在队列层做。队列的去重只能防"同一个 job 被投两次",防不了"进程重启后巡检又捞了一次"、"多实例同时处理"、"人工补单"。所以业务层必须自己有一层不依赖队列的幂等判断。"
【代码依据】 backend/services/queue/videoWorker.ts:113-135(三层守卫)、backend/services/queue/videoPollWorker.ts:1926-1940(兜底扣费双重幂等 + 37 单事故注释)。
Q9. 多个 job 同时收尾同一个任务会怎样?你怎么防的?
【考点】 这是这套系统里最"生产级"的一个问题,答好非常加分。
【口述答案】 会重复做花钱的事。收尾阶段的核心动作是画质增强——把 480P 的成片提交给腾讯云超分到 720P/1080P,这是一次真实的转码计费。如果同一个任务被并发 finalize 两次,就会提交两次增强,双份转码费,而且两个结果还会互相覆盖落库。 触发并发收尾的真实路径有三条:任务恢复时对 processing 任务做了"立刻重轮一次"(delay=0 时不做 jobId 去重)、已有的延迟轮询 job 还在队列里、以及多实例部署时另一个实例也在轮询。 我的做法是用 Redis 的 SET key value EX 1800 NX 做收尾抢占,抢不到的那个直接 return,不报错、不重试。TTL 给 1800 秒,远大于一次正常的收尾(增强 + 转存 + 落库)耗时;成功落库之后不主动释放锁,让后续 job 被开头的"终态卫语句"拦住,锁靠 TTL 自然过期。 这里还有个更麻烦的坑:画布/首页对话这类任务有第二条收尾链路——前端自己也会轮询并触发一次增强,用它自己的 Redis 键做去重。worker 侧原来用另一个键(shatang:enhance-claim),两条链路的锁互相不可见,结果同一个任务被腾讯超分了两遍(生产实测同一任务增强两次、各约 52 秒的双份转码费)。修法是:画布类任务统一去抢前端那条链路的键(shatang:canvas:pp:<taskId>),并按同一份 JSON 状态机协议(processing/done/failed)推进。
【备注讲解】 这个案例的价值在于它揭示了一条通用教训:"分布式锁只有在所有参与者用同一把钥匙时才有意义。系统里存在两条独立演化的链路时,最容易出现的就是"两边都做了去重、但去的是不同的重"。"这句话适合面试官问"你从这个 case 学到什么"时用。
【代码依据】 backend/services/queue/videoPollWorker.ts:229-291(抢占逻辑 + 双链路注释)、videoPollWorker.ts:271-284(canvas pp 键协议)。
Q10. 轮询间隔和超时是怎么设计的?为什么是 2 小时?
【考点】 有没有认真想过"退避与终止条件",而不是无脑每 2 秒轮一次。
【口述答案】 间隔用的是一张前密后疏的表:[10, 10, 10, 15, 20, 30, 30, 30] 秒——前几次密集,是为了让"秒级就能出结果的小任务"尽早被收尾;后面统一 30 秒,是因为我们的生成动辄几分钟,30 秒的感知差异可以忽略,但请求量能降一个数量级。超出表长就钳在最后一档。 超时是 2 小时,这个数字有两个约束:一是和 taskRecovery 里单次复刻的 SR_TIMEOUT_MS 必须是同一个口径,否则会出现"巡检说超时了、轮询还在转"的错位;二是它由间隔表推导出来,而不是写死一个次数:
MAX_POLL_ATTEMPTS = maxPollAttemptsFor(POLL_INTERVALS_SEC, POLL_TIMEOUT_MS)这么做是因为仓库里反复出过"两份清单只改一份"的事故——如果写死次数,改了间隔表就会让实际超时悄悄变成别的值。现在改间隔表,超时依然精确是 2 小时。 更关键的是为什么必须有超时:在加这个之前,scheduleNextPoll 和三条分段轮询分支都没有终止条件,一旦上游卡死或者回调丢失,这些 job 就会永远每 30 秒空转一次,直到有人手工清理。这是 2026-08-04 那次"假 DDoS"的成因之一——我们自己的巡检和轮询把 Redis/DB 打满,看起来像被攻击。超时判定我做成了纯函数 isPollTimedOut(),一是能单测,二是它的调用点必须收口在 scheduleNextPoll 里,因为这个函数有多个调用方,"三个调用点只封了一个"在本仓库也是吃过的亏。
【备注讲解】
- 追问"为什么不是固定间隔":固定 2 秒 → 一条 5 分钟的任务要 150 次查询,而实际状态在最后才变;退避表把总量降到十几到几十次。
- 追问"脏数据怎么办":
pollAttempt可能来自老 job 而缺失/为脏值,一律按 0 处理——"宁可多转几次,也不能把正常任务误判成超时",因为误判的后果是标失败 + 退款,而片子其实快出来了。
【代码依据】 backend/services/queue/pollTimeoutPolicy.ts:12-16(间隔表与 2 小时)、pollTimeoutPolicy.ts:24-48(由间隔表推导次数)、pollTimeoutPolicy.ts:50-60(脏值容错)、videoPollWorker.ts:1893-1902(超时判定收口在 scheduleNextPoll)。
Q11. 轮询超时之后怎么处理?为什么不能简单地标个失败?
【考点】 终点处理是否完备,这里有个非常漂亮的细节。
【口述答案】 超时后做两件事:标最终失败 + 退款。但有两个不能省的细节。 第一,必须走"最终失败"分支,不能走"可重试的失败"分支。 因为重试分支只改 video_tasks,不把 video_history 从"生成中"落到"失败"。而前端的 useVideoHistory 只要看见"生成中"就会每 3 秒继续轮询下去——于是任务在服务端已经终结了,前端还在无限轮询,这正是 2026-08-04 那次事故的"放大器"。所以我显式地把 retry_count 传成 max_retries,强制它走最终失败路径。 第二,退款必须能重复调用而不出事。 refundCredits 在"没有 held 记录"时是 no-op,所以超时路径和巡检路径同时退款也不会重复退。
【备注讲解】 可以主动延伸一句关于"事件放大器"的思考:"异步系统里有两种 bug 会互相放大:服务端没能把任务推到终态,和客户端看见非终态就一直重试。修的时候两边都要看——只修服务端,客户端还在打;只修客户端,用户看不到失败原因。"
【代码依据】 backend/services/queue/videoPollWorker.ts:1904-1924(含 retry_count = max_retries 的注释与"3 秒轮询放大器")。
Q12. 崩溃恢复的整体设计是什么?启动时和周期性巡检有什么区别?
【考点】 简历里"启动自动扫描异常任务重新入队"就是这里,必须讲得比简历细。
【口述答案】 恢复是一个函数 recoverStalledTasks(isStartup),启动时调一次(isStartup=true),之后每 10 分钟由巡检调一次(isStartup=false)。它的流程是: 第一步,扫描范围:SELECT * FROM video_tasks WHERE status IN ('pending','queued','processing') ORDER BY created_at——只看非终态任务。 第二步,先做一类特殊拦截:如果任务是"内存自管"的(长视频、批量镜头、复杂复刻、脚本库、字幕擦除、换脸、标准复刻这些),走各自的专用对账函数,绝不落到通用分支(原因见 Q13)。 第三步,通用分支按"有没有外部任务号"分两条路:
- 没有
external_task_id:加一个 5 分钟的新鲜度保护(updated_at在 5 分钟内的一律跳过),然后重新入队video-generate。5 分钟这个保护是为了不误杀"刚刚提交、还在正常处理"的任务。 - 有
external_task_id:去问供应商真实状态,四种结果四种动作——done补收尾、failed标失败(内部会退款)、not_found把external_task_id清空后重新入队、processing就不做重活,只把phase更新成polling并投一条"立刻轮询"的 job,交给专用轮询队列续跑。 启动与周期巡检的唯一区别是forceActiveJobs:启动时,重启后所有 BullMQ 里的activejob 都是上一进程留下的僵尸(Redis 里的锁还没过期,但已经没有 Worker 在处理它),所以必须无条件覆盖重排;而周期性巡检时,active意味着"当前进程的 Worker 正在跑它",必须跳过,否则会破坏它的 lock 上下文。这个参数在forceEnqueueVideoTask的注释里写得很清楚。
【备注讲解】 有几个容易追问的点:
- "为什么要 5 分钟新鲜度保护?"——因为提交路径是异步的,一个刚创建、还在
pending的任务完全正常,不能立刻重投。 - "为什么
processing不重跑整个executeTask,而是转轮询?"——重跑executeTask会重新走提交逻辑,虽然第三层守卫能挡住重复提交,但会多一次无意义的 DB 往返;更重要的是,已经提交成功过的任务本来就该由轮询负责,恢复逻辑只负责"把它接回正确的轨道"。 - "巡检为什么是 10 分钟?"——它是兜底层,正常路径不应该依赖它;10 分钟是"用户可接受的最坏悬挂时间"和"巡检开销"之间的折中。
【代码依据】 backend/services/taskRecovery.ts:11-17(扫描范围)、taskRecovery.ts:249-289(Case 1 / Case 2)、taskRecovery.ts:8-10 与 videoQueue.ts:53-57(isStartup / forceActiveJobs 语义)、backend/index.ts:318-320(10 分钟周期)、backend/index.ts:293-300(启动顺序:先恢复任务、再恢复积分)。
Q13. 为什么"内存自管"的任务必须被恢复逻辑跳过?说清误杀后果。
【考点】 这是最能体现"你真的理解这个系统"的一题。答好了面试官会明显提高评价。
【口述答案】 因为状态不在数据库里,而在进程内存里。这些链路(长视频、批量镜头复刻、复杂复刻、脚本库一键生成、字幕擦除、换脸)在 video_tasks 里只写了一行占位行——有的是为了满足 video_history.task_id 的外键,有的是历史遗留写法。它们的真实进度在生成器的内存对象或者 Redis 里。 问题是这行占位行长得跟正常任务一模一样(provider='kuaizi' 或 'ark'、status='pending'/'processing'),所以通用恢复逻辑会把它当成标准任务处理,后果分几种:
- 被误标失败 + 清零积分:落到"Kuaizi 已废弃"分支,会把任务标失败并把
credits_used清 0; - 拿内部占位 taskId 去查供应商,报
InvalidParameterValue,job 反复失败,还会和前端自己的收尾逻辑互相覆盖; - 用错误的 provider 去
getProvider:字幕擦除写的是provider='volcengine'、换脸写的是'tencent-mps',它们都不在视频供应商注册表里,会抛"未知视频生成供应商",被 catch 吞掉后保守返回processing——于是巡检每轮都以为它还在跑,眼睁睁看着任务永远停在"生成中"、积分永久冻结。生产实锤:2026-09-02lb:820d66…,火山侧 11:08:03 就 Success 了,我们库里 25 分钟没动。 - 用空 prompt 去调模型烧钱:脚本库任务如果落到 Case 1 被重投,会拿一个空 prompt 去调 Ark,烧真钱出一条 4 秒废片,再覆盖用户的占位行并结算掉冻结——用户付了钱、历史里是废片、真成片永远看不到。
所以现在的写法是:每个自管链路在恢复逻辑的最前面有自己的拦截分支,并且每条分支的注释里都写清了"落到下面会被怎么误杀"。有的分支是真跳过(交给自管逻辑),有的是改成"自己来对账"——比如标准复刻以前是无条件 continue(理由是"由 status 路由独占收尾"),但那条路由只有在用户浏览器还开着页面时才会被调用,用户一走任务就永久停在"生成中"、积分永久 held。生产上有一条批次这样刷了 68 小时、57.6 积分没退。
【备注讲解】 这一题回答的结构比细节更重要。建议按三层说:"为什么会撞上"(占位行长得一样)→ "撞上会怎样"(四个具体后果,其中两个直接造成资损)→ "怎么修"(按 generateMode/sourceMode 前置拦截 + 每条分支写明误杀原因)。 最后可以加一句非常有分量的自省:"这些分支本质上是技术债。更好的做法是让占位行有明确的标识别(比如 provider='internal' + 一个 self_managed 字段),让通用恢复一眼就能排除它们,而不是靠一长串 if 分支。我们已经在靠注释维持这个清单了,这是脆的。"
【代码依据】 taskRecovery.ts:26-75(长视频)、taskRecovery.ts:77-106(shots / complex-remake / standard-remake)、taskRecovery.ts:108-126(字幕擦除、换脸)、taskRecovery.ts:128-145(脚本库)、taskRecovery.ts:174-207(批量复刻、Kuaizi 废弃)、videoWorker.ts:67-94(Worker 侧同样的拦截)。
Q14. 长视频任务重启后为什么不能直接重跑?你们的"孤儿判定"是怎么做的?
【考点】 判断你是否理解"不可恢复的任务该怎么收场"。
【口述答案】 长视频这类任务是在内存里自管的:进程重启后内存对象就没了,既没有外部任务号可以查询,也没有中间状态可以续跑——重跑等于从头再花钱生成一遍。 所以我们的策略不是"恢复它",而是**"安全地宣判它死亡并退款"。判定依据是 Redis 里的一个 lease(租约)键:活跃进程会定期给 shatang:longvideo:lease:<taskId> 续命;如果这个键不存在了,说明那个进程已经不在了。 但只看 lease 还不够,还要加宽限期**:任务创建时间在 2 分钟以内的一律跳过——因为任务可能刚提交、lease 还没来得及写。这就是"宽限 + 租约"的组合判定。 另外有个安全细节:如果 Redis 查询本身抛异常,必须 continue 跳过,不能当成"lease 不存在"。否则 Redis 抖一下就会把还在正常跑的任务误杀退款——"不确定的时候什么都不做"是这条链路的判据。 判定成立之后做三件事:把 video_tasks 推成 failed(条件更新,保证只有一个赢家)、把 video_history 落成"失败"、然后退款。退款这里还有个兼容处理:先调 refundCredits(走 credit_holds 正式链路),如果返回 0 说明是旧数据没有冻结记录,再走 refundLegacyTaskCredits 这条兼容旧流水的路径。
【备注讲解】 "租约(lease)"这个概念值得展开一句,它是分布式系统的通用手法:"一个进程说"我还活着",不应该是它自己声称,而应该由它持续续期一个会过期的外部信物。心跳本身会因进程假死而不准,而"信物过期"是一个不会被伪造的事实。"(Redis lease 和 etcd lease 是同一思路。)
【代码依据】 taskRecovery.ts:29-75(lease 判定、2 分钟宽限、Redis 异常跳过、双退款路径)。
Q15. 单次复刻为什么是 2 小时超时?为什么这个数字要和轮询口径统一?
【考点】 一致性细节题,考察你有没有全局观。
【口述答案】 单次复刻同样是内存管理、重启丢失的任务。它没有外部任务号,所以恢复逻辑既不能续跑也不能查上游,只能等一个足够的时长之后宣判超时:超过 2 小时就标失败并退款,让用户重新发起。 2 小时这个数字选择和轮询超时(POLL_TIMEOUT_MS)必须一致,原因很直接:这是两条独立运行的超时路径——一条是轮询队列自己数轮次数到 2 小时,一条是每 10 分钟跑一次的服务端巡检看创建时间。如果两个口径不一样(比如巡检说 1 小时超时、轮询说 2 小时),就会出现"巡检已经把它标失败退款了、轮询还在傻等"的错位,用户会看到任务又活过来又死掉。 所以我在 pollTimeoutPolicy.ts 的注释里明确写了:改这里就要同步改 taskRecovery.ts 的 SR_TIMEOUT_MS。更理想的做法是把这个常量抽到一处共享,现在我们至少用注释互相钉住了。
【备注讲解】 这题的正确答案里藏着一个可以主动交底的不足:"我们有两份超时口径,靠注释维持一致,这是脆的。"主动说出来比被问出来强。如果面试官问"那你怎么改",答:"把超时时长收敛成一个共享常量模块,两条路径都从那里取,并加一条测试断言两者相等。"
【代码依据】 taskRecovery.ts:147-172(SR_TIMEOUT_MS = 2 小时 + 注释)、backend/services/queue/pollTimeoutPolicy.ts:14-16(注释明确"改这里就要同步改那里")。
Q16. 供应商的状态怎么归一化?not_found 为什么要单独一个状态?
【考点】 对外部依赖的健壮性处理。
【口述答案】 不同供应商返回的状态字符串五花八门,所以有一层归一化:把上游结果映射成四个语义状态——done(成功)、failed(失败)、processing(还在跑)、not_found(查不到这个任务)。 not_found 单独存在的理由是它对应一个完全不同的处置动作:processing 就继续等,failed 是走失败退款,而 not_found 意味着"这个任务在供应商那边不存在了"——常见原因是任务过期被清理、或者我们库里存的任务号本身就是脏数据。 处置方式也不同:清空 external_task_id 然后重新入队。因为既然供应商侧没有这个任务,那这一单实际上从没提交成功过,重新走一遍提交是正确且唯一的出路。如果是标准轮询路径上遇到 not_found,就直接标失败(因为此时通常已经过了很久)。
【备注讲解】 追问"怎么区分供应商的'任务不存在'和'网络错了'?"——归一化里有一层 isProviderTaskNotFoundError(err) 专门识别"查不到"这类错误响应;识别不出来的异常一律向上抛,让队列重试,绝不猜。原则是"归一到保守的那一侧":不确定的时候当成'还在跑',而不是当成'失败',因为后者的代价是误退款 + 误标失败。
【代码依据】 videoPollWorker.ts:1875-1891(checkProviderStatus 归一化)、taskRecovery.ts:262-289(四种结果的处置)。
Q17. 失败原因你是怎么拿到真实信息的?
【考点】 可观测性与客服成本意识。这一题很小,但答案很出彩。
【口述答案】 我们原来在失败分支写的是固定文案"供应商返回任务失败"。上线一段时间后发现问题:这成了生产 error_message 里出现最多的一条(近 60 天 21 次),等于把真实原因(PixelCountTooSmall、内容审核拦截、版权问题……)全丢了,客服只能去翻 pm2 日志。 修法是:在失败分支再问一次供应商,把上游原文取出来(extractProviderFailDetail)。创意复刻那条链路早就这么做了,标准链路是对齐它。成本上只在失败分支多一次状态查询,不影响正常轮询的开销。 前端还有一层配合:shared/generation-error-dict.json 这份词典前后端共用,把上游错误正则分类成"审核类/素材类/我们的问题/计费类/未知",直接给用户出中文终态文案。
【备注讲解】 这一题可以升华成一句话:"错误信息是给下一个人看的。异步系统里,一次失败如果只留下'任务失败'四个字,等于把排查成本转嫁给客服和未来的自己。"这也是个很好的"你在项目里做过什么小而有价值的改进"的素材。
【代码依据】 videoPollWorker.ts:163-170(失败分支取原文 + "近 60 天 21 次"注释)、backend/services/history/failureReason.ts:67(video_history.title 与 error_message 的双写问题)、shared/generation-error-dict.json。
Q18. 上游有并发额度限制,你们怎么治理队列和上游之间的关系?
【考点】 资源治理,看有没有"限流在正确的地方"的意识。
【口述答案】 分两层。 第一层是队列并发度:production 队列的 concurrency 就是"一天能吐多少条"的总闸,代码注释里写得很直白——"Seedance 并发治理:一天吐 100 条的墙钟由此调"。 第二层是上游账号级的并发槽:接了腾讯云之后有这个问题——每个腾讯账号有独立的 AIGC 并发额度,而我们有主账号和一个"免审"账号。这里有个坑:如果两个账号共用一个 Redis 计数键,配额会互相饿死(A 账号的任务占了槽,B 账号的任务在等,而两边的真实额度都是空的)。 所以并发槽的池名就是账号 id,而且账号解析必须是"唯一真相源"(vodAccounts.ts)——因为账号之间有三样东西是硬隔离的:SubAppId(跨账号签名会鉴权失败)、素材 assetId(跨账号引用会报"素材不存在",看起来像素材过期)、并发额度。这三样混用都不会报"你用错账号了",只会报一些指向别处的错,所以必须从一开始就把账号一路传下去,不允许任何一层直接读全局 config。
【备注讲解】 "配额池要按资源主体(这里是账号)分片,而不是全局共享"是一个可以推广到很多场景的原则(多租户限流、多集群调度)。说出这条抽象,比讲腾讯云细节更值钱。
【代码依据】 backend/services/tencent/vodAccounts.ts:1-45(账号三样硬隔离 + 槽池按账号分)、backend/config/index.ts:709(PRODUCTION_CONCURRENCY 注释)、backend/services/tencent/slotAdmission.ts、slotLease.ts。
Q19. 这套队列系统你怎么做可观测性?出了问题你怎么知道?
【考点】 生产可用性意识。
【口述答案】 四层。 第一层,审计表:task_events 表记录任务的关键事件(created / queued / processing_started / progress_updated / completed / failed / retry_scheduled / cancelled),每次状态变化都写一行,带 payload。这是排查单条任务时的事实来源。 第二层,热状态:任务的最新状态会写一份到 Redis(shatang:task:<taskId>:status,TTL 1 小时),给前端快速读,不查库。Redis 挂了不影响主流程,是 best-effort 写入。 第三层,结构化日志 + 关键词告警:关键异常用固定的 [ALERT][...] 前缀打日志(比如"已阻止无音轨成片发布并退款"),这样日志系统可以直接按前缀做告警规则。 第四层,巡检与对账:任务侧每 10 分钟巡检一次悬挂任务;资金侧有独立的对账巡检抓五类异常(见 06-积分计费与支付深挖问答.md)。管理后台还有一个失败告警的闪烁提示,30 秒轮询、失败退避 5 分钟。
【备注讲解】 主动说清缺口会显得更可信:"我们目前没有 metrics 体系——没有 Prometheus/OpenTelemetry,队列积压量、轮询次数分布、上游 P99 延迟这些指标都看不到。现在的告警是"日志关键词 + 定时巡检"级别的。如果要继续做,第一件事是把 BullMQ 的队列指标和关键路径的耗时接入打点。"字节这类公司很重视可观测性,主动承认并给出改进方向,比假装完备好。
【代码依据】 backend/migrations/001_enums_and_trigger.up.sql(task_event_type 枚举)、videoWorker.ts:220-235(logTaskEvent / updateRedisStatus)、videoPollWorker.ts:224([ALERT] 日志)、backend/services/history/generationAlerts.ts。
Q20. 这套设计现在还有哪些问题?(主动交底清单)
【考点】 字节必问的自我批判题。准备好的不足比准备好的亮点更有杀伤力。
【口述答案】(挑 3~4 条说,不要全倒)
- 自管链路的拦截是 if 分支堆出来的:靠
generateMode/sourceMode字符串判断,散在恢复逻辑、Worker、轮询 worker 三个地方,靠注释维持清单。新增一种生成方式很容易漏一处。正确的做法是给这类任务一个显式的类型标识,让通用逻辑一眼排除。 - 超时口径有两份(轮询的 2 小时、巡检的单次复刻 2 小时),靠注释互相钉住,应该收敛成共享常量 + 测试断言。
- 多实例部署会踩坑:BullMQ 本身支持多 Worker,但我们有一批"进程内存自管"的链路(长视频、批量复刻),多副本部署时这些任务的状态不共享,
taskRecovery只能通过 Redis lease 猜。要做水平扩展,这些链路必须先改成状态外置。 - 轮询成本还在:现在还是"我们主动轮询供应商",每次轮询都是一次 HTTP。更省的方式是让供应商回调(webhook),我们只在超时兜底时轮询。我们有支付侧的回调经验(微信/支付宝),但视频供应商侧没有走这条。
- 缺可观测性基建:没有 metrics、没有链路追踪,排查靠日志。
【备注讲解】 交底的三条纪律:① 每条不足都要能说出"为什么当初这么做"(不是懒,是有历史约束)② 每条都要有"如果要改怎么改" ③ 不要交底那些"改起来没意义"的不足(比如"代码注释不够多")。另外不要一次说完,留两三条给面试官"挖出来"——让他挖到一两条你准备好的,比你把所有缺点都说完更真实。
Q21. 如果让你重新设计,你会怎么改?
【考点】 收尾题,看你有没有系统性思考。
【口述答案】 我会做四处改动: 第一,把"任务"抽象成一个独立的执行实体,用一个显式的 execution_kind 字段区分"平台编排的任务"和"自管任务",让恢复逻辑、Worker、轮询三处都用同一个判据,而不是三处各自维护字符串清单。 第二,把状态迁移收敛成一张显式的状态机表(当前态 × 事件 → 目标态 + 副作用),用一个函数统一执行。现在状态迁移散在各个 service 里,靠 WHERE status IN (...) 各自守卫,正确但不好审查。 第三,超时、重试、并发这类策略参数全站单点定义,并从一份配置派生,杜绝"两份清单只改一份"。 第四,把主动轮询改成回调优先:供应商支持 webhook 的就用回调推进状态,轮询只作为超时兜底。这样能把上游请求量降一到两个数量级。 但有一点我会保留:"先写业务终态、再动钱"的顺序不变量。这个顺序是我用事故换来的,任何重构都不能把退款挪到写库之前。
【备注讲解】 最后那句"有一点我会保留"是本答案的点睛之笔。重构题最怕答成"我全都要改";能指出什么是不能动的,才说明你分得清原则和实现。
Q22. 口述速记版(把 Q1~Q21 压成 90 秒,用于突击复习)
我们的后端本质是个调度器,所以核心是"任务别丢、状态别乱、钱别错"。 队列拆两条:
video-generate只负责提交给供应商,几秒就返回、立刻把结果交给video-poll,这样并发槽位不会被长时间占住。去重用 jobId,但 jobId 里不能有冒号(我们任务号里真有)、delay=0时不能带 jobId(会被静默丢弃)、重入队前要检查 active 状态——这三个都是踩过的坑。 状态机是pending→queued→processing→done/failed,processing再用phase细分 submitting/polling。并发下靠条件更新(WHERE status IN (非终态)+ 影响行数)保证只有一个赢家,不用版本号。 幂等三层:终态直接跳过、video_history做权威纠正、已有外部任务号就不重复提交。 崩溃恢复按"有没有外部任务号"分两条路:没有就 5 分钟新鲜度保护后重投,有就问供应商真实状态(done 补收尾、failed 退款、not_found 清号重投、processing 转轮询续跑)。启动时必须强制覆盖 active job(那是上一进程的僵尸),周期巡检必须跳过 active(那是自己正在跑的)。 最麻烦的是内存自管的任务(长视频等),它们必须在通用分支之前被拦掉,否则会被误标失败、清零积分、或者拿空 prompt 烧钱出废片。 超时 2 小时,由轮询间隔表[10,10,10,15,20,30,30,30]推导而来,不是写死次数——因为仓库里反复出过"两份清单只改一份"的事故。 并发收尾用 Redis SET NX 抢占,因为重复收尾会重复提交付费的画质增强。 全站有一条不变量:先写业务终态,最后才动钱。