☰
多路视频录制系统迭代实录:状态机、FFmpeg与任务调度优化
2026/10/19 2:34:35 网站建设 项目流程

平时在业务里做音视频相关的系统,最怕的不是功能写不出来,而是链路一长,问题就藏得深。最近把一套多路视频录制与回放系统从 29.6 迭代到 29.7,重点解决录制任务偶发中断、转码积压、回放延迟这几个老问题。整个过程踩了不少坑,也沉淀了一批能直接复用的配置和代码。

本文围绕“雾山实录 29.7”这轮版本迭代,从架构、环境、核心模块、实战案例、排错思路到工程建议,完整拆解一套多路视频录制系统怎么做版本演进。无论是做直播录制、课程录播、监控录像还是音视频存档,下面的内容都可以直接参考。

1. 背景与核心概念

1.1 “雾山实录”是一个什么样的系统

“雾山实录”本质上是一套面向多路音视频源的录制与回放系统。它接收来自摄像头、直播流、桌面端推流等不同来源的音视频数据,按照预设策略完成录制、切片、转码、存储和回放。

29.7 是这套系统的第 29 个大版本中的第 7 个迭代版本。它不是一个全新系统,而是在既有录制链路上做的一次稳定性优化。

很多人会误以为录制系统只是“拿流存文件”这么简单,实际落地时你会发现链路远不止这一步:

  • 推流端怎么断线重连;
  • 录制进程崩溃后任务怎么恢复;
  • 转码任务挤压时怎么削峰;
  • 回放请求变多时怎么保证秒开。

这些才是版本迭代真正要解决的问题。

1.2 解决什么问题

29.7 版本要解决的核心问题集中在四个场景:

场景老版本痛点29.7 目标
录制任务启动人工配置繁琐,容易漏配参数任务模板化,统一参数默认值
网络抖动拉流中断后任务直接失败自动重连与任务恢复
转码处理积压严重时 CPU 飙高、任务超时转码队列削峰,失败任务自动重试
回放体验回放列表加载慢,文件定位不准优化索引结构,回放入口秒开

也就是说,29.7 的更新重点不是“新增一个大功能”,而是把录制系统的稳定性、可运维性和回放体验整体往上推一档。

1.3 为什么开发者需要掌握这类系统设计

音视频录制系统的难点在于它混合了流媒体协议、文件处理、异步任务、分布式存储等多方面知识。很多后端开发者接触的业务是 CRUD,而录制系统是典型的“CRUD + 调度 + IO密集型处理”综合场景。

掌握这一类系统的设计,对你理解以下内容非常有帮助:

  • 任务调度的状态机设计;
  • 断点恢复与幂等处理;
  • 异步任务队列与削峰;
  • FFmpeg 命令行集成;
  • 文件存储与索引一致性问题。

哪怕你不是专门做音视频的,这套思路也可以迁移到文件上传、数据同步、定时任务等其他场景。

2. 环境准备与版本说明

2.1 运行环境

本文的实操示例基于以下常见环境组合。版本需要根据你的项目实际情况调整,这里重点演示配置思路和代码结构。

软件版本建议说明
操作系统CentOS 7.9 / Ubuntu 20.04生产环境推荐 Linux
JDK1.8 或 11Spring Boot 项目常用版本
Spring Boot2.7.xWeb 工程与任务调度
FFmpeg4.x录制、转码核心工具
MySQL5.7 或 8.0存储任务与索引元数据
Redis6.x任务队列与缓存
MinIO2023 版本S3 协议的对象存储

提示:FFmpeg 版本差异可能导致命令行参数不一致,遇到报错时优先用ffmpeg -version确认本机版本。

2.2 示例项目结构

为了让代码更清晰,我们按模块拆分项目。下面是一个简化的项目结构:

mist-recorder/ ├── mist-recorder-common # 通用工具、常量、异常 ├── mist-recorder-task # 录制任务调度模块 ├── mist-recorder-core # 录制核心逻辑,封装 FFmpeg 调用 ├── mist-recorder-store # 文件存储与索引 ├── mist-recorder-web # Web 接口层,提供管理端 API └── mist-recorder-admin # 管理后台前端资源(可选)

