关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言
作为开发者,你是否遇到过这些场景:用户上传1GB
视频时网络中断,必须从头开始;多人重复上传相同文件,浪费服务器资源;移动端上传大文件时频繁失败......
而作为大文件上传频繁失败的解决方案,经常会出现秒传、断点续传或者分片上传的这样的技术名词。这些解决方案到底是怎样的呢?使用的过程又有什么坑点?有改如何避免呢?
本节一文将带你彻底理解,本节技术内容基于springboot 2.x
实现。
02 秒传
秒传又称为急速上传,本质上不是上传,仅仅是判断当前文件是否已经存在服务器上了,如果已经存在,则直接显示上传成功并返回缩略图或者上传的网络地址。
而判断文件是否已经存在是需要客户端计算文件的唯一标识,专业名词又叫文件指纹 。文件指纹的计算是通过哈希算法(如:MD5
、SHA-1
、SHA-256
等)计算指纹值。
2.1 文件指纹算法
本文使用spark-md5.js
的脚本实现:https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js
。
官网地址:www.npmjs.com/package/spa...

官方案例

2.2 客户端实现
以javasrcript
、jquery
为例:
js
//<script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js" />
//<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js /">
$(".part").change(function () {
let file = this.files[0];
if (!file) {
alert("请选择文件!");
return;
}
let fileUid;
const fileName = file.name;
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = async function (e) {
const arrayBuffer = e.target.result; // 获取ArrayBuffer对象
spark.append(arrayBuffer); // 将ArrayBuffer数据添加到SparkMD5实例中
fileUid = spark.end();
console.info("MD5的值:", fileUid);
// 上传
const instantFormData = new FormData();
instantFormData.append('fileUid', fileUid);
await fetch('/file/instantUpload', {
method: 'POST',
body: instantFormData
}).then(resp => {
if (resp.status == 200) {
continueFlag = false;
return resp.text();
}
console.info("请求失败:", resp.text());
}).then(resp => {
if (resp) {
console.info("文件已秒传,上传后的地址:", resp);
}
});
};
reader.onerror = function () {
console.error('文件读取失败');
};
});
页面展示:

2.3 服务端实现
java
@RequestMapping("/instantUpload")
public ResponseEntity<String> instantUpload(String fileUid) {
if (FILE_UID_SET.contains(fileUid)) {
return ResponseEntity.ok("group/M01/D5/68/rBg8IpojiihfADYm3AAR5MSHU570.jpg");
}
return ResponseEntity.status(202).build();
}
这里的实现很简单,FILE_UID_SET
模拟数据的存储,如果包含文件指纹,直接返回上传后的网络地址。
03 分片上传
主要针对大文件,需要将大文件分片,切割成小片段(如5MB/片)。分片的逻辑主要由客户端处理,客户端的File对象自带分片功能:file.slice(chunkStart, chunkEnd)
。
通过起始位置和结束位置将文件分片。
3.1 客户端实现
客户端需要处理的就是分片,以此将分片的序号、总分片数以及文件上传。
js
$(".part").change(function () {
let file = this.files[0];
if (!file) {
alert("请选择文件!");
return;
}
let fileUid;
const fileName = file.name;
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = async function (e) {
const arrayBuffer = e.target.result; // 获取ArrayBuffer对象
spark.append(arrayBuffer); // 将ArrayBuffer数据添加到SparkMD5实例中
fileUid = spark.end();
console.info("MD5的值:", fileUid);
// 开始分片
const chunkSize = 1024 * 1024; // 每个分片1MB
const chunkCount = Math.ceil(file.size / chunkSize)
console.info("总分片数:", chunkCount);
for (let i = 0; i < chunkCount; i++) {
const chunkStart = i * chunkSize;
const chunkEnd = Math.min(chunkStart + chunkSize, file.size);
const chunk = file.slice(chunkStart, chunkEnd);
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileName', fileName);
formData.append('fileUid', fileUid);
formData.append('partIndex', i);
formData.append('partCount', chunkCount);
await fetch('/file/partUpload', {
method: 'POST',
body: formData
}).then(resp => resp.text()).then(resp => {
console.info("第" + (i + 1) + "片上传", resp);
});
}
};
reader.onerror = function () {
console.error('文件读取失败');
};
});
3.2 服务端实现
服务的需要考虑将分片数据保存,上传完成后合并分片数据,并删除临时分片。案例将分片数据和合并的数据存放在项目路径下。

