02 架构全景与一次生成的完整时序
这一篇是地图:讲清"系统由什么组成"和"一次生成到底发生了什么"。 面试时如果让你画架构图,就照第一节画;如果问"完整讲一遍这条链路",就照第四节讲。
一、系统全景(白板就画这个)
┌───────────────────────── 客户端 ─────────────────────────┐
│ 主站 SPA(React 19 + Vite,49 页 / 54 feature 域) │
│ 管理后台(独立 React 应用,28 页,独立子域) │
│ 鉴权:JWT(Authorization: Bearer, 7d / 24h)+ X-Login-User│
└───────────────────────────┬──────────────────────────────┘
│ HTTPS /api/*(nginx 反代 → 127.0.0.1:3001,读超时 900s)
▼
┌──────────────── Node 22 + Express 5(单进程,pm2 托管)────┐
│ ── 中间件链 ── │
│ cors → express.json(回调路由用 express.raw 前置) │
│ → 限流(按 IP+path 通用 + 按用户名的动作配额) │
│ → userResolver(验 JWT + 实时比 token_version → 覆写身份)│
│ → 维护模式 / Tab 权限 / 企业权限 / 错误处理 │
│ │
│ ── 路由层(38 个文件)── │
│ video / auth / tools / admin / payment / scriptWriter / │
│ longVideo / standardRemake / videoEdit / production ... │
│ │
│ ── 服务层(618 个文件,按领域分目录)── │
│ queue │ video(providers) │ user │ billing │ payment │ │
│ storage │ media │ shots │ viral │ longVideo │ enterprise │
│ distribution │ history │ lock │ cache │ pricing ... │
│ │
│ ── 9 条 BullMQ 队列(同进程内 8 个 Worker)── │
│ video-generate(2) │ video-poll(10) │ production(3) │ │
│ script-writer(3) │ image(3) │ retouch(1) │ transcode(1) │
│ payment-poll(3) │ video-edit(1) │
│ │
│ ── 定时任务 ── │
│ 任务巡检 10min │ 积分对账 1h │ 支付恢复 │ OSS 生命周期 1h │
└───────┬──────────────┬───────────────┬──────────────┬─────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌───────────┐ ┌────────────┐ ┌──────────────┐
│PostgreSQL17│ │ Redis 7 │ │ 对象存储 │ │ 外部 AI/支付 │
│135 组迁移 │ │ 队列/缓存/ │ │OSS(生产) │ │腾讯云 VOD AIGC│
│钱包/任务/账本│ │锁/限流/租约│ │本地磁盘(开发)│ │+超分+MPS │
│部分唯一索引 │ │AOF everysec│ │(另有 TOS/COS│ │阿里云 DashScope│
│JSONB/触发器 │ │ │ │ 的点状签名) │ │微信支付/支付宝│
└────────────┘ └───────────┘ └────────────┘ └──────────────┘三句话记住这个系统
- 它是个调度器:自己不生产视频,价值在于「编排 + 状态一致 + 钱不出错」。
- 可靠性靠三样东西:数据库约束(部分唯一索引、CHECK、外键)、队列的持久化与重投、以及服务端巡检。
- 前端只做展示:任务能不能收尾永远不由浏览器决定(这是花了一次 68 小时事故换来的)。
二、部署拓扑(一句话版本)
shatang.top / admin.shatang.top
│
宿主 nginx(真实 conf 不在仓库,deploy/ 只有 example)
│ /api/* → 127.0.0.1:3001
pm2: shatangai-backend(Node 22 + tsx 直跑 TS,单进程无 cluster)
├─ PostgreSQL 17(docker 容器,host 5511,库 shatang_ai)
├─ Redis 7(host 6511,AOF everysec)
├─ 阿里云 OSS(bucket.oss-cn-*.aliyuncs.com 直连,无 CDN)
└─ 腾讯云 VOD+MPS / DashScope / 微信支付 / 支付宝 / 阿里云短信
同机还有测试环境:/opt/shatang_test(pm2 shatang-test,端口 3002)发布:bash scripts/deploy.sh(约 196 行、7 步、set -euo pipefail):git fetch → 未跟踪文件挡 pull 检查 → git pull --ff-only + HEAD 断言 → 依赖变更需人工 DEPS=1 → diff 门控跑迁移 → diff 门控 build → 按 pm_cwd 推断 pm2 进程名重启 → 探活 20×3s 期望 401。 已知欠账:无回滚、非原子(vite 清空 dist 有几十秒 404 窗口)、实测约 70 秒 502。细节见 08-基础设施与部署深挖问答.md。
三、数据模型(面试常让你"画张表关系图")
users ─┬── video_tasks ──┬── video_history (task_id UNIQUE,用户可见记录)
│ ├── video_task_segments(多段任务,ON DELETE CASCADE)
│ └── task_events (审计日志:created/queued/.../failed)
├── credit_holds (积分冻结:held / settled / refunded)
├── user_credit_transactions(不可变流水:amount 带符号 + 变动前后余额 + 归因三列)
├── payment_orders (支付订单:状态机 + credit_idempotency_key UNIQUE)
├── asset_images / asset_voices / assets
└── user_oauth_accounts (UNIQUE(provider, provider_uid))四个必须主动讲的设计点
| 设计 | 内容 | 为什么重要 |
|---|---|---|
| 状态枚举下沉到 DB | task_status 是 PostgreSQL 原生 ENUM(pending/queued/processing/done/failed/cancelled),credit_holds.status 是 CHECK 约束 | 状态值不可能被写脏;配合 phase 字段做二次细分 |
| 部分唯一索引 | credit_holds (task_id) WHERE status='held' | 一个索引同时表达幂等和状态机不变式:同一任务最多一条活跃冻结;配套 ON CONFLICT (task_id) WHERE status='held' DO NOTHING |
| 更新触发器 + 部分索引 | trg_video_tasks_updated_at 自动维护 updated_at;idx_vt_status 只索引非终态行 | 恢复逻辑扫的就是"非终态",索引和查询模式对齐 |
| 外键 + 唯一约束的副作用 | video_history.task_id → video_tasks.id(FK + UNIQUE) | 每条历史必须对应一个任务行——这正是"占位行"技术债的根源,要主动说 |
四、一次生成的完整时序(这是最该背熟的一段)
以「通用生成」为例,从点击到拿到视频:
阶段 1:提交(秒级)
① 前端带 JWT + X-Login-User 提交 POST /api/video/generate
② 限流:按 IP+path 的通用规则 + 按登录用户名的动作配额(要保护的是账号里的钱,不是 IP)
③ 校验:模型是否被管理员禁用(assertModelEnabled)、参数、素材合规
④ 取价:pricingService.calculateCost(serviceKey, units) = max(min_cost, round(units × rate, 2)),Redis 缓存 600s
⑤ 扣费:
事务内 SELECT credits, gift_credits, paid_credits FROM users WHERE id=$1 FOR UPDATE
├─ UPDATE users(splitDebit:先扣赠送池再扣充值池)
├─ INSERT user_credit_transactions (direction='debit')
└─ INSERT credit_holds('held') ← 部分唯一索引防重复冻结,冲突则整事务回滚
⑥ 调供应商 createTask → 拿到 externalTaskId
⑦ rebindCreditHoldTaskId(平台临时号 → 供应商任务号) ← 让后续结算/退款能按同一个 key 找到它
⑧ INSERT video_tasks(status='pending') + video_history(status='生成中')
⑨ 入 video-poll 队列(jobId = poll-<taskId>-0,delay 2s)阶段 2:轮询(分钟级)
video-poll Worker(concurrency 10)每一轮:
① 读 video_tasks,若已是终态 → 直接返回(幂等第一层)
② 按 sourceMode/generateMode 前置拦截自管链路(标准复刻/字幕擦除/换脸/长视频/复杂复刻...)
③ queryStatus 归一化成四态:done / failed / processing / not_found
├─ processing → scheduleNextPoll:间隔表 [10,10,10,15,20,30,30,30] 秒,超 2 小时则超时收尾
├─ failed → 再问一次拿上游原文 → 标失败 → 退款
├─ not_found → 标失败 → 退款(保守:判不准时当 processing,绝不误退)
└─ done → finalizeStandardTask阶段 3:收尾(分钟级,最容易出错的一步)
finalizeStandardTask:
① poll() 拿原始 videoUrl;若有"补黑尾还原"则剪回原片真实时长
② 音轨校验(需要有声的链路:无可用音轨 → 阻止发布 + 退款 + 告警)
③ 【抢占收尾锁】Redis SET NX EX 1800(画布类任务改抢与前端共享的键)
← 抢不到直接返回,因为重复收尾 = 重复提交付费的画质增强
④ 画质增强:480P 原片 → 腾讯云超分模板 101710(720P) / 101720(1080P)
⑤ 临时 URL 转存自有存储(OSS)+ 生成封面
⑥ 音轨回贴(复杂复刻的原声模式:按窗口切原音轨再 mux)
⑦ 写库:UPDATE video_history('已完成') + UPDATE video_tasks('done')
← ON CONFLICT DO UPDATE ... WHERE status NOT IN ('已完成','失败'),不覆盖用户可见终态
⑧ 最后一步才动钱:settleCredits(held → settled)或按真实时长的差额调整(只退不补)阶段 4:展示
前端 react-query refetchInterval:历史里有"生成中/部分完成"时每 3 秒拉一次,全部终态则停止
进度条 = max(后端 progress, 本地持久化进度, 模拟 S 曲线, 阶段下限) 再 min 到上限(94~99)
⚠️ "模拟进度"是 mulberry32(hash(taskType:taskId:startedAt)) 播种的确定性伪随机 —— 体验兜底,不是真实进度阶段 3 的三条不变量(口述时必须说出来)
- 抢占在增强之前——否则重复扣转码费(生产实测同一任务被超分两遍、各约 52 秒)。
- 先写业务终态,最后才动钱——反了会出现"钱退了、历史还在生成中、没人补记录"。
- 前端永不参与收尾——收尾由服务端最终决定,前端只触发"尽快收尾"。
五、技术选型理由(面试官最爱的追问)
| 选型 | 一句话理由 | 追问时补充 |
|---|---|---|
| BullMQ + Redis | 我们要的是延迟任务(2 秒/30 秒后再轮询)和固定 jobId 去重,这两件事是 BullMQ 的一等公民;且 Redis 本来就在用(缓存/锁/限流),不引入第二个中间件 | 量级上不是 Kafka 的场景;什么时候该换:吞吐超 Redis 单实例 or 需要多消费者组 |
| PostgreSQL | 需要 部分唯一索引(防重复冻结)、JSONB 深扫(文件引用判定)、plpgsql 触发器(生命周期约束下沉)、FOR UPDATE(钱包串行化) | 项目早期短暂用过 MySQL,5 月中旬整体切换 |
| Express 5 + tsx 直跑 | 省掉构建步骤,后端改完直接生效;TypeScript 全栈语言统一 | 代价:启动时转译、类型错误只在运行时暴露(这是欠账) |
| 自研迁移系统 | 轻量、可控、能带 checksum 校验和重复版本检测(数字前缀命名曾在并行分支撞号,导致一个迁移被静默跳过) | 缺 advisory lock,deploy 和启动都会跑迁移 |
| 自研 ffmpeg 编排 | 拼接要和抽帧/抽音色/响度归一化/字幕烧录串在一条链里;走云剪辑要来回上传下载 | 代价:强依赖宿主机 ffmpeg,且现在有四套 concat 实现并存(债) |
| Redis 分布式槽租约 | 上游并发额度是账号级的,且要跨进程、跨服务共享(同一腾讯账号还服务另一个转售 API) | 配额池必须按账号分片,否则两边互相饿死 |
| 存业务表即文件注册表 | 不建冗余的全局对象表;文件能不能删由 oss_key_in_use() 实时判定,而不是维护引用计数 | 计数的失败模式是静默漂移,漏一次就是永久错误 |
六、这张架构图的"弱点"(主动交底,比被问出来强)
在面试里讲完架构,主动补一句:
这个架构有三个我清楚的弱点: 第一,权威状态分散——一部分任务状态在数据库、一部分在进程内存、一部分在 Redis,还有一部分收尾原先依赖前端。我们大部分事故都源于此,方向是状态外置 + 统一执行模型。 第二,单进程无水平扩展——8 个 worker 都在一个 Node 进程里、pm2 没有 cluster;而且内存自管的链路天然不支持多副本。所以"加机器"这条路现在是走不通的。 第三,没有执行时间上限的地方——
fetch默认不带超时,一次网络黑洞能让一个 job 永久占住槽位(production只有 3 个槽,三次黑洞就整批停摆)。正确做法是所有外部调用都显式设超时 + 超时后释放锁。
【备注讲解】把"弱点"讲成结构性的三条(分散 / 不可扩展 / 无超时),而不是罗列 bug。这样显得你是在用架构视角看问题。
七、画图口述版(如果面试官说"你画一下架构")
我分三层画: 第一层是接入层:nginx 反代到单个 Node 进程,请求进来先过限流和身份解析——身份最终以 JWT 为准,会实时比对
token_version以保证改密即时吊销。 第二层是编排层,这也是这个系统的核心:9 条 BullMQ 队列,其中最重要的两条是"提交"和"轮询"——提交只负责调供应商拿任务号(几秒),拿到就立刻把结果交给轮询队列(分钟级),这样并发槽位不会被长任务占死。另外还有巡检:每 10 分钟扫非终态任务,按"有没有供应商任务号"分支恢复。 第三层是数据层:PostgreSQL 是账本和状态的权威,Redis 承担队列/缓存/锁/限流/上游并发租约,对象存储放素材和成片。 最重要的一个设计:钱和状态的一致性——扣费走"冻结 → 结算/退款",冻结记录上有部分唯一索引保证同一任务只能冻结一次;并且有一条硬顺序:先写业务终态,最后才动钱。