实际业务中不需要严格按这个模块拆,但建议至少把“任务调度”和“录制执行”拆开,否则后续扩展会很痛苦。

2.3 基础依赖

如果是 Maven 工程,核心依赖片段如下:

<!-- pom.xml 核心依赖片段,需按实际版本调整 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency> </dependencies>

注意:引入 MinIO 客户端时,版本和 JDK 版本有对应关系。JDK 8 环境下不要盲目使用最新版,建议选择 8.x 系列。

2.4 FFmpeg 安装

FFmpeg 是整个录制系统的执行核心。CentOS 环境下可以用 yum 安装,也可以编译安装。这里给一个常见的安装方式:

# CentOS 7 下使用 EPEL 源安装 yum install -y epel-release yum install -y ffmpeg # 验证安装 ffmpeg -version

如果源里没有 FFmpeg,可以考虑使用 static build 包,解压后放入/usr/local/bin。

3. 核心模块与原理拆解

3.1 录制任务状态机

录制任务不是“开始”和“结束”两个状态就能表达的。实际运行中任务会经历启动、拉流中、推流中断、重连、转码、完成、失败等多个状态。29.7 版本把任务状态收敛为一张清晰的状态机:

状态含义可流转状态
CREATED任务已创建,等待调度SCHEDULING
SCHEDULING正在分配执行节点RUNNING, FAILED
RUNNING正在录制STOPPING, RECONNECTING, FAILED
RECONNECTING网络中断,自动重连RUNNING, FAILED
STOPPING收到停止指令FINISHED, FAILED
FINISHED录制完成,等待转码TRANSCODING
TRANSCODING正在转码STORED, FAILED
STORED文件已存储RETRY, FINISHED
FAILED任务失败RETRY, FINISHED

这个状态机的好处是:每个状态都对应明确的处理分支,遇到异常时可以精准定位是哪个环节出了问题。

核心状态流转代码片段如下:

// 文件路径:mist-recorder-task/src/main/java/com/mist/recorder/task/enums/TaskState.java public enum TaskState { CREATED("CREATED"), SCHEDULING("SCHEDULING"), RUNNING("RUNNING"), RECONNECTING("RECONNECTING"), STOPPING("STOPPING"), TRANSCODING("TRANSCODING"), STORED("STORED"), FAILED("FAILED"); private final String code; TaskState(String code) { this.code = code; } public String getCode() { return code; } }

3.2 录制执行链路

录制执行链路是整个系统的核心,典型流程如下:

  1. 管理端创建录制任务,写入 MySQL。
  2. 调度器扫描待执行任务,发送到 Redis 队列。
  3. 执行器从 Redis 队列获取任务。
  4. 执行器调用 FFmpeg 命令拉流录制。
  5. FFmpeg 输出切片文件到临时目录。
  6. 录制完成后,将切片上传到 MinIO。
  7. 上传成功后,生成回放索引记录。

这里需要注意一点:FFmpeg 是进程级工具,不要在主线程中直接调用阻塞执行。建议用异步任务线程池,同时要设置超时控制,避免 FFmpeg 进程卡死。

3.3 FFmpeg 命令封装

FFmpeg 命令的封装是 29.7 版本的重点优化对象。之前很多参数是写死的,现在改成了模板化配置。

// 文件路径:mist-recorder-core/src/main/java/com/mist/recorder/core/FFmpegCommandBuilder.java public class FFmpegCommandBuilder { /** * 构建录制命令 * * @param inputUrl 输入流地址 * @param outputPath 输出文件路径 * @param segmentTime 切片时长,单位秒 * @return FFmpeg 命令列表 */ public List<String> buildRecordCommand(String inputUrl, String outputPath, int segmentTime) { List<String> command = new ArrayList<>(); command.add("ffmpeg"); command.add("-y"); command.add("-i"); command.add(inputUrl); command.add("-c"); command.add("copy"); command.add("-f"); command.add("segment"); command.add("-segment_time"); command.add(String.valueOf(segmentTime)); command.add("-reset_timestamps"); command.add("1"); command.add(outputPath + "_%03d.mp4"); return command; } /** * 构建转码命令 * * @param inputPath 输入文件路径 * @param outputPath 输出文件路径 */ public List<String> buildTranscodeCommand(String inputPath, String outputPath) { List<String> command = new ArrayList<>(); command.add("ffmpeg"); command.add("-i"); command.add(inputPath); command.add("-c:v"); command.add("libx264"); command.add("-preset"); command.add("veryfast"); command.add("-c:a"); command.add("aac"); command.add(outputPath); return command; } }

