从零设计 AI 音乐生成系统:架构选型与高并发方案

5 阅读11分钟

从零设计 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 + GinPython + FastAPI
WebSocket 并发goroutine 天然适合,每连接 ~2KBasyncio 可以,但 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 互通,跨厂商通信走公网。安全措施:

  1. Redis:启用 TLS + requirepass + 防火墙白名单(只放行 AutoDL IP 段)
  2. 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 StreamGPU 是瓶颈;复用已有 Redis;Kafka 大材小用
WebSocket 扩展Redis Pub/SubWorker 回调任意实例,广播到持有连接的实例
认证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:任务队列选型实战》,深入讲队列选型和实现细节。