做短视频服务,绕不开两件事:一是把用户上传的视频转成统一的格式并切成小片段便于边下边播,二是给每个用户返回一条他可能感兴趣的视频流。前者决定播放体验和带宽成本,后者决定用户停留时长。这篇文章基于 Node.js 搭建一条完整的链路,从上传接口、FFmpeg 转码切片,到推荐流的生成策略,把关键环节的代码和设计取舍都讲清楚。

一、上传与转码切片:FFmpeg 是核心
视频处理这块 Node.js 本身做不了太多,真正干活的是 FFmpeg。Node.js 的角色是调度者:接收上传、生成任务、管理 FFmpeg 子进程、把产物登记入库。常见的做法有三种:直接用 child_process 调用 ffmpeg 命令行、用 fluent-ffmpeg 这个封装库、或者交给独立的转码 worker 服务。中小规模场景下,fluent-ffmpeg 加任务队列已经够用,本文也采用这个方案。
先看上传接口。上传文件建议走临时目录,校验通过后再触发转码,避免把坏文件直接送进处理流程:
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
const storage = multer.diskStorage({
destination: '/tmp/uploads',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
}
});
const upload = multer({ storage, limits: { fileSize: 200 * 1024 * 1024 } });
app.post('/api/videos', upload.single('video'), async (req, res) => {
const videoId = path.basename(req.file.filename, path.extname(req.file.filename));
// 入队转码任务,立即返回,异步处理
await transcodeQueue.push({ videoId, filePath: req.file.path });
res.json({ videoId, status: 'processing' });
});
app.listen(3000);
转码的核心目标是输出 HLS 格式:一个 m3u8 索引文件加一堆 ts 切片。切片时长一般取 4 到 6 秒,太短会导致切片数量爆炸、索引文件变大,太长则首屏和 seek 的延迟变高。下面是单码率切片的示例:
const ffmpeg = require('fluent-ffmpeg');
function transcodeToHLS(inputPath, outputDir, videoId) {
return new Promise((resolve, reject) => {
ffmpeg(inputPath)
.videoCodec('libx264')
.audioCodec('aac')
.outputOptions([
'-preset medium',
'-crf 23',
'-hls_time 5', // 每个切片 5 秒
'-hls_playlist_type vod',
'-hls_segment_filename', `${outputDir}/${videoId}_%03d.ts`
])
.output(`${outputDir}/${videoId}/index.m3u8`)
.on('progress', p => console.log(`处理进度: ${p.percent}%`))
.on('end', resolve)
.on('error', reject)
.run();
});
}
如果需要适配不同网络环境,就要做多码率(master playlist)。思路是为每个分辨率单独跑一路输出,最后生成一个总的 m3u8 指向各路子播放列表。码率档位通常取 360p、720p、1080p 三档,播放器会根据带宽自动切换,带宽成本能明显下降。
有一个容易被忽视的坑:FFmpeg 进程是 CPU 密集型的,如果并发转码数量不受控,机器很容易被打满导致所有任务都超时。所以必须用并发限制的队列,Node.js 里可以用 p-limit 或者更专业的 bull(基于 Redis)来做:
const Queue = require('bull');
const transcodeQueue = new Queue('transcode', { redis: { port: 6379, host: '127.0.0.1' } });
transcodeQueue.process(2, async (job) => {
// 第二个参数 2 表示本机最多同时跑 2 个转码任务
const { videoId, filePath } = job.data;
await transcodeToHLS(filePath, `/data/hls/${videoId}`, videoId);
await db.videos.update(videoId, { status: 'ready' });
});
transcodeQueue.on('failed', (job, err) => {
console.error(`任务失败: ${job.id}`, err);
// bull 自带重试,可在任务配置里设置 attempts 和 backoff
});
二、切片存储与分发策略
切片产物怎么存,直接决定了播放的可用性。本地磁盘方案最简单,配合 Nginx 直接静态托管即可,但扩容困难,机器挂了内容就没了。生产环境基本都走对象存储(比如 S3、OSS、COS),切片上传后配合 CDN 分发,回源压力和延迟都可控。
上云后要注意两点。第一,上传切片应该用并行批量上传,一段视频切出来几十个 ts 文件,串行上传会拖慢整个转码流水线;第二,m3u8 文件的 Content-Type 必须正确设置为 application/vnd.apple.mpegurl,否则部分播放器和 iOS 端 Safari 会拒绝播放,这是非常经典的排查半天的坑。上传示例:
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const pLimit = require('p-limit');
const s3 = new S3Client({ region: 'ap-northeast-1' });
const limit = pLimit(8); // 并行上传 8 个文件
async function uploadHLSDir(dir, videoId) {
const files = fs.readdirSync(dir);
await Promise.all(files.map(f => limit(() =>
s3.send(new PutObjectCommand({
Bucket: 'my-video-bucket',
Key: `hls/${videoId}/${f}`,
Body: fs.createReadStream(`${dir}/${f}`),
ContentType: f.endsWith('.m3u8')
? 'application/vnd.apple.mpegurl'
: 'video/mp2t'
}))
)));
}
另外别忘了防盗链。切片的 URL 最好带上过期签名,否则被外站直接嵌入播放,流量账单会很难看。主流对象存储都支持预签名 URL,生成时设置一小时的过期时间通常够用。
三、推荐流的生成:从热度排序到个性化召回
推荐流(feed 流)是留住用户的关键。最朴素的方案是按发布时间倒序,实现零成本,但新内容少的平台体验会很差。第二步演进是加权热度分:综合播放量、点赞、完播率计算一个分数,按分数混排。一个可用的公式类似 score = (plays + 5 * likes + 10 * finishRate * plays) / (ageHours + 2)^1.5,分母的时间衰减让老内容自然沉底。
真正的个性化需要召回加排序的两段式架构。召回层从多个渠道各取一批候选:用户看过的标签、协同过滤的相似用户偏好、运营置顶内容;排序层把候选合并去重后统一打分。Node.js 做这个逻辑完全够用,相似度计算可以离线跑,线上只做查询。下面是一个简化版的标签召回实现:
async function buildFeed(userId, page = 1, pageSize = 10) {
// 1. 拿到用户的兴趣标签(来自观看行为统计)
const tags = await db.userTags.top(userId, 5);
// 2. 各召回渠道取候选,互相补充
const [byTags, hot, followed] = await Promise.all([
db.videos.findByTags(tags, 50), // 标签召回
db.videos.findHot(20), // 热度兜底,冷启动用户也有内容可看
db.videos.findByFollowee(userId, 10) // 关注的人发布的内容
]);
// 3. 合并去重,按热度分排序
const seen = new Set();
const pool = [...followed, ...byTags, ...hot].filter(v => {
if (seen.has(v.id)) return false;
seen.add(v.id);
return !v.viewedBy?.includes(userId); // 过滤已看过的
});
return pool
.sort((a, b) => b.hotScore - a.hotScore)
.slice((page - 1) * pageSize, page * pageSize);
}
分页这里建议用游标而不是页码。推荐流的内容会不断插入和重排,用 page 参数翻页容易出现重复或漏内容,改成传上一页最后一条的 id 或分数作为游标,稳定性会好很多。
四、性能与稳定性的一些补充
几个实践中总结的要点。转码服务一定要和 API 服务分开部署,FFmpeg 吃 CPU 的特性会拖慢所有在线请求,隔离后互不影响。转码失败要有死信处理,超过重试次数的任务记录下来人工排查,常见原因包括上传中断导致的坏文件和不支持的编码格式。推荐流的计算结果可以按用户维度缓存到 Redis,设置几分钟的过期时间,命中率高的场景能省掉大量数据库查询。
监控方面,重点关注转码队列的堆积长度和平均耗时。队列长度持续增长说明转码能力不足,要么加机器要么限制上传速率;平均耗时异常升高则可能是上传了大分辨率的长视频,可以在入队前先用 ffprobe 探测时长和分辨率,对超限的视频提前拒绝或提示用户压缩。把这条链路的每个环节都加上指标上报,排查问题时会感谢自己。
整体来看,Node.js 在这套架构里承担的是胶水层角色,把 FFmpeg、存储、推荐逻辑串联成一个完整服务。量级上去之后,转码可以拆成独立的 worker 集群,推荐召回可以引入向量检索,但整体的接口设计和数据流向是不变的,先跑通这条最小链路,再按瓶颈逐步演进,是最稳妥的路径。