平时在业务里做音视频相关的系统,最怕的不是功能写不出来,而是链路一长,问题就藏得深。最近把一套多路视频录制与回放系统从 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 |
| JDK | 1.8 或 11 | Spring Boot 项目常用版本 |
| Spring Boot | 2.7.x | Web 工程与任务调度 |
| FFmpeg | 4.x | 录制、转码核心工具 |
| MySQL | 5.7 或 8.0 | 存储任务与索引元数据 |
| Redis | 6.x | 任务队列与缓存 |
| MinIO | 2023 版本 | 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 录制执行链路
录制执行链路是整个系统的核心,典型流程如下:
- 管理端创建录制任务,写入 MySQL。
- 调度器扫描待执行任务,发送到 Redis 队列。
- 执行器从 Redis 队列获取任务。
- 执行器调用 FFmpeg 命令拉流录制。
- FFmpeg 输出切片文件到临时目录。
- 录制完成后,将切片上传到 MinIO。
- 上传成功后,生成回放索引记录。
这里需要注意一点: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.java4.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 进程占满。
排查思路
- 查看当前运行中的 FFmpeg 进程数:
ps -ef | grep ffmpeg | grep -v grep | wc -l- 查看系统负载:
uptime top -c- 查看转码队列积压数量,确认消费速度是否小于生产速度。
解决方案
- 限制并发 FFmpeg 进程数,建议按 CPU 核数的一半设置。
- 引入队列削峰,比如使用 Redis List 做任务队列。
- 对转码任务设置超时和重试上限。
5.3 录制文件上传 MinIO 后回放找不到
可能原因
- 上传路径和回放路径不一致。
- 文件上传成功但数据库索引未更新。
- 文件名包含特殊字符,导致 URL 拼接失败。
排查步骤
- 在 MinIO 控制台确认文件是否存在。
- 对比数据库中的
file_path和实际存储路径。 - 检查回放接口的 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 任务表设计建议
录制任务表至少要包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_no | varchar | 业务编号,方便排查 |
| stream_url | varchar | 输入流地址 |
| output_dir | varchar | 输出目录 |
| state | varchar | 任务状态 |
| retry_count | int | 重试次数 |
| max_retry | int | 最大重试次数 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
| error_msg | varchar | 错误信息 |
| created_at | datetime | 创建时间 |
索引建议:
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.target6.4 安全与权限
- 存储凭证不要写死在代码里,建议使用环境变量或配置中心。
- 管理端接口必须做权限校验,避免任意用户创建录制任务。
- 输出路径要做好过滤,防止路径穿越。
例如:
// 简单校验文件名,防止路径穿越 String safeName = new File(outputDir).getCanonicalPath(); if (!safeName.startsWith(allowedRoot)) { throw new IllegalArgumentException("非法的输出路径"); }6.5 数据一致性
录制系统的数据一致性主要体现在“文件状态”和“任务状态”不一致。29.7 版本采用了对账机制:定时扫描已经 FINISHED 但文件尚未出现在存储中的记录,做二次确认。
对账策略:
- 每 5 分钟扫描一次。
- 对状态为 STORED 但文件缺失的记录,触发一次 MinIO 查询。
- 确认文件确实缺失后,标记 MISSING 状态,进入人工处理队列。
7. 总结与学习路线
这一轮“雾山实录 29.7”的迭代,我最大的体感是:录制系统的难点不在单个技术点,而在任务状态控制、异步处理、文件一致性这些横向交叉的地方。
建议按下面的步骤继续深入:
- 掌握 FFmpeg 常用命令,尤其是切片、转码、推流参数。
- 理解任务状态机的设计思路,试着把状态迁移画成表格。
- 动手实现一个带数据库、消息队列、对象存储的完整录制系统。
- 学习流媒体协议基础,比如 RTMP、HLS 的工作原理。
- 深入排查一次录制失败问题,从抓包到 FFmpeg 日志到数据库状态,完整走一遍。
如果你正在做视频录制、直播回放或任何与文件处理相关的系统,希望这篇笔记能帮你少踩一些坑。建议先把状态机和重试机制想清楚,再写业务代码,后面维护起来会轻松很多。