旁路 Worker 的本质
面向 VOZEB PRO。讲清楚
generation-worker.mjs不是第二套后端,也不是 Celery / Sidekiq。它是一个会按点敲门的 HTTP 客户端:主进程里的业务原封不动,只是多了一个关页面之后仍会续跑的进程。
0. 什么时候必须懂这个
适合:
- ✅ 看到 Compose 里同时有
app和generation-worker,不知道谁才是「后端」 - ✅ 要加一种关标签后仍须完成的活(生成续取、退款、过期订单)
- ✅ 有人想在 Worker 脚本里直接连 Postgres 或 otokapi
- ✅ 想理解「整体式全栈」为什么没有立刻被长视频任务打死
现在不必深挖:
- ⚠️ 只改后台表格 / 登录文案 / antd 主题
- ⚠️ 把 Worker 当成通用消息队列来学(它不是)
在 VOZEB PRO 中的定位: Worker 用同一镜像、同一套 Route。业务状态机、SQL、上游协议全在
web进程;脚本只负责心跳、多 lane 认领、失败退避。
1. 什么时候用到旁路 Worker
能用到的时候只有一类:用户把页面关了,这件事还得继续往前做,而且不能再向下游重新下单。
先问自己一句:
关掉标签 / 刷新 / 换手机之后,这活还要不要有人接着干?
| 答案 | 用不用 Worker |
|---|---|
| 不要。下次用户再点就行 | 不用。 普通 POST /api/... 做完返回就结束 |
| 要。而且已经跟上游说过一次话了 | 要。 先落任务行,再让 Worker 到期来催 |
| 要,但是只是扫库做清理(退款、过期订单) | 也要。 同样走 maintenance,换一条 lane |
不是「写成了 async」就等于要 Worker。async 只表示这次请求里可以等;Worker 表示这次请求结束以后,世界上还得有人记得这单。
本仓库里已经在用的场合
这些都满足「关页后还要继续 + 不能二次 create」:
- 生图 / 视频 / 音频还在上游排队或生成
- Agent、短剧合成跑好几分钟
- 生成失败后的退积分(退款 lane)
- 订单过期、邀请结算这类定时扫库(同一套维护门,不一定是生成 lane)
接单当时 HTTP 很快返回「已接单」。真正出片、退款,是任务行 + 有人在线时页面顺手催一把 + 没人在线时 Worker 保底。
自己加功能时这样选
用 Worker(先落行,再加敲门):
- 上游要几十秒到几分钟(视频尤其是)
- 重复执行会多扣积分 / 多付账单 / 多创建上游任务
- 用户关页是正常操作,不是异常
- 你需要「过一会儿再问一次进度」,而不是「这次请求里 await 到死」
不要用 Worker:
- 登录、保存提示词、改后台开关、表格筛选
- 读一张已有的图(那是读路径,不是催单)
- 只想把代码写成
async好看一点 - 想在脚本里图省事直接查库、直接打 otokapi(那是拆成第二套后端,本仓禁止)
还不到 Worker,先把任务行做对:
- 连库都没写入,Worker 敲门也是空的
- 恢复时还会再
create一次上游 → 先修幂等,再加 lane
和「页面自己轮询」的分工
有人盯着屏幕时,页面 SSE / 轮询 / after() 已经能推一把。
Worker 解决的是:没人盯的时候谁来推。
所以不是「有生成就必须 Worker 才能出图」——人在线时主进程也能续。
是「人走了图还得出」才必须有它。
落地时的最小形状
以后加新活也按这个,缺前三步加脚本没有意义:
- 先有一张别人能认领的行(
nextPollAt、租约) - 推进逻辑放进
lib/server的 batch(页面和 Worker 共用) - 再开一个只做鉴权的
/api/maintenance/... - 最后才在
generation-worker.mjs里加一条fetch循环
一句话: 当场能做完、做错了重点就行的,别用;必须跨过「关页面」且再做一次会花钱的,才用旁路 Worker。
2. 为什么叫「旁路」
整体式全栈把 HTTP、业务、SQL、出站 AI 放进同一个 web 进程。这对短请求很合适,对「上游要跑几分钟的视频」不合适:
- 用户关标签 → 浏览器里的
await没了 - Next 热更新 / 换容器副本 → 进程内存里的任务没了
- 若恢复逻辑是「再 POST 一次创建」→ 账单翻倍
旁路的意思是:不把主进程拆开,只把「谁来敲门续跑」这件事挪到另一个进程。
用户请求 ──► web 进程(鉴权 / 建任务 / 扣积分 / 出站 / SQL)
▲
│ POST /api/maintenance/...
│ Bearer 维护令牌
generation-worker(没有业务,只有 fetch 循环)
主进程仍然是唯一的业务盒子。Worker 走侧门,不另开一扇业务门。
3. 核心公式
旁路 Worker = 持久任务行 + 租约认领 + 维护身份的 HTTP 客户端。
拆开三件事,缺一不可:
| 零件 | 在哪 | 干什么 |
|---|---|---|
| 任务行 | generation_tasks(或 JSON 回退) |
进程死了任务还在;带 next_poll_at、lease_until、worker_id |
| 恢复批处理 | runGenerationTaskRecoveryBatch |
认领到期行,按类型推进一步;已有 upstream.id 只 poll,禁止再 create |
| Worker 脚本 | web/scripts/generation-worker.mjs |
定时 POST 本站 maintenance;自己不解释 phase |
页面 after() 和 Worker 共用同一份恢复函数。所以:关页面只是少了一个敲门的人,不是少了一套逻辑。
4. 脚本里到底有什么(以及没有什么)
generation-worker.mjs 启动后做四件事:
- 校验
VOZEB_PRO_MAINTENANCE_TOKEN(至少 32 字符) - 解析
origin(VOZEB_PRO_WORKER_API_ORIGIN/ 运行时辅助函数),决定敲哪台 app - 定时心跳:
POST /api/maintenance/generation-tasks/heartbeat - 并行循环:
- N 条 generation lane(默认 2,范围 1~8)→
POST .../generation-tasks/run,单批超时 40 分钟 - 1 条 refund lane →
POST .../billing-refunds/run
- N 条 generation lane(默认 2,范围 1~8)→
认领到活就短睡(生成 250ms / 退款 1s),空转按间隔睡(生成默认 2s / 退款 10s)。连续失败指数退避,生成封顶 60s。
脚本里没有:
- SQL
- 渠道密钥解密
- 图/视频/音频/Agent runtime
- 积分计算公式
- 「要不要再 create 上游」的判断
那些全部在被敲门的 Route 后面,和用户请求走同一套 lib/server。
5. 它解决的是哪类失败,不是哪类失败
能扛住的:
- 用户关掉工作台
- 某个
web副本重启,租约过期后另一副本或另一 lane 续领 - 上游还在跑,本地只丢了内存里的 poll 循环
- 多 lane / 多 Worker 同时抢活:
FOR UPDATE SKIP LOCKED,一行只属于当前租约
扛不住、也不该指望它扛的:
- 任务从未落库(还在某个 Route 的局部变量里)
- 恢复时再次 create 上游(这是实现 bug,不是 Worker 能补的)
web进程本身已经卡死,maintenance Route 都打不进去——Worker 再勤也只是打到 5xx- 把 CPU 打满的本地转码仍挤在同一事件循环里(那是该不该把转码移出 Node 的问题,不是再加一条 lane 能解决的)
一句话:Worker 提高的是「已落库任务的送达率」,不是「任意重活的吞吐量」。
6. 和真正的队列框架差在哪
| 本仓库 Worker | Celery / Sidekiq / Bull | |
|---|---|---|
| 任务真相 | PostgreSQL 任务表 | 通常是 Redis / Broker 里的消息 |
| 执行端 | 仍是 Next Route + lib/server |
worker 进程里跑函数体 |
| 鉴权 | 维护令牌进本站 HTTP | 进程内调业务代码 |
| 重复消费 | 租约 + SKIP LOCKED + 「只 poll 不 create」 | 消息 ack / 幂等键,模型不同 |
| 加一种活 | 加 maintenance Route + 一条 loop | 加一个 task 函数注册到 broker |
所以不要把 generation-worker.mjs 越写越厚。新活的正确形状是:
- 先有一张别人能认领的行(或复用现有任务 / 退款行)
- 再有一个只做鉴权 + 调
runXxxBatch的 Route - 最后才在脚本里加一条
runXxxLane(),内容仍然只有fetch
7. 对照本仓库的观察点
读代码或看环境时,用这些信号确认你理解对了:
| 观察 | 含义 |
|---|---|
Compose 日志 Generation worker started: |
独立进程已起来,不是 Next 内部 setInterval |
Worker POST /api/maintenance/generation-tasks/run |
带 Bearer;可选 x-vozeb-pro-worker-id |
| 令牌太短 / 未配 | 脚本直接 throw;配错则 Route 401 |
| schema 未就绪 | { claimed: 0 },文案等待初始化 |
claimed > 0 |
有活,短睡再敲 |
| 心跳约 15s,租约约 90s | 环境变量可调;过期才能被别人领 |
Route maxDuration 很长(生成批 2400s 量级) |
单批可以在 app 进程里跑很久,Worker 只是调用方 |
页面 after(recovery) 仍在 |
有人在线时不必全靠旁路;旁路是保底 |
源码入口:
web/scripts/generation-worker.mjsweb/src/app/api/maintenance/generation-tasks/run/route.tsweb/src/app/api/maintenance/generation-tasks/heartbeat/route.tsweb/src/lib/server/generation-task-recovery-service.tsweb/src/lib/server/generation-task-scheduler.ts
8. 写代码时的三问
- 关页面后还要不要推进? 不要,就别碰 Worker。
- 推进逻辑能不能放进现有 recovery / 一个新的
runXxxBatch? 不能写进.mjs。 - 失败重试会不会二次 create 上游或二次退款? 会,先补幂等和「只 poll」不变量,再加 lane。
9. 读完能指挥自己(或 AI)做什么
- 「续取加在
generation-task-recovery-service/ maintenance Route,不要在generation-worker.mjs里连库或连上游。」 - 「新后台活:先设计可认领的行和租约,再加 Route,最后只加一条 fetch lane。」
- 「自动恢复路径禁止再次创建上游任务。」