一、前言:大文件上传的痛点与解决方案
在 Java 开发中,大文件上传是高频需求也是技术难点。传统单文件直接上传方案在面对 1GB 以上文件时,常会出现超时失败、内存溢出、用户体验差等问题 —— 比如网络波动导致上传中断后需重新上传、大文件加载占用过多内存导致服务 OOM、上传进度无法感知等。
本文将从底层原理出发,结合实战场景,手把手实现一套支持分片上传、断点续传、秒传、并发控制的大文件上传方案。方案基于 JDK 17+Spring Boot 3.x 构建,整合 MyBatis-Plus、MinIO、Redis 等组件,所有代码均可直接运行,同时兼顾深度与可读性,让新手能快速上手,资深开发者能夯实底层逻辑。
二、底层原理:看透大文件上传的核心逻辑
2.1 为什么传统单文件上传行不通?
传统上传方案是将文件作为一个整体通过 HTTP 请求发送到服务端,核心问题集中在三点:
超时风险:大文件传输时间长,容易触发 HTTP 超时(默认 Tomcat 超时为 60 秒),网络波动时直接上传失败;内存压力:服务端接收文件时,会将整个文件加载到内存处理,1GB 文件可能直接导致 JVM OOM;体验极差:上传中断后需重新上传整个文件,无进度反馈,用户无法预估时间。
2.2 核心解决方案:分片上传
2.2.1 分片上传原理
分片上传是将大文件拆分为多个小分片(如 5MB / 片),分别上传到服务端,服务端接收完所有分片后再合并为原始文件。核心流程如下:

2.2.2 关键技术点拆解
文件唯一标识:用文件 MD5 作为唯一标识,确保分片与原始文件一一对应,也是秒传和断点续传的核心依据;分片拆分规则:按固定大小拆分(如 5MB),最后一片大小可能小于固定值,需记录总分片数和当前分片索引;分片传输保障:每个分片上传时携带 MD5、分片索引、总分片数等元数据,服务端校验合法性;合并逻辑:所有分片上传完成后,按分片索引顺序合并,避免文件损坏。
2.3 断点续传与秒传的底层逻辑
2.3.1 断点续传原理
断点续传基于分片上传,核心是「记录已上传分片」,避免重复上传:
前端上传前先查询服务端:该文件已上传的分片索引列表;前端跳过已上传分片,仅上传未完成的分片;支持暂停 / 继续功能:暂停时仅停止分片上传,不清理已上传分片;继续时重复第一步逻辑。

