做服务端的兄弟应该都有同感,视频处理看起来是个小需求,真做起来全是坑。压缩压得狠了画质没法看,压得轻了文件体积没变化;切片切出来播放器不兼容;任务一多,Tomcat线程直接被拖死。这套 Spring Boot + FFmpeg 的批量处理方案,就是把我这两年踩过的坑总结成了一套能直接落地的架构。它解决了三个最核心的问题:怎么调 FFmpeg 参数才能兼顾体积和画质,怎么把视频切成 HLS 流方便播放器拉流,以及怎么用异步任务引擎扛住批量处理场景下的并发压力。适合正在做视频上传、转码、点播类后端服务的 Java 开发者参考。
1. 整体设计与思路拆解
1.1 为什么选 Spring Boot + FFmpeg 这个组合
先说选型。视频处理的方案看着不少,但真正能落到生产环境的基本只有几条路。一是纯 Java 方案,比如 JCodec、JavaCV 底层封装的那套,优点是部署简单、不依赖外部进程,缺点是格式支持、编解码器完整度、高性能压片这块,跟 FFmpeg 的生态比还是有差距。二是调云服务商提供的转码 API,省事,但视频文件要传出去,而且批量大时费用不低。三就是 Spring Boot 应用内嵌 FFmpeg 进程调用,这也是我最终选的方向。
FFmpeg 本身是独立的多媒体处理框架,通过命令行参数驱动,能处理几乎所有主流的音视频格式,H.264、H.265、VP9、AAC 这些编码器全都能调动。Spring Boot 这边负责的是业务编排、任务管理、API 暴露、文件存储,两边通过进程调用对接。这样职责非常清晰:Java 层管状态、管调度,FFmpeg 管真正的音视频处理,互不干扰。
这个组合还有一个隐藏优势——FFmpeg 是独立进程,就算编码器崩溃,影响的也只是当前任务,不会把整个 JVM 带崩。Java 层的 Crash 跟 FFmpeg 层的 Crash 被进程边界天然隔开了,这个在稳定性上太重要了。
1.2 异步任务引擎解决的核心痛点
如果只是单条视频转码,同步处理完全够用,毕竟一个请求进来,等 FFmpeg 跑完再返回结果,逻辑最简单。但批量处理场景下同步方案会立刻遇到两个硬伤。
第一个是 HTTP 连接占用。一条 1GB 的视频,压缩转码可能要跑几分钟甚至十几分钟,期间 HTTP 连接一直被占着。客户端等得起吗?就算等得起,服务端 Tomcat 的线程池也被拖垮了。默认 200 个线程,50 个转码任务就能把线程池占满,其他接口全部阻塞。
第二个是失败恢复问题。转码到一半进程崩了,同步模式下这条链路就算断了,客户端要重新上传、重新处理。异步任务引擎把任务状态持久化到数据库,失败后可以重试,服务重启后还能恢复未完成任务,这就是可靠性上的本质差异。
所以整个架构的核心就是:上传接口只负责收文件和建任务,真正的视频处理放到异步任务引擎里跑。这个思路几乎是所有视频处理服务的标准姿势。
1.3 模块划分与请求链路
实际编码时我把项目拆成了几个清晰的分层:
- Controller 层:只做参数校验、文件接收、任务 ID 返回
- Service 层:组装任务数据、调用任务引擎、查询任务状态
- Task 引擎模块:线程池管理、任务状态机、重试调度
- Process 模块:封装 FFmpeg/FFprobe 进程调用,解析输出
- Repository 层:任务记录、文件元数据的持久化
请求链路是这样的:客户端 POST 上传视频文件,Controller 收到后落盘到临时目录,然后往任务表插一条待处理记录,返回任务 ID 给前端。前端轮询任务状态接口,等状态变成 SUCCESS 后拿到输出文件地址。
整条链路里,真正耗时的 FFmpeg 转码完全发生在异步线程中,HTTP 请求在文件落盘后就立刻释放掉了。这也是整个设计里最核心的取舍。
2. 环境准备与 FFmpeg 进程集成细节
2.1 安装与版本选择
FFmpeg 的安装本身不复杂,但版本选择有讲究。我建议直接用官方静态编译版本,或者用系统包管理器装,但要注意版本不能太老。比如在 Ubuntu 上执行:
sudo apt update sudo apt install ffmpeg ffmpeg -version如果显示的是 4.x 以上的版本,基本够用。我自己目前生产环境用的是 6.0 版本,H.264、H.265、AAC 编码器都齐全。Windows 环境下从官网下载 release 包后,把 bin 目录加到系统 PATH 里就行。有一点要特别提醒:装好后一定要确认 libx264 编码器存在,跑一下这个命令:
ffmpeg -encoders | grep libx264之前遇到过有人装的是精简版 FFmpeg,只有基础的解码能力,没有 x264 编码器,结果执行压缩命令时报Unknown encoder 'libx264',排查了半天。
2.2 Java 进程调用的正确打开方式
Java 侧调用 FFmpeg 最稳妥的方式是ProcessBuilder,不要用Runtime.exec。ProcessBuilder 在参数传递、错误流重定向、工作目录设置上都更可控。
一个容易踩的坑是:很多人在 Java 里拼命令时图省事,把整个命令串成一个字符串传进去。比如:
Process process = Runtime.getRuntime().exec("ffmpeg -i input.mp4 output.mp4");这样在参数里有空格、中文路径、特殊字符时极容易出问题。正确做法是用 ProcessBuilder 的列表参数形式:
List<String> command = new ArrayList<>(); command.add("ffmpeg"); command.add("-i"); command.add(inputPath); command.add("-c:v"); command.add("libx264"); // ... 其他参数 command.add(outputPath); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); // 分开处理标准输出和错误输出 Process process = pb.start();每个参数都是独立的列表元素,路径再奇怪也不会被错误解析。
2.3 输出流处理——进程假死的元凶
FFmpeg 处理视频时,日志输出量很大。如果 Java 进程启动后不读取它的输出流,当输出缓冲区写满时,FFmpeg 进程会阻塞等待,表现就是进程假死——明明视频处理还在进行,但任务卡住不动了。
解决方式很简单:启动两个线程分别消费标准输出流和错误输出流。这里有个经验之谈:FFmpeg 的大部分运行日志都走标准错误流,但也要把标准输出流读走,不能只处理一个。
CompletableFuture<Void> outputFuture = CompletableFuture.runAsync(() -> { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { // 处理标准输出 } } catch (IOException e) { // 忽略 } }); CompletableFuture<Void> errorFuture = CompletableFuture.runAsync(() -> { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { String line; while ((line = reader.readLine()) != null) { // 解析进度信息 } } catch (IOException e) { // 忽略 } }); int exitCode = process.waitFor(); outputFuture.get(); errorFuture.get();2.4 用 FFprobe 读取视频元信息
在决定压缩参数之前,得先知道原视频的分辨率、码率、时长。这些信息用 FFprobe 拿,它是 FFmpeg 配套的工具。用 Java 调用 FFprobe 拿 JSON 格式输出,然后解析:
ffprobe -v quiet -print_format json -show_format -show_streams input.mp4返回的 JSON 里能拿到视频流的width、height、bit_rate、codec_name,以及总时长duration。这些信息直接决定压缩策略:分辨率超过 1080p 的降不降,码率高的要不要压,编码格式是不是 H.265 要转成 H.264 等等。
拿不到码率也很正常,有些封装格式的 stream 不写这个字段,那就用文件大小除以时长估算。这个逻辑我写在任务预检阶段,处理完的数据全部存进数据库记录里。
3. 视频压缩实操:参数选型与命令设计
3.1 压缩的本质与参数逻辑
视频压缩的本质是转码,核心是调整编码器、帧率、分辨率、码率这几个维度。我们最常用的是 H.264 编码器,配合 CRF 模式控制质量。
CRF(Constant Rate Factor)是 x264 编码器的质量参数,范围从 0 到 51,数字越小画质越好文件越大,一般建议 18 到 28 之间。我实测下来,CRF 23 对普通视频是个甜点值——肉眼几乎看不出画质损失,体积能缩小 50% 以上。如果是监控类、存档类不太在意画质的,可以放到 26-28,体积更小。
还有一个关键参数是preset,它控制编码速度和压缩率的平衡。ultrafast最快但文件大,veryslow最慢但文件最小。生产环境我一般用medium或slow档,压缩率够,速度也不会慢得太离谱。
3.2 一版可用的压缩命令
先看一个完整例子:
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -vf scale=-2:1080 -c:a aac -b:a 128k -movflags +faststart output.mp4逐项解释一下:
-c:v libx264:指定视频编码器为 H.264-crf 23:质量系数,这里取 23-preset medium:速度与体积的平衡档-vf scale=-2:1080:如果视频超过 1080p,缩放到 1080p 高度,宽度按比例自动计算,-2是为了保证宽度是偶数,避免 YUV 色彩空间的兼容性问题-c:a aac:音频编码为 AAC-b:a 128k:音频码率设为 128kbps-movflags +faststart:把 moov 元数据移到文件头部,这个参数太重要了。不加的话,MP4 文件在网页播放器里要等整个文件下载完才能播,加上后可以边下边播
如果不确定原视频是不是已经很小了,可以在代码里先判断文件大小,小于设定阈值(比如 10MB)直接跳过压缩,只做格式规范化。
3.3 编码器选型:libx264 还是 libx265
H.265 的压缩率比 H.264 高 30%-50%,看起来更香,但实际使用要谨慎。H.265 编码慢,同样画质下编码时间是 H.264 的两到三倍;解码兼容性也差一些,很多老设备、部分浏览器不支持 H.265 硬解。
我的原则是:默认走 H.264,除非明确知道目标播放端全支持 H.265。比如做一套内部 App 用的视频库,libx265值得用;面向公网的 H5 视频服务,老老实实用 H.264。
另外给个经验值:如果原视频本身就是 H.265 编码的,我们可以不重新编码,直接用-c:v copy复制视频流,只处理音频,速度快很多,画质零损失:
ffmpeg -i input.mp4 -c:v copy -c:a aac -b:a 128k output.mp4这条命令很适合那些"转封装 + 音频重编码"的场景。
3.4 压缩策略的完整判断流程
实际编码时我把判断逻辑做成了可配置的规则引擎:
- 先用 FFprobe 读取输入文件的分辨率、码率、编码器信息
- 如果编码器不是 H.264/H.265,走完整重编码流程
- 如果是 H.264 但分辨率超过 1080p,做分辨率和码率双重压缩
- 如果分辨率低于 720p,只调整码率到合理范围,不做缩放
- 文件小于阈值,直接走 copy 流程
这样避免了对小文件无脑重编码导致画质白白损失。
4. HLS 切片:把长视频变成可流畅播放的流
4.1 HLS 的基本原理
HLS(HTTP Live Streaming)是苹果提出的流媒体传输协议,现在已经成为点播和直播领域的事实标准。它的核心思想很简单:把一个完整的视频文件切成多个小片段(.ts 分片),再生成一个索引文件(.m3u8),播放器根据索引文件按顺序拉取切片进行播放。
为什么要把视频切片?因为直接播 MP4 有几个问题:文件太大时首屏加载慢;想要拖到中间某个时间点,服务器要支持 Range 请求,而且浏览器要从相应位置开始解析。HLS 切片后,每个切片只有几秒钟,播放器可以边下边播,拖进度条时只需要从对应序号开始拉流,体验好很多。
4.2 切片命令详解
用 FFmpeg 生成 HLS 切片的经典命令如下:
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k \ -f hls -hls_time 6 -hls_list_size 0 -hls_segment_filename output/segment_%04d.ts output/index.m3u8参数含义:
-f hls:指定输出格式为 HLS-hls_time 6:每个切片的时长,单位秒。6 秒是点播场景常用的值,直播场景可以调到 2-4 秒减小延迟-hls_list_size 0:生成的 m3u8 索引文件保留所有切片。如果不设这个参数,FFmpeg 默认只保留最近 5 个切片记录,这在直播场景是合理的,但点播场景会把前面的切片记录清掉,播放器播不到开头-hls_segment_filename:切片文件的命名模板
HLS 对编码格式有严格要求:视频必须是 H.264,音频必须 AAC,封装必须是 MPEG-TS。所以即使输入视频是 H.265 编码,切 HLS 前也必须转成 H.264,否则很多播放器播不了。
4.3 服务端的文件管理策略
切片后产生的不再是一个文件,而是一个目录加几十上百个片段文件。服务端文件管理要专门设计:
- 按任务 ID 建目录,目录内放 m3u8 文件和 ts 切片
- m3u8 里的切片路径用相对路径,这样整个目录可以整体迁移、整体删除
- 上传到 CDN 或对象存储时,要把整个目录的文件通通过去,且路径结构保持一致
- 清理过期视频时,直接删除整个任务目录,避免残留垃圾文件
4.4 m3u8 转回 MP4 的实用场景
偶尔会有运营人员需要下载一段 HLS 流里的内容,或者后端要做视频审核,需要把切片合并回单个 MP4 文件。反向操作也很简单:
ffmpeg -i index.m3u8 -c:v copy -c:a copy output.mp4因为切片是 H.264 + AAC 的封装,直接 copy 流就能无损合并,速度非常快。这个命令不需要重新编码,就是个封装层面的重新组合。
5. 异步任务引擎的设计与实现
5.1 线程池的配置与调优
任务引擎的心脏是线程池。先看一版我在生产环境用的配置:
@Bean("videoTaskExecutor") public ThreadPoolTaskExecutor videoTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix("video-task-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.initialize(); return executor; }核心线程数只配了 2,最大 4,看着很保守,这是故意的。视频转码是 CPU 密集型且内存占用高的操作,一台 8 核 16GB 的机器,同时跑 2-3 个 FFmpeg 转码任务基本就到瓶颈了。线程开多了不但不会加速,反而会因为 CPU 争抢和内存吃紧导致每次转码都变慢,磁盘 IO 也可能成为瓶颈。
CallerRunsPolicy是我在拒绝策略上的选择。任务队列满了之后,新提交的任务由调用方线程(提交任务的线程)来执行,相当于一种天然的背压机制。对视频任务来说,宁可让上传接口慢一点,也不能丢任务。
5.2 任务状态机与数据库表设计
异步任务引擎必须有可靠的状态管理。我设计了这样一张任务表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_type | varchar | 任务类型:COMPRESS / HLS |
| file_name | varchar | 原始文件名 |
| input_path | varchar | 输入文件路径 |
| output_path | varchar | 输出文件路径 |
| status | varchar | PENDING / RUNNING / SUCCESS / FAILED / CANCELLED |
| progress | int | 进度百分比 |
| error_msg | varchar | 失败原因 |
| retry_count | int | 重试次数 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
状态机的流转关系是这样的:任务创建后是 PENDING,被线程池捞起后变成 RUNNING,正常完成是 SUCCESS,执行异常是 FAILED。FAILED 的任务如果重试次数没超上限,会被重新提交回到 PENDING。CANCELLED 是用户主动取消。
这个状态机是整个异步引擎可靠性的基础。服务重启后,可以从数据库里把 RUNNING 状态的任务捞出来,标记为 FAILED 并更新错误信息,然后重新投递。
5.3 任务提交流程
Controller 收到上传文件后,服务层做这几件事:
public Long submitVideoTask(MultipartFile file, VideoProcessRequest request) { // 1. 文件落盘到临时目录 String fileName = UUID.randomUUID().toString() + getExtension(file.getOriginalFilename()); File tempFile = new File(storagePath, fileName); file.transferTo(tempFile); // 2. 读取视频元信息 VideoMetaInfo meta = videoProcessService.probe(tempFile.getAbsolutePath()); // 3. 创建任务记录 VideoTask task = new VideoTask(); task.setTaskType(request.getTaskType()); task.setFileName(file.getOriginalFilename()); task.setInputPath(tempFile.getAbsolutePath()); task.setStatus(TaskStatus.PENDING); task.setCreateTime(new Date()); videoTaskRepository.save(task); // 4. 提交到异步执行器 taskExecutor.execute(new VideoTaskRunner(task.getId(), request)); return task.getId(); }注意文件名的处理,落盘时我用 UUID 重命名,避免中文文件名和特殊字符带来的各种问题。原始文件名只存在数据库里,等最终下载时再通过响应头把原始文件名带回去。
5.4 进度上报是怎么实现的
任务进度对前端交互很重要,不然用户看着一个"处理中"好几分钟,完全不知道要等多久。
FFmpeg 会在标准错误流里持续输出进度信息,形如frame= 1234 fps= 25 q=28.0 size= 10240kB time= 00:00:49.60 bitrate= 1690.2kbits/s。解析time=字段,拿到当前处理到的时长,再除以总时长就能算出百分比。
解析逻辑大致是这样:
Pattern timePattern = Pattern.compile("time=(\\d+):(\\d+):(\\d+)\\.(\\d+)"); Matcher matcher = timePattern.matcher(line); if (matcher.find()) { int hours = Integer.parseInt(matcher.group(1)); int minutes = Integer.parseInt(matcher.group(2)); int seconds = Integer.parseInt(matcher.group(3)); long currentMillis = ((hours * 60L + minutes) * 60L + seconds) * 1000L; int percent = (int) (currentMillis * 100 / durationMillis); // 更新数据库 }进程结束后,手动把进度更新到 100%。要提醒的是,HLS 切片任务里 FFmpeg 的 time 输出有时会有波动,计算后记得跟 100 做 Math.min 限制,防止进度超过 100。
5.5 失败重试与任务取消
失败重试我用的是简单的指数退避策略。第一次失败等 30 秒重试,第二次等 60 秒,第三次等 120 秒,超过三次标记 FAILED 不再重试。
取消任务这块容易被忽略,其实很重要。用户提交了任务发现参数传错了,或者不想处理了,要能主动取消。实现方式是通过 Process 对象的destroy()方法强制终止 FFmpeg 进程:
public void cancelTask(Long taskId) { VideoTask task = videoTaskRepository.findById(taskId).orElseThrow(); if (task.getStatus() == TaskStatus.RUNNING) { Process process = runningProcessMap.get(taskId); if (process != null) { process.destroy(); process.waitFor(5, TimeUnit.SECONDS); if (process.isAlive()) { process.destroyForcibly(); } } } task.setStatus(TaskStatus.CANCELLED); videoTaskRepository.save(task); }destroy()是温和终止,destroyForcibly()是强制杀掉。FFmpeg 在收到终止信号后可能还要清理一下临时文件,所以先温和后强杀,给一段缓冲时间。
5.6 并发控制与资源保护
异步任务引擎跑起来后,最怕的就是并发任务把服务器资源打满。除了线程池限流,还需要在任务执行前做一次系统资源检查。简单做法是读取系统 CPU 核数和内存总量,再设定一个"当前最多同时运行 N 个转码任务"的开关,超过就排队等待。
我用的是信号量(Semaphore)做并发闸门:
private final Semaphore videoSlot = new Semaphore(3); public void acquireSlot() throws InterruptedException { videoSlot.acquire(); } public void releaseSlot() { videoSlot.release(); }线程池允许 4 个任务同时跑,但真正进入 FFmpeg 执行阶段的只有 3 个,多出来的任务先阻塞在信号量上。这层保护可以让线程池大一点应对突发任务提交,又不会让真正费资源的转码任务全部冲进来。
6. 踩过的坑与排查技巧实录
6.1 环境变量问题:命令行能跑,Java 调用报错
一个非常经典的问题:在服务器上手动执行ffmpeg -version一切正常,但 Java 程序调用时报Cannot run program "ffmpeg": error=2, No such file or directory。
原因很简单:Java 进程是系统服务(比如 systemd 托管)启动的,它的 PATH 环境变量和你在终端里登录用户的不一样。终端里/usr/bin在 PATH 中,服务进程的 PATH 可能只有/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin这些。
解决办法:不要依赖 PATH 查找,在配置文件里写死 FFmpeg 的完整路径。
video: ffmpeg-path: /usr/bin/ffmpeg ffprobe-path: /usr/bin/ffprobe6.2 中文文件名与路径空格
视频文件的中文文件名太常见了。Java 调用 FFmpeg 时,如果通过 ProcessBuilder 传参,中文通常没问题,反而是 Windows 系统路径里带空格时容易踩坑,比如C:\Program Files\xxx。ProcessBuilder 的列表参数形式能正确解析带空格的路径,但如果你图省事拼成字符串再sh -c,空格就会被拆分。
还要注意工作目录设置,FFmpeg 写相对路径时以当前工作目录为基准:
pb.directory(new File("/data/video-processing/tmp"));6.3 磁盘空间耗尽问题
视频处理过程中的中间文件非常大。压缩还好,HLS 切片会把一个 2GB 的视频切成几十个 ts 文件,每个也有十几兆,处理过程中磁盘占用是原文件体积的 2-3 倍。
这个问题必须提前预防,不能在任务执行到一半时才发现磁盘满了。我做了两层防护:
一是提交任务前检查剩余磁盘空间,低于阈值(比如 10GB)直接拒绝任务。
二是处理完毕后立刻删除输入文件和中间文件。FFmpeg 输出完成后立即删除临时文件,只在最终结果需要的目录保留完整文件。
6.4 判断文件是否为有效视频
上传接口接收文件后一定要做格式校验。有个常见场景:用户传了一个扩展名是 .mp4 但实际是文本文件,FFprobe 会直接报错。所以提交任务前必须先用 FFprobe 探测,探测失败直接返回"无效的视频文件"错误。
ffprobe -v error -show_entries format=format_name -of default=noprint_wrappers=1:nokey=1 input.mp4执行退出码非零就说明文件有问题。要注意这个校验必须放在异步任务提交之前,不然坏文件会直接进任务队列,浪费一个线程池名额。
6.5 FFmpeg 日志里常见的报错速查
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
| Unknown encoder 'libx264' | 编译版本没有 x264 | 换完整版 FFmpeg |
| Invalid data found when processing input | 文件损坏或不是视频 | 确认文件有效性 |
| Codec 'hevc' is not supported by the muxer for stream | 封装的容器不支持 H.265 | 转码为 H.264 或换容器 |
| Cannot allocate memory | 内存不足 | 降低并发数,减少预设复杂度 |
| Output file #0 does not contain any stream | 参数写错导致没有输出流 | 检查编码器参数 |
6.6 小心 movflags +faststart 的副作用
之前为了优化播放体验,压缩命令里都加了-movflags +faststart。但这个参数在 HLS 切片任务里会导致问题——FFmpeg 会缓存一部分输出在内存里,等文件写完后再做一次 moov 前置,对点播 MP4 没问题,但对切片任务来说会导致 ts 文件输出延迟,占用的临时磁盘空间更多。
所以压缩和切片分别用不同的命令模板:压缩 MP4 时加faststart,切片时不加。
7. 批量处理场景下的实操建议
单条视频处理搞定后,批量处理只是把单条逻辑放进循环。但批量场景有三件小事很容易被忽略。
第一是任务优先级。批量上传 100 条视频时,用户可能只关心第一条尽快出结果。我做了优先级字段,紧急任务插队到队列前面。实现上可以给任务加priority列,线程池里用 PriorityBlockingQueue。
第二是任务去重。同一文件短时间内重复提交,应该直接返回已有任务的 ID,而不是重复创建任务。我用文件的 MD5 值做唯一标识,提交任务前先查该 MD5 是否有未完成任务。
第三是批量任务的整体进度。一个小技巧:批量任务给一个批次号,前端查进度时先查总数,再查已完成数,就能算出批次维度上的完成百分比。批量上传的场景,这个比单任务进度更好用。
8. 后续可以扩展的方向
最后聊几个我踩过之后觉得值得升级的方向,喜欢折腾的可以试试。
第一个是任务失败的事件通知。给任务表增加callback_url字段,任务完成或失败后往该地址 POST 一个 JSON 通知。这个对异步处理是刚需,前端不轮询了,后端主动通知,省资源。
第二个是分布式扩展。单机线程池方案在当时够用,但如果任务量继续涨,可以引入 MQ(比如 RabbitMQ 或 Kafka)做任务分发,多台机器消费任务。任务表里的worker_id字段记录消费节点,保证同一任务不会被两个节点同时执行。
第三个是硬解加速。Intel CPU 支持 Quick Sync Video 硬编码,FFmpeg 可以用h264_qsv编码器替代libx264,编码速度能快好几倍。代价是要装 Intel Media SDK,而且画质调校没有 x264 成熟。如果视频量大到软编码扛不住了,值得研究。
说到底,视频处理这套东西,瓶颈永远在 FFmpeg 参数调优和资源管理上,Spring Boot 只是外面包了一层方便业务接入的壳。把这层壳做薄,把 FFmpeg 的调用参数做规范,这个服务就稳了。