前言
随着多媒体行业的快速发展,大家对于视频分辨率、码率的要求也越来越高。与此同时,PUGC 用户也希望提供出更加优质、良好体验的视频内容来收获更多的粉丝。这样也就出现了大量大文件上传的需求场景,而 PC 端作为这类用户的生产力工具,如何在 Web 上提供良好的文件上传体验也就变得非常重要。针对这一问题,我们经过一段时间的迭代优化,目前文件上传、本地文件解析以及下载这套全链路 SDK,已经在西瓜、抖音、皮皮虾、教育等业务中提供有力的支持。
本文首先会提出文件上传面临的问题,然后针对各个问题分别介绍我们目前的解决方案,最后做一个简单的总结。
面临的问题
我们先来看看文件上传需要解决哪些问题,以及我们需要重点关注的点在哪里:
- 上传速度,用户:X 网站上传速度为什么比你们快!
- 上传成功率,用户:我传了10多分钟的视频失败了,又要从头开始传一遍?
- 上传预处理,研发:某用户在上传不符合规定的文件或者恶意文件
上传SDK整体流程
现有的整体上传流程:
如图所示,目前上传 SDK 主要可以分成以下几个阶段:
- 第一阶段会根据不同文件生成唯一的 key(作为 localstorage 的 key,可用于断点续传、各阶段的信息暂存)
- 上传选路,以及初始化上传信息,如上传节点域名等
- 在选路完成后,我们根据选路结果,向对应的节点进行分片上传。如果是小文件,则不再分片直接上传
- 对于分片上传,该阶段会合并已经上传完的分片,而直传将跳过该阶段
- 完成上传,返回文件的 meta 信息,触发对应上传流程配置的后续工作流,如转码、抽帧等视频服务
解决方案
针对上文中提到的上传速度、上传成功率以及上传预处理这三个问题,分别给出了我们一些现有的解决方案,以及对于不同情况和应用场景下的一些思考。
上传速度
对于上传速度的优化,我们从边缘节点下发、客户端选路、HTTP 协议、秒传、流式上传这几个方面进行分析。
服务端节点下发
上传过程中的开始阶段会向服务端发起获取上传地址的请求,根据上传用户所在的物理位置,使用的运营商,结合上传服务各个机房的资源健康状况,为上传用户分配一个最优的上传线路。通过这一动态下发的能力,有效的提升了区域用户上传速度,而且在机房容灾方面也有不错的表现。
客户端选路
单纯依赖服务端下发单一节点也是存在问题的,服务端下发的单一节点更多的会考虑该区域的整体上传速度。而对于单个用户来说,用户所属运营商等其他因素也对上传速度会有较大影响,所以可能会遇到部分用户反馈,为什么别的网站比你们的上传速度快这么多。
针对这种情况,我们在初始阶段向服务器获取多个候选的上传节点,客户端对这些候选节点进行小文件测速,根据测速情况选择该用户的最优节点进行上传。如图:
- 首先判断满足客户端选路条件,比如对于单个用户而言,尤其是对于 Web 场景下,一段时间内只需要进行一次客户端选路。设置一定文件大小阈值,对于小文件来说直接上传
- 如果满足客户端选路条件,则从服务端获取多个候选上传节点地址。否则从 localstorage 中查看是否有暂存的最快节点或者直接从服务端获取最优节点,直接跳到第四步。
- 对候选节点进行小文件上传测速,类似 Promise.race 的过程,一旦某个节点测速完成则立即 abort 其他测速请求,把完成测速的节点作为最优节点
- 开始向最终选定的最优节点进行上传
HTTP协议
HTTP-over-QUIC(也就是HTTP3),基于 UDP 协议,从根本上解决了 TCP 协议队头阻塞的问题,在存在网络丢包的情况下对上传速度会有较大的改善,当然对于前端来说,QUIC 的兼容性也是一个问题,目前只有 Chrome 会默认开启,Safari、Firefox 这几个主流浏览器目前都是默认不开启的,需要用户主动 enable。
秒传
对于同一文件来说,多次上传都可以理解成一次上传,在完成第一次上传之后,另外几次上传可以直接返回相同的结果(对于视频来说也就是某个唯一的 id )。上传 SDK 在上传完成之后,能够返回对应文件的 MD5,使用方可以通过维护一套 MD5 值与视频 id 的映射关系,上传前在前端计算 MD5 值来判断是否为同一文件,以实现秒传能力。当然 MD5 值会有一定的碰撞概率,这样必然会出现用户数据丢失的问题,需要结合更多方案来解决,大家可以更进一步去思考,本文不再深入讨论。
流式上传
流式上传主要适用于一些音视频合成的场景,可以节省一部分等待上传的时间。举个例子,一般情况下,发送一段 2mins 的语音,我们需要先等待录音完毕之后再进行上传。而使用流式上传,我们可以在录制的过程中实时上传语音,缩短等待时间。如下图所示:
上传成功率
对于前端来说,成功率主要焦聚在出现异常情况下,如何对上传进行恢复以及现有的重试机制,尽可能的提升用户体验。
断点续传
为保证上传成功率,尤其是在大文件的上传场景下,断点续传是必不可少的一项功能。基本的断点续传可以看下面这个流程:
- 首先对某一文件生成唯一 key,且多次生成都要一致,常见方法有 CRC32、MD5、SHA1 等。
- 分片校验码计算以及分片上传。如果上传过程中失败,利用上一步中生成的唯一 key 作为 localStorage 的 key,储存当前失败状态以及相关信息,如视频 id、上传节点域名等
- 再次上传同一文件时,从 localStorage 中查找是否有该文件的上传记录,如果有则从 localstorage 中获取断点信息,触发续传
- 从服务端获取已上传分片信息,直接从断点开始上传
同步方案
先来看看我们最开始的方案 - 同步方案。因为分片上传过程中为保证分片的完整性,我们会对每个分片计算 hash 值,而我们为了实现断点续传,对于单个文件来说需要生成一个唯一 key,所以我们直接将分片 hash 的计算过程提前到第一阶段的唯一 key 生成,将所有分片的 hash 拼接在一起作为 key 使用。
简单异步方案+采样 hash
对于几百兆的文件来说,同步方案没有太大问题,校验码的计算时间基本在 1s 以内。但是当遇到超大文件上传时,会暴露出非常明显的问题,校验码的计算时间非常长,同时也会阻塞 JS 线程。为了解决这个问题,我们使用 Web Worker 做简单的异步校验码计算,而异步计算校验码同样也会引入新的问题,我们断点续传需要使用的 key 应该如果生成?这个问题其实很好解决,我们对于大文件来说计算采样 hash ,选取首尾以及中间一个分片进行 hash 值的计算,再配合filesize作为文件的唯一 key。对于单个用户来说,这样 filesize 加采样 hash 所生成的 key 的碰撞概率基本可以忽略不计。
异步+队列+采样 hash 方案
前面我们利用简单的异步方案配合采样hash解决了超大文件上传时面临的问题,整体上看是不会发现出有什么问题的。但在实际的上传过程中,一次性异步计算所有分片的校验码,会将所有分片都加载到内存当中,会出现内存溢出的问题,造成页面的 crash。所以我们维护了一个队列,仅仅先计算正在上传以及将要上传的分片 hash,并且在上传的过程中及时回收内存。
发散思考
上文中提到了文件校验码的计算,那么我们应该选用什么方案进行校验码的计算呢?在 Web 环境中,遇到强计算的问题时,我们首先会联想到三种方案,分别是 Web Worker、WebAssembly、WebGL,我们首先排除 WebGL,因为如果想在 shader 中进行文件校验码的计算,整个过程操作起来比较繁琐,成本较高,而本身我们只需要一个异步计算的过程即可。接下来剩下 Web Worker、WebAssembly,我们第一反应可能会选择 WebAssembly,但实际上,校验码的计算时间是远远小于分片上传时间的,我们只是要利用 Web Worker 做一个简单的异步处理就能达到相同的上传效率,而 WebAssembly 的兼容性也比较差,需要做额外的降级处理。
重试机制
在整个上传的流程中,会多次与服务端进行交互,而重试机制主要就是用于保证在这一过程中的成功率以及上传速度
- 上传过程中,各阶段重试
- 分片上传过程中,单个分片上传失败后的重试
- 低速分片取消重试,我们会对单个分片的上传进度进行监听,如果长期低于 1kb 的情况下,会主动断开重试
上传预处理
我们先看看上传预处理的一些应用场景:
- 如果想限制用户上传的文件是视频或者图片,普遍采用的方式是直接判断文件的后缀是不是视频或者图片。这样就导致,如果某些用户自行修改上传文件的后缀就可以轻松绕开这种检测,成功上传其他类型的文件。这样会存在一定的安全漏洞,黑客可以轻松上传脚本到服务端,利用其他常见音视频处理库的漏洞发起 SSRF 攻击。
- 还有一种情况,对于短视频业务,可能会需要限制用户上传视频的时长,对于不满足时长要求的视频直接禁止上传,用以节省带宽以及存储空间的成本。原来的方法可能是直接利用原生 video 标签来获取视频的时长,但是这样对于 video 标签不支持的视频格式是无法成功获取到时长的。从用户体验的角度出发,试想一下,如果某个用户在上传一个超时视频没有任何限制,经过漫长的上传过程后,最终服务端提示时长不满足要求无法使用,这样会带来非常不好的用户体验。
- 快速本地预览以及抽取备选封面
针对以上的几个问题,我们提供了多媒体离线解析 SDK 对文件进行预处理,SDK 能够通过对二进制数据进行解析,识别视频(或者图片)的格式、时长、码率等信息。
关于下载
在 Web 环境中,目前并没有提供 File Writer 相关的 API,所以一般的下载方案都是利用 a 标签或者将文件下载到内存当中,无法做到断点续下等功能。所以目前我们提供的下载SDK主要是支持在 Electron 中使用。