← All posts

批量视频转码 API:架构、实现与扩展指南

用 API 跑大批量视频转码:队列怎么设计、错误怎么处理、重试怎么写,附完整 Node.js 代码。

FFHub·2026-05-11
批量视频转码 API:架构、实现与扩展指南

转一个视频很容易。一万个视频转完不出问题,是另一回事。不管是迁旧媒体库、归一化用户上传,还是把存档换个编码,规模一上来就会冒出小批量根本不会遇到的问题:队列怎么管、并发怎么压、出错怎么救、钱怎么不花冤。

这篇把架构选型、实现细节和运维上的坑串起来讲一遍,目标是能直接抄走跑生产的批量转码流水线。

什么时候才算「批量转码」

不是写个 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~$1501-2 天约 4 小时$300+
云 FFmpeg API~$1002-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 veryfastmediumfast 输出体积差大概 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,扩缩容、重试、版本管理都在里面——发命令、收结果就行。

延伸阅读

批量视频转码 API:架构、实现与扩展指南 | FFHub