Spring Boot 3 正式发布之后,我接手的内容平台开始暴露出一个很现实的问题:用户上传的视频格式五花八门,手机拍的、剪辑软件导出的、录屏工具转存的,到了后端却只有一个存储服务,播放体验完全靠前端“猜”。项目组评估了一圈,最后定下的方案就是在 Spring Boot 3 服务里集成 FFmpeg,把转码、抽帧、元信息提取这些能力收口到后端。这篇东西就是整个过程中从环境搭建到视频处理实战的记录,适合那些准备在 Java 服务里接 FFmpeg、又不想被进程管理和参数调试拖垮的人。
我先把结论放在前面:Spring Boot 3 集成 FFmpeg 这件事,技术上不复杂,真正麻烦的是环境一致性和进程边界。只要你把 FFmpeg 当作一个独立的、可靠的命令行工具来对待,而不是想用 Java 代码去“模拟”它的能力,后面的路会顺畅很多。接下来我按自己实际落地的顺序,把环境搭建、Spring Boot 3 工程接入、常见视频处理场景、以及一堆躲不开的坑,一条一条说清楚。
1. 先想明白:服务端为什么要自己扛视频处理
1.1 FFmpeg 在业务里承担的职责边界
在决定集成 FFmpeg 之前,团队内部其实吵过一轮:直接用云厂商的转码服务不香吗?确实,如果你们公司不在乎成本、视频量又大,云转码是省心选择。但对我们这种私有化部署占比很高的项目来说,用户数据要留在自己的服务器上,不可能把原始视频全部丢到第三方平台,这时候本地 FFmpeg 就成了刚需。
具体到能力边界,FFmpeg 能替我们做的远不止“转码”两个字。我列一下我们线上实际用到的高频功能:
- 统一视频编码格式,把用户上传的 MOV、AVI、FLV、MKV 全部转成 H.264 + AAC 的 MP4,保证 Web 端和移动端都能直接播放;
- 抽取视频封面图,用户没有上传封面时,自动从视频中间截一帧;
- 获取视频时长、分辨率、码率、编码格式等元信息,用于内容审核和播放器适配;
- 将长视频切片成 HLS(m3u8 + ts),满足点播场景的拖动播放和码率自适应;
- 处理用户的一些“奇怪需求”,比如把缓存目录里的 m4s 文件转成 mp4、修复损坏的 AVI 文件、精准裁掉片尾几秒钟。
这些需求如果全部依赖人工处理,那内容运营团队就不用干别的了。有了 FFmpeg,我们只需要在后端封装一套命令,按业务需要传递参数即可。
1.2 Java 侧调用 FFmpeg 的三条路线对比
当时摆在我们面前的有三条路:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| ProcessBuilder / Runtime.exec 调用命令行 | 直接执行 ffmpeg 可执行文件 | 依赖简单、进程隔离、命令行参数即 API,FFmpeg 升级不影响业务代码 | 需要自己管理进程生命周期和输出流 |
| JavaCV 封装 | 引入 org.bytedeco:javacv | 提供 Java 接口,很多能力开箱即用 | 依赖体积大,底层也是调 FFmpeg 的 native 库,遇到诡异问题反而更难排查 |
| JNI / JNA 直接绑定 native 库 | 自己写绑定层 | 性能最高,可以精细控制 | 维护成本高,团队没人有精力长期跟随 FFmpeg 版本迭代 |
最终我们选了第一条路。理由很朴素:FFmpeg 本身就是一个设计良好的命令行工具,进程边界清晰,所有能力都能通过参数表达。用 ProcessBuilder 调用,本质上和我们在服务器上手动敲一条 ffmpeg 命令没有区别,出任何问题都可以直接在命令行复现、排查。JavaCV 虽然封装得漂亮,但它引入了一层抽象,一旦某个滤镜、某个编码器参数在 native 层表现异常,你得同时懂 Java、懂 FFmpeg、懂 native 库编译,排障链路非常长。
注意:我不建议为了“纯 Java 方案”强行用 JavaCV。视频处理这种重 IO、重计算的任务,进程调用带来的那点性能损耗可以忽略不计,真正的开销在编码器本身。
1.3 Spring Boot 3 在这套架构里的真实角色
Spring Boot 3 在这套体系里的定位,是任务调度层和业务组织层。它负责接收上传文件、把视频落盘、构造 FFmpeg 命令、异步执行并监听结果、把处理结果写回业务表里。真正干重活的永远是被拉起的新进程。
这里有一个很多人容易误解的点:Spring Boot 3 内嵌的 Tomcat 线程池和 FFmpeg 的 CPU 消耗是两套资源体系。如果你直接在 Controller 里同步执行 ffmpeg 命令,哪怕只有一个用户上传视频,Tomcat 线程也会被长时间占用,其他请求全部排队。所以我们在工程结构上把“接收请求”和“执行 FFmpeg 任务”彻底分开,用独立的线程池来处理视频任务,Tomcat 线程只是把任务丢进队列就立即返回。这个设计在后面章节会详细展开。
2. 环境搭建:给 FFmpeg 一个可预期的运行环境
2.1 三平台安装方式与版本选择
FFmpeg 安装本身不复杂,但版本问题非常关键。系统源里自带的 FFmpeg 往往偏旧,比如某些 Ubuntu 源还停留在 4.x,而我们需要用的 xfade 滤镜、某些 hls 参数在旧版本上行为不一致。我的建议是:不要用系统源,直接下载官方构建版本,并在所有环境统一版本号。
Linux(Ubuntu / Debian)上我用的方式是下载静态构建包:
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz tar xf ffmpeg-release-amd64-static.tar.xz mv ffmpeg-*-static/ffmpeg /usr/local/bin/ mv ffmpeg-*-static/ffprobe /usr/local/bin/CentOS / RHEL / AlmaLinux 如果习惯用包管理器,可以启用 EPEL 和 RPM Fusion:
dnf install epel-release -y dnf localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm dnf install ffmpeg ffmpeg-devel -ymacOS 开发机直接 brew 一把梭,但生产环境基本不会用 macOS 跑服务:
brew install ffmpegWindows 服务器建议下载 gyan.dev 提供的 stable 版本,解压后把 bin 目录加入 PATH。注意 Windows 下命令行的空格和转义问题,我们后面单独说。
装完之后,务必做一次完整校验:
ffmpeg -version ffprobe -version ffmpeg -buildconf-buildconf里重点看有没有--enable-libx264、--enable-libfdk_aac。很多精简版构建只带内置编码器,转 H.264 时会报Unknown encoder 'libx264',这个坑我们后来在客户服务器上踩过一次,排查了半天才发现是对方用了一个精简版二进制。
2.2 静态构建与动态链接的取舍
生产环境我强烈推荐静态构建版本。所谓静态构建,就是把 x264、x265、AAC 等编解码器全部编进了 ffmpeg 可执行文件里,不依赖系统动态链接库。好处很直接:拷过去就能跑,不管底层操作系统里有没有这些库。
动态库版本虽然体积小,但非常容易被环境“背刺”。比如某些精简系统缺了 libx264.so,或者 glibc 版本不一样导致加载失败,这些错误信息还不直观,排查起来很费劲。我们内部定了条规矩:所有环境的 FFmpeg 二进制统一由运维用同一个脚本下载、统一放置到/opt/ffmpeg目录,业务系统通过配置文件指定路径,禁止依赖 PATH 隐式查找。这样开发、测试、生产三套环境的行为才可能保持一致。
2.3 双进程分工:ffprobe 负责感知,ffmpeg 负责执行
我特别想强调一点:很多初学集成 FFmpeg 的人会把所有工作都塞给 ffmpeg 命令,其实应该让 ffprobe 来干“感知”的活。
ffprobe 是 FFmpeg 套件里的媒体信息探查工具,可以用 JSON 格式输出视频的完整信息。我们用 Java 解析 JSON 就能拿到时长、分辨率、编码器、码率这些字段,不需要自己去解析 ffmpeg 的日志。职责划分如下:
- ffprobe:只负责读取信息,不修改文件,安全可靠;
- ffmpeg:负责转码、抽帧、切片、修复等一切有写入动作的操作。
为什么要把它们分开?因为 ffmpeg 命令本身也带有探测能力,但它的输出格式面向人眼,不适合程序解析。而且一旦参数写错,ffmpeg 会直接报错退出,你连元信息都拿不到。用 ffprobe 隔离这部分职责,可以让业务代码更稳定:先探测,确认格式无误,再进入转码流程。
另外,Spring Boot 应用启动时不要阻塞等待 FFmpeg 可用。我更推荐在配置类里做一个懒加载检测:第一次真正调用 ffprobe 时如果失败,可以快速返回一个业务错误码,而不是让整个服务起不来。
3. Spring Boot 3 工程接入 FFmpeg:最少代码把命令跑起来
3.1 构建工程与配置绑定
Spring Boot 3 要求 Java 17 起步,我们用的是 3.2.x + Java 21。工程依赖只需要最基础的两个 starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>不使用任何 FFmpeg 相关的 Java 依赖,核心逻辑就是构造命令、启进程、读输出。
配置文件里把可执行文件路径、工作目录、超时时间都抽出来:
ffmpeg: binary-path: /opt/ffmpeg/ffmpeg ffprobe-path: /opt/ffmpeg/ffprobe work-dir: /data/video-work timeout-seconds: 600 max-concurrent-process: 2用@ConfigurationProperties绑定一下:
@ConfigurationProperties(prefix = "ffmpeg") public record FfmpegProperties( String binaryPath, String ffprobePath, String workDir, int timeoutSeconds, int maxConcurrentProcess ) {}这里有个小细节:binaryPath不要拼在命令字符串里,而是作为启动进程时的第一个参数传入。ProcessBuilder 天生支持参数列表,直接传new ProcessBuilder(ffmpegPath, "-i", inputFile, ...)最安全,省去了手动处理空格和特殊字符的麻烦。
3.2 命令执行器的核心实现:从“能跑”到“跑得稳”
一个基本的命令执行器,需要处理三个关键问题:
- 构造干净的 List 命令参数,而不是拼大字符串;
- 异步读取子进程的标准输出和标准错误,防止缓冲区写满导致进程阻塞;
- 超时控制,防止 ffmpeg 卡死把业务线程拖垮。
我给出一个简化可用的版本:
@Component public class FfmpegProcessRunner { private final FfmpegProperties properties; private final Semaphore semaphore; public FfmpegProcessRunner(FfmpegProperties properties) { this.properties = properties; this.semaphore = new Semaphore(properties.maxConcurrentProcess()); } public FfmpegResult execute(List<String> args, Duration timeout) throws IOException, InterruptedException { semaphore.acquire(); Process process = null; try { List<String> fullArgs = new ArrayList<>(); fullArgs.add(properties.binaryPath()); fullArgs.addAll(args); ProcessBuilder builder = new ProcessBuilder(fullArgs); builder.directory(new File(properties.workDir())); builder.redirectErrorStream(false); process = builder.start(); // 用独立线程消费输出流,避免阻塞 CompletableFuture<String> stdoutFuture = readStream(process.getInputStream()); CompletableFuture<String> stderrFuture = readStream(process.getErrorStream()); boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); if (!finished) { process.destroyForcibly(); process.waitFor(); throw new IOException("ffmpeg 执行超时:" + timeout.getSeconds() + "s"); } int exitCode = process.exitValue(); String stdout = stdoutFuture.get(); String stderr = stderrFuture.get(); return new FfmpegResult(exitCode == 0, exitCode, stdout, stderr); } finally { if (process != null && process.isAlive()) { process.destroyForcibly(); } semaphore.release(); } } private CompletableFuture<String> readStream(InputStream inputStream) { return CompletableFuture.supplyAsync(() -> { try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { return reader.lines().collect(Collectors.joining("\n")); } catch (IOException e) { return ""; } }); } }这里你可能注意到我用了Semaphore限制并发进程数。这是个非常关键的兜底策略,因为视频转码是 CPU 密集型任务,如果同时跑 10 个 1080p 转码任务,机器直接被打满,连正常的业务接口都会跟着变慢。max-concurrent-process建议设成 CPU 核心数的一半,配合队列使用。
为什么用CompletableFuture.supplyAsync读输出流而不直接用process.getInputStream()在同一个线程里读?因为一个进程有两个管道(stdout 和 stderr),如果当前线程阻塞在读取 stdout 上,而 ffmpeg 的 stderr 写满了管道缓冲区,它就不会继续往 stdout 写,两边互相等,进程就“假死”了。这个坑在真实生产环境非常常见。
3.3 Controller 异步化:别把请求线程和计算线程混在一起
有了命令执行器之后,如果还是同步调用,Controller 依然会被长时间占用。我们是这样设计的:
@RestController @RequestMapping("/api/video") public class VideoProcessController { private final VideoTaskService videoTaskService; @PostMapping("/transcode") public ResponseEntity<String> startTranscode(@RequestParam("file") MultipartFile file) { String taskId = videoTaskService.submitTranscodeTask(file); return ResponseEntity.accepted().body(taskId); } @GetMapping("/tasks/{taskId}") public ResponseEntity<TaskStatus> queryTask(@PathVariable String taskId) { return ResponseEntity.ok(videoTaskService.getStatus(taskId)); } }提交任务时只把文件保存到磁盘,然后在内存队列里放一个任务对象,由专门的视频处理线程池消费。
提示:如果你用的是 Java 21,可以把执行线程池换成虚拟线程,但注意视频任务是 CPU 密集,虚拟线程主要解决 IO 阻塞问题,这里不是决定性因素。真正决定吞吐的是
Semaphore限制的那几个进程名额。
4. 视频处理实战:从转码到封面图的完整链路
4.1 用 ffprobe 提取媒体元信息
不管用户上传的是什么格式,第一步永远是探测。ffprobe 的标准命令:
ffprobe -v error -print_format json -show_format -show_streams input.mp4Java 侧封装:
public MediaInfo probe(String filePath) throws IOException, InterruptedException { List<String> args = Arrays.asList( "-v", "error", "-print_format", "json", "-show_format", "-show_streams", filePath ); FfmpegResult result = ffprobeRunner.execute(args, Duration.ofSeconds(30)); if (!result.success()) { throw new VideoProcessException("无法读取媒体信息: " + result.stderr()); } return JsonUtils.parse(result.stdout(), MediaInfo.class); }MediaInfo 里最常用的字段包括视频流的宽度、高度、编码名、时长,以及格式里的format.duration。注意一个容易忽略的点:不是所有视频都带duration字段,有的流式文件时长是未知的,解析的时候要做好缺省值兜底。
4.2 视频转码与压缩:CRF 参数的背后逻辑
大多数业务场景要求输出 H.264 + AAC 的 MP4。我们的标准命令:
ffmpeg -i input.mov -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k -movflags +faststart output.mp4这里两个参数值得解释:
-crf 23:恒定质量因子,范围 0-51,数值越小画质越高、文件越大。23 是 x264 的默认值,对普通视频来说视觉无损。为什么不直接用固定码率-b:v 2M?因为固定码率在画面复杂时容易糊,画面简单时又浪费码率。CRF 让编码器自己根据画面复杂度分配码率,压缩效率更高。-preset veryfast:编码速度和压缩率的权衡。preset 越慢,同画质下文件越小,但耗时成倍增加。我们生产环境用veryfast,因为服务器资源有限,文件大一点可以接受,但任务不能卡太久。
-movflags +faststart也很重要,它把 moov 原子信息挪到文件头部,这样播放器不用等整个文件下载完就能开始播放。
4.3 抽取视频封面图:注意关键帧和定位方式
用户没传封面时,自动截帧是刚需。命令:
ffmpeg -i input.mp4 -ss 10 -frames:v 1 -vf "scale=1280:-1" cover.jpg这里-ss放在-i后面的含义是“输出定位”,先解码到第 10 秒附近再截帧;如果把-ss放在-i前面,则是“输入定位”,FFmpeg 会快速跳转到关键帧附近,速度快但可能截到的画面不那么精确。对封面场景,我推荐放-i前面,因为速度快、对 CPU 消耗小,封面本来就不需要精确到某一帧:
ffmpeg -ss 10 -i input.mp4 -frames:v 1 -vf "scale=1280:-1" cover.jpgscale=1280:-1表示宽度 1280,高度按原始宽高比自动计算,-1 就是让 FFmpeg 自己算。注意如果视频是竖屏的,这里宽度 1280 会导致高度超过 1280,业务上需要先判断旋转信息。
4.4 HLS 切片:把单文件变成流媒体
视频点播场景,我们会对长视频做 HLS 切片:
ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -crf 23 -c:a aac -hls_time 10 -hls_list_size 0 -hls_segment_filename "output_%03d.ts" output.m3u8-hls_time 10表示每个切片 10 秒左右;-hls_list_size 0表示在 m3u8 索引文件中保留所有切片记录,不清理旧记录;-hls_segment_filename指定切片命名规则。
这里要注意的是切片文件数量多,如果业务量大,会产生海量小文件,对文件系统和对象存储都不友好。我们的做法是切片后立即把整个目录打包成压缩包传给存储服务,客户端按需解压播放。如果追求更高性能,可以研究 FFmpeg 6 的-f hls结合 DASH 的方式,但基本逻辑是一样的。
4.5 四个高频小工具:m3u8 合并、m4s 转 mp4、破损 AVI 修复、精准裁片尾
前面说的都是日常转码,下面这四个是我被运营同事问过最多的小需求,索性做成了后端工具接口。
第一个是 m3u8 合并。用户下载的课程视频经常是几十个 ts 片段加一个 m3u8 索引,想合并成一个 mp4:
ffmpeg -f concat -safe 0 -i playlist.m3u8 -c copy output.mp4如果不放心 m3u8 里的路径,也可以手动生成 concat 列表文件:
ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp4filelist.txt 内容长这样:
file 'segment_001.ts' file 'segment_002.ts'用-c copy直接复制流,不重新编码,秒级完成,但前提是所有 ts 片段的编码参数完全一致,否则拼接处会出现花屏或音画不同步。
第二个是 m4s 转 mp4。很多人从 App 缓存目录里扒出来的视频是 m4s 格式,其实它通常是裸的 H.264 流或 AAC 流,直接改后缀不行,要用 FFmpeg 重新封装:
ffmpeg -i video.m4s -i audio.m4s -c copy output.mp4这个命令会把视频流和音频流分别封装进 MP4 容器,速度很快。如果只有一个 m4s 文件,那就是纯视频流:
ffmpeg -i video.m4s -c copy output.mp4第三个是修复破损的 AVI 文件。有些老设备录出来的 AVI 在断电或非正常退出后会损坏,播放器只能放开头几秒。FFmpeg 的重封装能力能救一部分:
ffmpeg -err_detect ignore_err -i broken.avi -c copy repaired.avi如果-c copy不行,就尝试重新编码视频流:
ffmpeg -err_detect ignore_err -i broken.avi -c:v libx264 -c:a aac repaired.mp4但这个办法不是万能的,如果文件头部索引损坏严重,FFmpeg 可能也读不出有效流,那就真的没救了。
第四个是精准裁掉片尾。用户上传了带片尾广告的视频,想从最后 N 秒切掉。用基于文件末尾的偏移定位:
ffmpeg -sseof -10 -i input.mp4 -c copy temp_tail.mp4这句是取出最后 10 秒,通常配合其他命令使用。如果想直接生成一个去掉片尾 10 秒的新文件,更可靠的做法是先精确算出总时长,再手动指定-t:
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 input.mp4拿到总时长后,ffmpeg -i input.mp4 -t <总时长-10> -c copy output.mp4。注意这里踩过坑:-c copy是按关键帧切割的,切出来的尾部时间不一定完全准确,但对“去掉片尾”这个场景够用了。追求帧级精确就得重新编码。
至于视频转场效果,我们用 FFmpeg 的 xfade 滤镜在运营那边做节日活动视频时试过:
ffmpeg -i video1.mp4 -i video2.mp4 -filter_complex "xfade=transition=fade:duration=1:offset=4" -c:v libx264 output.mp4这个属于锦上添花,不是核心链路,但确实能看出 FFmpeg 滤镜体系的上限。
5. 躲不开的坑:进程、路径、日志与容器化
5.1 输出流不读取,进程被缓冲区憋死
这是 Java 调 FFmpeg 最常见的坑,我前面提到过原理。这里给一个非常具体的场景:当 ffmpeg 转码一个长视频,stderr 会持续输出进度信息,如果应用把 stderr 重定向到管道但不读取,而管道缓冲区满了之后,C 库的write调用就会阻塞,ffmpeg 自己卡住,业务侧看起来就是“进程还在,很久都不退出,CPU 也不涨”。
解决方式就是我封装的那套:启动进程后立刻起两个独立线程,分别读 stdout 和 stderr,或者直接用redirectErrorStream(true)合并成一个流再读。要注意的是redirectErrorStream(true)会丢失输出类型的区分,而 ffmpeg 的进度信息都在 stderr 上,业务日志里通常只关心 stderr,不关心 stdout,所以我建议保持两个流分离,分别在日志里打标签。
5.2 超时销毁后进程变成了僵尸进程
process.destroy()和process.destroyForcibly()行为不一样。destroy()是温柔地请求退出,ffmpeg 收到 SIGTERM 后可能还会等当前帧完成;destroyForcibly()直接发 SIGKILL。但即便调用了destroyForcibly(),也不能立刻以为进程没了,它需要一点时间回收,所以在超时处理里我写了:
if (!finished) { process.destroyForcibly(); process.waitFor(); throw new IOException("ffmpeg 执行超时"); }这段waitFor()是必须的。如果不等待就直接返回,子进程变成僵尸进程,占着进程表不释放,日积月累系统会出现“Cannot allocate memory”或者打开不了新进程的奇怪错误。
5.3 文件路径的空格、中文与权限问题
Windows 上路径带空格是个常规坑,比如C:\Program Files\...。用 ProcessBuilder 传参不会因为空格断掉,但如果你是在命令字符串里手动拼接,很容易引号套引号。所以核心原则是:永远用参数列表,不用字符串。
另外,FFmpeg 对中文路径的支持在不同版本上有差异。Linux 下如果系统 locale 是C,中文文件名可能乱码。我们统一约定:进入处理流程的文件一律重命名为 UUID.原后缀,放到工作目录,处理完再按业务规则存储。这样既绕开了编码问题,也保证了同一任务的所有临时文件都在同一个可控目录下,方便清理。
还有文件权限:ffmpeg 进程是以 Java 服务同样的系统用户运行的,工作目录的写权限要给足。如果文件突然变成 root 所有,后面清理临时文件时会遇到 Permission denied。
5.4 Docker 里跑 FFmpeg 的镜像选型
如果你的 Spring Boot 服务是容器化部署,FFmpeg 初始化时千万别用那种“一行 apt install”的懒人办法。我踩过的坑是:基于openjdk:17-jdk镜像,启动后执行apt-get install ffmpeg,装出来的是 Debian 源里的老版本,而且没有 libx264。后面好几个功能都用不了,只能重新构建镜像。
现在我的 Dockerfile 参考这样:
FROM eclipse-temurin:17-jdk-jammy # 拷贝静态构建的 ffmpeg 和 ffprobe COPY --from=ffmpeg-static:latest /ffmpeg /ffmpeg ENV FFMPEG_PATH=/ffmpeg/ffmpeg ENV FFPROBE_PATH=/ffmpeg/ffprobe WORKDIR /app COPY target/app.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]或者更简单:用linuxserver/ffmpeg这样的基础镜像,再把 JDK 打进去。无论哪种方式,核心都是:FFmpeg 二进制和 Java 运行环境一起打包进镜像,不要在容器启动后再去安装,否则每次扩容都是一次踩坑机会。
容器里还有一点要注意:默认容器环境没有/tmp足够大空间的话,FFmpeg 处理大视频会爆磁盘。工作目录要挂载到持久化卷,或者至少给/tmp一个足够的配额。
5.5 日志与监控:把 FFmpeg 当服务来治理
FFmpeg 命令不是跑完就完了。我们后来在内部做了一个“视频处理任务表”,记录每次任务的输入文件、完整命令、退出码、stderr 关键信息、耗时、文件大小变化。这张表给我排查问题提供极大帮助。
举个例子,有段时间转码任务偶发失败,单个任务重试又成功,完全抓不到规律。后来翻任务表发现失败集中在凌晨 3 点到 4 点,运维那边一查,是备份任务占满了 IO。如果没有这张表,这种幽灵 Bug 根本查不出来。
监控层面,我们还把视频处理任务上报了四类指标:任务提交量、立即完成量、失败量、平均处理时长/文件大小。FFmpeg 执行确实有波动,比如同一个视频在机器负载高的时候耗时能差 5 倍,所以“平均处理时长”要用百分位而不是平均值来看,P95 才是用户体验的真实反映。
项目上线到现在,FFmpeg 这层已经稳定跑了两个多月。有一点我一直跟团队强调:别把 FFmpeg 当成黑盒,它的输出日志、退出码、甚至某个滤镜参数写错的提示信息,都是排障的入口。我们后来把每次处理的 ffmpeg 命令和 stderr 都存了一份,出问题直接回放,效率高很多。
最后再分享一个小习惯:每次升级 FFmpeg 版本后,先用它跑一遍线上最常用的三五个命令,对比输出结果和耗时,再决定要不要全量替换。视频处理这东西,版本不一致带来的差异有时候比业务 Bug 还难查,滤镜行为、编码器默认参数、甚至日志输出格式都可能不同。把我的这套框架拿过去之后,第一件事就是先把ffmpeg -version的输出固化到项目的 README 里,让所有人都知道你在用的是哪个版本,这就成功了一半。