媒体处理微服务从0到1

775 阅读6分钟

一、项目简介

在媒体处理微服务诞生之前,业务的媒体处理处理需求主要通过云服务商的能力完成,主要架构图如下: image.png

1.1 项目目的和功能

构建媒体处理微服务,实现基本处理能力。

主要功能包括:

  1. 视频处理:视频转码、视频拼接、视频截图、视频水印等等。
  2. 音频处理:音频转码、音频拼接
  3. 文件安全:音视频加密

1.2 关键技术和风险点

  1. 选用什么样的技术方案去实现媒体处理;
  2. 并发处理时任务的完整性如何保证,效率如何保证,又如何感知服务的异常;
  3. 如果上线接手现有的业务转码请求,是否可以承担这部分压力;
  4. 自研的成本优势在哪。

二、技术选型

能解决媒体处理问题的开源框架有很多,如FFmpeg, OpneCV等,但是就业务需求功能角度考虑,其实可选的就只有FFmpeg。理由主要如下:

  1. 对于编解码格式的支持方面,它是最全的;
  2. 在开源开源活跃度方面,它是应用在编解码领域最多的。 比较火的live555主要用于流式传输已经编码的电影/音频和流式读取(RTSP); 其他的如x264、Xvid等可能在某一个编码环境中表现优异,但是对于任意格式的视频编解码处理方面,支持不够完善。

在集成项目考虑而言,目前有两种方式集成FFmpeg:

  1. CMD本地命令
  2. JNI 在这里先贴一张图(可能不太清晰,原件有点久远找不到了): image.png

三、架构设计

在设计架构之前,我们先就目的层面思考一下,我们需要干什么?

因为转码相关的任务普遍耗时都是非常高,这样的话,真正的转码逻辑其实必须使用异步逻辑,处理结果以回调或主动查询的方式告知。

这样的话,项目的主体架构就已经确定了: image.png

  • media-api模块:对外暴露dubbo接口
  • media-server模块:媒体处理主服务,提供http接口(音视频转码、拼接、加密、流式处理等)
  • transcoding-async模块:异步任务处理
  • common模块:公共代码抽离模块

四、核心实现

4.1 执行流程

4.1.1 转码执行流程

image.png

4.1.2 加密执行流程

1、加密 image.png 2、解密 image.png 3、秘钥服务 image.png

4.2 关键设计与实现

4.2.1 模块解耦

分离同步和异步逻辑,分为两个模块:media-server 和 media-async,这样可以做到业务逻辑的解耦以增加可维护性。

4.2.2 可靠性保证:

  1. 下载上传速度保证:独立异步模块应用的带宽,如果是尽量让文件IO走内网,下载和上传都使用线程池,做到资源高复用。
  2. 处理能力保证:独立机器配置 + 任务时间的监控。
  3. 完整性保证:任务如果失败均有重试机制,重试为3次,放入消息队列的尾部;回调通知也由指数退避的重试机制保障。
  4. 异常感知:应用上层的容器告警机制 + 应用内部的邮件告警机制 + 关键步骤日志打印。 下面给出消费者关键伪代码(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目录下: image.png

这里需要有一步操作就是将文件复制到宿主机:

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的,为什么呢?

  1. 集成过于复杂,后期新功能的开发和旧功能的维护成本都较高,不容易上手
  2. 使用JNI的方式,在当前业务体量下不够明显(错误自定义、异常监控等等) 这里贴一个自己集成JNI的项目(github地址),最后也算成功集成了(步骤有点久远了可能不全):
  3. 从官网找到源码
  4. 然后就开始找FFmpeg库,把自己需要的引入即可
  5. 生成.so文件

C项目目录如下,因为本人很少写C,所以很多东西都比较不规范,这次也只是作为测试使用,暂时凑合看看: image.png 最折腾人的就是这个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秒左右,但是结果文件很多分片都是不准确的:

image.png

产生的原因: 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 预上线研究

媒体处理服务,在上线之后需要接管目前已有的音视频转码需求。这就对媒体处理服务的性能稳定性以及问题定位与恢复能力提出了较高的要求。

研究数据来源: 业务真实数据

研究过程:

  1. 多次计算转码过程中各资源(CPU、内存、带宽等)的消耗情况与任务耗时后取平均值
  2. 分析一天中转码的峰值请求情况,制造并发测试场景

研究目的: 确定可保证业务正常运行的最低配置

研究结果: 以阿里云机器,两台4核4G,带宽500M,可保证线上请求被正常执行

五、总结

媒体处理微服务不算一个非常复杂的项目,主要是使用事件驱动模型:创建任务 -> 执行任务 -> 文件处理 -> 回调业务。

其中也有一些比较有意思的思考点:

  1. 文件下载上传的I/O池化,为什么需要池化?
  2. FFmpeg CMD方式的调用可否池化?(参考im4java)
  3. 在防盗链技术被轻而易举攻破后,视频防盗为什么要选择对文件本身进行加密?

如果大家有兴趣欢迎在评论区交流~

项目数据

每分钟可完成 100 多个音频转码,任务耗时控制在 1s 内。

日处理任务数约 1 万个,每月节省超过 15 万元成本。