Java文件分片上传与下载实战:秒传、断点续传、并发合并与Range下载
工单系统里用户要传设备日志包、现场视频——几十 MB 起步,网络一抖全白传;下载也是,断了就得从头来。这篇把文件分片上传(切片、并发、秒传、断点续传、合并)和大文件下载(Range 断点续传、流式输出)一次讲透,Spring Boot 3 + Java 21 完整代码 + 前端切片逻辑 + 对象存储进阶方案,工程细节全部给到。
一、开篇:为什么大文件必须分片
| 痛点 | 整包上传 | 分片上传 |
|---|---|---|
| 网络抖动 | 整个文件重传 | 只重传失败分片 |
| 上传超时 | 大文件必超时 | 单片小,逐片成功 |
| 秒传 | 无从谈起 | MD5 校验,已有文件秒回 |
| 进度显示 | 只能装样子 | 按分片精确到 1% |
| 并发能力 | 单连接顶满 | 多分片并发,带宽打满 |
核心思想:把"一个大问题"切成"很多小问题",每个小问题可独立成功、可独立重试——和微服务拆分异曲同工。
二、总体设计:四个接口走完全流程
①check 秒传/续传检查 → 已存在?已传哪些分片? ②chunk 分片上传 → 失败只重传该片 ③merge 合并分片 → 校验完整性,落正式存储 ④download 下载 → Range 断点续传 + 流式输出
数据模型(MySQL,MyBatis-Plus 风格):
CREATE TABLE file_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_md5 VARCHAR(32) NOT NULL, -- 秒传主键 file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, file_path VARCHAR(500) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0 上传中 1 完成 uploader_id BIGINT NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_md5 (file_md5) ); CREATE TABLE file_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_md5 VARCHAR(32) NOT NULL, chunk_idx INT NOT NULL, chunk_size BIGINT NOT NULL, UNIQUE KEY uk_md5_idx (file_md5, chunk_idx) );
目录约定:临时分片 /data/upload/tmp/{md5}/{index}.part,正式文件 /data/upload/files/{md5}{suffix}——用 MD5 当文件名,天然去重、天然防重名。
三、核心实现:上传三接口
1. 秒传/续传检查(check)
@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
public class FileUploadController {
private final FileInfoMapper fileInfoMapper;
private final FileChunkMapper fileChunkMapper;
private final FileStorageService storage;
/** ① 秒传 + 断点续传检查 */
@PostMapping("/check")
public Result<UploadCheckVO> check(@RequestBody FileCheckReq req) {
UploadCheckVO vo = new UploadCheckVO();
// 秒传:同 MD5 文件已存在
FileInfo exist = fileInfoMapper.selectOne(
new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileMd5, req.getMd5()));
if (exist != null) {
vo.setUploaded(true);
vo.setFileId(exist.getId());
return Result.ok(vo);
}
// 断点续传:返回已传分片索引集合
vo.setUploaded(false);
vo.setUploadedChunks(fileChunkMapper.selectList(
new LambdaQueryWrapper<FileChunk>().eq(FileChunk::getFileMd5, req.getMd5()))
.stream().map(FileChunk::getChunkIdx).collect(Collectors.toSet()));
return Result.ok(vo);
}
2. 分片上传(chunk)——流式写盘,绝不 OOM
/** ② 单分片上传(幂等:同分片重复上传覆盖即可) */
@PostMapping("/chunk")
public Result<Void> uploadChunk(@RequestParam String md5,
@RequestParam int chunkIdx,
@RequestParam("file") MultipartFile part) throws IOException {
// ⚠️ 路径安全:chunkIdx 必须校验范围,md5 必须正则校验,防目录穿越
if (!md5.matches("[0-9a-f]{32}")) {
return Result.fail("非法参数");
}
storage.saveChunk(md5, chunkIdx, part.getInputStream(), part.getSize());
// 记录分片(幂等 upsert)
fileChunkMapper.insert(new FileChunk()
.setFileMd5(md5).setChunkIdx(chunkIdx).setChunkSize(part.getSize()));
return Result.ok();
}
3. 合并分片(merge)——顺序流合并 + 完整性校验
/** ③ 合并:顺序写 + 校验大小 + 校验 MD5 */
@PostMapping("/merge")
public Result<Long> merge(@RequestBody FileMergeReq req) throws Exception {
String md5 = req.getMd5();
File tmpDir = new File(storage.chunkDir(md5));
File[] parts = tmpDir.listFiles((d, n) -> n.endsWith(".part"));
if (parts == null || parts.length != req.getTotalChunks()) {
return Result.fail("分片缺失,请重传");
}
File target = new File(storage.filePath(md5, req.getSuffix()));
long total = 0;
// ⚠️ 别用 Files.write 一次性读入 byte[](OOM 元凶),用流式追加
try (FileOutputStream fos = new FileOutputStream(target)) {
for (int i = 0; i < req.getTotalChunks(); i++) {
File part = new File(tmpDir, i + ".part");
try (InputStream in = new FileInputStream(part)) {
total += in.transferTo(fos); // 流式拷贝,JDK 21 transferTo
}
}
}
// 完整性校验:大小 + MD5(合并后重算一次)
if (total != req.getTotalSize() || !req.getMd5().equals(storage.md5(target))) {
target.delete();
return Result.fail("文件校验失败");
}
Long fileId = storage.saveFileInfo(req, target); // 落库 + 清理临时分片
FileUtils.deleteDirectory(tmpDir); // 合并成功即清分片
return Result.ok(fileId);
}
}
前端配合(三行核心逻辑):
// 切片(每片 5MB)
const CHUNK_SIZE = 5 * 1024 * 1024;
const chunks = [];
for (let i = 0; i * CHUNK_SIZE < file.size; i++) {
chunks.push(file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
}
// MD5 用 Web Worker 算(别卡主线程);并发上传用 Promise 池(并发 3~4)
const { uploaded, uploadedChunks } = await axios.post('/file/check', { md5 });
if (uploaded) return; // 秒传直接完成
await pool(3, chunks.map((c, i) =>
uploadedChunks.has(i) ? Promise.resolve() : // 断点续传跳过
() => uploadChunk(md5, i, c))); // 失败自动重试 3 次
await axios.post('/file/merge', { md5, totalChunks: chunks.length, totalSize: file.size });
四、大文件下载:Range 断点续传 + 流式输出
浏览器/下载工具的断点续传靠 HTTP Range 协议:请求带 Range: bytes=100-,服务端返回 206 Partial Content。
/** ④ 支持 Range 的流式下载 */
@GetMapping("/download/{id}")
public void download(@PathVariable Long id,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
FileInfo info = fileInfoMapper.selectById(id);
File file = new File(info.getFilePath());
long fileLen = file.length();
// 解析 Range 头:bytes=start-end
String range = request.getHeader("Range");
long start = 0, end = fileLen - 1;
if (range != null && range.startsWith("bytes=")) {
String[] parts = range.substring(6).split("-");
start = Long.parseLong(parts[0]);
if (parts.length > 1 && !parts[1].isEmpty()) {
end = Long.parseLong(parts[1]);
}
response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); // 206
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLen);
} else {
response.setStatus(HttpStatus.OK.value());
}
response.setContentType("application/octet-stream");
response.setHeader("Accept-Ranges", "bytes");
// ⚠️ 中文文件名:RFC 5987 编码,直接放会乱码
String encoded = URLEncoder.encode(info.getFileName(), StandardCharsets.UTF_8)
.replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename*=UTF-8''" + encoded);
response.setContentLengthLong(end - start + 1);
// 流式输出(RandomAccessFile 支持从任意位置读)
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(start);
OutputStream out = response.getOutputStream();
byte[] buf = new byte[8192];
long remaining = end - start + 1;
while (remaining > 0) {
int read = raf.read(buf, 0, (int) Math.min(buf.length, remaining));
if (read < 0) break;
out.write(buf, 0, read);
remaining -= read;
}
}
}
关键点:
- 200 vs 206:无
Range头返回 200 全量,有则 206 +Content-Range; Accept-Ranges: bytes声明支持断点,下载工具(IDM/迅雷)才会分段并发下载;- 流式:
RandomAccessFile.seek+ 8KB 缓冲,10GB 文件内存占用恒定; - Nginx 注意:反向代理要透传 Range 头(默认透传,但
proxy_buffering on会缓冲大响应,建议下载接口配X-Accel-Redirect或关缓冲):
location /file/download/ {
proxy_buffering off; # 大文件下载关缓冲
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
}五、工程细节:并发、幂等与可靠性
| 问题 | 方案 |
|---|---|
| 分片并发上传乱序 | 每片独立文件 {idx}.part,合并时按序号取,顺序无关 |
| 同分片重复上传 | 覆盖写 + uk_md5_idx 唯一键 upsert,幂等 |
| 合并中途失败 | 分片不删,用户重进走 check 拿到已传分片继续(断点续传) |
| 上传一半放弃 | 定时任务清理 tmp/{md5} 下 7 天未动的分片(垃圾回收) |
| MD5 计算卡页面 | 前端 Web Worker + 大文件抽样 MD5(首中尾各 2MB,精度换速度) |
| 并发合并冲突 | merge 加分布式锁(Redis SETNX md5),防止双击触发两次合并 |
| 校验被绕过 | 后端合并后重算 MD5,不信前端声明的值 |
六、进阶:对象存储(OSS)直传方案
单机存储到瓶颈后(磁盘/多实例共享),标准答案是 OSS 分片直传:
前端 → 向后端要"分片上传凭证"(STS 临时密钥/签名)
→ 直传 OSS(Multipart Upload,不占应用带宽)
→ 全部分片完成后调"合并"(OSS CompleteMultipartUpload)
→ 回调通知后端落库
| 维度 | 本地磁盘 | OSS 直传 |
|---|---|---|
| 带宽 | 走应用服务器 | 客户端直连 OSS |
| 扩容 | 换盘/挂载 | 无限 |
| 秒传 | 自己实现 | OSS 也有秒传(同 bucket 同 MD5) |
| 成本 | 低 | 按量付费 |
架构建议:应用起步用本地磁盘(简单可控),用户规模上来再迁 OSS——存储层抽个 FileStorageService 接口,两种实现切换,业务代码零改动(这也是面试加分点:留好演进空间)。
七、实战场景:工单系统附件模块
把上面的能力放进工单系统:
- 上传约束:日志包限 500MB、图片限 10MB、格式白名单(
.log .zip .jpg)——上传前前端校验 + 上传后后端魔数校验(防改后缀传木马); - 安全钩子:文件落盘后异步病毒扫描(ClamAV),扫描通过才允许下载;
- 权限:下载接口校验
uploader_id或工单归属(数据权限切面统一注入)——文件链接不能是裸的公网可猜 ID; - 审计:上传/下载都走 AOP 审计日志(谁、何时、哪个文件)——和之前工单系统的
@OperateLog切面复用。
八、避坑清单(8 条血泪)
| # | 坑 | 后果 | 解法 |
|---|---|---|---|
| 1 | 用 byte[] 存整个文件 | 大文件 OOM | 流式 transferTo |
| 2 | 文件名直接落路径 | 目录穿越漏洞 | MD5 命名 + 参数正则校验 |
| 3 | 中文文件名乱码 | 用户投诉 | filename*=UTF-8'' RFC 5987 |
| 4 | 前端传的 MD5 不复核 | 校验形同虚设 | 合并后重算比对 |
| 5 | 分片永不清理 | 磁盘被打满 | 定时清理 + 监控告警 |
| 6 | Nginx 缓冲下载 | 大文件卡死 | proxy_buffering off |
| 7 | 合并不加锁 | 双击出两个文件 | 分布式锁 + 幂等 |
| 8 | 下载链接永久有效 | 越权/盗链 | 签名 URL + 过期时间 |
九、面试追问 Top5
| 问题 | 答题要点 |
|---|---|
| 分片上传流程? | check(秒传/续传)→ chunk(并发+幂等)→ merge(校验+落库) |
| 怎么实现秒传? | 文件 MD5 全局唯一键,命中即秒回 |
| 断点续传原理? | 上传:记录已传分片;下载:HTTP Range + 206 |
| 大文件下载怎么防 OOM? | 流式 + RandomAccessFile.seek + 小缓冲 |
| 多实例部署怎么共享文件? | OSS 对象存储 + FileStorageService 抽象层 |
十、总结与自测清单
- ✅ 分片上传四接口:check / chunk / merge / download,职责清晰;
- ✅ 秒传 = MD5 唯一键;断点续传 = 已传分片记录 + Range 协议;
- ✅ 流式读写是底线,
byte[]大文件必 OOM; - ✅ 幂等三件套:分片唯一键、覆盖写、合并分布式锁;
- ✅ 安全三防线:路径校验(防穿越)、魔数校验(防木马)、签名 URL(防盗链);
- ✅ 存储层留抽象(本地/OSS 可切换),是架构演进的伏笔;
- ✅ 下载接口配套 Nginx 配置(透传 Range、关缓冲),别只调 Java。
以上就是Java文件分片上传与下载实战:秒传、断点续传、并发合并与Range下载的详细内容,更多关于Java文件分片上传与下载的资料请关注脚本之家其它相关文章!
相关文章
SpringBoot整合Springsecurity实现数据库登录及权限控制功能
本教程详细介绍了如何使用SpringBoot整合SpringSecurity实现数据库登录和权限控制,本文分步骤结合实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧2024-10-10
Java服务调用RestTemplate与HttpClient的使用详解
无论是微服务还是SOA,都面临着服务间的远程调用,这篇文章主要介绍了服务调用RestTemplate与HttpClient的使用,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧2022-06-06


最新评论