架构决策的思维框架
面向 VOZEB PRO。不是 mermaid 图怎么画,而是接到一个需求时,按什么顺序问,才不会把实时接口写成后台任务,也不会把该落库的状态塞进进程内存。
0. 什么时候用这套框架
适合:
- ✅ 新产品或新模块还在验证,默认倾向「先放进现有盒子」
- ✅ 纠结一段逻辑该写在 Route、service,还是扔给 Worker
- ✅ 有人提议「拆成微服务 / 上 Redis 队列 / 换 Prisma」时,要先算代价
- ✅ 长任务(视频、Agent、退款)和短请求(登录、列表、保存提示词)混在同一需求里
不必硬套:
- ⚠️ 改文案、改主题 token、改一个 antd 表单校验
- ⚠️ 决策已经被
AGENTS.md写死(例如密钥不准进浏览器) - ⚠️ 团队已经拆仓,你只是在其中一个服务里修 bug
在 VOZEB PRO 中的定位: 默认答案是整体式全栈。旁路 Worker 是对长任务的补丁,不是第二套后端。拆服务是最后一步,不是第一步。
1. 先记住仓库已经替你选过的默认值
做新决策前,把这些当成约束,而不是选项:
- 一个
web/进程承担页面、API、业务、SQL、出站 AI。 - Worker 只当 HTTP 客户端:同一镜像,打本站 maintenance Route,不直连数据库,不直连上游。
- 状态以 PostgreSQL(或文件回退)为准。进程内 Map、Zustand、React state 都不是产品真相。
- 换依赖先问代价。
pg换 ORM、Next 换成纯前端 + 独立 API,都等于重接半个仓。
默认值的含义:你的框架是在这个盒子里做加减法,不是每次从白纸重选架构。
2. 三步思考法
接到需求,按这个顺序问,不要跳。
Step 1:用户关掉标签后,这件事还要不要继续?
| 关掉标签之后 | 放哪里 | 本仓库例子 |
|---|---|---|
| 不必继续,下一次请求重来即可 | 主进程同步请求:Route → service → 立刻返回 | 登录、保存提示词、后台改开关、读列表 |
| 必须继续,且不能再 create 一次上游 | 先落 generation_tasks(或同类持久行),再由页面 after() 和 Worker 续跑同一行 |
图 / 视频 / 音频 / Agent / 短剧合成 |
| 必须继续,但是计费或清理,不是生成 | 同样走 maintenance,但用专用 lane / 专用 Route | billing-refunds、订单过期、邀请结算 |
判断口诀:内存里的 await 扛不住关标签、热更新、换容器副本。 扛不住就落库。
Step 2:这段逻辑是「响应一次请求」还是「推进一行状态」?
- 响应一次请求:入参、鉴权、校验、写一条业务记录、返回
{ code, data, msg }。留在主进程。 - 推进一行状态:已有
upstream.id就只 poll;没有且从未提交过才 create 一次;不确定就needs_review。这是任务底座,不是普通 CRUD。
不要用「这个函数也是 async」当分类标准。分类标准是:有没有一张别人能认领的任务行。
Step 3:还要不要离开这个盒子?
只有同时满足,才值得讨论拆进程 / 拆仓:
- 某类负载已经把整个
web进程拖死,而 Worker 续取也救不了(例如 CPU 密集转码占满事件循环) - 团队边界已经按发布节奏拆开,两边需要独立回滚
- 你算过:Cookie、SSE、standalone、Worker origin、维护令牌全部重接的成本,仍然低于继续挤在一起
否则继续整体式。旁路 Worker 已经是这个阶段对「单点拖累」的标准答案。
3. 决策表:常见需求往哪放
| 需求 | 主进程同步 | 落任务行 + Worker 续取 | 先别做的事 |
|---|---|---|---|
| 后台表格筛选、表单保存 | ✓ | 不要为此起队列 | |
| 用户点「生成」后马上看到占位 | ✓ 建任务、扣积分、返回 recordId | ✓ 后续 poll / 落盘 | 不要在 Route 里 await 完整视频 |
| 用户点重试 | ✓ 校验并创建新的上游尝试身份 | ✓ 沿用同一记录槽 | 不要按提示词文本去重合并 |
| 关页面后视频仍要出片 | ✓ 认领同一行,只许 poll | 不要再 POST 一次 create | |
| 生成失败退积分 | 可以在 runtime 终态触发 | ✓ 退款 lane 扫未完成退款 | 不要让 Worker 自己改钱包表 |
| 「先用 Redis 做任务队列」 | 先问:现有表 + lease 是否已够 | 不要为了队列而加基础设施 | |
「Worker 里直接 pg.query」 |
禁止。业务必须回 maintenance Route |
4. 本仓库里已经做过的示范决策
对照源码看,比背框架快。
长生成必须可租赁。
generation-task-store + claimDueGenerationTasks(FOR UPDATE SKIP LOCKED)+ leaseUntil。多个 lane / 多个 Worker 不会抢同一行。过期租约谁都能续领。这是在「不拆服务」的前提下解决多实例。
页面和 Worker 共用恢复函数。
runGenerationTaskRecoveryBatch 只有一份。页面 after() 是顺手推一把;Worker 是关页面之后的保底。决策含义:恢复逻辑属于领域层,不属于「Worker 脚本」。
Worker 脚本故意很瘦。
generation-worker.mjs:维护令牌、origin、心跳、N 条 generation lane、1 条 refund lane、失败指数退避。状态机、runtime、SQL 全在 web 进程。决策含义:再加一种后台活,优先加一条 maintenance Route + 一条 lane,而不是把脚本写成第二套后端。
进程内并发只是软限制。
withGenerationConcurrencyLimit 用进程内 Map 排队。多实例时每进程一份,硬约束仍是库里的 ACTIVE 行。决策含义:单机够用先别上分布式锁中间件。
人审不占生成槽。
needs_review 不进 ACTIVE_CONCURRENCY_PHASES。决策含义:产品规则(别卡死用户)优先于「所有未完成都算占用」。
5. 换栈 / 加中间件时怎么问代价
开口之前列出受影响的边界,不要只说「更现代」。
| 提议 | 先列出的边界 |
|---|---|
pg → Prisma / Drizzle |
web/src/lib/server/database/ 全部 Repository、文件 Provider 回退还要不要 |
| 独立 Nest / Spring 后端 | Cookie 名与策略、SSE、standalone、VOZEB_PRO_WORKER_API_ORIGIN、维护令牌 |
| Redis / Bull 队列 | 现有 lease + next_poll_at 哪一条不够;多一处状态怎么和任务表对齐 |
| Worker 直连上游 | 密钥封装、SSRF、一次 create 多次 poll 的不变量谁来守 |
视频超时只改 AbortSignal |
同时核对 Route maxDuration、undici headersTimeout/bodyTimeout、模型策略 |
docs/learning/01-技术栈.md 的「换它代价」列就是给这一步用的。
6. 写补丁前的五秒清单
- 用户关标签,这件事死掉是否可接受?
- 权威状态在哪张表 / 哪一行?没有行就先设计行,再写 UI。
- 推进状态的代码能不能让页面和 Worker 共用?不能,说明你写进了脚本或组件。
- 失败时会不会二次 create 上游?会,就还没做成任务底座。
- 这个改动有没有逼你违反「密钥不进浏览器 / Route 不堆 SQL / Worker 不直连库」?有,方案重做。
7. 读完能指挥自己(或 AI)做什么
- 「视频超时不要只改 AbortSignal。同时核对 Route
maxDuration、undici 超时、模型策略是否覆盖同一窗口。」 - 「续取逻辑加在
generation-task-recovery-service/ maintenance Route,不要在generation-worker.mjs里连数据库或上游。」 - 「先回答:这是同步响应,还是推进已有任务行。再选文件。」
相关文档
- 全栈开发的核心思维
- 旁路 Worker 的本质
docs/learning/01-技术栈.mddocs/learning/02-分层架构.mddocs/learning/CH-06-生成任务底座.mdStudyVault/01-架构/系统架构.mdAGENTS.md「后端规范」