命令行拼接很容易出现参数顺序错误。FFmpeg 对参数顺序敏感,-i必须在输入文件之前,输出参数要放在输出文件之前,这些细节要格外注意。

3.4 任务调度与重试机制

29.7 版本引入了一个比较实用的重试思路:录制任务失败后不立即重新拉流,而是根据失败类型决定策略。

  • 网络类错误:延时 5 秒后重连,最多重试 5 次。
  • 参数类错误:不重试,直接标记失败,需要人工检查。
  • 转码失败:进入重试队列,最多重试 3 次。

实现上可以用 Redis 的延迟队列,也可以用定时任务扫描。如果不想引入额外中间件,建议使用 Spring 的@Scheduled定时扫描数据库中的失败任务。

// 文件路径:mist-recorder-task/src/main/java/com/mist/recorder/task/RetryTaskScheduler.java @Component public class RetryTaskScheduler { private static final Logger log = LoggerFactory.getLogger(RetryTaskScheduler.class); @Resource private TaskService taskService; /** * 每 30 秒扫描一次失败任务,自动重试 */ @Scheduled(fixedDelay = 30000) public void retryFailedTasks() { List<TaskInfo> failedTasks = taskService.listRetryTasks(); for (TaskInfo task : failedTasks) { try { taskService.retryTask(task.getId()); log.info("任务重试成功,taskId={}", task.getId()); } catch (Exception e) { log.error("任务重试失败,taskId={}", task.getId(), e); } } } }

这里特别强调:失败重试必须设置最大次数和退避时间,否则任务会陷入无限重试循环,把系统拖垮。

4. 完整实战案例:搭建一个简化版录制系统

下面我们用代码搭建一个简化的“雾山实录”任务链路。目标不是实现完整的商业系统,而是把核心链路跑通:创建任务、调度执行、FFmpeg 录制、结果存储。

4.1 创建项目结构

按下面的目录创建工程:

mist-recorder-demo/ ├── pom.xml └── src/main/java/com/mist/demo/ ├── MistRecorderApplication.java ├── controller/RecordTaskController.java ├── service/RecordTaskService.java ├── service/impl/RecordTaskServiceImpl.java ├── task/RecordTaskScheduler.java └── model/RecordTask.java

4.2 添加依赖

pom.xml文件如下:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.mist</groupId> <artifactId>mist-recorder-demo</artifactId> <version>29.7</version> <name>mist-recorder-demo</name> <description>雾山实录 29.7 简化演示工程</description> <properties> <java.version>8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

4.3 创建启动类

// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/MistRecorderApplication.java package com.mist.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class MistRecorderApplication { public static void main(String[] args) { SpringApplication.run(MistRecorderApplication.class, args); } }

@EnableScheduling是必须的,否则后面的定时扫描不会生效。

4.4 创建任务实体

// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/model/RecordTask.java package com.mist.demo.model; public class RecordTask { private Long id; /** * 直播流地址 */ private String streamUrl; /** * 录制输出目录 */ private String outputDir; /** * 切片时长(秒) */ private Integer segmentTime; /** * 任务状态:CREATED/RUNNING/FAILED/FINISHED */ private String state; /** * 重试次数 */ private Integer retryCount; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getStreamUrl() { return streamUrl; } public void setStreamUrl(String streamUrl) { this.streamUrl = streamUrl; } public String getOutputDir() { return outputDir; } public void setOutputDir(String outputDir) { this.outputDir = outputDir; } public Integer getSegmentTime() { return segmentTime; } public void setSegmentTime(Integer segmentTime) { this.segmentTime = segmentTime; } public String getState() { return state; } public void setState(String state) { this.state = state; } public Integer getRetryCount() { return retryCount; } public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; } }

这里省略了数据库持久化,实际项目中可以将RecordTask映射为数据库表。为了演示方便,我们先放在内存中。

