文件上传下载是后端接口的常见需求(如头像上传、报表下载),但简单实现往往存在隐患 —— 大文件上传超时、下载并发过高导致服务器带宽耗尽、文件存储占用过多磁盘空间。优化方案需兼顾可靠性(不丢文件)、性能(快传快下)、经济性(节省存储和带宽),构建高可用的文件处理体系。
文件上传:突破大小限制与并发瓶颈
1. 基础实现的问题
传统单文件上传接口(如 Spring MVC 的MultipartFile)在处理大文件(如 100MB 以上)时容易出现:
- 超时:上传时间超过接口超时设置
- 内存溢出:文件临时存储占用过多内存
- 并发低:单线程处理,无法利用多线程加速
2. 分片上传:将大文件 “化整为零”
将大文件拆分为多个小分片(如 5MB / 片),分多次上传,最后合并:
前端流程:
-
计算文件 MD5(用于校验完整性)
-
拆分文件为分片(如
file_1.part、file_2.part...) -
依次上传分片(支持并发上传)
-
所有分片上传完成后,请求合并
后端实现:
@RestController
@RequestMapping("/files")
public class FileUploadController {
@Autowired
private FileService fileService;
// 上传分片
@PostMapping("/upload/part")
public Result uploadPart(
@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("partIndex") int partIndex,
@RequestParam("totalParts") int totalParts) {
fileService.savePart(fileMd5, partIndex, file);
return Result.success();
}
// 合并分片
@PostMapping("/upload/merge")
public Result mergeFile(
@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName) {
String filePath = fileService.mergeParts(fileMd5, fileName);
return Result.success(filePath);
}
}
// 服务层实现
@Service
public class FileService {
// 临时存储分片(使用MD5作为目录名)
private final String TEMP_DIR = "/data/temp/";
public void savePart(String fileMd5, int partIndex, MultipartFile file) {
String partDir = TEMP_DIR + fileMd5 + "/";
File dir = new File(partDir);
if (!dir.exists()) {
dir.mkdirs();
}
// 保存分片到临时目录
File partFile = new File(partDir + partIndex);
file.transferTo(partFile);
}
public String mergeParts(String fileMd5, String fileName) {
String partDir = TEMP_DIR + fileMd5 + "/";
File dir = new File(partDir);
File[] parts = dir.listFiles();
if (parts == null || parts.length == 0) {
throw new BusinessException("分片不存在");
}
// 按索引排序分片
Arrays.sort(parts, Comparator.comparingInt(f -> Integer.parseInt(f.getName())));
// 合并分片到目标文件
String targetPath = "/data/files/" + fileName;
try (FileOutputStream out = new FileOutputStream(targetPath)) {
for (File part : parts) {
try (FileInputStream in = new FileInputStream(part)) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
// 删除临时分片
part.delete();
}
} catch (IOException e) {
throw new BusinessException("合并文件失败");
}
// 删除临时目录
dir.delete();
return targetPath;
}
}
分片上传优势:
- 支持大文件:规避单文件大小限制
- 断点续传:某分片上传失败,只需重传该分片
- 并发加速:多线程同时上传不同分片
3. 上传优化:减少服务端压力
-
前端校验:上传前检查文件大小、类型(如 “仅支持 jpg/png,最大 50MB”),减少无效请求
-
CDN 加速:静态文件(如头像、附件)通过 CDN 上传,绕过应用服务器
-
对象存储:将文件存储到 OSS(如阿里云 OSS、AWS S3),避免占用服务器磁盘
// 直传OSS示例(前端获取签名后直接上传到OSS)
@GetMapping("/upload/sign")
public Result getOssSign(@RequestParam("fileName") String fileName) {
// 生成OSS上传签名(有效期30分钟)
String policy = ossService.generatePolicy(fileName, 30);
return Result.success(policy);
}
文件下载:提升速度与并发能力
1. 基础下载的问题
简单的response.getOutputStream()下载存在:
- 大文件下载占用服务器内存
- 并发高时带宽被占满,影响其他服务
- 无断点续传,网络中断需重新下载
2. 断点续传:支持部分下载
通过Range请求头实现断点续传,客户端可指定下载的字节范围:
@GetMapping("/files/{fileName}")
public void downloadFile(
@PathVariable String fileName,
HttpServletRequest request,
HttpServletResponse response) {
String filePath = "/data/files/" + fileName;
File file = new File(filePath);
if (!file.exists()) {
response.setStatus(404);
return;
}
try {
long fileLength = file.length();
// 获取Range请求头(如Range: bytes=1024-2047)
String range = request.getHeader("Range");
if (range != null) {
// 处理断点续传
response.setStatus(206); // 部分内容
String[] rangeParts = range.replace("bytes=", "").split("-");
long start = Long.parseLong(rangeParts[0]);
long end = rangeParts.length > 1 ? Long.parseLong(rangeParts[1]) : fileLength - 1;
// 设置响应头:Content-Range
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
// 传输指定范围的字节
try (FileInputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream()) {
in.skip(start);
byte[] buffer = new byte[1024 * 1024];
long remaining = end - start + 1;
while (remaining > 0) {
int len = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
out.write(buffer, 0, len);
remaining -= len;
}
}
} else {
// 正常下载(全量)
response.setHeader("Content-Length", String.valueOf(fileLength));
try (FileInputStream in = new FileInputStream(file);
OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
} catch (IOException e) {
log.error("下载文件失败:{}", fileName, e);
}
}
3. 下载优化:减轻服务器负担
-
使用 CDN 分发:热门文件(如报表模板)通过 CDN 缓存,用户从 CDN 节点下载
-
压缩传输:对文本类文件(如 CSV 报表)启用 Gzip 压缩(参考 “数据压缩” 实践)
-
异步生成大文件:如 “导出 100 万条数据”,先异步生成文件,完成后通知用户下载
// 异步生成并通知下载
@PostMapping("/reports/export")
public Result exportReport(@RequestBody ReportParam param) {
// 生成任务ID
String taskId = UUID.randomUUID().toString();
// 异步生成报表
CompletableFuture.runAsync(() -> {
String filePath = reportService.generateLargeReport(param);
// 生成完成后,通知用户(如发送站内信)
notificationService.send(param.getUserId(), "报表已生成,可下载:" + filePath);
});
return Result.success("报表生成中,任务ID:" + taskId);
}
避坑指南
-
限制文件大小和类型:防止恶意上传超大文件或可执行文件(如
.exe) -
校验文件完整性:通过 MD5/SHA256 校验上传文件,避免损坏
-
清理临时文件:定时删除未合并的分片、过期的下载文件,释放磁盘空间
-
监控存储使用:设置磁盘使用率告警(如超过 80% 时预警)
文件上传下载的优化,核心是 “将压力从应用服务器转移出去”—— 通过分片、CDN、对象存储等手段,让应用服务器专注于业务逻辑,而非文件 IO。一个稳定的文件处理体系,既能提升用户体验(快传快下),又能保障系统在高并发下的可用性,这是后端接口 “细节决定成败” 的典型场景。