批量视频转码 API:架构、实现与扩展指南
用 API 跑大批量视频转码:队列怎么设计、错误怎么处理、重试怎么写,附完整 Node.js 代码。

转一个视频很容易。一万个视频转完不出问题,是另一回事。不管是迁旧媒体库、归一化用户上传,还是把存档换个编码,规模一上来就会冒出小批量根本不会遇到的问题:队列怎么管、并发怎么压、出错怎么救、钱怎么不花冤。
这篇把架构选型、实现细节和运维上的坑串起来讲一遍,目标是能直接抄走跑生产的批量转码流水线。
什么时候才算「批量转码」
不是写个 for 循环调 FFmpeg 就叫批量。下面这几种场景才是真正会撞到规模瓶颈的:
UGC 平台历史回刷
早期上线时来啥收啥。现在要把存量 50,000 个源文件统统转成 H.264 MP4,每个再切三档分辨率给自适应播放——一下子变成 150,000 个输出。完整的 UGC 流水线见我们的 UGC 平台视频处理指南。
媒体库迁移
从机房搬上云,或者换 CDN。每个素材都要按新规格重编一遍。
格式归一化
平台积累了 MKV、AVI、MOV、WMV、FLV 这一堆历史包袱。某些格式在手机上播不了,工单一个接一个。需要做一次(或周期性的)全量统一。
归档与合规
法规或内部规定要求所有历史内容用统一的编码、分辨率,或者必须打水印。
为什么批量转码不简单
单机循环跑 ffmpeg,20 个文件没事,20,000 个就开始炸:
资源抢占
视频转码是 CPU 密集型。一路 1080p 编码就能吃掉 4-8 个核。8 核机器同时塞 10 个任务,结果就是 CPU 颠簸、OOM kill、超时跑出来的输出还掉质量。
队列管理
哪些待处理、哪些进行中、哪些完成、哪些失败——你都得追踪。没有一个像样的队列,重启一次就会出现重复处理、任务丢失、状态对不上账。
错误处理
10,000 个文件里 1% 出错就是 100 个失败。源文件损坏、编解码器不认、网络超时、磁盘满——每种都得分别处理。
横向扩展
单机的核心数是死的。积压一多就得横向扩:多 worker、任务分发、结果聚合,每一步都是新工程量。
三种架构
常见路子就这三种,各有各的代价。
方案一:队列 + Worker 池(自建)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Job DB │───>│ Queue │───>│ Worker 1 │
│ │ │ (Redis / │ │ (FFmpeg) │
│ │ │ RabbitMQ)│ ├──────────────┤
│ │ │ │───>│ Worker 2 │
│ │ │ │ │ (FFmpeg) │
│ │ │ │ ├──────────────┤
│ │ │ │───>│ Worker N │
└──────────┘ └───────────┘ └──────────────┘
优点: 完全可控,没有按任务计费,可以离线跑。
缺点: 服务器、FFmpeg 版本、扩缩容、监控、故障恢复全是你的事。Worker 编排本身就是一个独立工程项目,得有专门的人维护。
方案二:事件驱动(云函数)
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ S3 │───>│ Lambda / │───>│ S3 Output │
│ Upload │ │ Cloud Fn │ │ Bucket │
│ Event │ │ (FFmpeg) │ │ │
└──────────┘ └───────────┘ └──────────────┘
优点: 跟着流量自动扩,没有空跑成本,按调用计费。
缺点: 函数有超时上限(AWS Lambda 最长 15 分钟)、冷启动慢、原生二进制支持差、内存吃紧。截缩略图能用,长视频转码一撞就死。具体踩过的坑见 FFmpeg 在 Serverless 上的挑战与解决方案。
方案三:云转码 API
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Your │───>│ API │───>│ Webhook │
│ App │ │ Service │ │ Callback │
│ │ │ │ │ │
└──────────┘ └───────────┘ └──────────────┘
优点: 不用维护基础设施,量级随便扩,重试和错误处理内置,FFmpeg 版本稳定。
缺点: 按任务计费,需要联网,数据要走出你自己的环境。
横向对比
| 维度 | 自建队列 | 云函数 | 云 API |
|---|---|---|---|
| 搭建时间 | 几天到几周 | 几小时 | 几分钟 |
| 扩展方式 | 手动 | 自动(有上限) | 自动 |
| 最长视频 | 不限 | 约 15 分钟 | 不限 |
| 维护成本 | 高 | 中等 | 没有 |
| 100 视频/天 | $$(服务器) | $ | $ |
| 10,000 视频/天 | $$$(集群) | $$$(调用费) | $$ |
| 错误处理 | 自己写 | 自己写 | 内置 |
实现:用云 API 跑批量转码
下面用 Node.js 写一个能直接跑的批量转码脚本。思路很直接:读源视频列表,按受控并发提交任务,跟踪进度,处理失败。
项目初始化
mkdir batch-transcode && cd batch-transcode
npm init -y
npm install p-limit
主脚本
// batch-transcode.js
import pLimit from "p-limit";
const API_BASE = "https://api.ffhub.io/v1";
const API_KEY = process.env.FFHUB_API_KEY;
const CONCURRENCY = 20; // 最大并行任务数
const POLL_INTERVAL = 5000; // 轮询间隔 5 秒
const MAX_RETRIES = 3;
// 模拟视频源列表 — 实际场景从数据库或 S3 列表获取
function getVideoList() {
return Array.from({ length: 100 }, (_, i) => ({
id: `video-${String(i + 1).padStart(4, "0")}`,
url: `https://your-bucket.s3.amazonaws.com/raw/video-${i + 1}.mp4`,
}));
}
// 提交单个转码任务
async function submitTask(video) {
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart output.mp4`,
}),
});
if (!response.ok) {
throw new Error(`提交失败: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.task_id;
}
// 轮询任务状态直到完成或失败
async function waitForCompletion(taskId) {
while (true) {
const response = await fetch(`${API_BASE}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const task = await response.json();
if (task.status === "completed") {
return { success: true, output_url: task.output_url, duration: task.duration };
}
if (task.status === "failed") {
throw new Error(`任务失败: ${task.error}`);
}
// 还在跑,继续等
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
// 带重试的单视频处理
async function processVideo(video, attempt = 1) {
try {
console.log(`[${video.id}] 提交任务 (尝试 ${attempt}/${MAX_RETRIES})`);
const taskId = await submitTask(video);
console.log(`[${video.id}] 任务已提交: ${taskId}`);
const result = await waitForCompletion(taskId);
console.log(`[${video.id}] 完成 — 耗时 ${result.duration}s`);
return { video, status: "completed", ...result };
} catch (error) {
if (attempt < MAX_RETRIES) {
const delay = Math.pow(2, attempt) * 1000; // 指数退避
console.warn(`[${video.id}] 失败,${delay / 1000}s 后重试: ${error.message}`);
await new Promise((resolve) => setTimeout(resolve, delay));
return processVideo(video, attempt + 1);
}
console.error(`[${video.id}] 最终失败: ${error.message}`);
return { video, status: "failed", error: error.message };
}
}
// 主流程
async function main() {
const videos = getVideoList();
const limit = pLimit(CONCURRENCY);
console.log(`开始批量转码: ${videos.length} 个视频, 并发: ${CONCURRENCY}`);
const startTime = Date.now();
const results = await Promise.all(
videos.map((video) => limit(() => processVideo(video)))
);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const completed = results.filter((r) => r.status === "completed").length;
const failed = results.filter((r) => r.status === "failed").length;
console.log(`\n===== 批量处理完成 =====`);
console.log(`总计: ${videos.length} | 成功: ${completed} | 失败: ${failed}`);
console.log(`总耗时: ${elapsed}s`);
// 失败列表单独打出来好排查
if (failed > 0) {
console.log(`\n失败列表:`);
results
.filter((r) => r.status === "failed")
.forEach((r) => console.log(` ${r.video.id}: ${r.error}`));
}
}
main();
跑:
FFHUB_API_KEY=your_key node batch-transcode.js
几个关键决定
为什么用 p-limit 而不是直接 Promise.all? 一次发出 10,000 个 API 请求,任何后端都会被打趴。p-limit 把并发卡死在 CONCURRENCY,相当于客户端自己做了限流。
为什么用指数退避? 临时性故障(网络抖动、上游限流)多数会自己恢复。立刻重试只会把负载压得更高。退到 2s、4s、8s,给上游留出喘气时间。
为什么这里用轮询不用 webhook? 脚本式批处理,轮询写起来更简单。如果你已经有一台 Web 服务器在跑,webhook 更省——见下一节。
webhook 架构(生产版)
生产环境里,几千个任务一直轮询很浪费带宽。换 webhook:
// Express webhook 端点
app.post("/webhooks/transcode-complete", async (req, res) => {
const { task_id, status, output_url, error } = req.body;
// 更新数据库中的任务状态
await db.query(
`UPDATE transcode_jobs
SET status = $1, output_url = $2, error = $3, completed_at = NOW()
WHERE task_id = $4`,
[status, output_url, error, task_id]
);
// 失败且还没用完重试次数,重新入队
if (status === "failed") {
const job = await db.query(
"SELECT * FROM transcode_jobs WHERE task_id = $1",
[task_id]
);
if (job.retry_count < MAX_RETRIES) {
await enqueueRetry(job);
}
}
res.sendStatus(200);
});
提交任务时把 webhook 地址带上:
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
command: `ffmpeg -i ${video.url} -c:v libx264 -crf 23 output.mp4`,
webhook_url: "https://your-app.com/webhooks/transcode-complete",
}),
});
错误怎么分类处理
到了批量规模,错误是常态。按类型分开处理:
| 错误类型 | 例子 | 怎么办 |
|---|---|---|
| 临时性 | 网络超时、503 | 退避后重试 |
| 源文件 | 文件损坏、编解码器不支持 | 记日志、跳过、丢人工排队 |
| 配置错 | FFmpeg 参数错了 | 改命令,整批重跑 |
| 限流 | 429 Too Many Requests | 降并发、加间隔 |
| 永久性 | 文件 404 | 记日志,不重试 |
死信队列
超过 MAX_RETRIES 还失败的,不要打个日志就扔。塞死信队列,留人工看:
async function handlePermanentFailure(video, error) {
await db.query(
`INSERT INTO dead_letter_queue (video_id, source_url, error, failed_at)
VALUES ($1, $2, $3, NOW())`,
[video.id, video.url, error]
);
}
进度追踪
跑大批量得能看到进度,最简版:
class BatchProgress {
constructor(total) {
this.total = total;
this.completed = 0;
this.failed = 0;
this.startTime = Date.now();
}
update(status) {
if (status === "completed") this.completed++;
if (status === "failed") this.failed++;
const done = this.completed + this.failed;
const percent = ((done / this.total) * 100).toFixed(1);
const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(0);
const rate = (done / (elapsed || 1)).toFixed(1);
const eta = ((this.total - done) / (rate || 1)).toFixed(0);
process.stdout.write(
`\r[${percent}%] ${done}/${this.total} | ` +
`OK: ${this.completed} FAIL: ${this.failed} | ` +
`${rate}/s | ETA: ${eta}s `
);
}
}
规模化的成本账
一次性转 10,000 个视频(每个平均 5 分钟,1080p H.264 输出)大致这样:
| 方案 | 计算费 | 工程时间 | 总耗时 | 综合花费 |
|---|---|---|---|---|
| 单台服务器(16 核) | ~$200/月 | 2-3 天搭建 | 约 7 天 | $400+ |
| Kubernetes 集群(40 核) | ~$500/月 | 1-2 周搭建 | 约 2 天 | $1,500+ |
| AWS Elastic Transcoder | ~$150 | 1-2 天 | 约 4 小时 | $300+ |
| 云 FFmpeg API | ~$100 | 2-3 小时 | 约 2 小时 | $150+ |
自建方案账面计算费便宜,但工程时间才是大头。后端工程师人时 $80-150,两天调试就把 10,000 视频批次的 API 费用甩开了。
如果是周期性负载,账要重算。每月 100,000 个以上,自建集群可以摊薄搭建成本;低于这个量,API 几乎都更划算。SaaS 团队的「自建 vs 外购」详细分析见 SaaS 视频处理指南。
几个优化建议
1. 按文件大小排序
先跑小的。小文件跑得快,能很早暴露 FFmpeg 命令是不是写错了,避免大文件白白浪费时间。
videos.sort((a, b) => a.fileSize - b.fileSize);
2. 调并发
从 10-20 起步,盯着 API 响应时间。一旦延迟变高或者出 429,往下降;如果一直稳,再慢慢往上加。
3. 用合适的 preset
批量场景往往速度比体积重要,用 -preset fast 或 -preset veryfast。medium 和 fast 输出体积差大概 5-10%,编码速度差一倍。
4. 跳过已经处理过的
维护一张「已完成」表,提交前先查,避免重复扣费:
async function shouldProcess(video) {
const existing = await db.query(
"SELECT 1 FROM completed_jobs WHERE video_id = $1",
[video.id]
);
return existing.rows.length === 0;
}
小结
批量转码本身是个老问题,关键在选对架构:
- 一次性 10,000 以下:直接上云 API。本文代码拿来就能跑。
- 周期性 100,000/月 以下:API + webhook + 一张状态表。
- 周期性 100,000/月 以上:考虑混合——自建处理稳态负载,API 接峰值溢出。
本文的 Node.js 版本能覆盖大部分场景。换上你的视频源、改 FFmpeg 命令、调并发,就能跑。
不想自己折腾基础设施,FFHub.io 提供云 FFmpeg API,扩缩容、重试、版本管理都在里面——发命令、收结果就行。
延伸阅读
- UGC 平台视频处理 - 大规模处理用户上传视频的完整流水线
- SaaS 产品的视频处理 - 各阶段成本测算的「自建 vs 外购」决策框架
- FFmpeg 视频压缩最佳实践 - 批量任务里调好画质和体积的命令参数