那会儿给某高校做教学资源平台,老师要传的课程录像动不动就是2GB,用最原始的multipart表单提交,传到一半断了又得重来,办公室老师说“传个视频比上一节课还久”。后来给JSP网页加上了大文件上传的秒传能力,局面才彻底改观:不管文件多大,只要服务器上已经有人传过,一秒内就能提示“上传完成”,数据已经在了。这篇文章就把这套方案的完整代码、底层原理和生产环境踩过的坑都写出来,适合维护JSP老项目、又在做文件上传功能的开发者直接参考。
1. 秒传不是魔术:先搞清楚它到底做了什么
1.1 秒传的“传”字会骗人:它实际只在做指纹比对
秒传的字面意思容易把人带偏。它并不是真的在一秒内把1GB数据传到了服务器,而是在传输动作开始之前就判断:这个文件服务器上到底有没有?如果有,就直接跳过网络传输,返回“上传成功”。
判断靠的是文件指纹。最常见的是MD5摘要,再配合文件大小和文件名做复核。为什么一套指纹就能做出这种判断?因为同一个文件的内容是确定的,不管谁上传、从哪个设备上传,计算出来的MD5必然相同;而内容不同的文件,哪怕文件名一样,MD5也几乎不可能相同。这就是秒传能成立的根基。
打个比方:图书馆里要确认有没有某本书,管理员只需要在检索系统里查一个索书号,而不是把整本书逐字抄一遍再比对。秒传也一样,它用“本地算一排指纹”替换了“网络传一整份文件”。
1.2 20分钟变成5秒:一笔时间账算清楚
这个收益到底有多大?算一笔实际账。
假设一个1GB的文件要通过公网传到服务器,哪怕你的上行带宽有10MB/s,理论时间也要约100秒,现实中公网带宽往往达不到标称值,几十MB上行已经算不错了。如果是2GB甚至更大,半小时是很常见的事。
而计算一个1GB文件的MD5需要多久?本地磁盘顺序读取的速度通常在200MB/s以上,SSD上能到500MB/s以上,也就是说读取并计算摘要最多几秒钟的事。只要服务器上已经存在相同内容,用户看到的反馈就是“秒级完成”。
这背后的本质是:**用几秒钟的本地计算,换掉几十分钟的网络传输;用一次数据库查询,换掉一次真实的带宽消耗。**秒传体验好,不是因为它真的让数据飞起来了,而是因为它在物理世界里省略了传输。
1.3 秒传、分片、断点续传:三张牌的分工
做完整的大文件上传方案,不能只靠秒传一张牌。秒传解决的是“服务器已有这份文件”的场景,但如果文件确实是新的,就该轮到分片上传上场;分片上传中途网络断了,又需要断点续传来兜底。这三者的关系一开始就要分清,否则做到一半会变得很乱。
| 能力 | 解决的问题 | 触发条件 | 核心消耗 |
|---|---|---|---|
| 秒传 | 服务器已有相同文件,跳过传输 | 文件MD5+大小在库中已存在 | 本地算MD5 + 一次数据库查询 |
| 分片上传 | 大文件传输不稳定,单次请求容易被中断 | 文件不存在或MD5未命中 | 多个HTTP小请求代替单个大请求 |
| 断点续传 | 网络中断后从上次完成的位置继续 | 分片传输过程中有部分未完成 | 状态记录 + 跳过已上传分片 |
这套组合拳在JSP/Servlet技术栈里完全可行,下面从链路到代码一层层拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次秒传请求的完整链路:前端算指纹,后端认指纹
2.1 为什么指纹必须由浏览器先算出来
这是秒传方案里最关键的架构决策:MD5必须在浏览器端计算,不能等文件传到了服务器再算。原因很简单——如果先把整个文件传上去,服务器才能判断“这个文件存在”,那传输已经发生了,秒传就没有任何意义。
所以方案的第一步就是:用户在页面上选中文件后,JavaScript立刻开始读文件内容、算MD5。算完之后再带着指纹去问服务器“这个文件在你这里吗”。服务器回答“在”,浏览器就什么都不用传了;回答“不在”,浏览器再启动真正的分片上传。
有人会担心浏览器算MD5会不会太慢。实测下来,一个几百MB的文件在普通笔记本上算指纹也就几秒,而且可以带进度条反馈给用户,体验上完全能接受。
2.2 一次秒传校验请求的完整信息流
整套流程梳理下来是这样的:
- 用户通过
<input type="file">选择文件。 - 前端用
FileReader配合Blob.slice()把文件切成小块,分块读取二进制内容。 - 每读取一块,就往MD5计算器里追加一块数据,全部读完得到32位十六进制指纹。
- 前端把
md5、size、fileName三个字段通过表单形式POST到校验接口。 - 后端拿着这三个字段去数据库查
file_store表。 - 如果记录存在且物理文件也在,返回
{"exist":true}。 - 前端收到
true后提示“文件已存在,秒传成功”,流程结束。 - 如果返回
false,前端立即进入分片上传流程。
这段链路里,校验接口的响应必须快,所以查询语句要命中索引,不能用模糊查询,也不能在接口里做重活。
2.3 file_store表与唯一索引的设计
秒传的“认指纹”最终落在数据库上,表结构设计得好不好直接影响稳定性和并发表现。
sql复制CREATE TABLE file_store (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_md5 VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
store_path VARCHAR(512) NOT NULL,
ref_count INT DEFAULT 1,
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_md5_size (file_md5, file_size)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为什么唯一键要放在file_md5 + file_size上,而不是file_name + file_size?因为两个老师可能给同一个视频文件起了完全不同的文件名,但内容相同。如果拿文件名做判重依据,同一个视频就会被当成两份文件重复存储,秒传能省下的空间就大打折扣。
ref_count字段记录“这个物理文件被多少条业务记录引用”。当某个上传任务命中秒传,不需要复制物理文件,只需要新建业务引用记录并把ref_count加一。这个字段看着不起眼,但等你想做“删除文件”功能时就会发现它特别重要——只有ref_count归零的时候才能真正删物理文件。
2.4 物理文件路径规划:按MD5存放避免重复占盘
数据库记录归记录,物理文件最终还是要落到磁盘。我的习惯是:物理文件名直接采用MD5值,原始文件名只存在于数据库的file_name字段里。
text复制/data/upload/202405/3f8e2c1b9a4d6f...
好处非常直接:
- 同一个文件即使被反复上传,磁盘上永远只有一份。
- 物理文件名不包含中文和特殊字符,彻底避开编码和特殊字符问题。
- 用户上传的文件不会直接暴露在网页目录下,降低被下载的风险。
还有一个安全原则:上传目录要放在Web应用的部署目录之外,不要放进webapps下,否则别人能通过URL直接访问到老师上传的内部资料。访问下载时走一个专门的Servlet,在Servlet里做权限控制后再把文件流输出给用户。
3. 前端实现:JSP页面里的分片计算与秒传校验
3.1 JSP页面的基础骨架:选文件、显示进度、触发校验
既然是JSP网页,页面本身还是要用JSP输出。JSP在整套方案里的职责是“输出HTML和JavaScript”,业务逻辑放到Servlet去处理。
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>大文件上传</title>
<script src="${pageContext.request.contextPath}/js/lib-md5.js"></script>
</head>
<body>
<div>
<input type="file" id="fileInput"/>
<button id="uploadBtn">开始上传</button>
</div>
<div id="progressWrap" style="display:none;">
<div class="progress-bar" style="width:0%;"></div>
<span id="progressText">0%</span>
</div>
<script>
var fileInput = document.getElementById('fileInput');
var uploadBtn = document.getElementById('uploadBtn');
var progressWrap = document.getElementById('progressWrap');
var progressBar = document.querySelector('.progress-bar');
var progressText = document.getElementById('progressText');
uploadBtn.addEventListener('click', function () {
var file = fileInput.files[0];
if (!file) {
alert('请先选择文件');
return;
}
if (file.size > 4 * 1024 * 1024 * 1024) {
alert('单个文件不能超过4GB');
return;
}
progressWrap.style.display = 'block';
calculateFileMd5(file);
});
</script>
</body>
</html>
页面里引入的lib-md5.js是一个成熟的MD5计算脚本。选文件后点击上传按钮,先校验文件大小,然后进入指纹计算阶段。注意这里别用原生的crypto.subtle接口,因为很多老项目跑在HTTP协议下,crypto.subtle要求安全上下文,直接会不可用。
3.2 分片读取与MD5增量计算
这是容易被写崩的地方。很多人第一次写会直接FileReader.readAsArrayBuffer(file),文件一大浏览器立刻内存爆炸。正确做法是分片读取,每读完一片就往MD5对象里增量追加数据。
javascript复制var CHUNK_SIZE = 2 * 1024 * 1024; // 2MB一片
function calculateFileMd5(file) {
var fileSize = file.size;
var totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
var currentChunk = 0;
var spark = new SparkMD5.ArrayBuffer();
var fileReader = new FileReader();
function readNext() {
var start = currentChunk * CHUNK_SIZE;
var end = Math.min(start + CHUNK_SIZE, fileSize);
var blob = file.slice(start, end);
fileReader.onload = function (event) {
spark.append(event.target.result);
currentChunk++;
var percent = Math.round(currentChunk / totalChunks * 100);
progressBar.style.width = percent + '%';
progressText.textContent = '计算文件指纹:' + percent + '%';
if (currentChunk < totalChunks) {
readNext();
} else {
var md5 = spark.end();
checkFastUpload(file, md5);
}
};
fileReader.readAsArrayBuffer(blob);
}
readNext();
}
2MB的分片大小是折中结果:分片太小会导致读取次数剧增,增加事件循环开销;分片太大又会让单次读入的内存偏高。实测2MB到4MB这个区间,二三十个分片读完几百MB的文件,进度平滑,内存占用也稳定。Math.min这一行很重要,最后一片往往不是完整长度,不处理会越界。
整个MD5计算阶段没有网络传输,很多用户不知道你在干什么,所以进度条文案一定要写成“计算文件指纹”,否则用户会以为页面卡住了。
3.3 调用校验接口并判断命中结果
MD5算出来后,浏览器要带着指纹和文件信息去问服务器。我用表单字段的方式提交,不引入JSON序列化,老项目接入场景更通用。
javascript复制function checkFastUpload(file, md5) {
progressText.textContent = '正在校验文件是否已存在...';
var formData = new FormData();
formData.append('md5', md5);
formData.append('size', file.size);
formData.append('fileName', file.name);
var xhr = new XMLHttpRequest();
xhr.open('POST', '${pageContext.request.contextPath}/upload/check');
xhr.onload = function () {
if (xhr.status === 200) {
var result = JSON.parse(xhr.responseText);
if (result.exist) {
progressBar.style.width = '100%';
progressText.textContent = '秒传成功:服务器已有相同文件';
return;
}
// 文件不存在,进入分片上传
startChunkUpload(file, md5);
} else {
progressText.textContent = '校验失败,请重试';
}
};
xhr.onerror = function () {
progressText.textContent = '网络异常,请重试';
};
xhr.send(formData);
}
这里有个容易忽略的点:校验成功后,前端不要直接提示“上传完成”就完事,要看服务端返回内容里有没有业务记录的ID或状态。如果后面要接“我的文件列表”功能,前端最好在秒传成功后主动刷新一次列表数据,让用户能在列表里立刻看到刚“秒传”的文件。
3.4 前端进度与交互细节
整个交互过程实际上有两个阶段,进度条的含义完全不同:
- 第一阶段是“计算文件指纹”,进展跟着分片读取进度走。
- 第二阶段是“实际上传分片”,进展跟已传输字节量走。
我在实际项目里会把这两个阶段的文案明确区分,避免用户看到进度条先涨到100%然后又变成10%而困惑。还有一个细节:计算指纹期间按钮要禁用掉,防止用户反复点击导致多个MD5计算任务同时跑,把浏览器卡死。
4. 后端实现:Servlet接收校验、分片与合并
4.1 为什么业务代码放Servlet而不写进JSP
初学JSP的人喜欢在页面里写<%脚本片段处理请求,看起来方便,但文件上传这种逻辑稍微复杂一点,页面里就塞满了Java代码,改一个参数要翻几百行JSP,维护成本极高。
所以我的惯例是:JSP只输出页面结构,所有请求处理都放Servlet。JSP页面里的表单和AJAX请求,统一指向@WebServlet映射的地址。下面所有后端接口都按这个方式组织。
4.2 秒传校验Servlet:查表、校验、返回
秒传校验接口的逻辑不复杂,但参数校验一点不能省。
java复制@WebServlet("/upload/check")
@MultipartConfig(
maxFileSize = 1024 * 1024 * 1024L,
maxRequestSize = 1024 * 1024 * 1024L,
fileSizeThreshold = 1024 * 1024
)
public class UploadCheckServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String md5 = req.getParameter("md5");
String fileName = req.getParameter("fileName");
String sizeParam = req.getParameter("size");
// MD5必须是32位十六进制字符串
if (md5 == null || !md5.matches("^[a-fA-F0-9]{32}$")) {
resp.getWriter().write("{\"exist\":false,\"msg\":\"invalid_md5\"}");
return;
}
// 文件名做基础清洗,防止路径穿越
if (fileName != null) {
fileName = fileName.replaceAll("[\\\\/]", "_");
}
long size = Long.parseLong(sizeParam);
boolean exist = dao.existsByMd5AndSize(md5, size);
// 记录存在还不够,还要确认物理文件真实存在
if (exist && !new File(dao.getStorePathByMd5(md5)).exists()) {
dao.deleteDeadRecord(md5);
exist = false;
}
resp.getWriter().write("{\"exist\":" + exist + "}");
}
}
很多线上翻车事故都出在“数据库有记录但物理文件被误删”这种数据不一致上。所以校验接口里一定要加一道“物理文件存在性检查”,发现记录成了僵尸数据就顺手清理掉。虽然多一次File.exists()的开销,换来的可靠性非常值。
4.3 分片接收Servlet:落盘与记录
秒传没命中的文件,进入分片上传。前端把文件切成一片一片,每片通过一个独立的HTTP请求传到后端。
java复制@WebServlet("/upload/chunk")
@MultipartConfig(
maxFileSize = 20 * 1024 * 1024L,
maxRequestSize = 20 * 1024 * 1024L,
fileSizeThreshold = 1024 * 1024
)
public class ChunkUploadServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String md5 = req.getParameter("md5");
int index = Integer.parseInt(req.getParameter("index"));
Part filePart = req.getPart("file");
String tempDirPath = UploadConfig.TEMP_BASE + File.separator + md5;
File tempDir = new File(tempDirPath);
if (!tempDir.exists()) {
tempDir.mkdirs();
}
File chunkFile = new File(tempDir, index + ".part");
try (InputStream in = filePart.getInputStream();
OutputStream out = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
dao.saveChunkRecord(md5, index, filePart.getSize());
resp.getWriter().write("{\"ok\":true,\"index\":" + index + "}");
}
}
每个分片都单独写进一个以md5命名的临时目录,分片文件名就是它的索引号。同时往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,
upload_status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_md5_chunk (file_md5, chunk_index)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里的分片临时文件不建议直接放内存,几十个分片全堆内存,一个大文件任务就能把应用服务器的内存耗尽。
4.4 合并分片与最终校验
所有分片上传完成后,前端调用一次合并接口。合并逻辑的核心是按索引把分片顺序写入最终文件。
java复制public File mergeChunks(String md5, int totalChunks, String destPath) throws IOException {
File tempDir = new File(UploadConfig.TEMP_BASE + File.separator + md5);
File destFile = new File(destPath);
try (RandomAccessFile raf = new RandomAccessFile(destFile, "rw")) {
for (int i = 0; i < totalChunks; i++) {
File partFile = new File(tempDir, i + ".part");
if (!partFile.exists()) {
throw new IOException("分片缺失: " + i);
}
// 每个分片从自己的偏移量写入
raf.seek(i * CHUNK_SIZE);
try (InputStream in = new FileInputStream(partFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
raf.write(buffer, 0, len);
}
}
}
}
// 合并后校验总文件MD5
String mergedMd5 = FileMd5Util.compute(destFile);
if (!md5.equalsIgnoreCase(mergedMd5)) {
throw new IOException("合并后校验失败,文件可能已损坏");
}
// 清理临时分片目录
deleteDir(tempDir);
return destFile;
}
为什么用RandomAccessFile.seek而不是简单的FileOutputStream追加?因为分片请求可能是并发到达的,如果合并时严格按照索引顺序一段段追加,任何一个分片丢失就会导致后面全部错位。用seek定位偏移量,每个分片有固定位置,谁先谁后不影响最终结果。
合并完成后做一次全文件MD5校验,这一步不能省。网络传输过程中任何一段数据损坏,通过分片级别的校验不一定能发现,但整文件MD5对不上一定能发现。
4.5 断点续传的状态查询接口
断点续传的价值在于:用户的网络在上传过程中断了一次,重新上传时不必从头开始。前端进入分片上传前先问一下服务端:这个文件已经收到哪些分片了?
java复制@WebServlet("/upload/status")
public class UploadStatusServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json;charset=UTF-8");
String md5 = req.getParameter("md5");
List<Integer> doneIndexes = dao.getDoneChunkIndexes(md5);
StringBuilder sb = new StringBuilder("{\"done\":[");
for (int i = 0; i < doneIndexes.size(); i++) {
if (i > 0) sb.append(",");
sb.append(doneIndexes.get(i));
}
sb.append("]}");
resp.getWriter().write(sb.toString());
}
}
前端拿到done数组后,把其中已经存在的分片索引跳过去,只上传缺失的部分。这个接口会让用户的体验从“重新传一遍”变成“从断点继续”,在弱网场景下尤其重要。
5. 生产环境里的五类硬坑:限制、竞态、内存与部署
5.1 上传大小限制的坑与配置
大文件上传最常遇到的第一个坑是:明明代码逻辑没问题,传几百MB甚至几GB的文件时,请求直接报错甚至被静默中断。问题几乎都出在@MultipartConfig的配置上。
@MultipartConfig有三个关键参数:
maxFileSize:单个文件最大允许字节数。maxRequestSize:整个multipart请求的总体积上限。fileSizeThreshold:超过该大小的文件先写入磁盘临时文件,而不是全放内存。
很多老项目只写了@MultipartConfig,三个参数全用默认值,结果传大文件时请求会被拦截。我通常在分片场景下把这三个值都显式配置成合理范围。比如单分片20MB,那么maxFileSize和maxRequestSize都要大于20MB,否则请求在Servlet容器解析阶段就被拒了。另一个容易忽略的是容器连接器的编码配置,multipart表单里的中文文件名经常因为编码不对变成乱码,前端后端统一UTF-8是最稳妥的做法。
5.2 两个用户同时传同一文件:并发竞态处理
秒传在单用户场景下很顺畅,一旦两个用户同时上传同一个文件,就容易出问题。
假设A用户和B用户同时计算出了相同的MD5,同时发起秒传校验,服务端同时查询file_store表,发现都没有记录,于是同时启动分片上传。两个任务往同一个md5临时目录里写分片,互相覆盖,最终合并出来的文件一定是不完整的。
解决思路其实很简单:利用数据库的唯一索引uk_md5_size。在真正创建物理文件引用的环节,先尝试插入file_store记录,如果插入失败说明已经有人抢先插入了相同指纹的记录,那这次上传就可以直接按秒传处理。
java复制try {
dao.insertFileRecord(md5, fileName, size, destPath);
// 插入成功,我是第一个上传者,负责合并分片
} catch (DuplicateKeyException e) {
// 插入失败,别人已经传过这个文件了,直接走秒传成功逻辑
// 注意清理自己已经上传的临时分片
}
用数据库的唯一约束做并发控制,不需要额外加分布式锁,简单可靠。前提是表结构里真的建了唯一索引,很多人表里漏了这个索引,并发时照样出问题。
5.3 合并大文件时防止内存溢出
老项目里见过一种“抄近路”的合并写法:把所有分片用Files.readAllBytes()读进内存,再一次性写出去。分片少的时候没问题,但一个2GB的文件拆成上百个分片,这么干直接OutOfMemoryError。
正确的合并方式永远是流式读写:一个缓冲区,从分片读一块、往目标文件写一块,缓冲区大小8KB到64KB都可以。前面代码里的byte[] buffer = new byte[8192]就是干这个的。合并这个动作本身应该独立成一个后台线程,不要让HTTP请求线程一直占着,否则用户那边请求超时了,服务端线程还在傻傻地写盘。
5.4 多实例部署时秒传为什么失效
如果应用做了多实例部署,比如负载均衡后面挂了两个节点,秒传方案会突然“失灵”。原因是每个节点的临时分片目录各自独立,A节点收到了分片,B节点合并时找不到文件。
两条路可以解决:
- 临时分片目录落在共享存储上,所有节点读写同一份数据。
- 分片接收和合并逻辑固定在同一个节点上处理。
秒传校验的数据库记录本身已经是共享的,只要存储一致,多实例也能正常工作。但要注意:如果用了共享存储,临时目录的清理任务也要考虑多实例并发清理导致文件正在合并时被删的问题,加一个“文件使用中”状态标记会稳妥很多。
5.5 安全过滤你不能跳过的几行代码
文件上传接口天然是攻击面,下面的校验一个都不能省:
- MD5参数必须校验格式,必须是32位十六进制字符串,否则后续数据库查询和文件名拼接都可能有隐患。
- 原始文件名必须做清洗,替换掉
/、\、..这些路径穿越相关字符。 - 文件后缀走白名单校验,视频平台就只放行常见的视频格式,别搞黑名单。
- 物理文件不要用原始文件名存放,统一用MD5或UUID重命名。
- 上传目录不能暴露在Web应用的静态资源路径下面,下载走受控的Servlet接口。
这些代码加起来没几行,但没有它们,前面所有功能都等于白做。
6. 几件只有真传过大文件才会遇到的小事
6.1 进度条走完了,但请求还没结束
第一次做分片上传时,我用XHR的upload.onprogress事件驱动进度条,发现传一个200MB文件的时候进度条很快到了100%,但用户还得多等十几秒才能看到“上传完成”。原因在于onprogress反映的只是“浏览器已经发送了这么多字节给服务器”,并不代表“服务器已经写盘完成”。请求最终完成要以服务端HTTP响应返回为准。
所以后来前端进度条不再直接依赖onprogress,而是按照已成功返回的分片数量来算:每收到一个分片上传成功的响应,才把它计入已完成。最后一个分片上传成功之后,还要等合并接口返回成功,才真正显示100%和“上传完成”。否则就会出现“进度条走完,但还在转菊花”的尴尬局面。
6.2 上传中文文件名的乱码问题
JSP老项目做文件上传,中文文件名乱码几乎是必踩的。一个文件叫“课程录像.mp4”,传到服务器变成“课程录象.mp4”或者一堆乱码。
根源在于multipart请求里文件名的编码解析,涉及浏览器、容器连接器、应用三层。我的处理方案是三层锁死:前端发送的请求带charset=UTF-8;容器连接器的URIEncoding配置成UTF-8;后端Servlet里request.setCharacterEncoding("UTF-8")。三层都对了基本不会再乱。
即便这样,我还是强烈建议物理文件名别用中文,原始文件名只存数据库。这样乱码问题就彻底跟你无关了。
6.3 “秒传成功”了,列表里却没有文件
这个坑藏得比较深,也是最容易让用户产生“系统是不是在骗我”观感的。
一开始我实现秒传时,服务端校验到文件已存在,直接返回{"exist":true},前端提示成功。但用户回头打开“我的文件”列表,发现里面什么都没有。排查了很久才明白:秒传命中只是“文件内容已经在服务器上”,但用户的业务引用记录还没有创建。真实的上传成功,必须包含两步:一步是“文件内容存在”,一步是“这个文件和当前用户建立了关联关系”。
所以后来命中秒传时,服务端除了返回exist:true,还会做两件事:创建当前用户的文件业务记录,并把file_store.ref_count加一。这样用户列表里立刻能看到这个文件。
6.4 我建议的落地顺序
如果是从零开始做这套功能,我建议不要一次把所有能力都堆上去,按这个顺序推进会稳很多:
- 先做页面选文件和MD5计算,把前端指纹计算跑通。
- 再加秒传校验接口,实现“服务器已有文件就跳过上传”。
- 再写分片上传逻辑,把新文件切成2MB一片传上去并合并。
- 最后补断点续传的status接口,让弱网用户不被劝退。
每完成一步都能单独验证,不至于出了问题不知道是前端算错了指纹、还是后端合并逻辑写错了。
最后再分享一个心得:这套方案里真正难的往往不是代码本身,而是数据一致性的设计——文件记录要存什么、物理文件存在哪里、引用了多少次、并发情况下怎么防重。把这几件事想清楚,JSP的老项目也能承载几个GB级别的文件上传,而且稳定跑到今天没出过问题。