代码的完整实现
java
@RequestMapping("partUpload")
public ResponseEntity<String> chunkUpload(FileUploadDTO fileUploadDTO) throws IOException {
// 创建临时问价夹
Path tempDirPath = Path.of(FILE_PARENT_NAME, "/" + fileUploadDTO.getFileUid());
Files.createDirectories(tempDirPath);
String tempFileName = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMMddHHmmssSSS")) + ".part_" + fileUploadDTO.getPartIndex();
Path tempFilePath = Path.of(tempDirPath.toString(), tempFileName);
if (Files.exists(tempFilePath)) {
Files.delete(tempFilePath);
}else {
Files.createFile(tempFilePath);
}
// 将文件写入临时文件
try (OutputStream outputStream = new FileOutputStream(tempFilePath.toFile())) {
IOUtils.copy(fileUploadDTO.getFile().getInputStream(), outputStream);
}
File file = tempDirPath.toFile();
File[] files = file.listFiles();
if (files != null && files.length == fileUploadDTO.getPartCount()) {
// 上传完成
log.info("文件上传完成,总分片数:{}片, fileMd5:{}, 合并分片文件....", fileUploadDTO.getPartCount(), fileUploadDTO.getFileUid());
String[] split = fileUploadDTO.getFileName().split("\\.");
try(OutputStream outputStream = new FileOutputStream(Path.of(FILE_FINISHED_DIR_NAME,
split[0]+ "_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyMMddHHmmssSSS")) + "." + split[1]).toFile())){
for (File part : files) {
try (InputStream inputStream = new FileInputStream(part)) {
IOUtils.copy(inputStream, outputStream);
}
}
log.info("文件分片合并完成,删除临时分片");
File tempDirPathFile = tempDirPath.toFile();
FileUtils.cleanDirectory(tempDirPathFile);
tempDirPathFile.delete();
FILE_UID_SET.add(fileUploadDTO.getFileUid());
}
}
return ResponseEntity.ok("上传成功");
}
3.3 坑点
功能的实现非常简单,但是这个仅限于单机同步上传,案例代码可以直接使用。但是在企业中,多节点部署、客户端异步上传会不会有问题呢?
客户端异步上传
为了响应速度,客户端很有可能将文件分块后,采用异步上传,也就是多个文件块同时上传。因为方法的执行是在JVM
的方法栈上运行的,相互不影响,但是在判断分片是否全部上传时,就可能出现问题导致,最终没有合并文件。
风险代码:
java
File[] files = file.listFiles();
if (files != null && files.length == fileUploadDTO.getPartCount()) {
// 合并文件
}
因为多线程的原因,如果两个线程同时执行到这里,就会都满足合并条件。从而导致合并多次。还有一种可能就是这个条件一直达不到,导致无法合并文件。
如何解决呢:单纯的针对当前问题,需要加锁即可(如:ReentrantLock
, synchronized
)等。
多节点部署
多节点的部署问题很明显,因为基于项目存储临时文件,但是多节点的机器可能会使用负载均衡,导致分片分布在不同的节点上,从而无法合并。
针对当前的项目的多节点部署问题,可以通过nginx
对访问的ip
哈希处理,将相同的客户端路由到同一台节点上就可以解决此问题。
3.4 终极解决方案
对于共享需要的处理的资源,一般会通过中间件来解决。通过中间件来存储分片数据信息
Mysql
:这种方式一般效率比较低,需要频繁的更新数据库。Redis
:比较常用的中间件,用作缓存分片信息
其他的的存储介质都可以作为替代品。
04 断点续传
断点续传是分片上传的升级版,都是针对大文件的处理,需要记录已经上传分片的位置。
大体的实现逻辑分为3步:
- 1、将大文件分片处理依次上传
- 2、服务端需要记录上传的分片位置
- 3、重新上传时,返回已经上传分片的位置,已经上传的分片不在重新上传
为了避免分片以上文件与网络的交互,第一次上传之前先通过文件的指纹获取当前文件的已经上传的分片位置,然后从当前位置上传即可。
4.1 客户端实现

分片上传的逻辑不变,只是增加依次已上传分片的位置查询而已。
js
async function resumeUpload(fileUid) {
let i = 0;
const resumeFormData = new FormData();
resumeFormData.append('fileUid', fileUid);
await fetch('/file/resumeUpload', {
method: 'POST',
body: resumeFormData
}).then(resp => {
if (resp.status == 200) {
return resp.text();
}
console.info("请求失败:", resp.text());
}).then(resp => {
if (resp) {
i = parseInt(resp);
console.info("文件部分已上传,上传的断点:", resp);
}
});
return i;
}
4.2 服务端实现
java
@RequestMapping("resumeUpload")
public ResponseEntity<Integer> resumeUpload(String fileUid) {
// 创建临时问价夹
Path tempDirPath = Path.of(FILE_PARENT_NAME, "/" + fileUid);
if (!Files.exists(tempDirPath)) {
return ResponseEntity.status(226).build();
}
File[] files = tempDirPath.toFile().listFiles();
if (files == null || files.length == 0 ) {
return ResponseEntity.status(226).build();
}
File lastFile = files[files.length - 1];
Integer partIndex = Integer.valueOf(lastFile.getName().split(".part_")[1]);
if (lastFile.length() < CHUNK_SIZE) {
lastFile.delete();
}else if (lastFile.length() == CHUNK_SIZE) {
partIndex ++;
}
return ResponseEntity.ok(partIndex);
}
4.3 争议
有人认为上面的案例并不是真正意义上的断点续传,上面的案例默认的上传的文件块是完整的,所以只需要考虑未上传的分片块即可。
加入已上传的文件块不完整的话,就可能导致合并的文件数据缺失。比如每一个分片是1M,但是已经上传的块是30K,这样合并的时候数据就丢了。
为了解决上面的问题,可以使用java.io.RandomAccessFile
类,该类可以直接读取文件并从文件的指定位置读写数据。
本节不做讨论,因为小编认为对于文件上传没必要做的那么精细,分片快不足一个整块,删掉重新上传即可。
05 小结
分片上传、断点续传以及秒传不仅是性能优化手段,更是提升用户体验的利器。通过合理的技术选型和严谨的安全设计,
你的应用将获得:
- ✅ 上传成功率提升至99.9%
- ✅ 服务器资源消耗降低50%+
- ✅ 用户等待时间减少80%
技术的价值不在于复杂,而在于解决实际问题。 当你下次看到进度条平滑前进时,背后正是这些精妙设计在守护用户体验。
本文代码已开源:github.com/simonking-w...