1. 项目背景与需求拆解
1.1 为什么我最终选了WebUploader做分块上传
先交代一下项目背景。当时接到的是一个企业内部培训系统的文件管理模块,里面有个视频上传功能,教学视频动不动就是几百MB甚至1GB起步。最初用的就是Spring MVC标准的MultipartFile上传,结果上线没几天就被运维找上门了——服务器内存频繁告警,Nginx时不时报504超时,用户那边更直接,传个大文件传到一半断了就得从头再来,一天能接到十几个投诉工单。
这个场景你应该不陌生:用传统方式做文件上传,前端把整个文件塞进一次HTTP请求里,后端再一次性读取全部字节。小文件没问题,但文件一上GB,问题就全暴露出来了。首先是网络层,一次请求耗时太长,中间任何一次抖动、断连、服务器重启,整个上传就废了;其次是服务端,Servlet容器接收大文件时会把数据先写入内存或临时文件,并发一高内存就吃紧;再就是用户体验,进度条半天不动,用户根本不知道是死是活。
后来对比了市面上几款上传组件,最终敲定用WebUploader。理由很简单:它本身就是百度WebFE团队开源的东西,分块上传、并发上传、断点续传这些高频能力是原生支持的,API设计也不绕,前端配置半小时就能跑起来。关键是它没有强依赖框架,后端只需要按约定接收分块、合并分块就行,用Java写这部分逻辑非常顺手。下面我把完整步骤拆开讲,每一步都会说清楚为什么这么做,以及我在实际项目里踩过的坑。
1.2 分块上传这个方案到底解决了什么
分块上传的核心思路其实特别朴素:把一个大文件切成N个小块,一个一个传给服务器,服务器收齐所有分块后再拼成一个完整文件。
但朴素归朴素,它带来的收益是很实在的。第一个收益是失败成本大幅下降。5MB一个分块,传完一个就确认一个,第37块断了?重新传第37块就行,前面36块不用动。第二个收益是内存压力大幅缓解。后端每次只处理一个分块,对内存的消耗量级完全不一样。第三个收益是上传速度的质变。WebUploader支持多线程并发上传,默认并发数是3,也就是说同时有3个分块在飞,配合服务端分块独立落盘,整个上传吞吐量比单请求模式快很多。
还有一点容易被忽略:分块上传天然为秒传和断点续传做了铺垫。前端对文件算一个MD5,后端看到一个MD5就知道这个文件以前传没传过,如果传过了直接返回"已存在",这就是秒传。如果再配合一个查询接口,告诉前端哪些分块已经传过了,那前端只需要传缺失的块,这就是断点续传。这些能力放到现在的业务系统里,基本属于刚需。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前端配置与分块策略设计
2.1 WebUploader核心参数到底怎么设
WebUploader使用上不复杂,但参数能不能设对,直接决定了后面是否踩坑。我当时的前端页面是基于jQuery的,WebUploader本身也依赖jQuery,所以先保证页面里引了正确版本的jQuery。初始化上传组件的代码大概长这样:
javascript复制var uploader = WebUploader.create({
swf: '/static/js/Uploader.swf',
server: '/api/upload/chunk',
pick: '#filePicker',
auto: false,
chunked: true,
chunkSize: 2 * 1024 * 1024,
threads: 3,
fileVal: 'file',
formData: {
module: 'training'
},
accept: {
title: 'Video',
extensions: 'mp4,avi,mov,flv'
}
});
这里有几个参数我重点说明一下,因为很多人就是在这里设错的。首先是chunked和chunkSize,chunked设为true代表开启分块,chunkSize控制每块大小。我为什么选2MB而不是10MB或50MB?因为分块越小,单块的传输时间越短,失败重传的成本越低,但分块数量会变多,发起请求的次数也会变多;分块越大,请求次数越少,但单块传输时间变长,网络抖动导致的失败影响面更大。实测下来2MB到5MB是性价比比较稳的区间,教学视频场景我直接用了2MB,分块数量和对服务端临时存储的压力都可控。
然后是threads,这个是并发上传的线程数。WebUploader默认是3,这个值不用贪大,因为浏览器对同一域名的TCP连接数是有限制的,而且并发太高会给后端造成不必要的压力,尤其是分块落盘时如果临时文件没有做很好的隔离,可能会出现并发写同一个文件的问题。我项目里3个并发,上传1GB文件体感速度已经比单线程快了两三倍。
fileVal也很关键,它决定了后端接收文件时用哪个参数名。WebUploader默认的文件参数名是file,很多人会在后端写成MultipartFile file,但前端忘了设置fileVal,或者改成了别的名字,结果后端一直接不到文件。这个参数前后端必须严格对齐,不然就是最常见的"上传了但后端没收到"的尴尬情况。
2.2 分块信息怎么传给后端
前端只把文件分块传过去是不够的,后端还需要知道每一块是属于哪个文件的、是哪一块、总共多少块。这些信息WebUploader会通过额外的请求参数带过去,默认参数名分别是chunk、chunks、name、guid。其中chunk是当前分块的索引(从0开始),chunks是总块数,name是原始文件名,guid是WebUploader为文件生成的唯一标识。
你可以在uploader的uploadBeforeSend回调里看到这些参数,也可以在这里对请求做自定义处理:
javascript复制uploader.on('uploadBeforeSend', function (block, data) {
var file = block.file;
data.guid = file.guid;
data.name = file.name;
data.type = file.type;
data.lastModifiedDate = file.lastModifiedDate;
data.size = file.size;
});
注意,WebUploader默认其实已经会带上guid和name,这里重新赋值一次只是为了确保万无一失,同时把文件大小、类型这些元信息也补全。后端拿到这些参数后,就有足够的信息来组装分块了。这里有一个我在项目里栽过的跟头:前端自定义参数时,如果参数名和后端的接收字段不一致(比如前端传的是fileGuid,后端接收的是guid),后端解析不到就会报空指针,而且这个错误不会在上传阶段暴露,直到最后合并文件时才发现分块信息对不上,排查起来非常痛苦。所以前后端字段名一定要在一开始就统一好。
3. Java后端的分块接收与合并实现
3.1 分块上传接口的完整实现
后端这边我用的是Spring Boot,创建一个ChunkUploadController来接收分块请求。接口的核心职责很纯粹:把上传的分块数据保存到服务器临时目录里,并在数据库或内存中记录这个分块的状态。先看代码:
java复制@RestController
@RequestMapping("/api/upload")
public class ChunkUploadController {
private static final String CHUNK_TEMP_DIR = "/data/temp/chunks/";
@PostMapping("/chunk")
public Result chunkUpload(@RequestParam("file") MultipartFile file,
@RequestParam("chunk") Integer chunk,
@RequestParam("chunks") Integer chunks,
@RequestParam("name") String name,
@RequestParam("guid") String guid) {
if (file.isEmpty()) {
return Result.error("分块文件为空");
}
String chunkDir = CHUNK_TEMP_DIR + guid;
File dir = new File(chunkDir);
if (!dir.exists()) {
dir.mkdirs();
}
File chunkFile = new File(chunkDir, chunk + ".part");
try (InputStream in = file.getInputStream();
FileOutputStream out = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[4096];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
} catch (IOException e) {
return Result.error("分块保存失败");
}
return Result.success();
}
}
这段代码有几个设计点值得说。一是分块保存目录用guid来隔离,这样不同文件的分块互不干扰,避免了并发合并时文件错乱的问题。二是分块文件名直接用chunk索引加.part后缀,比如0.part、1.part,合并且排序时直接按数字排序就行,不用解析文件名的额外逻辑。三是分块写入用FileOutputStream而不是RandomAccessFile,因为这时每个分块是独立写入的,不会有并发的写偏移问题。
但实际项目中,这个接口通常还会干一件更重要的事:把分块记录写进数据库。我当时的表结构很简单,核心字段就是id、fileGuid、chunkIndex、chunkTotal、fileName、fileSize、uploadTime,每个分块保存成功后插入一条记录。为什么要在数据库里记录分块状态?因为后续做断点续传时,前端需要知道哪些分块已经传过了,总不能去服务器临时目录里挨个扫描文件吧,数据库查一下又快又准。另外也方便做后台管理,能直观看到每个文件传到了哪个进度。
上传流程到这里还没结束,因为WebUploader默认每个分块发完就完事了,至于后端有没有真正落盘,它并不关心。所以前端还需要在uploadSuccess回调里判断后端返回的code,如果返回的是错误状态,就该提示用户重试或记录日志。
3.2 合并接口怎么把分块还原成完整文件
等所有分块都传完,前端就该通知后端合并文件了。这里的流程是整个分块上传里最容易出错的一环,重点不在"写"上,而在"校验"和"顺序"上。合并接口我这样写的:
java复制@PostMapping("/merge")
public Result merge(@RequestParam("guid") String guid,
@RequestParam("name") String name) {
String chunkDir = CHUNK_TEMP_DIR + guid;
File dir = new File(chunkDir);
File[] chunkFiles = dir.listFiles();
if (chunkFiles == null || chunkFiles.length == 0) {
return Result.error("没有找到分块文件");
}
// 排序,按分块索引从小到大
Arrays.sort(chunkFiles, (a, b) -> {
int indexA = Integer.parseInt(a.getName().replace(".part", ""));
int indexB = Integer.parseInt(b.getName().replace(".part", ""));
return Integer.compare(indexA, indexB);
});
String destDir = "/data/upload/";
File destFile = new File(destDir + System.currentTimeMillis() + "_" + name);
try (FileOutputStream out = new FileOutputStream(destFile)) {
byte[] buffer = new byte[8192];
for (File chunkFile : chunkFiles) {
try (FileInputStream in = new FileInputStream(chunkFile)) {
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
} catch (IOException e) {
return Result.error("合并文件失败");
}
// 合并成功后,清除临时分块
deleteRecursively(dir);
return Result.success();
}
合并的核心就两步:排序、按序写入。排序这一步非常关键,如果分块顺序乱了,合并出来的文件就废了,视频直接播放不了,压缩包直接解压失败。我用的是把.part去掉后解析成整数再比较,稳妥且高效。
在合并之前还有一道检查步骤值得加上:比对实际分块数量和前端声明的总块数是否一致。如果前端告诉我总共有200个分块,但服务器临时目录里只有198个,那说明有分块丢了,直接合并会得到一个不完整的文件,即使文件创建成功,打开也是损坏的。我当时加了个简单的校验:
java复制int expectedChunks = getChunksFromDatabase(guid); // 从数据库查总块数
if (chunkFiles.length != expectedChunks) {
return Result.error("分块数量不完整,需重新上传缺失分块");
}
这个校验配合数据库里的分块记录一起做,才能形成闭环。只扫描临时目录文件是不够的,因为并发上传时可能上一个请求还没落盘,你扫描到的文件数量是准的但实际还在写,这种边界情况很隐蔽,但真实会发生。
删除临时目录也有讲究,不能只删分块文件不删目录,不然垃圾目录越积越多。我当时写了一个递归删除的方法,合并成功后调用一次清理,保证临时目录里不会残留垃圾文件。
3.3 为什么不用RandomAccessFile直接随机写
做合并方案时,我其实面临过两个选择:一个是上面这种"全部落盘,最后顺序合并"的方案;另一个是收到分块时就用RandomAccessFile随机写到目标文件的指定偏移位置,也就是边收边写。后者看起来更优雅,合并步骤都省了,但实际做下来问题很多。
随机写方案的核心代码大概是这样的:
java复制RandomAccessFile raf = new RandomAccessFile(destFile, "rw");
long offset = (long) chunkIndex * chunkSize;
raf.seek(offset);
raf.write(file.getBytes());
问题在于:如果某个分块编号传错了、传重了,或者某个分块一直没传成功,目标文件里对应位置的数据就是错的或空的,而且不好排查。另外,分块的index是前端算出来的,如果前端chunks和chunkSize变动过,index和文件的物理位置就对不上了。在多线程并发上传的环境下,还要额外处理同一个文件同时被多个线程写的问题,RandomAccessFile本身不支持并发写同一个文件,需要加锁或做文件分片。相比之下,先落临时分块、最后统一合并的方案,每个分块是独立文件,天然规避了并发写同一个文件的冲突,而且合并前能检查分块完整性,出错也容易定位到具体是哪个分块丢了。所以我宁可多写一个merge接口,也不愿意省这一步。这个思路可能只适合中小规模项目,但对大多数业务场景已经足够稳了。
4. 完整的上传流程与断点续传
4.1 一次完整上传要经过哪几步
把前端和后端串在一起看,一次成功的分块上传大概是这样一个流程:
-
用户选择文件,WebUploader初始化,生成文件的guid值。
-
前端计算文件的MD5(这一步通常在change事件里异步做)。
-
前端请求后端的check接口,传MD5或guid,询问这个文件是否已经上传过。
-
如果后端返回"已存在",前端直接提示秒传成功,整个流程结束。
-
如果后端返回"未传过"或"传过一部分",前端根据返回的已上传分块列表,只上传缺失的分块。
-
每个分块上传成功后,前端标记该分块为已上传,全部完成后调用merge接口。
-
后端merge接口校验分块数量和完整性,执行合并,合并成功返回文件访问路径。
-
前端拿到文件路径后,把路径提交给业务表单,完成整个业务数据入库。
这里第3到第5步,是我后来加上的断点续传逻辑。WebUploader本身在页面不刷新的情况下,如果某个分块上传失败会自动重试,但真正的断点续传——也就是刷新页面或关闭浏览器后还能接着传——必须靠后端记录分块状态来实现。check接口返回的已是分块列表,前端就能精确知道还缺哪些分块。
check接口的实现很简单:
java复制@GetMapping("/check")
public Result check(@RequestParam("md5") String md5,
@RequestParam("name") String name) {
FileRecord record = fileRecordMapper.findByMd5(md5);
if (record != null) {
return Result.success(record); // 秒传
}
// 查分块记录表,返回已上传的分块索引列表
List<Integer> uploadedChunks = chunkRecordMapper.getUploadedChunks(md5);
return Result.success(uploadedChunks);
}
有了一次完整的流程后你会发现,分块上传本质上是在"上传文件"和"记录状态"两条线上同时进行的,前端的并发上传负责效率,后端的记录与校验负责可靠。两者配合好了,上传模块才能既快又稳。
4.2 合并完成后还要做什么
合并接口把文件写出来之后,事情并没有结束。我当时还做了三件事,每一件都是被线上问题逼出来的。
第一件事是校验合并完成后的文件大小。前端传上来的文件元信息里带了一个fileSize字段,合并成功后我拿目标文件的实际大小和fileSize做对比,如果不一致,说明合并过程中出了问题,直接删除这个损坏文件并且报错。这个校验看起来简单,但真能拦截一大部分因分块丢失、重复或网络传输错乱导致的问题。
第二件事是把文件信息写入正式的文件记录表。之前分块记录表只记录分块状态,但文件最终要被业务系统使用,所以还需要一条正式记录,包含文件访问路径、MD5、文件大小、上传时间、上传人等信息。这样后续业务的下载、预览、删除都基于这张正式表来做。
第三件事是把文件上传到OSS或云存储。我们项目前期是本地磁盘存储,后来量大了以后切到了云存储,merge完成后直接把合并好的文件PUT到云端,再把云上的访问URL返回给前端。逻辑上只是多一步转发,但收益很明显,应用服务器不再承担大文件的磁盘IO压力,备份和扩容都轻松很多。
5. 典型问题排查与经验复盘
5.1 合并出来的文件损坏怎么办
这是分块上传最高频的问题,没有之一。文件可以合并,但合并出来播放器打不开、压缩包解压失败、图片打不开,基本都属于这一类。
排查顺序我建议这样走:第一,确认分块是否完整。用数据库分块记录和临时目录里的实际文件数量对比,缺块会导致文件大小直接不对。第二,确认分块顺序是否被打乱。如果你在合并前用了sort排序,并且排序方式是解析分块文件名中的数字索引,这块通常不会出问题,但如果你用字符串排序,那就会出大问题:比如0.part、1.part、10.part、2.part,字符串排序的结果是0、1、10、2,顺序全乱了。第三,确认是否出现分块重复写入。尤其是前端并发上传时,如果WebUploader因为网络超时对某个分块触发了自动重试,而后端没有做幂等处理,同一个分块可能被写入两次,覆盖了正确数据。
针对第一点和第三点,我给后端加了一个幂等校验:在保存分块前先检查这个chunk索引是否已经存在,如果存在就跳过写入直接返回成功。这样即使前端重试也不会写坏数据。
5.2 前端报了成功但后端找不到分块
还有一类场景很迷惑:前端每个分块都回调了uploadSuccess,但到合并时后端却提示分块数量不足。我当时排查了很久,最后发现问题出在WebUploader的serverSuccess回调判断上。
WebUploader判断一个分块是否上传成功,默认看的是HTTP状态码是不是200,它并不关心你返回的JSON里的code是0还是500。这意味着后端即使返回了业务错误,HTTP状态码是200,前端也会认为上传成功。解决办法是配置uploadAccept:
javascript复制uploader.on('uploadAccept', function (file, response) {
if (response.code !== 0) {
return false; // 返回false代表上传失败
}
});
这行配置非常关键,很多人在用WebUploader时没做这个处理,就会产生"假成功"的脏数据,最后在合并那一步集体爆发。
5.3 临时分块目录变成垃圾场
如果只是开发调试阶段,临时目录堆点垃圾无所谓。但上了生产环境,你会发现临时目录的清理是个不能忽视的运维问题。用户上传到一半放弃、网络反复失败、合并接口异常报错,都可能导致临时分块残留在服务器上,时间一长,磁盘占用蹭蹭往上涨。
我当时的处理方案是双保险。第一,在合并成功或失败后都主动清理临时目录,成功的清理是必然的,失败的我也会把previously分块删除,防止垃圾残留;第二,写一个定时任务,每天凌晨扫描临时目录,删除超过24小时没有更新的分块目录。这里要注意,扫描时不能只按目录的创建时间判断,因为长时间未完成上传的文件,其临时目录的创建时间可能很早,但最近一个分块可能是几分钟前刚传的,所以最好按目录的最后修改时间判断,或者扫描分块文件的最后修改时间取最大值来判断这个上传会话是否已经失活。
5.4 大文件上传时的Nginx配置问题
最后提醒一个很容易被忽视的环节:Nginx配置。很多人后端代码写得没问题,但上传大文件时总是莫名失败,八成是Nginx的client_max_body_size默认值在搞鬼。Nginx默认允许的请求体大小是1MB,分块之后每个请求虽然变小了,但如果你分块设置的比较大,或者分块请求的body超过了这个默认值,就会被Nginx直接拦截,返回413错误。
分块场景下更隐蔽的一个坑是:如果前端某些请求没有走分块路径(比如check请求),但请求体的URL长度或Header过长,也可能触发Nginx的限制。最稳妥的办法是在Nginx的location配置里,对上传接口单独设置client_max_body_size,比如:
nginx复制location /api/upload/ {
client_max_body_size 10m;
proxy_request_buffering off;
proxy_pass http://backend-server;
}
如果用的是HTTPS,还要注意client_body_buffer_size等参数的配合,不然也容易出现偶发性的上传中断。这块不是Java代码的问题,但它确确实实会卡住整个上传流程,排查起来还特别隐蔽。
5.5 断点续传时MD5计算太慢怎么办
断点续传依赖MD5做文件唯一性判断,但一个1GB的视频文件,前端算一个MD5要好几秒,用户在这个等待期体验很糟。这个问题当时也困扰了我一阵子。
WebUploader本身不做MD5计算,需要借助spark-md5之类的库在前端算。spark-md5支持增量计算,所以可以在WebUploader的文件分块过程中边传边算,等最后一个分块传完,MD5也算完了。这样可以和上传并行,而不是卡在选择文件这个环节干等。代码实现上,可以在uploadProgress回调里每传完一个分块就追加一个分块的MD5增量,最后在uploadFinished时拿到完整的文件MD5,再决定是否需要触发秒传或续传逻辑。
但如果文件实在太大了,前端算MD5本身就有性能瓶颈,一个务实的方案是放弃秒传检测,只做断点续传。也就是说,前端以guid作为文件唯一标识,guid可以随机生成一次并存在localStorage里,下次刷新页面时用同一个guid继续传。这样断点续传照样有效,省掉了MD5计算的等待时间。MD5在秒传场景里还是需要做,但如果你的业务不像网盘那样追求极致体验,这个优化值得做。
6. 从实际项目里总结的几个经验
先说并发参数的调优。WebUploader的threads参数默认是3,这个值在大多数场景下是不错的起点。如果你内网带宽好、服务器配置高,调到5甚至7会有更明显的加速效果;但如果你的用户多在弱网环境,比如移动网络,并发太高反而会增加失败概率,因为每个分块都在抢有限的带宽,单个分块的传输时间会拉长,失败重试的频率也会升高。我的经验是:内网系统开5,公网系统保持3,弱网场景降到1或2是合理的调优方向。
再说chunkSize。我的默认选择一直是2MB,但有一位读者问过我,为什么视频网站上传大文件时往往用更大的分块,比如10MB或20MB?那是因为视频网站的服务器带宽和服务端处理能力都很强,大分块可以减少请求次数,降低服务端的锁开销和数据库写入次数。如果只是企业级应用,2MB到5MB基本够用,没必要盲目追求大分块,毕竟分块越大,单块失败后的重传成本也越高。
还有一点关于分块文件的命名。千万不要用原始文件名来命名分块,因为不同用户可能上传同名文件,会导致分块互相覆盖。我当时用guid建目录、用分块索引做文件名,就是为了彻底规避这个问题。
最后讲一个心态层面的体会:分块上传看起来是一个不大的功能,但真要做到稳、快、可恢复、可排查,需要前端、后端、Web服务器三个层面配合好。前端负责把大文件拆开并发上传,后端负责按约定接收、记录、校验和合并,Nginx这类中间层负责不拖后腿。每一层都不复杂,但每一层都有自己独特的陷阱。我在这篇文章里写到的几个坑,全都是真实项目里踩过的,有些问题花了我整整一个下午才定位到。希望这篇详解能让你绕开这些弯路,直接在自己的项目里跑通分块上传。
