0%

浅谈大文件上传方案

文件上传是一个很常见的功能,在业务场景中其又可分为单文件上传、分片上传、断点续传、秒传等。

一个小文件上传在一个http连接便可以很快的完成,其无需担心上传失败重新上传的问题。而一个大文件的上传则不能这样,试想一个场景:10G的文件直接上传,如果上传一方的网速很好,服务器的网络带宽很小,那么服务器的带宽全被这个上传连接占用,其他人上传文件则已没有带宽可用;如果在网速较差的环境下上传,快要上传完成的时候网络中断,又得重新上传,一定会抓狂。

那么如何解决大文件的上传呢?此时则需要引入分片上传,可以带来以下优点:

  • 不占用服务器网络带宽:一次上传一个分片,可以很快的完成上传。
  • 断点续传:分片之间是独立的,上传中网络中断后可以直接续传,即上传成功的分片无需再次上传。

那么又如何达到秒传的效果呢?每个文件可以用加密算法生成一个加密值,比如MD5值,一个内容完全相同的文件只要使用同一种加密算法加密得到的值一定相同,在开始分片上传前先拿这个加密值去判断是否有相同的文件即可。

上传方案

现在来聊聊具体的方案,看一下从上传到完成经历的整个过程:

sequenceDiagram
    participant user
    participant client
    participant server
    user ->> client: upload file
    client ->> client: split file
    loop complete=false
        client ->> server: upload slice
        server ->> server: process slice
        server -->> client: response  
    end
    client -->> user: success

可见其重要部分在于客户端上传分片到服务端进行处理的这个闭环,而这个闭环中可存在多种实现。

单分片上传

单分片上传的流程是一个分片上传形成闭环之后再上传下一个分片,关键点在于每上传一个分片是直接进行合并操作,使得server端只会保留该文件的一份数据,直到最后一个分片上传完成时,也就表示当前文件上传完成。单个闭环过程如下:

sequenceDiagram
    participant client
    participant server
    client ->> server: upload slice, index=n
    server ->> server: verify slice
    server ->> server: merge slice
    server -->> client: response success

这种方式优点在于流程单一,容易控制,且实现相对简单;缺点在于上传消耗的时间不会减少,且会出K次分片合并消耗。

并行上传

并行上传是在客户端分割完整个文件后,根据服务端的并发数情况控制每一次发送的并发请求数,直到最后所有分片上传完成。并行上传闭环流程过程如下;

sequenceDiagram
    participant client
    participant server
    client ->> server: upload slice, index=1
    client ->> server: upload slice, index=2
    client ->> server: ...
    client ->> server: upload slice, index=n
    server -->> client: index=1 success
    server -->> client: index=2 success
    server -->> client: ...
    server -->> client: index=n success
    client ->> server: upload complete
    server ->> server: verify slice
    server ->> server: merge slice to file
    server -->> client: response success

并行上传的优点在于快速,如果文件很大的话就可以节约大量上传时间;但其缺点也很明显,其处理逻辑相对单个分片上传而言复杂,且涉及多分片的存储维护以及最终的多分片合并问题。

单分片上传最终合并

单分片上传最终合并是对前面两种方案的中和,其过程是一片一片的上传,最后一次上传时进行合并。此方案去除了单分片上传时的k次合并时间消耗代价,对分片的控制亦相对简单合理。方案闭环过程如下:

sequenceDiagram
    participant client
    participant server
    client ->> server: upload slice, index=n
    server ->> server: verify slice
    server -->> client: response success
    client ->> server: upload complete
    server ->> server: merge slice to file
    server -->> client: response success

分片的合并时机选取

对于前面的方案,第一种是一边上传一边合并,后两种是先上传最后进行合并,可见合并操作属于整个上传流程之中。那么上传流程是否可以只上传分片不合并呢?当然是可以的,只是需要业务场景允许用这样的方式。针对并行上传单分片上传最终合并两种方式,如果去掉最后的合并操作,当需要使用此文件的时候,再来做一次合并操作,那么此时整体的上传流程较少了一笔时间消耗。

