引言
线上跑着跑着突然报 outofmemoryerror,或者传个几百兆的视频动不动就网络中断,这种坑做 java 基本都踩过。文件上传模块平时看着不起眼,一到 gb 级别就开始暴露各种底层问题。传统的那套 multipartfile 同步接收根本扛不住,今天咱们就按生产环境的实际标准,把分片协议、服务端异步合并、云端直传和断点续传这条链路理清楚。不整虚的,直接上干货和踩坑记录。
1. 传统直传为什么扛不住大文件?
spring boot 默认的上传走的是 servlet 容器的临时文件机制。文件一过百兆,堆外内存直接报警。就算你老老实实配了 spring.servlet.multipart.max-file-size,把临时文件落到磁盘,同步读取整个文件再做处理,照样会卡住 tomcat 的工作线程。
公网环境更是不讲武德。wi-fi 切 4g、用户锁屏、运营商限流,http 长连接说断就断。失败后只能从 0 开始重传,体验极差不说,还白白浪费带宽。最致命的是应用服务器本来就该处理业务逻辑的,非让它当文件中转网关,出口带宽一打满,核心查询接口全跟着拖慢,雪崩就是这么来的。客户端 → 应用服务器 → 对象存储,流量硬生生走了两遍,io 放大一倍,这种架构在微服务时代基本属于反面教材。
解决思路其实就一条:把大文件拆成能独立传输的小块,服务端只做路由和元数据记录,数据流能绕开应用服务器就坚决不经过它。
2. 分片协议怎么设计才不扯皮?
协议定好了,前后端和存储端才能对齐。一套能跑通的分片协议,核心就盯住三个东西:任务怎么标识、传输怎么控、合并怎么排。
2.1 基础字段约定
{
"uploadid": "biz_20231024_x9d2",
"filehash": "a1b2c3d4e5f6...",
"filename": "raw_video.mp4",
"totalsize": 1048576000,
"totalchunks": 200,
"chunkindex": 15,
"chunksize": 5242880
}uploadid 是全局任务标识,chunkindex 从 0 开始计数。总分片数 totalchunks 必须和前端切片逻辑强一致,别两边各算各的。
2.2 hash 校验要不要上?
文件级 hash(通常是 md5 或 sha256)是必须的,前端传过来做秒传判断,最后合并完了再对一遍,防篡改。分片级 hash 看业务场景,一般生产环境不推荐全开。https 本身有完整性校验,云存储底层也有 crc,服务端每片再算一遍摘要,cpu 开销直接翻倍。除非你做金融级强校验,否则别折腾自己。
2.3 并发与乱序处理
浏览器并发请求数别开太高,3 到 5 个活跃窗口刚好。开多了容易被网关限流,或者触发云厂商的 429 too many requests;开少了带宽跑不满,传得慢。乱序是网络常态,index=18 比 index=5 先到服务端太正常了。记住,合并的时候严格按 chunkindex 排序,绝对不要按文件修改时间或者接收时间排,否则出来的文件全是乱码。
3. 服务端接收与异步合并(附生产级代码)
服务端角色要尽量做薄。接收接口只管落盘和记流水,合并操作必须抽离 http 请求线程。
3.1 分片接收
@restcontroller
@requestmapping("/api/v1/upload")
@requiredargsconstructor
public class chunkuploadcontroller {
private final chunkuploadservice chunkservice;
@postmapping("/chunk")
public responseentity<void> uploadchunk(
@requestparam("file") multipartfile chunk,
@requestparam string uploadid,
@requestparam int chunkindex) {
chunkservice.savechunk(uploadid, chunk, chunkindex);
return responseentity.ok().build();
}
}
3.2 异步合并逻辑
合并千万别放在主线程里,容易把网关请求拖超时。用独立线程池,配合 filechannel 做零拷贝。
@service
@slf4j
@requiredargsconstructor
public class chunkuploadservice {
private final string tempbasedir = system.getproperty("java.io.tmpdir") + "/uploads";
private final threadpooltaskexecutor mergeexecutor;
public void savechunk(string uploadid, multipartfile chunk, int index) {
path taskdir = paths.get(tempbasedir, uploadid);
try {
files.createdirectories(taskdir);
path target = taskdir.resolve("chunk_" + index);
// 用 files.copy 替代 transferto,规避部分容器临时文件被提前清理的坑
try (inputstream is = chunk.getinputstream()) {
files.copy(is, target, standardcopyoption.replace_existing);
}
// 记录已上传分片到 redis set,key: upload:chunks:{uploadid}
} catch (ioexception e) {
throw new runtimeexception("chunk save failed", e);
}
}
@async("mergeexecutor")
public void mergechunks(string uploadid, string filename, int totalchunks) {
path taskdir = paths.get(tempbasedir, uploadid);
try {
list<path> chunks = files.list(taskdir)
.filter(p -> p.getfilename().tostring().startswith("chunk_"))
.sorted(comparator.comparingint(this::extractindex))
.collect(collectors.tolist());
if (chunks.size() != totalchunks) {
log.warn("chunk mismatch for {}: expected {}, got {}", uploadid, totalchunks, chunks.size());
// 实际生产应触发重试或告警,这里抛异常示意
throw new illegalstateexception("missing chunks");
}
path finaldir = paths.get("/data/files/final");
files.createdirectories(finaldir);
path merged = finaldir.resolve(filename);
// 零拷贝合并
try (filechannel out = filechannel.open(merged, standardopenoption.create,
standardopenoption.write, standardopenoption.truncate_existing)) {
for (path chunk : chunks) {
try (filechannel in = filechannel.open(chunk, standardopenoption.read)) {
out.transferfrom(in, out.position(), in.size());
}
}
}
// 清理临时任务目录
deleterecursively(taskdir.tofile());
log.info("merge success: {}", filename);
} catch (exception e) {
log.error("merge failed for uploadid: {}", uploadid, e);
// 生产环境建议记录失败日志并推入重试队列
}
}
private int extractindex(path p) {
string name = p.getfilename().tostring();
return integer.parseint(name.substring(name.indexof("_") + 1));
}
}
几点实际经验:
filechannel.transferfrom在 linux 下效率很高,但要注意position的累加。代码里直接用out.position()更稳妥,避免手动维护变量出错。- 临时目录一定要配定时清理。跑个 xxl-job 或 spring
@scheduled,扫 ttl 超过 24 小时的孤儿任务目录,不然服务器磁盘几天就爆了。 - 合并失败要有降级策略。比如重试 3 次后标记
merge_failed,前端提示重新触发合并或人工介入。
4. 流量绕开应用服务器:oss/minio 直传
文件一上 gb,就别让流量经过 spring boot 了。架构演进到这一步,应用服务器只干两件事:发令牌、记元数据。数据读写全交给云存储。
4.1 sts 临时凭证 vs 预签名 url
sts 适合客户端自己调云厂商 sdk 的场景,权限控制粒度细,能严格限定 bucket、object 前缀、最大体积,甚至只允许 putobject。预签名 url 更轻量,服务端算好带签名的 put 地址直接丢给前端,客户端拿标准 http 请求就能传。两种都行,看你们前端基建。预签名 url 记得把有效期压到 10~15 分钟,超时即焚,防被爬。
@service
public class osspresignservice {
private final s3client ossclient; // minio 或 aws sdk 兼容客户端
public string generatepresignedurl(string objectkey, int expireminutes) {
generatepresignedurlrequest req = new generatepresignedurlrequest("your-bucket", objectkey);
req.setexpiration(date.from(instant.now().plusminutes(expireminutes)));
req.setmethod(httpmethod.put);
return ossclient.generatepresignedurl(req).tostring();
}
}
云厂商底层对分片上传做了深度优化,支持 cdn 加速和全球传输,服务端合并也是自动的。咱们把 initiatemultipartupload、uploadpart、completemultipartupload 这几个状态机跑通,剩下的交给基础设施就行。
5. 断点续传与前端进度对接
断点续传说白了就是查缺补漏+状态同步。
5.1 后端查状态
@getmapping("/status")
public responseentity<list<integer>> getuploadedchunks(@requestparam string filehash) {
set<string> indices = redis.opsforset().members("upload:chunks:" + filehash);
return responseentity.ok(indices.stream()
.map(integer::parseint)
.sorted()
.tolist());
}
redis set 存已上传的片号索引。前端拿这个列表对比总片数,缺哪片补哪片。
5.2 前端续传逻辑(注意 formdata 坑)
很多教程这里写错了。axios 发文件必须用 formdata,直接传 json 对象云存储或后端根本解析不出来。
async function resumeupload(file, hash) {
const { data: uploaded } = await axios.get(`/api/v1/upload/status?filehash=${hash}`);
const chunksize = 5 * 1024 * 1024;
const total = math.ceil(file.size / chunksize);
const missing = [];
for (let i = 0; i < total; i++) {
if (!uploaded.includes(i)) missing.push(i);
}
const uploadpromises = missing.map(index => {
const start = index * chunksize;
const end = math.min(start + chunksize, file.size);
const chunkblob = file.slice(start, end);
const formdata = new formdata();
formdata.append('file', chunkblob);
formdata.append('uploadid', generateuploadid(hash));
formdata.append('chunkindex', index);
return axios.post(`/api/v1/upload/chunk`, formdata, {
headers: { 'content-type': 'multipart/form-data' },
onuploadprogress: (evt) => updateprogress(index, evt.loaded, chunksize)
});
});
await promise.all(uploadpromises);
// 通知后端触发合并
}进度条计算别搞复杂了:(已传完片数 * 单片大小 + 当前片已传字节) / 总大小 * 100 足够精准。大文件 hash 计算极耗 cpu,千万别放主线程。上 web worker + sparkmd5,分块读取 file 对象,浏览器才不会假死。
6. 安全防线必须前置
大文件通道是黑产和爬虫的乐园,安全不做好,服务器早晚被塞满。
6.1 别信 content-type
前端传过来的 content-type 和文件后缀随便都能伪造。服务端得读文件头 magic number。apache tika 挺好使,但注意别全量读大文件,截取前 4kb 流进去检测就行。.php, .jsp, .sh 这类可执行文件直接拦截。图片视频最好结合 exif 头再校验一层,防木马藏进图片里。
6.2 访问控制与限流
云存储侧配好 referer 和 ip 白名单,只允许业务域名拉取。预签名 url 用完即废,别留长尾漏洞。限流用 redis + lua 做滑动窗口,比如单用户每分钟最多发起 20 个分片请求。结合业务上下文校验用户可用配额,防止恶意占满存储桶。上传完成后,异步丢给 clamav 或云安全中心扫一遍,命中病毒直接标记隔离并告警。
7. 落地建议
早年做文件服务,基本是客户端直传应用服务器,落本地盘。跑内部小系统还行,人一多就 oom 加带宽打满。后来升级到服务端接分片,异步合并再推给 nfs/ftp,线程池调优和临时目录清理能折腾掉半条命。现在生产环境基本都切到云端直传了。应用服务器退居二线只做控制面,数据面全交给 oss。架构看着是变简单了,但细节反而更考究:怎么保证元数据最终一致?合并失败怎么告警重试?sts 过期前端怎么无感刷新?这些才是真正卡脖子的地方。
实际落地别一上来就全量切分片。先压一压网络波动模拟断线,看看 redis 状态记录和临时文件清理能不能兜住。灰度放量时盯紧几个核心指标:分片堆积率、合并失败率、临时目录磁盘水位。把控制面和数据面彻底剥离开,大文件上传这关才算真正趟平。
以上就是springboot大文件处理实战指南(分片上传、断点续传与oss集成)的详细内容,更多关于springboot大文件处理的资料请关注代码网其它相关文章!
发表评论