从零设计 AI 音乐生成系统:架构选型与高并发方案
本文是 Calliope 项目系列文章的第一篇。Calliope 是一个模仿 Suno/Udio 的 AI 音乐生成系统,支持 Android、iOS、H5 三端。本文记录架构设计阶段的全部决策过程——不仅写"选了什么",更写"为什么选"以及"踩过哪些坑"。
背景
作为一名 Android 架构师,我一直想深入系统后端与 AI 工程。于是决定做一个项目:从零搭建一套 AI 音乐生成系统,目标对标 Suno/Udio,支持三端(Android Kotlin / iOS Swift / H5)。
附加目标:用这个项目从 Android 架构师成长为后端/系统/AI 架构师,所以技术选型优先学习价值而非最短路径。
系统整体架构
先看最终的架构全貌,再逐一拆解每个决策。
两服务 vs 微服务
最初我考虑拆成三个服务:API 网关 + 业务服务 + WebSocket 服务。拆开的理由也很充分——职责分离,可以独立扩展。
但仔细分析后,我选择了两服务架构:
Go API 服务(业务层)
├── REST API(用户认证、任务管理、作品管理)
├── WebSocket(实时任务进度推送)
└── 内部回调接口(接收 Python Worker 的结果通知)
Python 推理服务(AI 层)
├── Redis Stream Worker(消费任务队列)
├── AudioCraft MusicGen(音乐生成模型)
└── 七牛云上传(把生成的音频写到 OSS)
把 WebSocket 内嵌到 Go API 的理由:
- Go 的 goroutine 模型天然适合大量长连接。每个 WebSocket 连接一个 goroutine,初始栈 ~2KB,调度器自动管理。MVP 目标 50 人同时在线,goroutine 模型轻松应对,完全不需要为此单独起一个进程。
- 拆开的成本:多了一跳网络调用、多了一个部署单元、多了跨服务的连接状态同步问题。
两服务如何通信? 不直接调用,通过 Redis Stream 解耦:
┌──────────────────────────────────────────────────────────┐
│ 客户端层 │
│ Android App iOS App H5 页面 │
└──────────────────────┬───────────────────────────────────┘
│ HTTPS + WSS
┌──────▼──────┐
│ Nginx 反代 │(TLS 终止)
└──────┬──────┘
│
┌────────────▼────────────┐
│ Go API 服务 │
│ REST API + WebSocket │
└──┬──────────┬───────────┘
│ │
┌──▼──┐ ┌───▼────────────┐
│MySQL│ │ Redis │
│ │ │ Stream 队列 │◀── Python Worker 消费
└─────┘ │ RefreshToken │
│ Pub/Sub 通知 │
└────────────────┘
│
┌───────────▼──────────┐
│ Python 推理服务 │
│ AutoDL RTX 3090 │
│ AudioCraft MusicGen │
└───────────┬──────────┘
│ 上传音频
┌───────▼───────┐
│ 七牛云 OSS │
│ + CDN 分发 │
└───────────────┘
核心数据流:一次音乐生成请求
用户点"生成"到听到音乐,经历了什么?
Client → POST /tasks
① 内容过滤(关键词黑名单)
② 队列门禁(depth ≥ 20 → 429 QUEUE_FULL)
③ 原子扣减额度(MySQL 行锁,失败 → 402)
④ INSERT tasks(status=queued)
⑤ Lua 脚本原子执行 XADD + INCR depth
⑥ 返回 202 {task_id}
Client → WS /ws(订阅进度)
← {queued, position: 2}
Python Worker → XREADGROUP(取到任务)
→ HTTP 回调 Go API(status=processing)
← Go API 更新 MySQL + PUBLISH ws channel
← {processing, progress: 50%}
Python Worker → AudioCraft 推理(30s ~ 3min)
→ 上传两个候选音频到七牛云
→ HTTP 回调 Go API(status=completed, candidate_keys)
← Go API 更新 MySQL + PUBLISH ws channel
← {completed}
Client → GET /tasks/:id(获取候选音频 URL)
← {candidates: [signed_url_a, signed_url_b]}
Client → 直接请求七牛云 CDN URL
← 音频流(CDN 直传,Go API 不做音频中转)
这里有几个重要的设计决策,逐一展开。
决策一:API 层用 Go + Gin
为什么不用 Python FastAPI?
最直观的想法是:AI 推理层已经用 Python 了,API 层也用 Python,统一语言,维护方便。
但仔细想之后选择了 Go:
| 维度 | Go + Gin | Python + FastAPI |
|---|---|---|
| WebSocket 并发 | goroutine 天然适合,每连接 ~2KB | asyncio 可以,但 async/await 传染性强 |
| 调试体验 | 同步调用栈清晰 | async stacktrace 碎片化 |
| 类型安全 | 编译期发现错误 | 运行时才报错 |
| 部署 | 单二进制,Docker 镜像 < 20MB | 依赖地狱,镜像动辄几百 MB |
| 学习价值 | 字节/B站/Google 后端主流 | AI 脚本更多 |
一个容易忽略的细节: FastAPI 的 async/await 是"传染性"的。一旦某个底层函数是 async,整条调用链都得变成 async,协程切换的 stacktrace 很难读。Go 的 goroutine 完全对开发者透明,go func() 启动,channel 通信,代码和同步逻辑几乎一样写。
Go 的弱点也要承认: 错误处理冗余(if err != nil 到处写),初期模板代码多。用 Gin 框架可以减少部分,剩下的就当 Go 语言学习的一部分。
Go 项目目录结构
遵循 golang-standards/project-layout,结合实际业务划分:
calliope-api/
├── cmd/server/main.go # 入口:加载配置、初始化依赖、启动 HTTP server
├── internal/
│ ├── handler/ # HTTP 层:只做参数绑定和响应格式化
│ │ ├── auth.go # 注册/登录/刷新/登出
│ │ ├── task.go # 任务创建/查询
│ │ ├── work.go # 作品管理
│ │ └── websocket.go # WebSocket 升级
│ ├── service/ # 业务逻辑层:不依赖 HTTP 框架
│ │ ├── auth_service.go
│ │ ├── task_service.go # 任务创建、回调处理、超时检测
│ │ └── notification_service.go # WebSocket 连接管理 + Redis Pub/Sub
│ ├── repository/ # 数据访问层:只做 SQL
│ ├── queue/
│ │ └── redis_stream.go # Redis Stream 生产者
│ └── storage/
│ └── qiniu.go # 七牛云签名 URL 生成
├── pkg/
│ ├── jwt/ # JWT 签发/验证
│ └── response/ # 统一响应格式
└── migrations/ # golang-migrate 迁移文件
分层原则: handler 不含业务逻辑,service 不知道 HTTP 是什么,repository 不知道业务规则是什么。每层只向下依赖,测试时可以单独 mock 下层。
决策二:音频分发走七牛云 CDN 直链
为什么不让 Go API 中转音频?
最省事的方案是:客户端 → Go API → 从 OSS 拉音频 → 流式返回客户端。
这个方案有一个致命问题:带宽成本归零变成带宽成本爆炸。
一首 AI 生成的歌曲约 3-5MB。如果走 Go API 中转:
- 阿里云 ECS 出带宽:约 ¥0.8/GB
- 10 个内测用户每天 5 首 = 50 首 = 250MB/天 ≈ ¥0.2/天,尚可接受
- 但一旦用户增长,带宽成本线性放大
正确做法:生成签名 URL,让客户端直接访问七牛云 CDN。
Go API 生成 1 小时有效的签名 URL:
https://cdn.calliope-music.com/audio/67/12345/candidate_a.mp3
?e=1741510000
&token={access_key}:{hmac-sha1-sign}
客户端拿到 URL 后直接请求七牛云 CDN,Go API 不参与音频传输。
七牛云提供 10GB 永久免费存储,CDN 也有免费额度,MVP 阶段基本零成本。
候选音频的清理
AudioCraft 每次生成两个候选(candidate_a.mp3、candidate_b.mp3),用户选一个保存。未选择的候选音频 24 小时后清理:
-- 扫描条件:已终态 + 24h 已过 + 候选文件还在
SELECT id, candidate_a_key, candidate_b_key
FROM tasks
WHERE (status = 'completed' OR status = 'failed')
AND completed_at < NOW() - INTERVAL 24 HOUR
AND (candidate_a_key IS NOT NULL OR candidate_b_key IS NOT NULL);
决策三:认证方案
JWT Access Token + Redis Refresh Token
JWT Access Token (AT):
- 有效期 15 分钟
- 无状态:只验证签名,不查 Redis
- 过期后用 Refresh Token 换新 AT
Redis Refresh Token (RT):
Key: calliope:auth:refresh:{user_id}
Value: UUID v4
TTL: 7 天
优势:
- AT 验证无 Redis 查询,性能好
- RT 存 Redis,支持主动撤销(登出只需 DEL key)
- 单用户只保留最新 RT(覆盖写),MVP 不支持多设备
一个常见的错误设计: 每次刷新 AT 时同时轮换 RT(Refresh Token Rotation)。听起来更安全,但对 MVP 阶段来说增加了复杂度——RT 轮换需要处理并发刷新时的竞态(两个请求同时刷新,后到的 RT 作废,先到的请求拿到的 RT 失效)。当前方案不轮换 RT,简单可靠,登出时 DEL key 即可撤销。
暴力破解防护
两层防护:
Redis 层(快速拦截):
calliope:auth:lock:{email} → 失败次数计数器,TTL 15 分钟
≥ 5 次失败 → 拒绝后续登录请求,返回 403
MySQL 层(审计):
login_attempts 表记录每次登录(成功/失败)
可事后分析攻击模式
决策四:额度并发安全
每个用户每天只能生成 5 次,如何在高并发下不超扣?
错误方案:先读后写
// 先查还剩几次
used := db.QueryOne("SELECT used FROM credits WHERE user_id=? AND date=?")
if used >= 5 {
return ErrQuotaFull
}
// 再更新 +1
db.Exec("UPDATE credits SET used=used+1 WHERE user_id=? AND date=?")
这个方案在并发下会超扣:两个请求同时读到 used=4,都通过了校验,都执行了 +1,最终 used=6。
正确方案:写即检查(MySQL 行锁)
INSERT INTO credits (user_id, date, used, limit_count)
VALUES (?, ?, 1, 5)
ON DUPLICATE KEY UPDATE
used = IF(used < limit_count, used + 1, used);
-- 通过 ROW_COUNT() 判断结果:
-- ROW_COUNT() = 1 → 新行插入(当日第一次),成功
-- ROW_COUNT() = 2 → 已有行且 used 实际 +1,成功
-- ROW_COUNT() = 0 → used >= limit_count,额度已满
INSERT ON DUPLICATE KEY UPDATE 在 MySQL 行锁保护下执行,整个操作原子。ROW_COUNT() 的语义是"受影响的行数"——新插入返回 1,实际更新(值有变化)返回 2,值未变(IF 条件不满足)返回 0。不需要也不应该在这之前再读一次做"预检查"。
为什么不用 Redis 做额度控制? Redis 的 INCR/DECR 更快,但额度数据需要和 MySQL 的 credits 表保持一致——任务失败时要退还额度,跨零点时要按 credit_date(非当前日期)退回。如果主存储在 Redis,退款逻辑和账期对齐会更复杂。MySQL 行锁方案对于每用户每日 5 次的低频操作完全够用。
credit_date 的坑
tasks 表有一个字段 credit_date DATE NOT NULL,记录扣减额度时的 UTC+8 日期。
为什么不用 CURDATE()?
MySQL 默认 time_zone=UTC,凌晨 0:00~8:00 北京时间期间,CURDATE() 返回的是"昨天"(UTC 日期)。如果任务在北京时间 0:30 创建,用 CURDATE() 扣的是昨天的额度,任务失败时退款也退到昨天,账期错乱。
正确做法:Go API 侧计算 UTC+8 日期,作为参数传入:
cst, _ := time.LoadLocation("Asia/Shanghai")
creditDate := time.Now().In(cst).Format("2006-01-02")
// 然后作为参数 ? 传给 SQL,禁止在 SQL 里用 CURDATE()
决策五:WebSocket 多实例扩展
问题:Python Worker 回调任意一个 Go API 实例
当部署多个 Go API 实例时,用户 WebSocket 连接在实例 A,但 Python Worker 的 HTTP 回调可能打到实例 B——实例 B 无法推送到实例 A 上的 WebSocket 连接。
方案:Redis Pub/Sub 广播
Python Worker 完成任务 → HTTP 回调任意一个 Go API 实例
→ Go API 实例(无论哪个)更新 MySQL
→ PUBLISH calliope:ws:task:{task_id} 消息
所有 Go API 实例都订阅了这个 channel:
→ 持有该 task_id WebSocket 连接的实例收到消息
→ 推送到客户端
这个方案对 MVP 阶段足够。如果后续连接数达到百万级,可以考虑专门的 WebSocket 网关 + 连接路由,但那是另一个量级的问题。
Pub/Sub 不持久化的影响: 如果客户端 WebSocket 断开期间任务完成了,重连后收不到推送消息。解决方案:重连时先调用 GET /tasks/:id 轮询当前状态,再重新订阅 channel。
部署架构:最小成本运行
个人练习项目,预算目标 ¥0~150/月。
阿里云 ECS(2C4G,Ubuntu 22.04,约 ¥60-80/月)
└── Docker Compose
├── Nginx(TLS 终止,443/80)
├── Go API(:8080,ECS 内网)
└── Redis(:6379,ECS 内网)
阿里云 RDS MySQL(独立托管,内网访问)
七牛云 Kodo OSS + CDN(10GB 永久免费)
AutoDL RTX 3090(按需租用,¥2/小时)
└── Python 推理服务(Docker)
├── FastAPI(localhost:8000,不对外暴露)
├── Redis Stream Consumer(连接到 ECS Redis)
└── AudioCraft MusicGen-Small
AutoDL 与阿里云跨厂商通信
AutoDL 和阿里云是不同厂商,无法 VPC 互通,跨厂商通信走公网。安全措施:
- Redis:启用 TLS + requirepass + 防火墙白名单(只放行 AutoDL IP 段)
- Go API 回调接口:HTTPS + Bearer 共享密钥(
INTERNAL_CALLBACK_SECRET)+ X-Timestamp(60s 窗口防重放)
GPU 按需启停
推理服务不 24 小时运行。通过检测 Redis Stream 队列深度决定是否保持运行:
- 有任务:保持运行
- 空闲 10 分钟:关闭 GPU 实例
按 10 个内测用户、每人 5 次/天估算:约 50 次/天 × 2 分钟/次 = 100 分钟/天 ≈ ¥3.3/天 ≈ ¥100/月。控制在预算内。
冷启动影响: GPU 从停止到可用约 1-3 分钟。这段时间发生在 Worker 取到任务之前(started_at 之前),不计入推理超时窗口(180s)。MVP 内测阶段无硬性 SLA,前端通过队列位置和进度提示缓解体感。
总结:关键决策一览
| 决策点 | 选择 | 核心理由 |
|---|---|---|
| 服务数量 | 2 个(Go API + Python Worker) | 避免过度拆分;WebSocket 内嵌 goroutine 天然支持 |
| API 层语言 | Go + Gin | 并发模型好;类型安全;学习价值高;部署简单 |
| 任务队列 | Redis Stream | GPU 是瓶颈;复用已有 Redis;Kafka 大材小用 |
| WebSocket 扩展 | Redis Pub/Sub | Worker 回调任意实例,广播到持有连接的实例 |
| 认证 | JWT(15min) + Redis RT(7天) | AT 无状态快;RT 可撤销 |
| 音频分发 | 七牛云 CDN 直链 + 签名 URL | 带宽成本归零;七牛云 10GB 永久免费 |
| 额度并发 | MySQL INSERT ON DUPLICATE KEY UPDATE | 行锁原子写,写即检查,无需 Redis 分布式锁 |
| GPU 方案 | AutoDL 按需租用(RTX 3090,¥2/h) | 国内,支付宝,按秒计费,成本最低 |
后记
这套架构的设计原则是:在满足当前需求的前提下,选择最简单的方案。Kafka 很强大,但 GPU 是瓶颈,每秒入队不超过 10 个,Kafka 完全是大材小用。微服务很优雅,但 WebSocket 内嵌 Go API 更省事。
个人练习项目最大的敌人是过度设计。把精力花在真正有技术深度的地方——额度并发安全、XADD+INCR 的原子性、credit_date 的时区问题——这些细节才是架构能力的体现。
下一篇:《为什么选 Redis Stream 而不是 Kafka:任务队列选型实战》,深入讲队列选型和实现细节。