ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 3.3.4 接入 FFmpeg 7.0 处理 AIGC 视频拼接:Of...

Spring Boot 3.3.4 接入 FFmpeg 7.0 处理 AIGC 视频拼接:Of... Spring Boot 3.3.4 接入 FFmpeg 7.0 处理 AIGC 视频拼接Off-Heap 内存溢出排查与对象存储选型对比上周三凌晨三点告警短信把我从睡梦中拽起来视频合成任务队列堆积 2.3 万条P99 延迟从 400ms 飙到 12 分钟。日志里全是java.lang.OutOfMemoryError: Direct buffer memory堆内存却闲着只有 15% 使用率。这不是常规的堆溢出是 FFmpeg 通过 JNI 在堆外疯狂申请内存且没释放。现场还原从ProcessBuilder到堆外内存泄漏我们的异步任务链路是RocketMQ 5.3.1 消费生成指令 → Spring Boot 3.3.4 任务服务调用 FFmpeg 7.0.2 拼接切片 → 上传 MinIO RELEASE.2024-09-20T00-00-00Z → 回调通知前端。最初为了图省事直接用ProcessBuilder启动 FFmpeg 进程标准输出/错误流只在进程结束后一次性readAllBytes()。java// 问题代码TaskVideoStitchService.java (JDK 21.0.4)ComponentRequiredArgsConstructorpublic class TaskVideoStitchService {private final MinioClient minioClient; // MinIO Java SDK 8.5.13Transactionalpublic void stitchAndUpload(StitchTaskDTO task) throws IOException, InterruptedException {// 1. 生成 concat 协议文件列表Path listFile Files.createTempFile(ffmpeg_concat_, .txt);Files.write(listFile, task.getSliceUrls().stream().map(u - file u ).collect(Collectors.joining(\n)).getBytes());// 2. 直接启动进程未处理流ProcessBuilder pb new ProcessBuilder(ffmpeg, -y, -f, concat, -safe, 0,-i, listFile.toString(),-c, copy, // 直接流复制不转码-movflags, faststart,outputPath.toString());Process process pb.start();// 3. 等待结束一次性读取流 —— 这就是元凶int exitCode process.waitFor();String errMsg new String(process.getErrorStream().readAllBytes()); // 大视频直接撑爆 Direct Memoryif (exitCode ! 0) throw new RuntimeException(FFmpeg 失败: errMsg);// 4. 上传 MinIOminioClient.putObject(PutObjectArgs.builder().bucket(aigc-video).object(task.getOutputKey()).filename(outputPath.toString()).build());}}排查路径Heap Dump 无果jmap -dump:live,formatb,fileheap.hprof分析显示堆内仅 1.2GB-Xmx4g压根没动。NMT 定位开启-XX:NativeMemoryTrackingdetailjcmd VM.native_memory summary显示Internal区域占用 6.8GB远超-XX:MaxDirectMemorySize2g。源头锁定Process.getErrorStream()返回的InputStream底层是FileChannelImplreadAllBytes()会通过ByteBuffer.allocateDirect申请堆外内存缓冲区。FFmpeg 输出几百 MB 日志调试级别没关直接把 Direct Memory 撑爆触发 Full GC 疯狂回收却回收不掉线程阻塞在unsafe.allocateMemory。临时止血加上-loglevel error关掉 FFmpeg 啰嗦日志改用StreamGobbler线程实时消费流避免缓冲区无限膨胀。但这治标不治本高并发下进程上下文切换、临时文件 IO、MinIO 单upart 上传依然是瓶颈。方案对比视频处理与存储上传的技术选型针对「异步拼接、大文件上传、高并发吞吐」三个核心痛点对比了三套视频处理方案与三种对象存储上传策略。视频处理方案对比版本锁定 2026-09-30| 维度 | 方案 AFFmpeg CLI ProcessBuilder | 方案 BJavaCV 1.5.9 (FFmpeg 7.0 绑定) | 方案 C云厂商媒体转码 API (如阿里云 MPS / 腾讯云 MPS) || :--- | :--- | :--- | :--- ||内存可控性| 差依赖 OS 管道缓冲易溢出 Direct Memory |优FrameGrabber/FrameRecorder可精确控制 Off-Heap Buffer 大小配合FFmpegFrameRecorder.setVideoOption(bufsize, ...) | 最优完全卸载到云厂商集群本地零内存压力 ||拼接性能 (1080p 5min)| ~45s (流复制) | ~50s (JNI 开销流复制模式下差异 10%) | ~15s (分布式转码集群并行) ||运维复杂度| 低仅需镜像装 FFmpeg | 中需处理javacpp平台依赖ARM/x86 多架构镜像构建麻烦 | 低SDK 调用无状态 ||成本 (峰值 500 并发)| 仅算力成本 (K8s Requests: 2C/4G * 50 100C/200G) | 同方案 A额外 ~5% CPU 开销 |按时长计费约 0.015 元/分钟 (1080p)峰值 500 并发约 450 元/小时 ||故障隔离| 进程崩溃不影响 JVM但僵尸进程需清理 | JNI 层 Crash 直接SIGSEGV干掉 JVM需容器级隔离 | 完全隔离失败重试语义清晰 ||适配性| 支持所有 FFmpeg 滤镜/协议 | 覆盖 95% 场景复杂滤镜图需写filter_graph字符串极其痛苦 | 受限于厂商支持的编解码/滤镜定制化受限 |对象存储上传策略对比| 维度 | 策略 1服务端流式转发 (Server-Side Proxy) | 策略 2预签名直传 | 策略 3分片上传 服务端合并 || :--- | :--- | :--- | :--- ||带宽压力|极大(视频流经应用服务器双倍流量) |零(客户端/任务节点直传存储) | 低 (仅协调元数据) ||内存占用| 高 (需缓冲MultipartFile或InputStream) | 低 (流式管道) | 低 ||实现难度| 简单 (minioClient.putObject(stream)) | 中 (需生成 Presigned URL、处理跨域、回调鉴权) | 高 (需实现 Initiate/UploadPart/Complete 编排处理断点续传) ||大文件可靠性| 差 (单连接超时/断网即失败5GB 易超时) | 中 (依赖客户端重试MinIO Presigned URL 默认 7 天有效) |最优(分片级重试支持 TB 级) ||适用场景| 100MB 短视频 | 100MB - 5GB客户端可控场景 | 5GB 长视频、生产级 AIGC 产出|深度复盘为什么最终选「JavaCV 分片直传」虽然方案 C云转码最省心但我们的合成逻辑包含动态贴纸轨道、滤镜参数实时插值、字幕烧录时间轴对齐等强定制化需求云厂商模板 API 根本覆盖不了。方案 A 的堆外内存不可控是硬伤方案 B 的 JavaCV 虽然有 JNI 风险但能把内存管理权收回 JVM 侧。核心改造代码JavaCV 流复制 MinIO 分片上传java// 优化后VideoStitchEngine.javaServiceRequiredArgsConstructorSlf4jpublic class VideoStitchEngine {private final MinioClient minioClient;private final RocketMQTemplate rocketMQTemplate; // Spring Cloud Stream RocketMQ 2.3.0// 线程池隔离CPU 密集型拼接任务不挤占 Tomcat 线程private final ThreadPoolTaskExecutor stitchExecutor new ThreadPoolTaskExecutor() {{setCorePoolSize(8);setMaxPoolSize(16);setQueueCapacity(200);setThreadNamePrefix(video-stitch-);setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());initialize();}};Transactionalpublic CompletableFuture asyncStitchAndUpload(StitchTaskDTO task) {return CompletableFuture.supplyAsync(() - {String outputKey aigc/ task.getUserId() / IdUtil.fastSimpleUUID() .mp4;Path tempOutput null;FFmpegFrameRecorder recorder null;FrameGrabber grabber null;try {// 1. 初始化 Recorder (流复制模式零解码开销)tempOutput Files.createTempFile(stitch_, .mp4);recorder new FFmpegFrameRecorder(tempOutput.toString(), 1920, 1080);recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);recorder.setFormat(mp4);recorder.setFrameRate(30);// 关键显式限制输出缓冲区防止 Off-Heap 失控recorder.setVideoOption(bufsize, 1024k);recorder.setVideoOption(maxrate, 5000k);recorder.start();// 2. 顺序抓取切片直接写入 Recorder (零拷贝堆外内存复用)for (String sliceUrl : task.getSliceUrls()) {grabber new FFmpegFrameGrabber(sliceUrl);grabber.start();Frame frame;while ((frame grabber.grab()) ! null) {// 这里的 frame.image 是 DirectByteBufferrecorder.record 直接 JNI 传递指针// 无需在 Java 堆申请 byte[]极大降低 GC 压力recorder.record(frame);}grabber.stop();grabber.close(); // 及时释放 grabber 持有的 Native 资源}recorder.stop();recorder.close();// 3. 分片上传 MinIO (5MB/片并发 4)// 避免把 2GB 视频全量读入内存再上传String uploadId initiateMultipartUpload(aigc-video, outputKey);List parts uploadPartsConcurrently(tempOutput, uploadId, 510241024, 4);completeMultipartUpload(aigc-video, outputKey, uploadId, parts);// 4. 清理临时文件Files.deleteIfExists(tempOutput);// 5. 发送完成事件rocketMQTemplate.convertAndSend(video-stitch-finished,VideoFinishedEvent.builder().key(outputKey).userId(task.getUserId()).build());return outputKey;} catch (Exception e) {log.error(视频拼接失败 taskId{}, task.getTaskId(), e);// 补偿清理MinIO Abort Multipart Upload、删除临时文件cleanupOnFailure(task, tempOutput);throw new CompletionException(e);} finally {// 双重保险释放 Native 资源CloseableUtil.closeQuietly(grabber);CloseableUtil.closeQuietly(recorder);}}, stitchExecutor);}// 分片上传核心逻辑 (MinIO Java SDK 8.5.13)private List uploadPartsConcurrently(Path file, String uploadId, int partSize, int concurrency) throws IOException {long fileSize Files.size(file);int partCount (int) Math.ceil((double) fileSize / partSize);// 使用 CompletableFuture 控制并发度而非无限开线程List futures new ArrayList();try (FileChannel channel FileChannel.open(file, StandardOpenOption.READ)) {for (int i 0; i partCount; i) {final int partNumber i 1;long position (long) i * partSize;long size Math.min(partSize, fileSize - position);// MappedByteBuffer 直接映射文件避免堆内存拷贝配合 MinIO SDK 发送futures.add(CompletableFuture.supplyAsync(() - {try {MappedByteBuffer buffer channel.map(FileChannel.MapMode.READ_ONLY, position, size);InputStream is Channels.newInputStream(buffer.asReadOnlyBuffer());UploadPartResponse resp minioClient.uploadPart(UploadPartArgs.builder().bucket(aigc-video).object(outputKey) // 需要从外部传入或捕获.uploadId(uploadId).partNumber(partNumber).stream(is, size, -1).build());return new Part(partNumber, resp.etag());} catch (Exception e) {throw new CompletionException(e);}}, stitchExecutor)); // 复用拼接线程池或单独配置 IO 密集型池}}return futures.stream().map(CompletableFuture::join).collect(Collectors.toList());}// ... initiateMultipartUpload / completeMultipartUpload / cleanupOnFailure 省略 ...}关键点解析FFmpegFrameRecorder.setVideoOption(bufsize, 1024k)这是治理 Off-Heap 的核心。FFmpeg 内部AVIOContext缓冲区默认可能很大显式限制后配合-c copy流复制模式内存曲线从「阶梯式飙升」变成「锯齿波动」峰值锁定在 1.2GB 以内。FrameGrabber.grab()复用Frame对象JavaCV 底层复用Frame.image(DirectByteBuffer)不再像ProcessBuilder管道那样源源不断产生新的 Direct Buffer。FileChannel.mapMappedByteBuffer分片上传利用 OS 页缓存视频文件不进入 JVM Heap也不用readAllBytes炸 Direct Memory。MinIO SDK 的uploadPart接受InputStream配合Channels.newInputStream实现零拷贝发送。线程池隔离 (stitchExecutor)拼接是 CPU/IO 混合型不再并发到 Tomcat 线程池避免业务接口超时。CallerRunsPolicy在队列满时回压到生产者RocketMQ 消费线程实现天然背压。选型建议别让架构图好看了生产环境跪了| 你的场景 | 推荐方案 | 核心理由 || :--- | :--- | :--- ||强定制化滤镜/合成、团队有 C/JNI 排查能力、追求极致成本控制|JavaCV (FFmpeg 7.x) MinIO 分片上传| 内存可控、功能全、无厂商锁定、单位算力成本最低 ||标准转码/水印/截图、无复杂合成、团队纯 Java、愿为稳定付费|云厂商 MPS API 回调流编排| 零运维、弹性无上限、故障域隔离彻底、开发专注业务 ||原型验证、低并发 (50 并发)、视频短 (1min)、不想引入 native 依赖|FFmpeg CLI ProcessBuilder (必须加 StreamGobbler -loglevel error)| 开发最快但上不了生产核心链路 |我们的结论既然决定自建 AIGC 视频中台JavaCV 分片直传是当前唯一能同时满足「定制化合成逻辑」「Off-Heap 内存可控」「TB 级文件上传可靠性」「单位成本最优」的组合。代价是必须在 Dockerfile 里搞定libffmpeg.so/libjniavcodec.so多架构依赖且要在 K8sPod级别配置securityContext: privileged: true(或SYS_PTRACE) 以防 JNI Crash 导致容器重启丢失现场——这部分坑比业务代码深得多留给下一篇《K8s 部署 JavaCV 微服务从javacpp-platform依赖地狱到多架构镜像构建实战》再细说。#后端 #Java #SpringBoot #FFmpeg #JavaCV #MinIO #RocketMQ #AIGC #视频处理 #堆外内存你在实际项目中有遇到类似问题吗欢迎在评论区分享你的经验和解决方案。
返回列表