2.3.2 秒传原理
秒传的核心是「文件预校验」,本质是利用文件唯一标识(MD5)快速判断文件是否已存在:
前端计算文件 MD5 后,先向服务端发送秒传校验请求;服务端查询该 MD5 对应的文件是否已上传完成;若已存在,则直接返回秒传成功,无需上传任何分片;若不存在或未上传完成,则进入分片上传流程。
2.3.3 易混淆点区分
| 技术点 | 核心逻辑 | 适用场景 |
|---|---|---|
| 分片上传 | 拆分文件 + 分块传输 + 合并 | 所有大文件上传(100MB+) |
| 断点续传 | 记录已上传分片 + 跳过重复上传 | 网络不稳定、大文件长时间上传 |
| 秒传 | MD5 预校验 + 已上传文件匹配 | 重复上传同一文件(如用户二次上传) |
2.4 关键技术选型说明
| 组件 | 版本号 | 作用说明 | 选择理由 |
|---|---|---|---|
| JDK | 17 | 开发运行环境 | 长期支持版本,兼容 Spring Boot 3.x |
| Spring Boot | 3.2.5 | 项目基础框架 | 最新稳定版,支持 JDK 17+ |
| MyBatis-Plus | 3.5.4.4 | 持久层框架 | 简化 CRUD,支持分页、乐观锁等 |
| MinIO | 8.5.12 | 分布式文件存储 | 轻量、兼容 S3 协议,适合大文件存储 |
| Redis | 7.2.4 | 分布式锁、缓存已上传分片 | 高性能,支持分布式场景 |
| Fastjson2 | 2.0.49 | JSON 序列化 / 反序列化 | 速度快,支持 JDK 17+ |
| Lombok | 1.18.30 | 简化 POJO 代码 | 减少 getter/setter 等冗余代码 |
| Springdoc-OpenAPI | 2.2.0 | Swagger3 接口文档 | 适配 Spring Boot 3.x,替代 Springfox |
| Vue 3 | 3.4.21 | 前端框架 | 轻量、响应式,适合上传组件开发 |
| Axios | 1.6.8 | 前端 HTTP 请求工具 | 支持中断请求、进度监听 |
| Spark-MD5 | 3.0.2 | 前端大文件 MD5 计算 | 支持分片计算 MD5,避免内存溢出 |
三、实战准备:环境搭建与项目初始化
3.1 开发环境要求
JDK 17(需配置环境变量)MySQL 8.0(用于存储文件元数据、分片信息)Redis 7.0+(用于分布式锁、缓存)MinIO 8.5+(分布式存储,可选本地存储替代)Maven 3.8+(项目构建工具)Node.js 16+(前端项目运行)
3.2 后端项目初始化(Spring Boot)
3.2.1 pom.xml 依赖配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.ken.file</groupId>
<artifactId>large-file-upload</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>large-file-upload</name>
<description>Java大文件上传实战项目</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.4.4</mybatis-plus.version>
<minio.version>8.5.12</minio.version>
<fastjson2.version>2.0.49</fastjson2.version>
<springdoc.version>2.2.0</springdoc.version>
<guava.version>32.1.3-jre</guava.version>
</properties>
<dependencies>
<!-- Spring Boot核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MinIO -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
3.2.2 核心配置文件(application.yml)
spring:
# 数据源配置
datasource:
url: jdbc:mysql://localhost:3306/large_file_upload?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root123456
driver-class-name: com.mysql.cj.jdbc.Driver
# Redis配置
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
# 上传文件临时存储路径
servlet:
multipart:
enabled: true
max-file-size: 10MB # 单个分片最大大小(需大于前端分片大小)
max-request-size: 100MB # 单次请求最大大小
# Spring Boot配置
server:
port: 8080
servlet:
context-path: /file-upload
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.ken.file.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
# MinIO配置
minio:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket-name: large-file-bucket # 存储大文件的桶名(需提前创建)
# 自定义上传配置
file:
upload:
chunk-size: 5242880 # 分片大小5MB(5*1024*1024)
local-storage-path: D:/large-file-upload/local-storage # 本地存储路径(单机模式)
expire-days: 7 # 未完成上传的分片过期时间(天)
3.3 数据库设计(MySQL 8.0)
需创建两张核心表:(文件元数据表)和
file_metadata(分片信息表),SQL 脚本如下:
file_chunk
CREATE DATABASE IF NOT EXISTS large_file_upload DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE large_file_upload;
-- 文件元数据表:存储文件整体信息
CREATE TABLE IF NOT EXISTS file_metadata (
id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件唯一标识(MD5)',
file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
file_size BIGINT NOT NULL COMMENT '文件总大小(字节)',
chunk_total INT NOT NULL COMMENT '总分片数',
file_suffix VARCHAR(50) COMMENT '文件后缀(如mp4、zip)',
storage_type TINYINT NOT NULL DEFAULT 1 COMMENT '存储类型:1-本地存储,2-MinIO',
file_path VARCHAR(512) COMMENT '文件存储路径(本地路径或MinIO访问路径)',
status TINYINT NOT NULL DEFAULT 0 COMMENT '文件状态:0-上传中,1-上传完成,2-上传失败',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
UNIQUE KEY uk_file_md5 (file_md5) COMMENT '文件MD5唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件元数据表';
-- 分片信息表:存储单个分片的信息
CREATE TABLE IF NOT EXISTS file_chunk (
id BIGINT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件唯一标识(关联file_metadata.file_md5)',
chunk_index INT NOT NULL COMMENT '分片索引(从0开始)',
chunk_size BIGINT NOT NULL COMMENT '分片大小(字节)',
chunk_path VARCHAR(512) NOT NULL COMMENT '分片存储路径',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
UNIQUE KEY uk_file_md5_chunk_index (file_md5, chunk_index) COMMENT '文件MD5+分片索引唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分片信息表';
-- 索引优化:加快查询速度
CREATE INDEX idx_file_md5_status ON file_metadata(file_md5, status);
CREATE INDEX idx_file_md5 ON file_chunk(file_md5);
3.4 前端项目初始化(Vue 3)
3.4.1 项目创建与依赖安装
# 创建Vue项目
npm create vue@latest large-file-upload-frontend
cd large-file-upload-frontend
# 安装核心依赖
npm install axios@1.6.8 spark-md5@3.0.2 element-plus@2.7.0
3.4.2 前端核心配置(src/utils/request.js)
import axios from 'axios';
// 创建Axios实例
const service = axios.create({
baseURL: 'http://localhost:8080/file-upload',
timeout: 60000, // 分片上传超时时间(1分钟)
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
service.interceptors.request.use(
config => {
// 可添加token等认证信息
return config;
},
error => {
Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
response => {
const res = response.data;
if (res.code !== 200) {
console.error('请求失败:', res.msg);
return Promise.reject(res);
}
return res;
},
error => {
console.error('请求异常:', error.message);
return Promise.reject(error);
}
);
export default service;
四、核心组件开发:后端实现
4.1 实体类设计(Entity)
4.1.1 文件元数据实体(FileMetadata.java)
package com.ken.file.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 文件元数据表实体
* @author ken
*/
@Data
@TableName("file_metadata")
public class FileMetadata {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 文件唯一标识(MD5)
*/
private String fileMd5;
/**
* 原始文件名
*/
private String fileName;
/**
* 文件总大小(字节)
*/
private Long fileSize;
/**
* 总分片数
*/
private Integer chunkTotal;
/**
* 文件后缀(如mp4、zip)
*/
private String fileSuffix;
/**
* 存储类型:1-本地存储,2-MinIO
*/
private Integer storageType;
/**
* 文件存储路径(本地路径或MinIO访问路径)
*/
private String filePath;
/**
* 文件状态:0-上传中,1-上传完成,2-上传失败
*/
private Integer status;
/**
* 创建时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 更新时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
/**
* 逻辑删除:0-未删除,1-已删除
*/
private Integer isDeleted;
}
4.1.2 分片信息实体(FileChunk.java)
package com.ken.file.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 分片信息表实体
* @author ken
*/
@Data
@TableName("file_chunk")
public class FileChunk {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 文件唯一标识(关联file_metadata.file_md5)
*/
private String fileMd5;
/**
* 分片索引(从0开始)
*/
private Integer chunkIndex;
/**
* 分片大小(字节)
*/
private Long chunkSize;
/**
* 分片存储路径
*/
private String chunkPath;
/**
* 创建时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
* 更新时间
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
/**
* 逻辑删除:0-未删除,1-已删除
*/
private Integer isDeleted;
}
4.2 DTO 与 VO 设计
4.2.1 上传请求 DTO(FileUploadDTO.java)
package com.ken.file.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/**
* 分片上传请求DTO
* @author ken
*/
@Data
@Schema(description = "分片上传请求参数")
public class FileUploadDTO {
/**
* 文件MD5唯一标识
*/
@NotBlank(message = "文件MD5不能为空")
@Schema(description = "文件MD5唯一标识", requiredMode = Schema.RequiredMode.REQUIRED)
private String fileMd5;
/**
* 原始文件名
*/
@NotBlank(message = "文件名不能为空")
@Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
private String fileName;
/**
* 文件总大小(字节)
*/
@NotNull(message = "文件大小不能为空")
@Schema(description = "文件总大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
private Long fileSize;
/**
* 当前分片索引(从0开始)
*/
@NotNull(message = "分片索引不能为空")
@Schema(description = "当前分片索引(从0开始)", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer chunkIndex;
/**
* 总分片数
*/
@NotNull(message = "总分片数不能为空")
@Schema(description = "总分片数", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer chunkTotal;
/**
* 分片文件
*/
@NotNull(message = "分片文件不能为空")
@Schema(description = "分片文件", requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile chunkFile;
}
4.2.2 响应 VO(ResultVO.java)
package com.ken.file.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 统一响应VO
* @author ken
*/
@Data
@Accessors(chain = true)
@Schema(description = "统一响应结果")
public class ResultVO<T> {
/**
* 响应码:200-成功,500-失败
*/
@Schema(description = "响应码", example = "200")
private Integer code;
/**
* 响应信息
*/
@Schema(description = "响应信息", example = "操作成功")
private String msg;
/**
* 响应数据
*/
@Schema(description = "响应数据")
private T data;
/**
* 成功响应(无数据)
*/
public static <T> ResultVO<T> success() {
return new ResultVO<T>().setCode(200).setMsg("操作成功");
}
/**
* 成功响应(带数据)
*/
public static <T> ResultVO<T> success(T data) {
return new ResultVO<T>().setCode(200).setMsg("操作成功").setData(data);
}
/**
* 失败响应
*/
public static <T> ResultVO<T> fail(String msg) {
return new ResultVO<T>().setCode(500).setMsg(msg);
}
/**
* 失败响应(带数据)
*/
public static <T> ResultVO<T> fail(String msg, T data) {
return new ResultVO<T>().setCode(500).setMsg(msg).setData(data);
}
}
4.3 Mapper 层开发(MyBatis-Plus)
4.3.1 文件元数据 Mapper(FileMetadataMapper.java)
package com.ken.file.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.file.entity.FileMetadata;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
/**
* 文件元数据Mapper
* @author ken
*/
@Repository
public interface FileMetadataMapper extends BaseMapper<FileMetadata> {
/**
* 根据文件MD5查询文件元数据
* @param fileMd5 文件MD5
* @return 文件元数据实体
*/
FileMetadata selectByFileMd5(@Param("fileMd5") String fileMd5);
/**
* 根据文件MD5更新文件状态
* @param fileMd5 文件MD5
* @param status 目标状态
* @return 影响行数
*/
int updateStatusByFileMd5(@Param("fileMd5") String fileMd5, @Param("status") Integer status);
}
4.3.2 分片信息 Mapper(FileChunkMapper.java)
package com.ken.file.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.file.entity.FileChunk;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 分片信息Mapper
* @author ken
*/
@Repository
public interface FileChunkMapper extends BaseMapper<FileChunk> {
/**
* 根据文件MD5查询已上传的分片索引
* @param fileMd5 文件MD5
* @return 已上传的分片索引列表
*/
List<Integer> selectUploadedChunkIndexes(@Param("fileMd5") String fileMd5);
/**
* 根据文件MD5查询所有分片
* @param fileMd5 文件MD5
* @return 分片列表(按索引升序排列)
*/
List<FileChunk> selectByFileMd5(@Param("fileMd5") String fileMd5);
/**
* 根据文件MD5删除所有分片
* @param fileMd5 文件MD5
* @return 影响行数
*/
int deleteByFileMd5(@Param("fileMd5") String fileMd5);
}
4.3.3 Mapper XML 文件(FileMetadataMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.file.mapper.FileMetadataMapper">
<select id="selectByFileMd5" resultType="com.ken.file.entity.FileMetadata">
SELECT id, file_md5, file_name, file_size, chunk_total, file_suffix,
storage_type, file_path, status, create_time, update_time, is_deleted
FROM file_metadata
WHERE file_md5 = #{fileMd5} AND is_deleted = 0
</select>
<update id="updateStatusByFileMd5">
UPDATE file_metadata
SET status = #{status}, update_time = CURRENT_TIMESTAMP
WHERE file_md5 = #{fileMd5} AND is_deleted = 0
</update>
</mapper>
4.3.4 Mapper XML 文件(FileChunkMapper.xml)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ken.file.mapper.FileChunkMapper">
<select id="selectUploadedChunkIndexes" resultType="java.lang.Integer">
SELECT chunk_index
FROM file_chunk
WHERE file_md5 = #{fileMd5} AND is_deleted = 0
ORDER BY chunk_index ASC
</select>
<select id="selectByFileMd5" resultType="com.ken.file.entity.FileChunk">
SELECT id, file_md5, chunk_index, chunk_size, chunk_path,
create_time, update_time, is_deleted
FROM file_chunk
WHERE file_md5 = #{fileMd5} AND is_deleted = 0
ORDER BY chunk_index ASC
</select>
<delete id="deleteByFileMd5">
DELETE FROM file_chunk
WHERE file_md5 = #{fileMd5} AND is_deleted = 0
</delete>
</mapper>
4.4 服务层开发(Service)
4.4.1 存储策略接口(FileStorageStrategy.java)
采用策略模式设计存储方案,支持本地存储和 MinIO 分布式存储切换:
package com.ken.file.strategy;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
/**
* 文件存储策略接口
* @author ken
*/
public interface FileStorageStrategy {
/**
* 存储分片文件
* @param fileMd5 文件MD5
* @param chunkIndex 分片索引
* @param chunkFile 分片文件
* @return 分片存储路径
* @throws Exception 存储异常
*/
String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception;
/**
* 合并分片文件
* @param fileMd5 文件MD5
* @param fileName 原始文件名
* @param chunkPaths 分片存储路径列表(按索引顺序)
* @return 合并后的文件存储路径
* @throws Exception 合并异常
*/
String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception;
/**
* 获取文件输入流
* @param filePath 文件存储路径
* @return 输入流
* @throws Exception 读取异常
*/
InputStream getFileInputStream(String filePath) throws Exception;
/**
* 删除分片文件
* @param chunkPaths 分片存储路径列表
* @throws Exception 删除异常
*/
void deleteChunks(String[] chunkPaths) throws Exception;
}
4.4.2 本地存储实现(LocalFileStorage.java)
package com.ken.file.strategy.impl;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
/**
* 本地文件存储实现
* @author ken
*/
@Slf4j
@Component("localFileStorage")
public class LocalFileStorage implements FileStorageStrategy {
/**
* 本地存储根路径
*/
@Value("${file.upload.local-storage-path}")
private String localStoragePath;
@Override
public String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception {
// 创建分片存储目录:根路径/文件MD5/分片索引
Path chunkDir = Paths.get(localStoragePath, fileMd5);
if (!Files.exists(chunkDir)) {
Files.createDirectories(chunkDir);
}
// 分片文件路径
String chunkFileName = chunkIndex + ".chunk";
Path chunkPath = chunkDir.resolve(chunkFileName);
// 写入分片文件
try (InputStream inputStream = chunkFile.getInputStream();
OutputStream outputStream = Files.newOutputStream(chunkPath)) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
log.info("本地存储分片成功:fileMd5={}, chunkIndex={}, path={}", fileMd5, chunkIndex, chunkPath);
return chunkPath.toString();
} catch (Exception e) {
log.error("本地存储分片失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
throw new Exception("分片存储失败", e);
}
}
@Override
public String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception {
if (ObjectUtils.isEmpty(chunkPaths)) {
throw new IllegalArgumentException("分片路径列表不能为空");
}
// 排序分片路径(确保按索引顺序合并)
Arrays.sort(chunkPaths);
// 创建合并后的文件路径:根路径/文件MD5_文件名
String mergedFileName = fileMd5 + "_" + fileName;
Path mergedFilePath = Paths.get(localStoragePath, mergedFileName);
// 合并分片
try (OutputStream outputStream = Files.newOutputStream(mergedFilePath)) {
for (String chunkPath : chunkPaths) {
Path chunkFile = Paths.get(chunkPath);
if (!Files.exists(chunkFile)) {
throw new FileNotFoundException("分片文件不存在:" + chunkPath);
}
try (InputStream inputStream = Files.newInputStream(chunkFile)) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
}
}
log.info("本地合并分片成功:fileMd5={}, mergedPath={}", fileMd5, mergedFilePath);
return mergedFilePath.toString();
} catch (Exception e) {
log.error("本地合并分片失败:fileMd5={}", fileMd5, e);
throw new Exception("分片合并失败", e);
}
}
@Override
public InputStream getFileInputStream(String filePath) throws Exception {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
throw new FileNotFoundException("文件不存在:" + filePath);
}
return Files.newInputStream(path);
}
@Override
public void deleteChunks(String[] chunkPaths) throws Exception {
if (ObjectUtils.isEmpty(chunkPaths)) {
return;
}
for (String chunkPath : chunkPaths) {
Path path = Paths.get(chunkPath);
if (Files.exists(path)) {
Files.delete(path);
log.info("删除分片文件:{}", chunkPath);
}
// 删除分片所在目录(如果为空)
Path chunkDir = path.getParent();
if (Files.exists(chunkDir) && ObjectUtils.isEmpty(Files.list(chunkDir).count())) {
Files.delete(chunkDir);
log.info("删除分片目录:{}", chunkDir);
}
}
}
}
4.4.3 MinIO 存储实现(MinIOFileStorage.java)
package com.ken.file.strategy.impl;
import com.ken.file.strategy.FileStorageStrategy;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.Arrays;
/**
* MinIO分布式存储实现
* @author ken
*/
@Slf4j
@Component("minIOFileStorage")
public class MinIOFileStorage implements FileStorageStrategy {
private final MinioClient minioClient;
/**
* MinIO桶名
*/
@Value("${minio.bucket-name}")
private String bucketName;
public MinIOFileStorage(
@Value("${minio.endpoint}") String endpoint,
@Value("${minio.access-key}") String accessKey,
@Value("${minio.secret-key}") String secretKey) {
this.minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
// 检查桶是否存在,不存在则创建
checkAndCreateBucket();
}
/**
* 检查并创建MinIO桶
*/
private void checkAndCreateBucket() {
try {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
log.info("创建MinIO桶成功:{}", bucketName);
}
} catch (Exception e) {
log.error("检查/创建MinIO桶失败", e);
throw new RuntimeException("MinIO初始化失败", e);
}
}
@Override
public String storeChunk(String fileMd5, Integer chunkIndex, MultipartFile chunkFile) throws Exception {
// MinIO分片存储路径:chunk/文件MD5/分片索引.chunk
String chunkPath = String.format("chunk/%s/%d.chunk", fileMd5, chunkIndex);
try (InputStream inputStream = chunkFile.getInputStream()) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(chunkPath)
.stream(inputStream, chunkFile.getSize(), -1)
.contentType(chunkFile.getContentType())
.build()
);
log.info("MinIO存储分片成功:fileMd5={}, chunkIndex={}, path={}", fileMd5, chunkIndex, chunkPath);
return chunkPath;
} catch (Exception e) {
log.error("MinIO存储分片失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
throw new Exception("分片存储失败", e);
}
}
@Override
public String mergeChunks(String fileMd5, String fileName, String[] chunkPaths) throws Exception {
if (ObjectUtils.isEmpty(chunkPaths)) {
throw new IllegalArgumentException("分片路径列表不能为空");
}
// 排序分片路径(确保按索引顺序合并)
Arrays.sort(chunkPaths);
// MinIO合并后的文件路径:file/文件MD5_文件名
String mergedFilePath = String.format("file/%s_%s", fileMd5, fileName);
// MinIO不支持直接合并分片,需先下载到本地临时目录合并,再上传
File tempDir = new File(System.getProperty("java.io.tmpdir"), "minio-chunk-merge");
if (!tempDir.exists()) {
tempDir.mkdirs();
}
// 临时合并文件
File tempMergedFile = new File(tempDir, mergedFilePath.replace("/", "_"));
try (OutputStream outputStream = new FileOutputStream(tempMergedFile)) {
for (String chunkPath : chunkPaths) {
// 下载分片到本地临时流
try (InputStream inputStream = minioClient.getObject(
io.minio.GetObjectArgs.builder()
.bucket(bucketName)
.object(chunkPath)
.build()
)) {
byte[] buffer = new byte[1024 * 1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
}
}
// 上传合并后的文件到MinIO
try (InputStream inputStream = new FileInputStream(tempMergedFile)) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(mergedFilePath)
.stream(inputStream, tempMergedFile.length(), -1)
.contentType(getContentType(fileName))
.build()
);
}
log.info("MinIO合并分片成功:fileMd5={}, mergedPath={}", fileMd5, mergedFilePath);
return mergedFilePath;
} catch (Exception e) {
log.error("MinIO合并分片失败:fileMd5={}", fileMd5, e);
throw new Exception("分片合并失败", e);
} finally {
// 删除临时文件
if (tempMergedFile.exists()) {
tempMergedFile.delete();
}
}
}
@Override
public InputStream getFileInputStream(String filePath) throws Exception {
return minioClient.getObject(
io.minio.GetObjectArgs.builder()
.bucket(bucketName)
.object(filePath)
.build()
);
}
@Override
public void deleteChunks(String[] chunkPaths) throws Exception {
if (ObjectUtils.isEmpty(chunkPaths)) {
return;
}
for (String chunkPath : chunkPaths) {
minioClient.removeObject(
io.minio.RemoveObjectArgs.builder()
.bucket(bucketName)
.object(chunkPath)
.build()
);
log.info("MinIO删除分片文件:{}", chunkPath);
}
}
/**
* 根据文件名获取Content-Type
* @param fileName 文件名
* @return Content-Type
*/
private String getContentType(String fileName) {
String suffix = fileName.substring(fileName.lastIndexOf("."));
return switch (suffix.toLowerCase()) {
case ".mp4" -> "video/mp4";
case ".zip" -> "application/zip";
case ".pdf" -> "application/pdf";
case ".jpg", ".jpeg" -> "image/jpeg";
case ".png" -> "image/png";
default -> "application/octet-stream";
};
}
}
4.4.3 存储策略工厂(FileStorageFactory.java)
package com.ken.file.factory;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Map;
/**
* 文件存储策略工厂
* @author ken
*/
@Component
@RequiredArgsConstructor
public class FileStorageFactory {
/**
* 所有存储策略实现(Spring自动注入)
*/
private final Map<String, FileStorageStrategy> storageStrategyMap;
/**
* 当前存储类型:local-本地存储,minio-MinIO存储
*/
@Value("${file.upload.storage-type:local}")
private String currentStorageType;
/**
* 获取存储策略实例
* @return 存储策略实现
*/
public FileStorageStrategy getStorageStrategy() {
String strategyBeanName = currentStorageType.equals("minio") ? "minIOFileStorage" : "localFileStorage";
FileStorageStrategy strategy = storageStrategyMap.get(strategyBeanName);
if (StringUtils.isEmpty(strategy)) {
throw new RuntimeException("不支持的存储类型:" + currentStorageType);
}
return strategy;
}
/**
* 切换存储策略
* @param storageType 存储类型:local/minio
*/
public void switchStorageStrategy(String storageType) {
if (!"local".equals(storageType) && !"minio".equals(storageType)) {
throw new IllegalArgumentException("无效的存储类型:" + storageType);
}
this.currentStorageType = storageType;
}
}
4.4.4 核心服务实现(FileUploadService.java)
package com.ken.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.google.common.collect.Lists;
import com.ken.file.dto.FileUploadDTO;
import com.ken.file.entity.FileChunk;
import com.ken.file.entity.FileMetadata;
import com.ken.file.factory.FileStorageFactory;
import com.ken.file.mapper.FileChunkMapper;
import com.ken.file.mapper.FileMetadataMapper;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 大文件上传核心服务
* @author ken
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileUploadService {
private final FileMetadataMapper fileMetadataMapper;
private final FileChunkMapper fileChunkMapper;
private final FileStorageFactory storageFactory;
private final RedissonClient redissonClient;
/**
* 分片大小(字节)
*/
@Value("${file.upload.chunk-size}")
private Long chunkSize;
/**
* 秒传校验:根据文件MD5判断是否已上传完成
* @param fileMd5 文件MD5
* @return true-秒传成功,false-需正常上传
*/
public boolean checkSecondUpload(String fileMd5) {
if (!StringUtils.hasText(fileMd5)) {
log.error("秒传校验失败:文件MD5不能为空");
return false;
}
// 查询文件元数据,状态为1(上传完成)则秒传成功
FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
boolean secondUpload = !ObjectUtils.isEmpty(metadata) && metadata.getStatus() == 1;
log.info("秒传校验结果:fileMd5={}, 秒传={}", fileMd5, secondUpload);
return secondUpload;
}
/**
* 断点续传校验:查询已上传的分片索引
* @param fileMd5 文件MD5
* @return 已上传的分片索引列表
*/
public List<Integer> checkBreakpointUpload(String fileMd5) {
if (!StringUtils.hasText(fileMd5)) {
log.error("断点续传校验失败:文件MD5不能为空");
return Lists.newArrayList();
}
List<Integer> uploadedIndexes = fileChunkMapper.selectUploadedChunkIndexes(fileMd5);
log.info("断点续传校验结果:fileMd5={}, 已上传分片数={}", fileMd5, uploadedIndexes.size());
return uploadedIndexes;
}
/**
* 上传分片文件
* @param uploadDTO 分片上传参数
* @return 分片上传结果(true-成功,false-失败)
*/
@Transactional(rollbackFor = Exception.class)
public boolean uploadChunk(FileUploadDTO uploadDTO) {
// 参数校验
validateChunkUploadParams(uploadDTO);
String fileMd5 = uploadDTO.getFileMd5();
Integer chunkIndex = uploadDTO.getChunkIndex();
Integer chunkTotal = uploadDTO.getChunkTotal();
String fileName = uploadDTO.getFileName();
Long fileSize = uploadDTO.getFileSize();
try {
// 1. 检查分片是否已上传(避免重复上传)
LambdaQueryWrapper<FileChunk> chunkQuery = Wrappers.lambdaQuery(FileChunk.class)
.eq(FileChunk::getFileMd5, fileMd5)
.eq(FileChunk::getChunkIndex, chunkIndex)
.eq(FileChunk::getIsDeleted, 0);
FileChunk existingChunk = fileChunkMapper.selectOne(chunkQuery);
if (!ObjectUtils.isEmpty(existingChunk)) {
log.info("分片已上传,无需重复上传:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex);
return true;
}
// 2. 存储分片文件
FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
String chunkPath = storageStrategy.storeChunk(fileMd5, chunkIndex, uploadDTO.getChunkFile());
// 3. 保存分片信息到数据库
FileChunk chunk = new FileChunk();
chunk.setFileMd5(fileMd5);
chunk.setChunkIndex(chunkIndex);
chunk.setChunkSize(uploadDTO.getChunkFile().getSize());
chunk.setChunkPath(chunkPath);
chunk.setCreateTime(LocalDateTime.now());
chunk.setUpdateTime(LocalDateTime.now());
chunk.setIsDeleted(0);
fileChunkMapper.insert(chunk);
// 4. 初始化文件元数据(如果不存在)
FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
if (ObjectUtils.isEmpty(metadata)) {
metadata = new FileMetadata();
metadata.setFileMd5(fileMd5);
metadata.setFileName(fileName);
metadata.setFileSize(fileSize);
metadata.setChunkTotal(chunkTotal);
metadata.setFileSuffix(getFileSuffix(fileName));
metadata.setStorageType(storageFactory.getStorageStrategy().getClass().getSimpleName().contains("MinIO") ? 2 : 1);
metadata.setStatus(0); // 状态:0-上传中
metadata.setCreateTime(LocalDateTime.now());
metadata.setUpdateTime(LocalDateTime.now());
metadata.setIsDeleted(0);
fileMetadataMapper.insert(metadata);
}
log.info("分片上传成功:fileMd5={}, chunkIndex={}/{})", fileMd5, chunkIndex, chunkTotal);
return true;
} catch (Exception e) {
log.error("分片上传失败:fileMd5={}, chunkIndex={}", fileMd5, chunkIndex, e);
throw new RuntimeException("分片上传失败", e);
}
}
/**
* 合并分片文件
* @param fileMd5 文件MD5
* @return 合并后的文件存储路径
*/
@Transactional(rollbackFor = Exception.class)
public String mergeChunks(String fileMd5) {
if (!StringUtils.hasText(fileMd5)) {
log.error("分片合并失败:文件MD5不能为空");
throw new IllegalArgumentException("文件MD5不能为空");
}
// 分布式锁:防止同一文件同时被合并
String lockKey = "file:merge:" + fileMd5;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待3秒,持有锁最多1分钟
boolean locked = lock.tryLock(3, 60, java.util.concurrent.TimeUnit.SECONDS);
if (!locked) {
log.error("分片合并失败:获取分布式锁超时,fileMd5={}", fileMd5);
throw new RuntimeException("合并任务繁忙,请稍后重试");
}
// 1. 查询文件元数据和所有分片
FileMetadata metadata = fileMetadataMapper.selectByFileMd5(fileMd5);
if (ObjectUtils.isEmpty(metadata)) {
log.error("分片合并失败:文件元数据不存在,fileMd5={}", fileMd5);
throw new RuntimeException("文件元数据不存在");
}
List<FileChunk> chunks = fileChunkMapper.selectByFileMd5(fileMd5);
if (CollectionUtils.isEmpty(chunks)) {
log.error("分片合并失败:未找到分片文件,fileMd5={}", fileMd5);
throw new RuntimeException("未找到分片文件");
}
// 2. 校验分片数量是否完整
int chunkTotal = metadata.getChunkTotal();
if (chunks.size() != chunkTotal) {
log.error("分片合并失败:分片数量不完整,fileMd5={}, 预期={}, 实际={}", fileMd5, chunkTotal, chunks.size());
throw new RuntimeException("分片数量不完整,无法合并");
}
// 3. 提取分片路径(按索引顺序)
String[] chunkPaths = chunks.stream()
.sorted((c1, c2) -> c1.getChunkIndex() - c2.getChunkIndex())
.map(FileChunk::getChunkPath)
.toArray(String[]::new);
// 4. 合并分片
FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
String mergedFilePath = storageStrategy.mergeChunks(fileMd5, metadata.getFileName(), chunkPaths);
// 5. 更新文件元数据(存储路径、状态)
metadata.setFilePath(mergedFilePath);
metadata.setStatus(1); // 状态:1-上传完成
metadata.setUpdateTime(LocalDateTime.now());
fileMetadataMapper.updateById(metadata);
// 6. 删除分片文件(合并成功后清理)
storageStrategy.deleteChunks(chunkPaths);
fileChunkMapper.deleteByFileMd5(fileMd5);
log.info("分片合并成功:fileMd5={}, 合并后路径={}", fileMd5, mergedFilePath);
return mergedFilePath;
} catch (Exception e) {
log.error("分片合并失败:fileMd5={}", fileMd5, e);
throw new RuntimeException("分片合并失败", e);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 校验分片上传参数
* @param uploadDTO 分片上传参数
*/
private void validateChunkUploadParams(FileUploadDTO uploadDTO) {
if (ObjectUtils.isEmpty(uploadDTO)) {
throw new IllegalArgumentException("分片上传参数不能为空");
}
if (!StringUtils.hasText(uploadDTO.getFileMd5())) {
throw new IllegalArgumentException("文件MD5不能为空");
}
if (!StringUtils.hasText(uploadDTO.getFileName())) {
throw new IllegalArgumentException("文件名不能为空");
}
if (ObjectUtils.isEmpty(uploadDTO.getChunkFile()) || uploadDTO.getChunkFile().isEmpty()) {
throw new IllegalArgumentException("分片文件不能为空");
}
if (ObjectUtils.isEmpty(uploadDTO.getChunkIndex()) || uploadDTO.getChunkIndex() < 0) {
throw new IllegalArgumentException("分片索引必须大于等于0");
}
if (ObjectUtils.isEmpty(uploadDTO.getChunkTotal()) || uploadDTO.getChunkTotal() <= 0) {
throw new IllegalArgumentException("总分片数必须大于0");
}
if (ObjectUtils.isEmpty(uploadDTO.getFileSize()) || uploadDTO.getFileSize() <= 0) {
throw new IllegalArgumentException("文件大小必须大于0");
}
// 校验分片索引不超过总分片数
if (uploadDTO.getChunkIndex() >= uploadDTO.getChunkTotal()) {
throw new IllegalArgumentException(
String.format("分片索引非法:索引=%d,总分片数=%d",
uploadDTO.getChunkIndex(), uploadDTO.getChunkTotal())
);
}
}
/**
* 获取文件后缀
* @param fileName 文件名
* @return 文件后缀(如mp4、zip)
*/
private String getFileSuffix(String fileName) {
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
return "";
}
return fileName.substring(fileName.lastIndexOf("."));
}
}
4.5 控制器开发(Controller)
package com.ken.file.controller;
import com.ken.file.dto.FileUploadDTO;
import com.ken.file.service.FileUploadService;
import com.ken.file.vo.ResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 大文件上传控制器
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/api/upload")
@RequiredArgsConstructor
@Tag(name = "大文件上传接口", description = "支持分片上传、断点续传、秒传")
public class FileUploadController {
private final FileUploadService fileUploadService;
/**
* 秒传校验接口
* @param fileMd5 文件MD5
* @return 秒传校验结果
*/
@GetMapping("/second-check")
@Operation(summary = "秒传校验", description = "根据文件MD5判断是否已上传完成,支持秒传")
public ResultVO<Boolean> checkSecondUpload(
@Parameter(description = "文件MD5唯一标识", required = true)
@RequestParam String fileMd5) {
try {
boolean secondUpload = fileUploadService.checkSecondUpload(fileMd5);
return ResultVO.success(secondUpload);
} catch (Exception e) {
log.error("秒传校验接口异常", e);
return ResultVO.fail("秒传校验失败:" + e.getMessage());
}
}
/**
* 断点续传校验接口
* @param fileMd5 文件MD5
* @return 已上传的分片索引列表
*/
@GetMapping("/breakpoint-check")
@Operation(summary = "断点续传校验", description = "查询已上传的分片索引,支持断点续传")
public ResultVO<List<Integer>> checkBreakpointUpload(
@Parameter(description = "文件MD5唯一标识", required = true)
@RequestParam String fileMd5) {
try {
List<Integer> uploadedIndexes = fileUploadService.checkBreakpointUpload(fileMd5);
return ResultVO.success(uploadedIndexes);
} catch (Exception e) {
log.error("断点续传校验接口异常", e);
return ResultVO.fail("断点续传校验失败:" + e.getMessage());
}
}
/**
* 分片上传接口
* @param uploadDTO 分片上传参数
* @return 分片上传结果
*/
@PostMapping("/chunk")
@Operation(summary = "分片上传", description = "上传单个分片文件,支持并发上传")
public ResultVO<Boolean> uploadChunk(@ModelAttribute FileUploadDTO uploadDTO) {
try {
boolean success = fileUploadService.uploadChunk(uploadDTO);
return ResultVO.success(success);
} catch (Exception e) {
log.error("分片上传接口异常", e);
return ResultVO.fail("分片上传失败:" + e.getMessage());
}
}
/**
* 合并分片接口
* @param fileMd5 文件MD5
* @return 合并后的文件存储路径
*/
@PostMapping("/merge")
@Operation(summary = "合并分片", description = "所有分片上传完成后,调用此接口合并分片")
public ResultVO<String> mergeChunks(
@Parameter(description = "文件MD5唯一标识", required = true)
@RequestParam String fileMd5) {
try {
String mergedFilePath = fileUploadService.mergeChunks(fileMd5);
return ResultVO.success(mergedFilePath, "分片合并成功");
} catch (Exception e) {
log.error("合并分片接口异常", e);
return ResultVO.fail("分片合并失败:" + e.getMessage());
}
}
}
4.6 全局配置类
4.6.1 Swagger3 配置(SwaggerConfig.java)
package com.ken.file.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger3配置类(Springdoc-OpenAPI)
* @author ken
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI largeFileUploadOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("大文件上传接口文档")
.description("支持分片上传、断点续传、秒传的大文件上传方案接口文档")
.version("v1.0.0")
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0")));
}
}
4.6.2 Redisson 配置(RedissonConfig.java)
package com.ken.file.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Redisson配置类(分布式锁)
* @author ken
*/
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password:}")
private String password;
@Value("${spring.redis.database:0}")
private int database;
@Bean
public RedissonClient redissonClient() {
// 配置Redisson客户端
Config config = new Config();
String redisAddress = String.format("redis://%s:%s", host, port);
config.useSingleServer()
.setAddress(redisAddress)
.setDatabase(database)
.setTimeout(3000);
// 配置密码(如果有)
if (!org.springframework.util.StringUtils.isEmpty(password)) {
config.useSingleServer().setPassword(password);
}
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
4.6.3 全局异常处理(GlobalExceptionHandler.java)
package com.ken.file.config;
import com.ken.file.vo.ResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/**
* 全局异常处理器
* @author ken
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理参数校验异常(@Valid注解触发)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringBuilder errorMsg = new StringBuilder("参数校验失败:");
for (FieldError fieldError : bindingResult.getFieldErrors()) {
errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
}
log.error(errorMsg.toString(), e);
return ResultVO.fail(errorMsg.toString());
}
/**
* 处理文件大小超限异常
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResultVO<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
String msg = "文件大小超过限制,请检查分片大小是否符合要求";
log.error(msg, e);
return ResultVO.fail(msg);
}
/**
* 处理非法参数异常
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResultVO<Void> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("非法参数异常:{}", e.getMessage(), e);
return ResultVO.fail(e.getMessage());
}
/**
* 处理运行时异常
*/
@ExceptionHandler(RuntimeException.class)
public ResultVO<Void> handleRuntimeException(RuntimeException e) {
log.error("运行时异常:{}", e.getMessage(), e);
return ResultVO.fail("操作失败:" + e.getMessage());
}
/**
* 处理所有未捕获的异常
*/
@ExceptionHandler(Exception.class)
public ResultVO<Void> handleException(Exception e) {
log.error("系统异常:", e);
return ResultVO.fail("系统异常,请联系管理员");
}
}
4.7 项目启动类(LargeFileUploadApplication.java)
package com.ken.file;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 大文件上传项目启动类
* @author ken
*/
@SpringBootApplication
@MapperScan("com.ken.file.mapper") // 扫描Mapper接口
@EnableScheduling // 启用定时任务(用于清理过期分片)
public class LargeFileUploadApplication {
public static void main(String[] args) {
SpringApplication.run(LargeFileUploadApplication.class, args);
System.out.println("大文件上传服务启动成功!访问地址:http://localhost:8080/file-upload/swagger-ui/index.html");
}
}
4.8 定时任务:清理过期分片(ExpiredChunkCleaner.java)
package com.ken.file.task;
import com.ken.file.entity.FileMetadata;
import com.ken.file.entity.FileChunk;
import com.ken.file.factory.FileStorageFactory;
import com.ken.file.mapper.FileChunkMapper;
import com.ken.file.mapper.FileMetadataMapper;
import com.ken.file.strategy.FileStorageStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 定时清理过期未完成的分片文件
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ExpiredChunkCleaner {
private final FileMetadataMapper fileMetadataMapper;
private final FileChunkMapper fileChunkMapper;
private final FileStorageFactory storageFactory;
/**
* 分片过期天数
*/
@Value("${file.upload.expire-days}")
private Integer expireDays;
/**
* 每天凌晨2点执行清理任务
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional(rollbackFor = Exception.class)
public void cleanExpiredChunks() {
log.info("开始清理过期未完成的分片文件,过期天数:{}天", expireDays);
// 计算过期时间点(当前时间 - expireDays天)
LocalDateTime expireTime = LocalDateTime.now().minusDays(expireDays);
// 1. 查询所有状态为“上传中”且创建时间早于过期时间的文件元数据
List<FileMetadata> expiredMetadataList = fileMetadataMapper.selectList(
com.baomidou.mybatisplus.core.conditions.query.Wrappers.lambdaQuery(FileMetadata.class)
.eq(FileMetadata::getStatus, 0) // 状态:0-上传中
.lt(FileMetadata::getCreateTime, expireTime)
.eq(FileMetadata::getIsDeleted, 0)
);
if (CollectionUtils.isEmpty(expiredMetadataList)) {
log.info("未发现过期未完成的分片文件,清理任务结束");
return;
}
// 2. 逐个清理过期文件的分片
FileStorageStrategy storageStrategy = storageFactory.getStorageStrategy();
for (FileMetadata metadata : expiredMetadataList) {
String fileMd5 = metadata.getFileMd5();
try {
// 2.1 查询该文件的所有分片
List<FileChunk> chunks = fileChunkMapper.selectByFileMd5(fileMd5);
if (!CollectionUtils.isEmpty(chunks)) {
// 2.2 删除存储的分片文件
String[] chunkPaths = chunks.stream()
.map(FileChunk::getChunkPath)
.toArray(String[]::new);
storageStrategy.deleteChunks(chunkPaths);
// 2.3 删除数据库中的分片记录
fileChunkMapper.deleteByFileMd5(fileMd5);
}
// 3. 标记文件元数据为删除状态
metadata.setIsDeleted(1);
metadata.setUpdateTime(LocalDateTime.now());
fileMetadataMapper.updateById(metadata);
log.info("清理过期分片成功:fileMd5={}, fileName={}", fileMd5, metadata.getFileName());
} catch (Exception e) {
log.error("清理过期分片失败:fileMd5={}", fileMd5, e);
// 单个文件清理失败不影响其他文件
continue;
}
}
log.info("过期分片清理任务完成,共清理{}个文件的分片", expiredMetadataList.size());
}
}
五、核心组件开发:前端实现(Vue 3)
5.1 大文件上传核心组件(FileUploader.vue)
vue
<template>
<div class="file-upload-container">
<el-upload
class="upload-demo"
action=""
:auto-upload="false"
:on-change="handleFileChange"
:show-file-list="false"
accept=""
>
<el-button type="primary">选择大文件</el-button>
</el-upload>
<!-- 上传状态展示 -->
<div v-if="uploading" class="upload-status">
<el-progress :percentage="progress" stroke-width="4"></el-progress>
<div class="upload-actions">
<el-button
type="warning"
size="small"
@click="toggleUpload"
v-if="!paused"
>
暂停
</el-button>
<el-button
type="success"
size="small"
@click="toggleUpload"
v-if="paused"
>
继续
</el-button>
<el-button
type="danger"
size="small"
@click="cancelUpload"
>
取消
</el-button>
</div>
<p class="upload-info">
{{ currentChunk }}/{{ totalChunks }} 分片上传中({{ formatSize(uploadedSize) }}/{{ formatSize(fileSize) }})
</p>
</div>
<!-- 上传结果展示 -->
<div v-if="uploadSuccess" class="upload-result success">
<el-icon><success-filled /></el-icon>
<span>文件上传成功!存储路径:{{ uploadedFilePath }}</span>
</div>
<div v-if="uploadError" class="upload-result error">
<el-icon><error-filled /></el-icon>
<span>上传失败:{{ errorMsg }}</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ElMessage, ElProgress, ElButton, ElIcon, ElUpload } from 'element-plus';
import { SuccessFilled, ErrorFilled } from '@element-plus/icons-vue';
import SparkMD5 from 'spark-md5';
import request from '@/utils/request';
// 组件状态
const file = ref(null); // 选中的文件
const fileMd5 = ref(''); // 文件MD5
const fileSize = ref(0); // 文件总大小(字节)
const fileName = ref(''); // 文件名
const chunkSize = ref(5 * 1024 * 1024); // 分片大小(5MB)
const totalChunks = ref(0); // 总分片数
const currentChunk = ref(0); // 当前正在上传的分片索引
const uploadedSize = ref(0); // 已上传大小(字节)
const progress = ref(0); // 上传进度(百分比)
const uploading = ref(false); // 是否正在上传
const paused = ref(false); // 是否暂停上传
const uploadSuccess = ref(false); // 上传是否成功
const uploadError = ref(false); // 上传是否失败
const errorMsg = ref(''); // 错误信息
const uploadedFilePath = ref(''); // 上传成功后的文件路径
const uploadedIndexes = ref([]); // 已上传的分片索引列表
const abortControllers = ref({}); // 存储每个分片的AbortController,用于取消请求
/**
* 处理文件选择
*/
const handleFileChange = (uploadFile) => {
// 重置状态
resetUploadStatus();
// 获取文件信息
file.value = uploadFile.raw;
fileSize.value = file.value.size;
fileName.value = file.value.name;
// 计算总分片数
totalChunks.value = Math.ceil(fileSize.value / chunkSize.value);
ElMessage.info(`文件准备就绪:${fileName.value}(${formatSize(fileSize.value)}),共${totalChunks.value}个分片`);
// 计算文件MD5(大文件分片计算)
calculateFileMd5();
};
/**
* 计算文件MD5(分片计算,避免大文件占用过多内存)
*/
const calculateFileMd5 = () => {
if (!file.value) return;
ElMessage.loading('正在计算文件MD5,请稍候...', { duration: 0 });
const fileReader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
const chunkSize = 2 * 1024 * 1024; // 计算MD5的分片大小(2MB)
let offset = 0;
// 递归读取文件分片计算MD5
const loadNextChunk = () => {
const fileSlice = file.value.slice(offset, offset + chunkSize);
fileReader.readAsArrayBuffer(fileSlice);
};
fileReader.onload = (e) => {
spark.append(e.target.result);
offset += chunkSize;
if (offset < file.value.size) {
// 继续读取下一个分片
loadNextChunk();
} else {
// 计算完成
fileMd5.value = spark.end();
ElMessage.success(`文件MD5计算完成:${fileMd5.value}`);
// 开始上传流程(先校验秒传和断点续传)
startUploadProcess();
}
};
fileReader.onerror = () => {
ElMessage.error('文件MD5计算失败,请重新选择文件');
resetUploadStatus();
};
// 开始读取第一个分片
loadNextChunk();
};
/**
* 开始上传流程(秒传校验 -> 断点续传校验 -> 分片上传)
*/
const startUploadProcess = async () => {
if (!fileMd5.value) {
ElMessage.error('文件MD5为空,无法上传');
return;
}
try {
// 1. 秒传校验
const secondCheckRes = await request.get('/api/upload/second-check', {
params: { fileMd5: fileMd5.value }
});
if (secondCheckRes.data) {
// 秒传成功
uploadSuccess.value = true;
uploadedFilePath.value = `已存在文件(MD5:${fileMd5.value})`;
ElMessage.success('文件已存在,秒传成功!');
return;
}
// 2. 断点续传校验(查询已上传的分片)
const breakpointCheckRes = await request.get('/api/upload/breakpoint-check', {
params: { fileMd5: fileMd5.value }
});
uploadedIndexes.value = breakpointCheckRes.data || [];
ElMessage.info(`发现已上传${uploadedIndexes.value.length}个分片,将继续上传剩余分片`);
// 计算已上传大小
uploadedSize.value = uploadedIndexes.value.length * chunkSize.value;
// 最后一个分片可能小于chunkSize,这里简化计算
if (uploadedIndexes.value.length === totalChunks.value) {
uploadedSize.value = fileSize.value;
}
progress.value = Math.round((uploadedSize.value / fileSize.value) * 100);
// 3. 开始上传分片
uploading.value = true;
paused.value = false;
uploadNextChunk();
} catch (err) {
handleUploadError('上传初始化失败:' + (err.msg || err.message || '未知错误'));
}
};
/**
* 上传下一个分片
*/
const uploadNextChunk = async () => {
if (paused.value || uploadError.value) return;
// 找到下一个未上传的分片索引
while (currentChunk.value < totalChunks.value) {
if (!uploadedIndexes.value.includes(currentChunk.value)) {
break;
}
currentChunk.value++;
}
// 所有分片上传完成,调用合并接口
if (currentChunk.value >= totalChunks.value) {
await mergeChunks();
return;
}
try {
// 准备当前分片数据
const start = currentChunk.value * chunkSize.value;
const end = Math.min(start + chunkSize.value, fileSize.value);
const chunkFile = file.value.slice(start, end);
// 创建FormData
const formData = new FormData();
formData.append('fileMd5', fileMd5.value);
formData.append('fileName', fileName.value);
formData.append('fileSize', fileSize.value);
formData.append('chunkIndex', currentChunk.value);
formData.append('chunkTotal', totalChunks.value);
formData.append('chunkFile', chunkFile, `${fileMd5.value}-${currentChunk.value}.chunk`);
// 创建AbortController,用于取消请求
const controller = new AbortController();
abortControllers.value[currentChunk.value] = controller;
// 上传分片
await request.post('/api/upload/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
signal: controller.signal,
onUploadProgress: (e) => {
// 计算当前分片的上传进度,并更新总进度
if (e.lengthComputable) {
const chunkProgress = e.loaded / e.total;
const totalProgress = (uploadedSize.value + chunkProgress * (end - start)) / fileSize.value * 100;
progress.value = Math.round(totalProgress);
}
}
});
// 分片上传成功
uploadedIndexes.value.push(currentChunk.value);
uploadedSize.value += (end - start);
ElMessage.success(`分片 ${currentChunk.value + 1}/${totalChunks.value} 上传成功`);
// 上传下一个分片
currentChunk.value++;
uploadNextChunk();
} catch (err) {
if (err.name !== 'AbortError') {
// 非主动取消的错误
handleUploadError(`分片 ${currentChunk.value + 1} 上传失败:${err.msg || err.message}`);
}
}
};
/**
* 合并分片
*/
const mergeChunks = async () => {
try {
ElMessage.loading('正在合并分片,请稍候...', { duration: 0 });
const res = await request.post('/api/upload/merge', null, {
params: { fileMd5: fileMd5.value }
});
// 合并成功
uploadedFilePath.value = res.data;
uploadSuccess.value = true;
uploading.value = false;
progress.value = 100;
ElMessage.success('所有分片上传完成,文件合并成功!');
} catch (err) {
handleUploadError('分片合并失败:' + (err.msg || err.message));
}
};
/**
* 暂停/继续上传
*/
const toggleUpload = () => {
if (paused.value) {
// 继续上传
paused.value = false;
ElMessage.info('继续上传...');
uploadNextChunk();
} else {
// 暂停上传(取消当前正在上传的分片请求)
paused.value = true;
const currentController = abortControllers.value[currentChunk.value];
if (currentController) {
currentController.abort();
ElMessage.info('已暂停上传');
}
}
};
/**
* 取消上传
*/
const cancelUpload = () => {
// 取消所有正在上传的请求
Object.values(abortControllers.value).forEach(controller => {
controller.abort();
});
// 重置状态
resetUploadStatus();
ElMessage.warning('已取消上传');
};
/**
* 处理上传错误
*/
const handleUploadError = (msg) => {
uploadError.value = true;
errorMsg.value = msg;
uploading.value = false;
ElMessage.error(msg);
};
/**
* 重置上传状态
*/
const resetUploadStatus = () => {
file.value = null;
fileMd5.value = '';
fileSize.value = 0;
fileName.value = '';
totalChunks.value = 0;
currentChunk.value = 0;
uploadedSize.value = 0;
progress.value = 0;
uploading.value = false;
paused.value = false;
uploadSuccess.value = false;
uploadError.value = false;
errorMsg.value = '';
uploadedFilePath.value = '';
uploadedIndexes.value = [];
abortControllers.value = {};
};
/**
* 格式化文件大小(字节 -> KB/MB/GB)
*/
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
onMounted(() => {
// 组件挂载时的初始化逻辑
console.log('大文件上传组件初始化完成');
});
</script>
<style scoped>
.file-upload-container {
max-width: 800px;
margin: 20px auto;
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.upload-status {
margin-top: 20px;
padding: 15px;
background-color: #f9fafb;
border-radius: 6px;
}
.upload-actions {
margin: 15px 0;
display: flex;
gap: 10px;
}
.upload-info {
color: #6b7280;
font-size: 14px;
margin-top: 5px;
}
.upload-result {
margin-top: 20px;
padding: 15px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.error {
background-color: #fef2f2;
color: #b91c1c;
border: 1px solid #fee2e2;
}
</style>
5.2 前端页面使用(App.vue)
vue
<template>
<div id="app">
<h1>Java大文件上传实战演示</h1>
<FileUploader />
</div>
</template>
<script setup>
import FileUploader from './components/FileUploader.vue';
</script>
<style>
#app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
color: #1e40af;
margin-bottom: 2rem;
}
</style>
5.3 前端路由配置(src/router/index.js)
javascript
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
}
]
});
export default router;
5.4 前端入口文件(src/main.js)
javascript
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
const app = createApp(App);
app.use(router);
app.use(ElementPlus);
app.mount('#app');
六、系统测试与验证
6.1 测试环境准备
启动依赖服务:
MySQL 8.0:创建数据库并执行 SQL 脚本Redis 7.0+:默认配置启动(无需额外配置)MinIO 8.5+:
large_file_upload
bash
# 启动MinIO(Windows示例)
minio.exe server D:minio-data --console-address ":9001"
# 访问控制台:http://localhost:9001,创建桶large-file-bucket
启动后端服务:
直接运行的 main 方法访问 Swagger 文档:http://localhost:8080/file-upload/swagger-ui/index.html 验证接口是否正常
LargeFileUploadApplication.java
启动前端服务:
cd large-file-upload-frontend
npm run dev
# 访问前端页面:http://localhost:5173
6.2 核心功能测试用例
6.2.1 秒传功能测试
选择一个小文件(如 1MB 的文本文件)上传,等待上传完成;再次选择同一个文件上传,观察是否直接提示 “秒传成功”,无需上传分片;检查数据库表,确认该文件状态为 1(上传完成)。
file_metadata
6.2.2 断点续传测试
选择一个大文件(如 1GB 的视频文件)开始上传;在上传过程中点击 “暂停” 按钮,观察上传是否停止;刷新页面后重新选择同一个文件,观察是否自动识别已上传分片并从断点继续;点击 “继续” 按钮,确认能从暂停处继续上传。
6.2.3 分片合并测试
上传一个中等大小的文件(如 50MB),确保分多个分片;所有分片上传完成后,观察是否自动调用合并接口;检查存储路径(本地或 MinIO),确认合并后的文件可正常打开且与原文件一致;检查数据库表,确认分片记录已被删除。
file_chunk
6.2.4 并发上传测试
打开两个浏览器窗口,同时上传同一个大文件;观察是否出现分片覆盖或合并冲突(通过分布式锁避免);确认最终只有一个合并操作成功,且文件完整。
6.2.5 异常处理测试
上传过程中断开网络,观察是否提示错误信息;上传超过分片大小限制的文件(如配置为 5MB 分片,上传 10MB 的单个分片),观察是否提示大小超限;数据库连接失败时,上传分片,观察是否返回友好错误信息。
6.3 性能测试建议
单文件上传速度测试:
分别测试 100MB、1GB、5GB 文件的上传耗时;记录平均分片上传时间和合并时间;对比不同存储策略(本地存储 vs MinIO)的性能差异。
并发量测试:
使用 JMeter 模拟 10/50/100 个并发用户同时上传文件;监控服务端 CPU、内存占用,数据库连接数,Redis 性能;观察是否出现超时、OOM 等问题。
断点续传效率测试:
模拟上传到 50% 时中断,测试重新上传的耗时(应接近剩余 50% 的上传时间);测试不同断点位置(10%/50%/90%)的续传效率。
七、技术优化与扩展
7.1 性能优化点
分片大小动态调整:
根据文件类型自动调整分片大小(如视频文件用 10MB 分片,文本文件用 1MB 分片);示例代码修改:在前端计算分片大小时,根据文件后缀动态设置。
chunkSize
分片上传并发控制:
同时上传多个分片(如并发上传 3-5 个分片),提升上传速度;注意:并发数不宜过多,避免占用过多网络资源;实现思路:维护一个上传队列,限制同时执行的上传请求数量。
MD5 计算优化:
前端使用 Web Worker 计算文件 MD5,避免阻塞主线程;后端缓存文件 MD5 与文件信息的映射,减少数据库查询。
存储优化:
MinIO 配置多节点集群,提升分布式存储性能和可靠性;本地存储使用 SSD 磁盘,减少 IO 瓶颈。
7.2 功能扩展建议
文件加密传输:
分片上传前进行加密,服务端合并后解密;可使用 AES 算法,密钥通过 HTTPS 传输。
上传权限控制:
整合 Spring Security,实现基于角色的上传权限控制;记录用户上传记录,实现文件归属管理。
文件预览与下载:
增加文件下载接口,支持断点续传下载;对图片、视频等文件提供在线预览功能。
上传进度实时同步:
使用 WebSocket 实时推送上传进度到前端;适合多端协作场景,如 A 上传文件,B 可查看实时进度。
分布式部署支持:
服务端集群部署时,使用 Nginx 负载均衡;确保分片存储在共享存储(如 MinIO 集群),避免单节点依赖。
八、总结与展望
大文件上传是 Java 开发中的典型场景,核心挑战在于如何平衡传输效率、可靠性和用户体验。本文从底层原理出发,通过分片上传、断点续传、秒传三大核心技术,结合 Spring Boot、MinIO、Redis 等组件,实现了一套可直接落地的大文件上传方案。