4.5 创建 Service 层

Service 层负责创建任务、执行录制和更新状态。

// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/service/RecordTaskService.java package com.mist.demo.service; import com.mist.demo.model.RecordTask; public interface RecordTaskService { /** * 创建录制任务 */ RecordTask createTask(String streamUrl, String outputDir, Integer segmentTime); /** * 执行录制 */ void executeTask(Long taskId); /** * 查询任务 */ RecordTask getTask(Long taskId); }
// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/service/impl/RecordTaskServiceImpl.java package com.mist.demo.service.impl; import com.mist.demo.model.RecordTask; import com.mist.demo.service.RecordTaskService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @Service public class RecordTaskServiceImpl implements RecordTaskService { private static final Logger log = LoggerFactory.getLogger(RecordTaskServiceImpl.class); private final Map<Long, RecordTask> taskStore = new ConcurrentHashMap<>(); @Override public RecordTask createTask(String streamUrl, String outputDir, Integer segmentTime) { RecordTask task = new RecordTask(); task.setId(Math.abs(UUID.randomUUID().getMostSignificantBits())); task.setStreamUrl(streamUrl); task.setOutputDir(outputDir); task.setSegmentTime(segmentTime); task.setState("CREATED"); task.setRetryCount(0); taskStore.put(task.getId(), task); log.info("创建录制任务成功,taskId={}, streamUrl={}", task.getId(), streamUrl); return task; } @Override public void executeTask(Long taskId) { RecordTask task = taskStore.get(taskId); if (task == null) { throw new RuntimeException("任务不存在:" + taskId); } task.setState("RUNNING"); log.info("任务开始执行,taskId={}", taskId); try { // 构建 FFmpeg 命令 ProcessBuilder builder = new ProcessBuilder( "ffmpeg", "-y", "-i", task.getStreamUrl(), "-c", "copy", "-f", "segment", "-segment_time", String.valueOf(task.getSegmentTime()), "-reset_timestamps", "1", task.getOutputDir() + "_%03d.mp4" ); builder.redirectErrorStream(true); Process process = builder.start(); // 读取输出日志,防止进程阻塞 try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { log.debug("ffmpeg: {}", line); } } int exitCode = process.waitFor(); if (exitCode == 0) { task.setState("FINISHED"); log.info("任务录制完成,taskId={}", taskId); } else { task.setState("FAILED"); log.error("任务录制失败,taskId={}, exitCode={}", taskId, exitCode); } } catch (Exception e) { task.setState("FAILED"); log.error("任务执行异常,taskId={}", taskId, e); } } @Override public RecordTask getTask(Long taskId) { return taskStore.get(taskId); } }

这里有一点要注意:redirectErrorStream(true)加上逐行读取输出很重要,否则 FFmpeg 的输出会填满系统管道缓冲区,导致进程卡死。

4.6 创建定时调度器

实际生产环境中,任务创建后不会立刻在接口线程里执行,通常是由调度器扫描待执行任务。这里我们用一个简单的定时任务演示:

// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/task/RecordTaskScheduler.java package com.mist.demo.task; import com.mist.demo.model.RecordTask; import com.mist.demo.service.RecordTaskService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.Map; @Component public class RecordTaskScheduler { private static final Logger log = LoggerFactory.getLogger(RecordTaskScheduler.class); @Resource private RecordTaskService recordTaskService; /** * 每 10 秒扫描一次任务。这里简化处理,直接创建一个固定任务演示。 */ @Scheduled(fixedDelay = 10000) public void scanAndExecute() { log.info("调度器开始扫描待执行任务..."); // 演示逻辑:当任务库中有 CREATED 状态任务时,逐个执行 // 实际项目中应从数据库查询 // 由于没有暴露查询列表接口,这里通过手动调用演示 } }

为了让演示可以跑通,我们可以在 Controller 中手动触发执行,避免定时器逻辑过于复杂。

4.7 创建 Controller

