尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot + FFmpeg 批量视频处理实战:压缩、切片与异步任务引擎

Spring Boot + FFmpeg 批量视频处理实战:压缩、切片与异步任务引擎 做服务端的兄弟应该都有同感视频处理看起来是个小需求真做起来全是坑。压缩压得狠了画质没法看压得轻了文件体积没变化切片切出来播放器不兼容任务一多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 的列表参数形式ListString 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 的大部分运行日志都走标准错误流但也要把标准输出流读走不能只处理一个。CompletableFutureVoid outputFuture CompletableFuture.runAsync(() - { try (BufferedReader reader new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { // 处理标准输出 } } catch (IOException e) { // 忽略 } }); CompletableFutureVoid 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 模式控制质量。CRFConstant 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 还是 libx265H.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 的基本原理HLSHTTP 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 任务状态机与数据库表设计异步任务引擎必须有可靠的状态管理。我设计了这样一张任务表字段类型说明idbigint主键task_typevarchar任务类型COMPRESS / HLSfile_namevarchar原始文件名input_pathvarchar输入文件路径output_pathvarchar输出文件路径statusvarcharPENDING / RUNNING / SUCCESS / FAILED / CANCELLEDprogressint进度百分比error_msgvarchar失败原因retry_countint重试次数create_timedatetime创建时间update_timedatetime更新时间状态机的流转关系是这样的任务创建后是 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 q28.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: error2, 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 formatformat_name -of defaultnoprint_wrappers1:nokey1 input.mp4执行退出码非零就说明文件有问题。要注意这个校验必须放在异步任务提交之前不然坏文件会直接进任务队列浪费一个线程池名额。6.5 FFmpeg 日志里常见的报错速查报错信息原因解决办法Unknown encoder libx264编译版本没有 x264换完整版 FFmpegInvalid 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 的调用参数做规范这个服务就稳了。
返回列表