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

资讯详情

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

多路视频录制系统迭代实录:状态机、FFmpeg与任务调度优化

多路视频录制系统迭代实录:状态机、FFmpeg与任务调度优化 平时在业务里做音视频相关的系统最怕的不是功能写不出来而是链路一长问题就藏得深。最近把一套多路视频录制与回放系统从 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生产环境推荐 LinuxJDK1.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.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任务已创建等待调度SCHEDULINGSCHEDULING正在分配执行节点RUNNING, FAILEDRUNNING正在录制STOPPING, RECONNECTING, FAILEDRECONNECTING网络中断自动重连RUNNING, FAILEDSTOPPING收到停止指令FINISHED, FAILEDFINISHED录制完成等待转码TRANSCODINGTRANSCODING正在转码STORED, FAILEDSTORED文件已存储RETRY, FINISHEDFAILED任务失败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 ListString buildRecordCommand(String inputUrl, String outputPath, int segmentTime) { ListString 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 ListString buildTranscodeCommand(String inputPath, String outputPath) { ListString 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() { ListTaskInfo 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 version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.mist/groupId artifactIdmist-recorder-demo/artifactId version29.7/version namemist-recorder-demo/name description雾山实录 29.7 简化演示工程/description properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project4.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 MapLong, 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?streamUrlrtmp://localhost/live/testoutputDir/data/record/testsegmentTime60 */ 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?streamUrlrtmp://localhost/live/demooutputDir/data/record/demosegmentTime60 # 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 任务表设计建议录制任务表至少要包含以下字段字段类型说明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 21同时在应用层打印关键节点的日志格式建议如下[taskId123][stateRUNNING] start execute record task [taskId123][stateRECONNECTING] stream disconnected, retry count1日志中带上 taskId 和 state可以非常快地定位问题。6.3 进程管理生产环境不要直接用ProcessBuilder裸跑 FFmpeg建议配合 supervisor 或 systemd 管理 FFmpeg 进程。一个简单的 systemd 守护服务示例[Unit] DescriptionRecord FFmpeg Process Afternetwork.target [Service] Typesimple Userapp ExecStart/usr/local/bin/ffmpeg -i rtmp://input/live/stream -c copy /data/record/output.mp4 Restartalways RestartSec5 [Install] WantedBymulti-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 日志到数据库状态完整走一遍。如果你正在做视频录制、直播回放或任何与文件处理相关的系统希望这篇笔记能帮你少踩一些坑。建议先把状态机和重试机制想清楚再写业务代码后面维护起来会轻松很多。
返回列表