// 文件路径:mist-recorder-demo/src/main/java/com/mist/demo/controller/RecordTaskController.java package com.mist.demo.controller; import com.mist.demo.model.RecordTask; import com.mist.demo.service.RecordTaskService; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @RestController @RequestMapping("/api/record") public class RecordTaskController { @Resource private RecordTaskService recordTaskService; /** * 创建录制任务 * 请求示例:POST /api/record/task?streamUrl=rtmp://localhost/live/test&outputDir=/data/record/test&segmentTime=60 */ @PostMapping("/task") public RecordTask createTask(@RequestParam String streamUrl, @RequestParam String outputDir, @RequestParam(defaultValue = "60") Integer segmentTime) { return recordTaskService.createTask(streamUrl, outputDir, segmentTime); } /** * 执行录制任务 */ @PostMapping("/task/{taskId}/execute") public String executeTask(@PathVariable Long taskId) { recordTaskService.executeTask(taskId); return "execute finished"; } /** * 查询任务状态 */ @GetMapping("/task/{taskId}") public RecordTask getTask(@PathVariable Long taskId) { return recordTaskService.getTask(taskId); } }

4.8 运行与验证

启动项目:

mvn spring-boot:run

然后执行以下命令测试:

# 1. 创建录制任务 curl -X POST "http://localhost:8080/api/record/task?streamUrl=rtmp://localhost/live/demo&outputDir=/data/record/demo&segmentTime=60" # 2. 执行任务 curl -X POST "http://localhost:8080/api/record/task/123456789/execute" # 3. 查询状态 curl "http://localhost:8080/api/record/task/123456789"

预期输出:

  • 创建任务返回 JSON,包含state: "CREATED"。
  • 执行后,如果 FFmpeg 正常退出,state变为FINISHED。
  • 如果流地址不可用,state变为FAILED,此时需要检查 ffmpeg 日志。

4.9 结果说明

通过这个简化案例,你应该能看到录制系统的核心链路:创建任务、构建命令、启动 FFmpeg、读取日志、判断状态。

这里没有引入数据库、消息队列、MinIO 等外部依赖,但链路骨架已经具备。实际项目中,你需要在executeTask中增加超时控制、进程清理、日志落盘、失败重试等逻辑。

5. 常见问题与排查思路

5.1 FFmpeg 启动后不退出,任务卡在 RUNNING

可能原因

  • 拉流地址没有结束时间,FFmpeg 会一直录制。
  • 没有设置超时控制。
  • 任务逻辑在waitFor()处阻塞。

解决方案

使用process.waitFor(timeout, TimeUnit.SECONDS)设置最大等待时间。超时后强制销毁进程:

// 等待最多 30 分钟 boolean finished = process.waitFor(30, TimeUnit.MINUTES); if (!finished) { process.destroyForcibly(); throw new RuntimeException("录制超时,已强制终止"); }

我建议执行录制时对单个任务设置最大时长。如果是直播录制场景,可以判断直播流断开后自动结束进程。

5.2 转码任务积压,CPU 飙高

背景:29.7 版本之前,转码任务一旦创建就立即执行,上游切片多的时候,CPU 被大量 FFmpeg 进程占满。

排查思路

  1. 查看当前运行中的 FFmpeg 进程数:
ps -ef | grep ffmpeg | grep -v grep | wc -l
  1. 查看系统负载:
uptime top -c
  1. 查看转码队列积压数量,确认消费速度是否小于生产速度。

解决方案

  • 限制并发 FFmpeg 进程数,建议按 CPU 核数的一半设置。
  • 引入队列削峰,比如使用 Redis List 做任务队列。
  • 对转码任务设置超时和重试上限。

5.3 录制文件上传 MinIO 后回放找不到

可能原因

  • 上传路径和回放路径不一致。
  • 文件上传成功但数据库索引未更新。
  • 文件名包含特殊字符,导致 URL 拼接失败。

排查步骤

  1. 在 MinIO 控制台确认文件是否存在。
  2. 对比数据库中的file_path和实际存储路径。
  3. 检查回放接口的 URL 拼接逻辑。

最佳实践

上传和索引更新要放在同一个事务边界内。如果先上传文件再更新数据库,一旦数据库更新失败,就会出现文件孤儿。

建议改为:

// 1. 先生成唯一文件名 String objectName = generateObjectName(taskId, segmentIndex); // 2. 上传文件到 MinIO minioClient.putObject(...); // 3. 再写入数据库索引 recordFileMapper.insert(...);

