1. 为什么企业文件上传绕不开秒传与断点续传
1.1 一个真实的崩溃场景
我早年在做企业内部协作工具时,接到过最让人头疼的一个反馈:运维同事要把一个 2 个多 G 的安装包上传到公司网盘,传了 40 多分钟,结果办公室网络闪断了一下,进度条直接归零,又要从第一个字节开始来一次。他当场在群里骂了半小时。
这不是个例。销售团队发产品演示视频、设计团队传源文件、测试团队传日志包,动不动就是几百 MB 甚至几个 GB。传统的单文件上传方式在这类场景下有两个致命问题:
- 网络稍微抖动一下,整个任务就白干,没有续传能力;
- 公司内部其实很多文件不同人手里都有副本,明明是一模一样的文件,每个人都要重复传一遍,占用带宽也浪费存储。
这两个痛点对应的解法就是标题里那六个字:断点秒传。秒传解决的是“这文件已经有人传过了,你别再传一遍”的问题;断点续传解决的是“没传完的部分接着传,而不是从头再来”的问题。JavaWeb 项目里把它们实现出来,是很多互联网企业网盘、附件系统、协作平台的基本功。
1.2 秒传的本质:不是传输,而是识别
很多刚入行的朋友一听到“秒传”就觉得很玄,以为是什么高速传输协议。实际上秒传根本不传文件内容,它只做一件事:判断你手里这个文件在服务器上是否已经存在。
怎么判断“是同一个文件”?最可靠的办法是算文件指纹。通常我们用 MD5 或 SHA-1 计算整个文件的哈希值。同一个文件内容完全一致,哈希值就一致;内容哪怕只差一个 bit,哈希值就完全不同。所以秒传的流程简化下来就是:
- 前端在上传前先计算整个文件的 MD5;
- 把这个 MD5 连同文件名、文件大小一起发给后端;
- 后端拿着 MD5 查数据库,如果发现已经有一模一样的文件记录,就直接返回“秒传成功”,把这条记录关联到当前用户的名下即可。
这里容易踩一个认知误区:MD5 只做内容校验,不做身份校验。也就是说,判断标准是文件内容而不是文件名。同一份文件叫 A.zip 还是 B.zip,只要内容一致,都应该命中秒传。
1.3 断点续传的本质:把大文件切成积木
断点续传的思路也很直白:把一个大文件按固定大小切成若干个小块,比如每块 5MB,然后逐块上传。前端记录哪些块已经传过了,哪些块还没传。网络断了之后,重新连接只需要查一下服务端“哪些块已经存在”,把缺的块补上就行。全部块传完之后,后端再把所有块按顺序拼回完整文件。
块大小怎么定?我见过不少项目直接用 10MB,也有用 4MB、5MB、8MB 的。这个值没有绝对标准,主要看场景:
- 块越小,断点续传的粒度越细,但请求次数会爆炸,比如 2GB 文件用 1MB 块就要发 2048 次请求,服务端压力大;
- 块越大,请求次数少,但每次传输时间变长,网络波动导致单块失败的代价变高;
- 企业内部网络比较稳、文件普遍偏大的场景,5MB 到 10MB 是常见选择;公网面向弱网用户的产品,建议 2MB 到 4MB。
另外,每个块都会有一个独立的索引号,前端必须按顺序记录好:chunkIndex = 0, 1, 2, 3...。合并的时候,后端必须严格按照这个索引排序拼接,顺序一乱,出来的文件就损坏了。这点后面踩坑部分我会细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程选型与地基:从IDEA配置到MySQL表设计
2.1 JavaWeb技术栈怎么选
先聊聊技术选型。标题写的是 JavaWeb,这个词在不少初学者那里意味着 Servlet + JSP + Tomcat 的传统三件套。但说实话,现在互联网企业内部做这类系统,很少直接用原生 Servlet 去写接口,基本都是用 Spring 系框架。你自己做毕业设计或者练手项目,SSM(Spring + SpringMVC + MyBatis)或者 Spring Boot 都可以,核心逻辑是相通的。
如果你是照着这个标题去做课设,我想强调一个点:课设项目最怕的不是功能实现不了,而是工程结构混乱到你自己都看不懂。我建议用经典分层:
code复制com.company.fileupload
├── controller // 接收HTTP请求,参数校验
├── service // 业务逻辑:秒传判断、分块落盘、合并
├── mapper // MyBatis接口,对应SQL
├── entity // 实体类
├── config // 配置类
└── util // MD5、文件路径等工具类
IDEA 里运行配置这一步,对新手来说确实容易卡住。我用的是最常见的方案:IDEA 社区版 + Maven + Tomcat 9 + JDK 1.8。注意几个细节:
- 项目语言一定要设置成 UTF-8,不然文件名里的中文上传后全变乱码。File -> Settings -> Editor -> File Encodings,把 Global Encoding、Project Encoding、Default encoding for properties files 全部改为 UTF-8;
- Tomcat 版本和 JDK 版本要匹配,Tomcat 10 默认 Servlet 是 jakarta.* 开头,和很多老教程里的 javax.* 不一样,跑不起来别急着怀疑代码,先检查依赖和运行环境;
- IDEA 里配置 Tomcat:Run -> Edit Configurations -> 添加 Tomcat Server Local,注意选 Deployment 标签页把项目 Artifact 加进去,Application context 填你项目的访问路径。
MySQL 连接这块,驱动版本建议不用太旧。我用 mysql-connector-java 8.0.33,连接串写成:
properties复制jdbc.url=jdbc:mysql://localhost:3306/file_share?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
jdbc.username=root
jdbc.password=yourpassword
新手最容易漏掉 serverTimezone 参数,不写的话很多版本会直接报时区错误。
2.2 数据库设计的五个关键表
断点秒传和分享功能,至少需要五张表配合。我把核心字段列出来,你直接在 Navicat 或者 MySQL 命令行里执行就行。
用户表 user:上传文件和创建分享记录都关联到用户。
sql复制CREATE TABLE `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(100) NOT NULL,
`created_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
文件元信息表 file_meta:记录完整文件的指纹和状态。这里的 status 字段很关键,它标识文件是“上传中”还是“已完成”,防止秒传时误把没传完的半成品当成完整文件。
sql复制CREATE TABLE `file_meta` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`file_md5` VARCHAR(32) NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_size` BIGINT NOT NULL,
`chunk_total` INT NOT NULL DEFAULT 0,
`upload_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-上传中 1-已完成',
`storage_path` VARCHAR(500) DEFAULT NULL,
`creator_id` INT NOT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_md5_status` (`file_md5`, `upload_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分块信息表 file_chunk:记录每个分块是否已经上传。前端每次断点重连,后端就查这张表告诉前端“哪些块已经有了”。
sql复制CREATE TABLE `file_chunk` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`file_md5` VARCHAR(32) NOT NULL,
`chunk_index` INT NOT NULL,
`chunk_size` BIGINT NOT NULL,
`chunk_path` VARCHAR(500) NOT NULL,
`upload_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_md5_chunk` (`file_md5`, `chunk_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分享链接表 share_info:生成分享的唯一 Token、提取码和有效期。
sql复制CREATE TABLE `share_info` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`file_id` BIGINT NOT NULL,
`share_token` VARCHAR(64) NOT NULL,
`extract_code` VARCHAR(10) DEFAULT NULL COMMENT '提取码,为空则任何人可访问',
`expire_time` DATETIME DEFAULT NULL COMMENT '有效期,NULL表示永久',
`download_count` INT DEFAULT 0,
`creator_id` INT NOT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_share_token` (`share_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
用户文件关联表 user_file:同一个文件内容可以同时被多个用户拥有。这样秒传时,不同用户传同一个文件,存储上只有一份,但各自的“我的文件”列表里都有这个记录。
sql复制CREATE TABLE `user_file` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT NOT NULL,
`file_id` BIGINT NOT NULL,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有一个设计上的取舍:秒传时到底用 file_md5 直接查,还是维护一个独立的文件去重表?网上很多教程只建一张文件表,MD5 查到了就算秒传。但我建议你至少要把“上传中”和“已完成”两种状态区分开,否则你很可能秒传了一个只传了 60% 的残缺文件,合并的时候直接爆炸。
2.3 上传目录规划
数据库设计完了,服务器的磁盘目录也不能随手写。我见过很多项目把所有上传文件堆在一个目录里,几个月后那个目录有十几万个文件,每次列出目录都卡半天。
合理的做法是按日期分目录,再按 MD5 前两个字符做二级目录:
code复制/opt/fileupload/
2025/06/
a1/
a1b2c3d4e5...完整文件
37/
37f8a9...分块临时文件
这样做的原因是:文件系统对单目录下的文件数量有限制,数量过大后索引性能会明显下降。二级分目录能把文件分散开,同时也能避免分享链接被暴力遍历。后面生成存储路径时我会给出代码。
3. 核心接口设计与后端实现细节
3.1 检查接口:秒传与续传状态一次搞定
整个断点秒传系统的核心入口是检查接口。前端上传前先调它,传三个参数:fileMd5、fileName、fileSize。
这个接口要做三件事:
- 判断是否存在相同 MD5 且状态为“已完成”的文件,如果有,直接返回秒传标识;
- 如果没有已完成的文件,就查 file_chunk 表,返回已上传的分块索引列表,供前端做断点续传;
- 如果这块 MD5 完全是新文件,就创建一条 file_meta 记录,状态为“上传中”,返回需要上传的全部分块。
接口设计如下:
java复制@RestController
@RequestMapping("/api/file")
public class FileController {
@Autowired
private FileService fileService;
@PostMapping("/checkUpload")
public Result checkUpload(@RequestParam String fileMd5,
@RequestParam String fileName,
@RequestParam Long fileSize) {
CheckUploadVO vo = fileService.checkUpload(fileMd5, fileName, fileSize);
return Result.success(vo);
}
}
Service 层的核心逻辑:
java复制public CheckUploadVO checkUpload(String fileMd5, String fileName, Long fileSize) {
CheckUploadVO vo = new CheckUploadVO();
// 1. 先查是否有已完成的文件,命中直接秒传
FileMeta completed = fileMetaMapper.findByMd5AndStatus(fileMd5, 1);
if (completed != null) {
vo.setSkipUpload(true);
vo.setFileId(completed.getId());
return vo;
}
// 2. 查分块表,返回已上传分块
List<Integer> uploadedChunks = fileChunkMapper.selectUploadedIndexes(fileMd5);
vo.setSkipUpload(false);
vo.setUploadedChunks(uploadedChunks);
// 3. 如果没有元信息记录,先创建一条上传中的记录
FileMeta meta = fileMetaMapper.findByMd5AndStatus(fileMd5, 0);
if (meta == null) {
meta = new FileMeta();
meta.setFileMd5(fileMd5);
meta.setFileName(fileName);
meta.setFileSize(fileSize);
meta.setUploadStatus(0);
fileMetaMapper.insert(meta);
}
vo.setFileId(meta.getId());
return vo;
}
这里有个容易被忽略的问题:并发场景下两个用户同时传同一个新文件,可能创建两条“上传中”的元数据。因为你查的时候两条都不存在,然后各自 insert。解决办法是在 file_meta 表加唯一索引 uk_md5_status(file_md5, upload_status),插入时如果冲突,再查一次或直接捕获 DuplicateKeyException。
前端拿到 uploadedChunks 列表后,就知道从哪个块继续传了:
javascript复制// 前端示例:判断当前块是否已上传
const uploadedSet = new Set(result.uploadedChunks);
for (let i = 0; i < totalChunks; i++) {
if (uploadedSet.has(i)) continue;
// 只上传未传过的块
await uploadChunk(file, i);
}
3.2 分块上传接口:把块落盘
检查接口完成后,前端就逐个上传分块。后端接口接收一个普通 multipart 文件请求,参数里带上 fileMd5 和 chunkIndex。
java复制@PostMapping("/uploadChunk")
public Result uploadChunk(@RequestParam String fileMd5,
@RequestParam Integer chunkIndex,
@RequestParam MultipartFile file) throws IOException {
// 生成分块存储路径
String relativeDir = buildRelativeDir(fileMd5);
String chunkPath = relativeDir + "/" + fileMd5 + "_" + chunkIndex + ".part";
File dest = new File(storageRoot, chunkPath);
FileUtils.forceMkdirParent(dest);
// 落盘
file.transferTo(dest);
// 记录分块信息(幂等插入,避免重复上传时插入失败)
fileChunkMapper.insertOrIgnore(fileMd5, chunkIndex, file.getSize(), chunkPath);
return Result.success();
}
落盘之后还要做一件事:记录分块信息必须幂等。因为这个接口可能被前端重试,同一个 chunkIndex 可能会被提交两次。如果你只做了简单 insert,第二次上传同一个块时就会主键冲突。所以在 INSERT 语句里加上 ON DUPLICATE KEY UPDATE 或者 INSERT IGNORE:
sql复制INSERT IGNORE INTO file_chunk (file_md5, chunk_index, chunk_size, chunk_path)
VALUES (#{fileMd5}, #{chunkIndex}, #{chunkSize}, #{chunkPath});
这样重复上传同一块,不会报错,也不会产生脏数据。
这里额外提一点:分块文件落盘时,可以选择直接覆盖同名文件,也可以每次都生成带随机后缀的临时文件再覆盖。考虑到断点续传场景下前端重试同一个块很常见,直接覆盖是没问题的,因为文件内容是一样的。
3.3 合并接口:流式合并与并发控制
所有分块传完后,前端调用合并接口。合并接口拿到 fileMd5 后,按 chunk_index 排序,把所有分块按顺序写入一个完整的文件。
java复制@PostMapping("/merge")
public Result merge(@RequestParam String fileMd5) {
// 1. 校验所有分块是否齐全,并计算总大小
List<FileChunk> chunks = fileChunkMapper.selectListByMd5(fileMd5);
Collections.sort(chunks, Comparator.comparingInt(FileChunk::getChunkIndex));
int chunkTotal = fileMetaMapper.selectChunkTotal(fileMd5);
if (chunks.size() != chunkTotal) {
return Result.error("分块不全,请补传缺失分块");
}
// 2. 流式合并,避免一次性读入内存
String mergedPath = buildMergedPath(fileMd5);
File mergedFile = new File(storageRoot, mergedPath);
mergedFile.getParentFile().mkdirs();
try (FileOutputStream fos = new FileOutputStream(mergedFile);
FileChannel outChannel = FileChannel.open(mergedFile.toPath(), StandardOpenOption.WRITE)) {
for (FileChunk chunk : chunks) {
File partFile = new File(storageRoot, chunk.getChunkPath());
try (FileChannel inChannel = FileChannel.open(partFile.toPath(), StandardOpenOption.READ)) {
// 使用零拷贝方式,大文件合并时内存占用极低
long size = inChannel.size();
inChannel.transferTo(0, size, outChannel);
}
}
}
// 3. 更新文件元数据状态为已完成
fileMetaMapper.updateStatus(fileMd5, 1, mergedPath);
// 4. 清理分块临时文件
chunks.forEach(chunk -> {
File partFile = new File(storageRoot, chunk.getChunkPath());
partFile.delete();
});
fileChunkMapper.deleteByMd5(fileMd5);
return Result.success();
}
合并时的性能点在于:不要用 ByteArrayOutputStream 把所有分块都读进内存再把大数组写出去。2GB 的文件如果一次性读进内存,JVM 直接 OutOfMemoryError。用 FileChannel.transferTo 做零拷贝,底层走操作系统内核的 sendfile 机制,既不占 JVM 堆内存,速度还快。这一步是我认为整个合并过程里最值得优化的地方。
合并接口还需要考虑并发问题。两个前端在同一个 MD5 上同时点了合并,就会导致两个线程同时拼接文件,互相覆盖。最简单的解决方案是给同一个 fileMd5 加 JVM 级锁:
java复制private final ConcurrentHashMap<String, Object> mergeLocks = new ConcurrentHashMap<>();
Object lock = mergeLocks.computeIfAbsent(fileMd5, k -> new Object());
synchronized (lock) {
// 合并逻辑
}
mergeLocks.remove(fileMd5);
单机部署下这样做没问题。如果公司是分布式部署多台 Tomcat,JVM 锁就失效了,得换 Redis 分布式锁或者数据库悲观锁。但作为 JavaWeb 项目练手或中小企业内部系统,单机锁已经足够。
3.4 分享链接接口
文件上传完了,接下来是分享。分享的核心是生成一条带 Token 的链接,同时支持设置提取码和有效期。
java复制@PostMapping("/share/create")
public Result createShare(@RequestParam Long fileId,
@RequestParam(required = false) String extractCode,
@RequestParam(required = false) String expireDate) {
ShareInfo share = new ShareInfo();
share.setFileId(fileId);
share.setShareToken(UUID.randomUUID().toString().replace("-", ""));
// 提取码可空,空则任何人打开链接即可访问
share.setExtractCode(extractCode);
if (StringUtils.hasText(expireDate)) {
share.setExpireTime(LocalDateTime.parse(expireDate));
}
share.setCreatorId(currentUserId());
shareInfoMapper.insert(share);
String shareUrl = "https://yourdomain.com/s/" + share.getShareToken();
return Result.success(shareUrl);
}
用户在分享的场景里其实默认是“我要把文件发给别人”,所以 Token 一定要够长够随机。我直接用了 UUID,32 位随机字符串,基本不存在暴力猜测的可能。提取码留空代表无需提取码,填入四到六位数字或字母,访问的人需要先输入提取码才能看到文件信息。这种做法在百度网盘里很常见,实现也不复杂。
3.5 下载接口与Range支持
分享链接访问的时候,后端做三步操作:
- 根据 shareToken 查到分享记录;
- 校验提取码是否正确;
- 校验是否超过有效期。
校验通过后返回文件信息,下载时跳转到下载接口。下载接口直接用流输出文件:
java复制@GetMapping("/download")
public void download(@RequestParam Long fileId,
HttpServletResponse response) throws IOException {
FileMeta fileMeta = fileMetaMapper.selectById(fileId);
File file = new File(storageRoot, fileMeta.getStoragePath());
response.setContentType("application/octet-stream");
// 对文件名做URL编码,避免中文文件名乱码
String downloadName = URLEncoder.encode(fileMeta.getFileName(), "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + downloadName);
response.setContentLengthLong(file.length());
Files.copy(file.toPath(), response.getOutputStream());
}
企业场景下最好再支持 HTTP Range 断点下载,这样浏览器下载时可以续传,播放器可以直接拖动视频进度条。Range 的支持本质上就是读文件字节流时跳过指定 offset 再输出指定长度,这块实现逻辑不复杂,但代码量会多一些,我在这篇里不展开了。基本思想是解析 Range: bytes=start-end 头,然后用 RandomAccessFile 定位到 start 开始输出。
4. 多平台适配:同一套接口如何服务Web、App与桌面端
4.1 接口协议约定
“多平台”是标题里的关键词。实际项目里,一套 JavaWeb 后端往往要同时服务浏览器 Web 端、Android/iOS App 端、桌面客户端。这些前端技术栈完全不同,但有一个共同点:它们都是通过 HTTP 接口与后端通信的。
所以关键不是给每个平台写一套独立的后端,而是把接口协议约定清楚。我在公司里一般会把接口文档里固定以下几项:
- 所有接口统一返回 JSON,结构固定为
{code, message, data}; - code 为 0 表示成功,非 0 表示失败,错误码含义在文档中说明;
- 上传文件时使用 multipart/form-data 格式,表单字段名统一:file、fileMd5、chunkIndex;
- 所有时间字段统一使用 ISO 格式字符串传递。
这套约定做下来,你会发现新增一个前端平台基本不需要改后端,只需要按照文档重新实现一份客户端逻辑。
移动端上传大文件有一点需要提醒:App 在弱网环境下更容易断连,而且切后台可能导致上传任务被系统挂起。所以移动端更依赖断点续传能力,你必须把“已上传分块查询”接口做得足够可靠。我建议 App 端每次启动上传任务前,都先调一次 /checkUpload,把服务器端的真实状态同步过来,而不是依赖本地记录的进度。因为本地记录的进度很可能是不准的,比如上一次上传任务其实在后端成功了,但 App 没收到响应,本地以为失败。
4.2 跨域问题
Web 端和部署在不同域名下的接口交互,第一个绕不开的就是跨域。很多人用前后端分离部署,前端跑在 8080 端口,后端跑在 8081 端口,结果浏览器一调接口直接报 CORS 错误。
SpringMVC 里最简单的做法是加一个全局跨域配置:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
我在实际项目中通常会把 allowedOriginPatterns 配成具体的公司域名列表,而不是 *。因为联调阶段无所谓,生产环境如果被人拿着你的接口去做恶意请求,跨域放开就是一种风险面。当然,allowCredentials(true) 和 allowedOriginPatterns("*") 在某些 Spring 版本里不能同时用,这也是我配成具体域名的一个原因。
多平台还有一个共性问题:文件上传需要携带用户身份信息。浏览器端我习惯用 Cookie + Session,App 端则用 Token 放在 Authorization 头里。后端接口需要同时兼容这两种鉴权方式,做法是写一个拦截器:
- 先看请求头里有没有 Authorization;
- 有就以 Token 解析用户;
- 没有就尝试从 Session 里取用户。
两种方式都取不到,就返回 401。这样 Web 端和 App 端共用一套拦截逻辑,不用各写一套。
4.3 移动端与桌面端的体验差异
不同平台在用户习惯上有明显差异。Web 端用户习惯一次性拖拽整个文件夹进来,所以前端要做文件夹遍历,而不是只选单个文件。Mobile 端用户更多是选择相册里的视频或文档,而且一次可能选多个。桌面端客户端的场景最复杂,需要考虑文件被占用、磁盘空间不足、系统休眠等异常。
从后端角度来说,这些差异主要影响的是前端的切片策略和异常重试逻辑,后端接口保持稳定即可。我在这里要特别建议:接口设计时不要把分块大小这个参数写死在后端。也就是说,后端只负责按 chunkIndex 存块,不强制要求块大小为某个固定值。前端平台可以根据自身网络情况决定用 2MB 还是 5MB 的切片。只要合并时按索引顺序读文件,块大小不一致也不影响最终结果。
当然,块大小不一致会带来一个隐患:一个文件的不同块大小参差不齐,校验文件总大小就不好算。所以接口规范里还是要约束:同一个文件的所有分块大小必须一致,最后一块可以例外(因为文件总大小不一定能整除块大小)。合并时判断分块是否齐全,除了数量,最好也校验一下分块总大小是否等于文件总大小。
5. 生产环境踩坑记录与优化
5.1 分块顺序错乱:合并出来的文件为什么总是损坏
第一次上线时,我们遇到过用户反馈“文件能传完,但合并之后打不开,提示文件损坏”。排查了半天,最后发现问题出在分块索引的排序上。
Chunk 表中的 chunk_index 是 INT 类型,按理说按它排序不会出错。但我们的分块列表在传给合并接口时,有人为了“性能”用了并行上传,又用 HashMap 收集结果,导致合并遍历的时候 chunk 顺序完全乱了。虽然文件所有块内容都在,但拼接顺序不对,整个文件字节流就不是原文件了。
这个坑的教训有两条:
- 后端合并时,必须显式地对分块列表按 chunkIndex 排序,不能依赖数据库返回的自然顺序,更不能依赖前端传过来的顺序;
- 前端并行上传时,必须自己维护每个块的索引映射,不能用一个无序集合存上传结果。
另外一个隐蔽问题:合并完成前,不能清理分块文件。如果中间某块拼接失败,或者校验发现文件大小不对,你要能回退重新合并。我在代码里把分块清理放到合并成功之后再执行,这样即使合并失败,分块还在,可以重试。
5.2 并发合并同一个文件:锁必须加在正确的位置
另一个踩过的坑是关于并发合并的。有一次测试同事开了两个浏览器标签页同时上传同一个大文件,一个传到一半断网重试,另一个已经传完触发秒传,结果秒传的接口和合并的接口同时操作同一个 file_meta 记录,把状态搞乱了。
后来我给合并逻辑加了 synchronized 锁,但一开始加的位置不对——加在了 Controller 方法上,只锁住了同一个 fileMd5 的请求吗?不是,synchronized 方法锁的是当前实例对象,所有 fileMd5 的合并请求都会互相阻塞,直接影响了整个系统的并发吞吐量。
正确做法是按 fileMd5 粒度加锁,只让同一个文件的合并请求排队,不同文件之间的合并互不干扰。这就是前面我用 ConcurrentHashMap.computeIfAbsent 实现局部锁的原因。这个优化做完之后,测试环境并发合并不同文件时,接口响应时间再也没互相拖累。
并发问题还有一个隐藏场景:秒传和合并同时发生。A 用户正在传一个新文件,传完最后一个块后调合并;B 用户同时上传同一个文件,checkUpload 查到该文件记录状态还是“上传中”,于是也进入分块上传流程,两个流程对这个文件的分块表同时做写入。因为 file_chunk 有唯一索引(file_md5 + chunk_index),重复写入被挡住了,但合并线程可能正好在遍历分块列表时,另一条线程正在删除分块,导致遍历出空对象。
解决办法是在合并前加一个“开始合并”的状态标记,把 file_meta 的字段从“上传中”改成“合并中”,其他流程一旦看到“合并中”就直接等待或返回“秒传成功”。这块在小型项目里其实很少触发,但既然是做企业级功能,这个边界意识还是要有的。
5.3 合并时内存溢出:一次惨痛的线上事故
我第一次写合并逻辑时图省事,把所有分块文件挨个读进 ArrayList<byte[]>,然后依次写入目标文件。1GB 文件能扛住,但到了 3GB 的视频文件,线上直接 OOM,Tomcat 服务挂了一片。
后来我改成流式合并。上面代码里用的 FileChannel.transferTo 是零拷贝实现,不经过 JVM 堆内存,性能很高。但有一个版本之间的坑:JDK 8 早期版本里 transferTo 在某些平台上有 bug,单次传输超过 2GB 会传不完整。稳妥起见,传输大小超过 2GB 时,建议循环调用,确保每次 transferTo 返回的字节数被累加,直到全部写完:
java复制long transferred = 0;
long size = inChannel.size();
while (transferred < size) {
long count = inChannel.transferTo(transferred, size - transferred, outChannel);
if (count <= 0) {
break; // 防止死循环
}
transferred += count;
}
另外要注意,零拷贝虽然不占 JVM 堆内存,但它会占用文件描述符,所以 inChannel 必须在 finally 块里关闭。忘记关闭文件句柄导致“打开文件过多”的报错,我在生产环境见过不止一次。
5.4 临时分块的磁盘清理:看不见的容量杀手
分块文件如果没有及时清理,会变成磁盘容量的隐形杀手。正常流程下合并成功后我们会删除分块,但异常场景呢?
- 用户上传到一半直接关闭浏览器,分块永远留在服务器上;
- 合并失败后用户放弃重试,分块一直占着空间;
- 恶意用户并发上传大量垃圾分块,把磁盘塞满。
所以必须有一个定时清理任务,比如每天凌晨执行一次。清理策略很简单:超过 24 小时仍处于“上传中”状态的 file_meta 关联的所有分块,直接删除,并更新元数据状态为“已失效”。
我实际执行过一轮清理任务,发现生产环境磁盘占用直接降了 30%。这个数字很惊人,说明日常使用中确实有大量用户传一半就放弃。如果你用 Spring 定时任务,写起来很简单:
java复制@Scheduled(cron = "0 0 3 * * ?")
public void cleanExpiredChunks() {
// 查询所有创建时间超过24h且status=0的file_meta
// 遍历删除对应分块文件和记录
}
注意,清理任务不能删除“正在上传中”的文件分块,否则用户传一个超过 24 小时的大文件会突然失败。稳妥的做法是:更新 file_meta 的 update_time,每上传一个分块就刷新时间,清理任务只删除 update_time 超过 24 小时且未完成的分块。这样长时间上传中的任务不会被误杀。
5.5 分享链接的安全边界
分享功能上线后,安全问题是必须提前想的。我总结几个要紧的点:
- 分享 Token 不要用自增 ID,否则别人可以通过
shareId=1001、shareId=1002遍历访问所有分享文件; - 提取码校验失败应该做次数限制,比如同一个 IP 或 Token 连续错误 5 次后锁定 10 分钟,防止暴力破解;
- 过期文件的分享链接要彻底失效,不能只在前端隐藏,后端必须校验时间戳;
- 如果分享的是企业内部敏感文件,最好支持“分享后禁止再次下载”的功能,即只允许在线预览。
这里还要提一个文件名的安全问题。用户上传的文件名可能是 ../../etc/passwd 这种字符串,如果你直接把用户输入的文件名拼到服务器路径里,很容易产生路径穿越漏洞。安全做法是:存储路径完全由后端自己生成,用户文件名仅作为元数据保存,用于下载时设置 Content-Disposition 头。我的代码里存储路径永远基于 fileMd5 生成,跟用户输入的文件名没有任何关系,这就是一种防御性的设计习惯。
6. 最后聊聊我做这类项目的真实体会
断点秒传和分享这套功能,看起来就是几个接口的事,但真正写起来涉及的东西远比想象中多:数据库状态设计、并发控制、磁盘规划、安全边界,每一块都是能在生产环境捅出篓子的地方。
我个人最深的感受是:技术选型不重要,状态流转的设计才重要。你用 SSM 也好、Spring Boot 也好,哪怕只用原生 Servlet,只要把“上传中 -> 合并中 -> 已完成”这个状态机设计清楚,把“分块唯一索引”和“合并锁粒度”这两个并发点处理明白,这套系统的骨架就立住了。
如果你现在正打算用 JavaWeb 做一个包含文件上传功能的项目,我的建议是先把核心流程画清楚:前端切块、后端存块、查缺补漏、合并成完整文件、生成分享链接。这个流程走通后,再去优化上传速度、加 CDN、做直传 OSS,都是水到渠成的事。
还有一个很多人忽略的小细节:上传进度条别信本地记录,要以服务端返回的已上传分块为准。我见过不少前端同学把进度存在 localStorage 里,结果换了个浏览器或者清了下缓存,进度就丢了,重新上传时又从头开始。让 checkUpload 接口做“进度真相”的来源,你以后少很多投诉。
最后分享一个我在项目里常用的目录规划习惯:存储根目录最好放在独立的磁盘分区,尽量不要和系统盘放在一起。上传文件写满磁盘是早晚的事,放在系统盘上,一旦磁盘满了,整个 Tomcat 都会因为无法写入日志而挂掉。独立分区的好处是就算存储盘满了,至少应用还能正常运行,接口报错也只会是“磁盘已满”的明确错误,而不是莫名其妙的服务崩溃。
