Skip to content

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/触发器 │  │           │  │ 的点状签名) │  │微信支付/支付宝│
 └────────────┘  └───────────┘  └────────────┘  └──────────────┘

三句话记住这个系统

  1. 它是个调度器:自己不生产视频,价值在于「编排 + 状态一致 + 钱不出错」。
  2. 可靠性靠三样东西:数据库约束(部分唯一索引、CHECK、外键)、队列的持久化与重投、以及服务端巡检。
  3. 前端只做展示:任务能不能收尾永远不由浏览器决定(这是花了一次 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))

四个必须主动讲的设计点

设计内容为什么重要
状态枚举下沉到 DBtask_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 的三条不变量(口述时必须说出来)

  1. 抢占在增强之前——否则重复扣转码费(生产实测同一任务被超分两遍、各约 52 秒)。
  2. 先写业务终态,最后才动钱——反了会出现"钱退了、历史还在生成中、没人补记录"。
  3. 前端永不参与收尾——收尾由服务端最终决定,前端只触发"尽快收尾"。

五、技术选型理由(面试官最爱的追问) ​

选型一句话理由追问时补充
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 承担队列/缓存/锁/限流/上游并发租约,对象存储放素材和成片。 最重要的一个设计:钱和状态的一致性——扣费走"冻结 → 结算/退款",冻结记录上有部分唯一索引保证同一任务只能冻结一次;并且有一条硬顺序:先写业务终态,最后才动钱。

持续学习,持续构建。