如果第 3 步失败,需要补偿机制定期扫描清理孤儿文件。

5.4 录制任务失败原因归类

问题现象常见原因解决思路
任务创建后一直处于 CREATED没有调度器扫描,或调度器异常检查 @Scheduled 是否生效
执行时立即失败参数错误、FFmpeg 不存在用 ffmpeg -version 验证
录制中途退出网络断开、流地址失效增加重连机制
转码输出文件损坏源文件不完整、磁盘空间不足检查磁盘并设置完整性校验
任务进程卡死输出缓冲区满、线程池耗尽读取日志流,设置超时

我觉得排查这类问题最有效的方式是:手动把 FFmpeg 命令拿出来,在服务器上单独执行一次。这样能快速确认是命令问题还是代码问题。

6. 最佳实践与工程建议

6.1 任务表设计建议

录制任务表至少要包含以下字段:

字段类型说明
idbigint主键
task_novarchar业务编号,方便排查
stream_urlvarchar输入流地址
output_dirvarchar输出目录
statevarchar任务状态
retry_countint重试次数
max_retryint最大重试次数
start_timedatetime开始时间
end_timedatetime结束时间
error_msgvarchar错误信息
created_atdatetime创建时间

索引建议:

CREATE INDEX idx_task_state ON record_task(state); CREATE INDEX idx_task_created_at ON record_task(created_at);

6.2 日志规范

FFmpeg 的日志默认会输出到标准输出,建议按任务维度落盘,方便排查。

ffmpeg -i input ... > /data/logs/record/task_123.log 2>&1

同时在应用层打印关键节点的日志,格式建议如下:

[taskId=123][state=RUNNING] start execute record task [taskId=123][state=RECONNECTING] stream disconnected, retry count=1

日志中带上 taskId 和 state,可以非常快地定位问题。

6.3 进程管理

生产环境不要直接用ProcessBuilder裸跑 FFmpeg,建议配合 supervisor 或 systemd 管理 FFmpeg 进程。

一个简单的 systemd 守护服务示例:

[Unit] Description=Record FFmpeg Process After=network.target [Service] Type=simple User=app ExecStart=/usr/local/bin/ffmpeg -i rtmp://input/live/stream -c copy /data/record/output.mp4 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target

6.4 安全与权限

  • 存储凭证不要写死在代码里,建议使用环境变量或配置中心。
  • 管理端接口必须做权限校验,避免任意用户创建录制任务。
  • 输出路径要做好过滤,防止路径穿越。

例如:

// 简单校验文件名,防止路径穿越 String safeName = new File(outputDir).getCanonicalPath(); if (!safeName.startsWith(allowedRoot)) { throw new IllegalArgumentException("非法的输出路径"); }

6.5 数据一致性

录制系统的数据一致性主要体现在“文件状态”和“任务状态”不一致。29.7 版本采用了对账机制:定时扫描已经 FINISHED 但文件尚未出现在存储中的记录,做二次确认。

对账策略:

  1. 每 5 分钟扫描一次。
  2. 对状态为 STORED 但文件缺失的记录,触发一次 MinIO 查询。
  3. 确认文件确实缺失后,标记 MISSING 状态,进入人工处理队列。

7. 总结与学习路线

这一轮“雾山实录 29.7”的迭代,我最大的体感是:录制系统的难点不在单个技术点,而在任务状态控制、异步处理、文件一致性这些横向交叉的地方。

建议按下面的步骤继续深入:

  1. 掌握 FFmpeg 常用命令,尤其是切片、转码、推流参数。
  2. 理解任务状态机的设计思路,试着把状态迁移画成表格。
  3. 动手实现一个带数据库、消息队列、对象存储的完整录制系统。
  4. 学习流媒体协议基础,比如 RTMP、HLS 的工作原理。
  5. 深入排查一次录制失败问题,从抓包到 FFmpeg 日志到数据库状态,完整走一遍。

如果你正在做视频录制、直播回放或任何与文件处理相关的系统,希望这篇笔记能帮你少踩一些坑。建议先把状态机和重试机制想清楚,再写业务代码,后面维护起来会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询