一、项目简介
在媒体处理微服务诞生之前,业务的媒体处理处理需求主要通过云服务商的能力完成,主要架构图如下:
1.1 项目目的和功能
构建媒体处理微服务,实现基本处理能力。
主要功能包括:
- 视频处理:视频转码、视频拼接、视频截图、视频水印等等。
- 音频处理:音频转码、音频拼接
- 文件安全:音视频加密
1.2 关键技术和风险点
- 选用什么样的
技术方案
去实现媒体处理; - 并发处理时任务的
完整性
如何保证,效率如何保证,又如何感知服务的异常; - 如果上线接手现有的业务转码请求,是否可以
承担
这部分压力; - 自研的
成本优势
在哪。
二、技术选型
能解决媒体处理问题的开源框架有很多,如FFmpeg, OpneCV等,但是就业务需求功能角度考虑,其实可选的就只有FFmpeg。理由主要如下:
- 对于编解码格式的支持方面,它是最全的;
- 在开源开源活跃度方面,它是应用在编解码领域最多的。 比较火的live555主要用于流式传输已经编码的电影/音频和流式读取(RTSP); 其他的如x264、Xvid等可能在某一个编码环境中表现优异,但是对于任意格式的视频编解码处理方面,支持不够完善。
在集成项目考虑而言,目前有两种方式集成FFmpeg:
- CMD本地命令
- JNI
在这里先贴一张图(可能不太清晰,原件有点久远找不到了):
三、架构设计
在设计架构之前,我们先就目的层面思考一下,我们需要干什么?
因为转码相关的任务普遍耗时都是非常高,这样的话,真正的转码逻辑其实必须使用异步逻辑,处理结果以回调或主动查询的方式告知。
这样的话,项目的主体架构就已经确定了:
- media-api模块:对外暴露dubbo接口
- media-server模块:媒体处理主服务,提供http接口(音视频转码、拼接、加密、流式处理等)
- transcoding-async模块:异步任务处理
- common模块:公共代码抽离模块
四、核心实现
4.1 执行流程
4.1.1 转码执行流程
4.1.2 加密执行流程
1、加密
2、解密
3、秘钥服务
4.2 关键设计与实现
4.2.1 模块解耦
分离同步和异步逻辑,分为两个模块:media-server 和 media-async,这样可以做到业务逻辑的解耦以增加可维护性。
4.2.2 可靠性保证:
- 下载上传速度保证:独立异步模块应用的带宽,如果是尽量让文件IO走内网,下载和上传都使用线程池,做到资源高复用。
- 处理能力保证:独立机器配置 + 任务时间的监控。
- 完整性保证:任务如果失败均有重试机制,重试为3次,放入消息队列的尾部;回调通知也由指数退避的重试机制保障。
- 异常感知:应用上层的容器告警机制 + 应用内部的邮件告警机制 + 关键步骤日志打印。 下面给出消费者关键伪代码(Kotlin):
/**
* 接收视频拼接消息
*/
fun audioTranscode(......) {
log.info("收到视频拼接mq消息")
// 对所有的媒体处理错误进行统一的处理
try {
handleVideoConcat(mqDto)
} catch (e: Exception) {
log.error("视频拼接异常", e)
if (消费次数小于约定次数) {
// 更新任务状态为等待重试中
// 重新投递MQ
} else {
// 将任务置为失败,并写入失败原因
}
} finally {
// 删除源文件和结果文件
}
}
/**
* 处理视频拼接任务
* 优先使用分离器拼接,如果分离器拼接失败,则先转码为ts格式后使用分离器拼接
*/
private fun handleVideoConcat(......) {
val startTime = System.currentTimeMillis()
// 第一步:查询数据库获取任务信息
getTransTaskByTaskId(taskId)
// 第二步:当任务状态被置为失败时退出执行,防止OOM导致的不停的消费
if (taskInfo.status == FAIL.code ||
taskInfo.status == SUCCESS.code) {
return
}
// 第三步:更新数据库任务状态为正在执行
updateStatusAndMsgById(taskInfoId, EXECUTING.code, EXECUTING.message)
// 第四步:下载源音频
getMediaOutputDict(taskId)
......
downloadFile(srcFileList, srcUrlList, taskId)
// 第五步:处理下载文件为0Kb情况,快速失败
handleDownloadZeroFile(srcFileList)
// 第六步:生成FFmpeg 视频拼接命令
generateVideoConcatCommand(......)
// 第七步:执行命令
var result = fFmpegManager.runProcess(commands)
// 第八步:判断命令执行过程中是否出现error
if (result.code == FAIL) {
// 执行转码再拼接操作
log.error("拼接任务执行失败,开始先转码再拼接")
result = handleVideoConcatCounterError(......)
if (result.code == FAIL) {
// 不用再重试,会浪费cpu资源
// 直接将重试次数置为最大,并抛出异常
}
}
// 第九步:任务执行成功,更新数据库任务的相关信息,上传结果文件
uploadFile(......)
// 第十步:更新数据库任务表
......
log.info("任务执行完成,发送mq消息通知server回调业务")
// 第十一步:发送mq通知server去回调cstore
sendMqWithRetry(......)
}
4.3 问题记录
4.3.1 集成FFmpeg
在SpringBoot集成Linux可执行命令的时候,我们将可执行文件放在了项目的resource目录下:
这里需要有一步操作就是将文件复制到宿主机:
private fun initFFmpeg() {
log.info("初始化ffmpeg")
val os = System.getProperty("os.name").toLowerCase()
if (StringUtils.isBlank(os)) {
throw MediaTransException(ErrorCode.SERVER_ERROR, "操作系统参数为null")
}
//ffmpeg可执行文件在jar包里面的文件
val fisInJar: InputStream
if (os.contains("win")) {
fisInJar = ClassPathResource("ffmpeg${File.separator}windows${File.separator}ffmpeg.exe").inputStream
} else if (os.contains("mac")) {
fisInJar = ClassPathResource("ffmpeg${File.separator}mac${File.separator}ffmpeg").inputStream
} else if (os.contains("linux")) {
fisInJar = ClassPathResource("ffmpeg${File.separator}linux${File.separator}ffmpeg").inputStream
} else {
throw MediaTransException(ErrorCode.SERVER_ERROR, "非法的操作系统: $os")
}
//将ffmpeg文件复制到操作系统文件的某个目录下
val ffmpegFileInOs = File.createTempFile("ffmpeg", "_ffmpeg")
ffmpegFileInOs.setExecutable(true)
ffmpegFileInOs.deleteOnExit()
ffmpegFileInOsPath = ffmpegFileInOs.absolutePath
//将jar包里的ffmpeg复制到操作系统的目录里
val fosInOs = FileOutputStream(ffmpegFileInOs)
val buffer = ByteArray(1024)
var readLength = fisInJar.read(buffer)
while (readLength != -1) {
fosInOs.write(buffer, 0, readLength)
readLength = fisInJar.read(buffer)
}
fosInOs.close()
fisInJar.close()
log.info("ffmpeg初始化完毕")
}
4.3.2 FFmpeg JNI
最后项目是没有使用JNI的方式集成FFmpeg的,为什么呢?
- 集成过于复杂,后期新功能的开发和旧功能的维护成本都较高,不容易上手
- 使用JNI的方式,在当前业务体量下不够明显(错误自定义、异常监控等等) 这里贴一个自己集成JNI的项目(github地址),最后也算成功集成了(步骤有点久远了可能不全):
- 从官网找到源码
- 然后就开始找FFmpeg库,把自己需要的引入即可
- 生成.so文件
C项目目录如下,因为本人很少写C,所以很多东西都比较不规范,这次也只是作为测试使用,暂时凑合看看:
最折腾人的就是这个CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(one)
set(CMAKE_CXX_STANDARD 14)
set(FFMPEG_DIR /Users/heziqi/Public/development/ffmpeg-4.2.2-change)
set(FFMPEG_LINK /Users/heziqi/Public/tools/ffmpeg/lib)
include_directories(${FFMPEG_DIR})
include_directories("/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/include")
include_directories("/Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/include/darwin")
link_directories(${FFMPEG_LINK})
link_libraries(avutil avformat avcodec avdevice avfilter avresample postproc swresample swscale)
add_executable(one ffmpeg_portal.c cmdutils.c ffmpeg.c ffmpeg_filter.c ffmpeg_opt.c ffmpeg_hw.c)
target_link_libraries(one avutil avformat avcodec avdevice avfilter avresample postproc swresample swscale)
我暂时只实现了一个入口的方法,主要目的就是验证JNI吧,后续可以在此基础上进行改造:
#include "com_demo_jni_jin_FFmpegUtils.h"
#include "ffmpeg.h"
JNIEXPORT jint JNICALL Java_com_demo_jni_jin_FFmpegUtils_run
(JNIEnv *env, jobject obj, jobjectArray commands){
int argc = (*env)->GetArrayLength(env, commands);
char **argv = (char**)malloc(argc * sizeof(char*));
int i;
int result;
for (i = 0; i < argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, commands, i);
char* temp = (char*) (*env)->GetStringUTFChars(env, jstr, 0);
argv[i] = malloc(1024);
strcpy(argv[i], temp);
(*env)->ReleaseStringUTFChars(env, jstr, temp);
}
//执行ffmpeg命令
result = run(argc, argv);
//释放内存
for (i = 0; i < argc; i++) {
free(argv[i]);
}
free(argv);
return result;
}
4.3.3 为什么HLS延时相对较高?
HLS 的延时包含了 TCP 握手、m3u8 文件下载与解析、ts 文件下载与解析等多个步骤。
可以缩短列表的长度和单个 ts 文件的大小来降低延迟,极致情况下可以缩减列表长度为 1,并且 ts 的时长为 1s,但是这样会造成请求次数增加,增大服务器压力,当网速慢时回造成更多的缓冲。苹果官方推荐的 ts 时长时 10s。
4.3.4 为什么结果文件的切片时长是不精确的?
假设我们视频切片设置参数“-hls_time 2”,希望生成的ts文件时长在2秒左右,但是结果文件很多分片都是不准确的:
产生的原因: ts切割跟视频的GoP大小(两个关键帧之间的间隔有关),并不是指定2s切出来就2s的ts文件。任何一个播放端都需要获取到完整的GoP才能播放端,所以一个ts文件实际包含的时间是GoP的整数倍。
解决办法: 既然已经知道了问题的原因是关键帧的间隔不对应,那么只需要在转码的时候,设置好关键帧的间距即可:
ffmpeg -y -i test.mp4 -force_key_frames "expr:gte(t,n_forced*2)" -hls_time 2 -hls_segment_filename "test-%d.ts" -hls_list_size 0 test.m3u8
// -force_key_frames "expr:gte(t,n_forced*2)":每2s打上一个关键帧
不过这个处理措施是不建议生产环境使用的,因为资源消耗太大,但重要性不高。业务其实不需要太关注每个切片的大小是否精准,业务关心的是我们播放视频的时候是否会造成卡顿。
4.4 预上线研究
媒体处理服务,在上线之后需要接管目前已有的音视频转码需求。这就对媒体处理服务的性能
、稳定性
以及问题定位与恢复能力
提出了较高的要求。
研究数据来源: 业务真实数据
研究过程:
- 多次计算转码过程中各资源(CPU、内存、带宽等)的消耗情况与任务耗时后取平均值
- 分析一天中转码的峰值请求情况,制造并发测试场景
研究目的: 确定可保证业务正常运行的最低配置
研究结果: 以阿里云机器,两台4核4G,带宽500M,可保证线上请求被正常执行
五、总结
媒体处理微服务不算一个非常复杂的项目,主要是使用事件驱动模型
:创建任务 -> 执行任务 -> 文件处理 -> 回调业务。
其中也有一些比较有意思的思考点:
- 文件下载上传的I/O池化,为什么需要池化?
- FFmpeg CMD方式的调用可否池化?(参考im4java)
- 在防盗链技术被轻而易举攻破后,视频防盗为什么要选择对文件本身进行加密?
如果大家有兴趣欢迎在评论区交流~
项目数据
每分钟可完成 100 多个音频转码,任务耗时控制在 1s 内。
日处理任务数约 1 万个,每月节省超过 15 万元成本。