「生成中」是界面上最贵的一个词。
它看起来像在干活,所以用户会等。等到不耐烦了刷新,还是生成中,于是发一条消息来问。这时候后台其实什么也没发生——没有进程在跑,没有请求在飞,那条记录就躺在数据库里。
这一天把历史上所有能找到的僵死任务都捞了一遍。最久的停在生成中 450 小时,另一条 670 小时,还有一条报告任务 834 小时。按天算是 19 天、28 天、35 天。
它们不是同一类任务,但停在原地的原因只有三种。
一、三条不同的路
没有回收机制。
排查结果:超时判死此前各模块各自实现,覆盖不均。生图与智能报告只有请求驱动的惰性判死——要有人打开页面、发一个请求,才会触发一次检查。Agent 工作流、建站、电商工具箱、定时任务这四类完全没有回收机制。
任务停在生成中 450 小时以上的就是这几类。
关闭进程时被硬杀。
出片、出图、写报告这类长任务用 void run(...) 直接跑在 api 进程里,不走队列,也不被 HTTP 请求持有。这意味着 fastify 的 app.close() 不会等它们。
发版滚动更新时,SIGTERM 一到、app.close() 立刻返回、进程随即 exit(0),任务被硬杀在半路,数据库记录就永远停在「生成中」。那条卡 834 小时的任务就是这么来的。
顺带纠正一个此前的判断:7 个 worker 的关闭本来就是优雅的(等 BullMQ 在途 job 跑完之后才退出),宽限期也配了 60~660 秒。问题只在 api 这一侧,它的 terminationGracePeriodSeconds 此前没有配置,走的是默认 30 秒。
回收机制本身是无限重试。
这是最隐蔽的一种。视频任务扣掉 1350 视频点之后长时间没有任何终态:既不出成片,也不报失败,更没有退款。
它不在统一 reaper 的覆盖范围内,但有自己的一套——recoverManagedVideoGenerationTasks。问题在这个函数做什么:它找出租约过期的 charging/running 任务,重新抢租约、重新调度。
这是恢复,不是判死。上游只要一直不返回,任务就在 running 与重新调度之间来回,永远到不了 failed,退款链路也就永远不会被触发。
画布运行是同一个形状:worker 的恢复逻辑只扫 running(租约过期→标失败),完全不碰 queued;而画布运行的默认状态恰恰是 queued。队列投递一旦失败,run 就永久停在 queued。
二、判死机制的边界
统一 reaper 建立时定了几条边界,每一条都有具体的理由。
只碰执行态。 合法的执行状态只有 running 之类的几种;awaiting_*、*_ready、partial、draft 这些是人工闸门,按设计永远等用户操作。判死它们等于批量清空用户的待确认任务。测试用 where 捕获把这条钉死:
it("各规则查询条件里不含任何人工闸门态")
用 createdAt 判龄,不用 updatedAt。
这条是被迫学来的。视频任务自己的 recovery 每 60 秒抢一次租约,会把 updatedAt 刷新一次——用 updatedAt 的话,卡死任务永远不会变「旧」,这条规则等于没写。画布运行那张表干脆没有 updatedAt 列。
// 2) 用 createdAt 判龄。updatedAt 是 Prisma @updatedAt,而 video 自己的
// recovery 每 60 秒抢一次租约就会刷新它——用 updatedAt 的话卡死任务永远
// 不会变「旧」,这条规则等于没写。
每个模块自己写 prisma 调用,不做声明式映射。 用 prisma[modelName] 那种写法,列名写错时类型检查和单测都发现不了,只有真库会炸。
Redis NX 锁。 api 有多个副本,reaper 只允许跑一份。锁 TTL 55 秒,扫描间隔 60 秒。单轮单表最多处理 200 条,避免历史存量一次打满连接池。
阈值下限 60 秒,低于这个值视为配置错误并回落默认——防止误判死正在跑的任务。
三、三条资金约束
视频任务的判死规则里,三条约束写在同一段注释里,都跟钱有关:
// 视频本身慢(H3 2K 15 秒可能跑十几分钟),阈值给足 60 分钟再判死。
// 三条约束都不能动:
// 1) 只抓 running。charging 是「扣费进行中」,扣没扣成功不确定,判死它可能
// 给未实际扣费的任务发起退款;那条链路交给既有的补偿机制。
// 2) 用 createdAt 判龄。(略)
// 3) 置 refundStatus=pending,交给 recoverPendingVideoGenerationRefunds 按
// operationId 退款。只判失败不退款,等于把用户已扣的视频点吞掉。
// 只对 refundStatus=none 的动手,避免覆盖已在退款流程中的任务。
第一条和第三条都是「不许误伤」。判死本身只做两件事:改状态、置退款标记。真正退钱由另一条既有链路按 operationId 完成。
生图的阈值还多一层考虑:它的判死时刻刻意大于生图自身的惰性恢复阈值(633 秒)。那套恢复是「继续重试」,reaper 是「判死退款」。让恢复先有约 3 轮机会,恢复不了才由 reaper 终结。
四、覆盖范围是怎么长出来的
这条机制不是一次写全的。初始 6 条规则,之后每遇到一个漏网的模块补一条:
| 加入日期 | 规则 | 阈值 |
|---|---|---|
| 08-11 | 生图 / 报告 / Agent 工作流 / 建站 / 电商工具箱 / 定时任务 | 30 分钟 |
| 08-11 | 演示文稿 | 30 分钟 |
| 08-12 | 视频 | 60 分钟 |
| 08-12 | 画布运行 | 30 分钟 |
| 08-19 | 漫剧正文 | 30 分钟 |
| 08-26 | 角色库 | 20 分钟 |
演示文稿那条的补充理由值得单独看:它此前不在任何回收范围内,卡在「生成风格预览」670 小时。但它只判死真正在跑的两态:
// 只判死真正在跑的两态。preview_ready 是「预览已出、等用户选风格」的人工闸门,
// 判死它等于把用户没来得及确认的演示文稿一把清空。
五、漫剧正文:任务不在表里
08-19 这条是本轮最麻烦的一个,因为它的任务数据不在独立表里。
漫剧产线的任务存在项目的 settings JSON 数组里。统一 reaper 按表扫描,扫不到它。
现象是一条完整的失败链。批量生成正文时余额不足,中途失败不回滚已创建的任务,留下一批永不执行的 queued 僵尸。前端无差别接管僵尸并轮询,等不到终态。30 集正文全部生成好了,界面仍显示生成中,而且分集没有取消入口。
四处改动:
- 批量端点中途失败时,对已创建任务逐个取消并退款,各自兜错,不让一个步骤的失败中断其他回滚。
- 单独写一条回收规则。因为
settings是整列读改写、不支持部分更新,它必须用与剧本、角色写入同一把咨询锁,否则并发修改会互相覆盖。 - 预留字符从 10000 校准到 3000。依据是生产实测的 70 条已结算记录:单集正文实际 1200~2116 字,实扣平均约 82 点,而资源价是 50 点/1000 字。预留 3000 字(150 点)覆盖实测峰值。
- 生成对空文本与 JSON 解析失败自动重试一次,重试发生在结算之前,同一任务最终只结算一次。
前端还有一个假的终态要处理。轮询超时判死的阈值原本是 5 分钟,按「单次请求 120 秒 + 重试排队留一倍」推的。但进度条是按 10 分钟展开的——5 分钟判死会在进度才走到 85%、还在正常涨的时候把任务掐掉,界面自相矛盾。改成 12 分钟:
// 12 分钟 = 等待进度条的 10 分钟基准再留 20% 余量。
// 代价是真死掉的任务要多等 7 分钟才被发现——用户随时可以点取消,不必等它。
六、优雅关闭要等到什么时候
回到 api 被硬杀那条。修法是登记在途任务,关闭时等它们收尾:
const drainTimeoutMs = Number(process.env.SHUTDOWN_DRAIN_TIMEOUT_MS ?? 120_000);
12 处长任务启动点改为登记。Deployment 的宽限期从默认 30 秒提到 150 秒:
# 必须大于 SHUTDOWN_DRAIN_TIMEOUT_MS(默认 120s)+ 断连时间,否则等不完就被 SIGKILL
terminationGracePeriodSeconds: 150
只登记「跑完就结束」的一次性任务。回收任务、恢复任务、定时任务这类常驻循环不登记——它们关闭时由 clearInterval 直接停,登记了反而会让等待永远等不完。
七、这类故障的代价在哪
判死机制本身不难写。难的是它必须同时满足两件相反的事:
- 对真的死了的任务,尽快终结并退款,不让点数挂着。
- 对还在跑的任务,一次都不能误判。
这两件事靠阈值区分不了——视频任务正常可以跑十几分钟,而僵死的任务和正在跑的任务在数据库里长得一模一样。所以这个机制靠的是不断的排除:不碰人工闸门态、不碰扣费未定的状态、用不会被刷新的时间戳判龄、给恢复机制留出重试的机会。
补完之后覆盖到 11 条规则。每一条都是先出现一个具体的僵死任务,再补一条:
- 「任务可停在生成中 450 小时以上」——补 6 类零覆盖的模块。
- 「卡 834 小时」——补关闭时等待在途任务。
- 「扣 1350 视频点后一直挂着」——补视频判死与退款。
- 「30 集全好了还显示生成中」——补漫剧产线。
- 「卡在生成风格预览 670 小时」——补演示文稿。
回头看,这条链路的每个缺口都是一类任务、一种状态、一次疏忽。补全的方式不是「想清楚所有情况」,而是让每一类任务都有一个出口:要么推进到终态,要么被判死并退款。中间没有第三种结果。
■