方案实例

现在以“用户上传文件”为场景描述单分片上传方案的简单实现方式,以此来加深印象方便更容易理解。并行上传单分片上传最终合并的实现不作说明。

实体信息

首先,定义出单分片上传方式相关的数据实体信息:

这里对文件的加密值以MD5的方式呈现

  • 单分片上传接口参数
1
2
3
4
5
6
7
8
9
10
11
12
13
public class UploadFileReq {
private Long userId; // 用户id
private String taskId; // 当前文件上传任务标识(第一个分片不传,其余分片必传 )
private boolean complete; // 是否完成(是否是最后一个分片)

private String fileMd5; // 文件的MD5值
private String filename; // 文件名

private MultipartFile slice; // 分片
private String sliceMd5; // 分片的MD5值
private Integer sliceIndex; // 分片索引(当前第几个分片)
private Long sliceOffset; // 分片在整个文件的偏移量
}
  • 文件上传记录信息实体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UploadRecord {
private Long userId; // 用户id
private String taskId; // 当前文件上传任务标识
private boolean complete; // 是否完成(是否是最后一个分片)

private String fileMd5; // 文件的MD5值
private String filename; // 文件名
private String filePath; // 文件路径
private long fileSize; // 文件大小

private String sliceMd5; // 分片的MD5值
private Integer sliceIndex; // 分片索引(当前第几个分片)
private Long sliceOffset; // 分片在整个文件的偏移量
private long sliceSize; // 分片大小

private Date createTime; // 上传任务创建时间
private Date updateTime; // 任务更新时间
}
  • 接口返回VO
1
2
3
4
public class UploadFileVo {
private String taskId; // 上传任务标识
private String filePath; // 文件路径(当整个文件上传完成后才会返回值)
}

对于PO实体中的字段信息,userId可以标识出当前上传记录的所属;complete可以很直观的看出当前的上传记录是否已经完成上传;分片相关的字段可以直观给出当前文件传到哪儿一个分片,以及相关的信息是什么;文件相关字段亦是直观的给出了文件相关信息。

三个实体的给出,便已经能想象得出接口的输入、存储、输出的闭环流程了,接下来便是看看另一个核心点分片处理过程

分片处理

单个分片上传的分片处理方式在前面已经有提到过,现在通过流程图的形式来描述服务端处理一个分片的详细过程,如下所示:

graph TD
    A(开始处理分片) --> B{分片md5值校验}
    B --> |通过| C{第一次上传}
    B --> |否| Z(处理结束)
    C-->|是|D(第一次分片处理)
    C-->|否|E(增量分片处理)
    D-->F{sliceIndex==1}
    F-->|否|Z
    F-->|是|G(保存分配:本地or文件服务器)
    G-->H(持久化任务记录)
    H-->I(包装返回信息:任务taskId)
    I-->J{complete==true}
    J-->|是|K(包装返回信息:文件路径filePath)
    K-->Z
    J-->|否|Z
    E-->L{任务存在}
    L-->|是|M{文件MD5匹配}
    L-->|否|Z
    M-->|是|N{分片偏移正确}
    M-->|否|Z
    N-->|是|O(合并文件,追加分片)
    N-->|否|Z
    O-->P(更新任务记录信息)
    P-->J

续传

当需要续传的时候,客户端需要调用接口获取当前用户某文件的上传情况信息。如果本地分片仍存在,可以根据已上传的分片(UploadRecord.sliceIndex)来推断应该续传的分片;如果本地分片已丢失,则可以根据已上传的文件大小信息(UploadRecord.fileSize)来对文件未上传的部分进行分片上传。

当然,客户端依然可以自行维护某个文件的上传状态、进度等情况,但不推荐此方式,应该以服务器的数据信息为准。

秒传

秒传功能可以根据文件的MD5值来实现,当在上传某个文件的时候,客户端向服务端询问当前MD5值的文件是否已经存在,服务端去比对查询已完成的记录,如果存在直接生成一条当前用户的上传记录即可。