说个可能让很多不接触工业软件的人意外的场景:我在和一家半导体级制造企业的设备数据团队对接时,工艺工程师需要把一批几 GB 的晶圆缺陷检测图片、设备运行日志和工艺参数文件传到 MES 边侧服务器上做后续分析。界面是 SpringBoot 搭建的后台管理系统,上传进度条转了半天,最后网关直接报 504,浏览器显示请求超时,文件却没有真正写进存储。那时候我就意识到,“大文件上传”在制造系统里从来不是一个“SpringBoot 接口怎么写”的小问题,而是一整套关于传输稳定性、服务端资源控制、断点续传、文件校验和任务状态管理的问题链。
为了说清楚这条链路,我会用 SpringBoot 为核心实现框架,从“为什么不能直接用普通文件上传”,到“分片上传到底怎么设计”,再到“后端如何安全合并文件”“部署时网关和容器参数怎么调整”,最后把我真实踩过的几个深坑总结出来。涉及芯片制造、半导体设备数据采集和一般企业系统建设的内容都适用。无论你是主要写业务 CRUD 的 Java 开发,还是正在搭建内部文件交换平台的架构师,都建议按顺序看完,尤其是后面那几个坑,它们往往不会出现在官方文档里。
1. 当“大文件上传”遇到制造系统:先想清楚问题,再写代码
很多人一听大文件上传就觉得“无非是把 Spring 的 multipart 配置调大一点”,这种思路在处理几十 MB 的附件时没问题,但到了半导体制造场景中的几个 GB、几十 GB 文件时,问题会迅速演化成三个层面。
1.1 半导体等制造场景里,为什么天然绕不开大文件
芯片制造流程复杂,包含光刻、刻蚀、薄膜沉积、检测等环节。每一道工序的设备都会产生大量过程数据:晶圆检测设备输出的高分辨率缺陷图像、SECS/GEM 通讯日志、设备配方文件、SPC 量测数据、Trace 日志。很多系统为了实现质量追溯,会把不同批次、不同设备、不同时间段的数据打包成一个大文件传给上层的分析平台或 MES。
以一套典型的晶圆缺陷检测系统为例,一片晶圆可能有几十甚至上百个 Die,每个 Die 的缺陷图如果保存为未压缩的 TIF 格式,单张可能就是几十 MB,一批产品检测完成后打包数据轻松超过 1GB。如果再做灰度图、3D 形貌图和原始像素数据归档,单个文件达到 10GB 级别完全正常。传统 OA 系统里那种“上传个 Excel、传个 Word”的思维在这里根本不适用。
还有一个容易被忽略的点:制造车间的网络环境通常是隔离的。工程师办公区到产线服务器之间可能经过多层防火墙、网闸、文件摆渡系统,带宽不一定高,链路还容易抖动。如果大文件传输不支持重试和续传,一旦传输到 90% 时网络闪断,整个流程就得从头再来。这种损耗带来的不仅是时间成本,还可能延误工艺分析窗口,甚至影响生产决策。
1.2 大文件上传的三个核心挑战:传输、存储、状态可见
在做技术方案前,我喜欢把问题拆成三个维度:
- 传输维度:文件太大时,单次 HTTP 请求的耗时很长,期间任何一个网络抖动、服务重启或浏览器标签页误关闭都会导致前功尽弃。传输本身要支持中断恢复,而不是失败后全部重来。
- 存储维度:服务端同时接收多个大文件时,内存、临时磁盘、合并时的 IO 都可能成为瓶颈。SpringBoot 默认的 multipart 解析会先把文件写到临时目录,如果临时目录不可控或磁盘空间不足,处理过程会非常脆弱。
- 状态维度:用户需要一个明确的“当前传到哪里了”的感知。传统上传没有进度反馈,用户只能看到浏览器转圈。大文件上传必须提供任务级别的进度信息,否则使用者无法判断是继续等待还是选择重传。
这三个维度决定了我们不能只写一个 MultipartFile 接口就收工,而是要把它当成一个有状态、可恢复、可监控的小型任务系统来做。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么我从不建议直接拿 MultipartFile 硬扛
先说明,SpringBoot 自带的 MultipartFile 并不是不能用,它非常适合处理小文件。但用它做大文件上传,有几个天然硬伤。
2.1 MultipartFile 单次请求模型的三个硬伤
第一个硬伤是请求体过大时,网关和服务端压力集中释放。SpringBoot 处理上传时,如果使用默认的 StandardServletMultipartResolver,会将整个请求体解析并暂存到临时文件。文件越大,解析时间越长,占用临时磁盘空间越大。一旦并发上来,临时目录瞬间被写满,整个应用都可能变慢。
第二个硬伤是网络中断后没有恢复能力。一次 HTTP 请求只要建立后中断,服务端已经接收到的数据无法复用。客户端只能重新选择文件、重新计算、重新上传。这对 GB 级文件来说不可接受。
第三个硬伤是用户体验不可控。浏览器不会给你一个准确的“服务端已接收多少字节”的进度,前端拿到的上传进度往往只是“请求已发送”,无法了解服务端正在写临时文件还是已完成落盘。实际项目里,用户看到进度条 100% 但接口迟迟没有返回,这种状态最让人焦虑。
2.2 从“传文件”到“传分片”的方案演进
常见的大文件传输方案可以归为三类:
| 方案 | 实现思路 | 优势 | 局限 |
|---|---|---|---|
| 对象存储直传 | 前端生成签名,直传 OSS/MinIO/S3 | 客户端直连存储,服务端压力小 | 内网部署对象存储需要额外运维,与业务系统的整合成本不低 |
| 开源分片组件 | 比如 WebUploader、Resumable.js 等 | 分片和续传逻辑封装较好 | 很多组件已停止维护,且上传参数和服务端校验规则需要自行实现 |
| 自研分片上传接口 | 前端切片、后端合并,SpringBoot 提供接口 | 完全贴合业务,便于和已有权限系统、任务系统集成 | 需要自己处理并发、合并、状态记录等细节 |
芯片制造企业的系统通常跑在隔离内网,不一定允许直接引入外部对象存储服务;即便内部部署了 MinIO,也需要一个业务侧的上传入口做权限校验、病毒扫描、格式识别和元数据保存。所以我在多数项目里倾向于“自研 SpringBoot 分片上传 + 内部文件服务/MinIO 落盘”的组合。前端把大文件切成若干分片,逐个上传;后端收到分片后先落到本地临时目录,等全部分片到达后触发合并任务,大文件落地后再异步同步到对象存储或归档系统。
这样设计的好处是:不需要在浏览器与对象存储之间暴露存储桶凭证;每个分片都很小,可以独立重试;服务端每收到一个分片都能立刻返回结果,前端可以实时更新进度。
注意:分片上传不是银弹。如果单个文件本身只有几十 MB,分片反而会增加请求次数和失败概率。想清楚场景边界很重要。
3. 核心链路一:前端分片、指纹计算与断点续传的信息设计
很多人一聊分片上传就开始写 MultipartFile 接口,但真正做到生产级,前端的信息设计比后端代码更关键。因为后端能正确合并文件,完全依赖于前端在每个分片请求中传递的元数据是否完整、准确。
3.1 文件的“身份标识”:MD5 与采样哈希怎么选
大文件上传首先要解决识别问题:这个文件是什么?所有分片属于谁?服务端要根据什么判断文件是全新文件还是已经上传过一部分的续传文件?
业界常见做法是给文件计算 MD5,作为全局唯一标识。前端先读完整文件,计算一次 MD5,再开始分片;服务端以 MD5 作为目录名和任务记录主键。但这里有个很现实的坑:计算一个几 GB 文件的 MD5,需要完整读取文件,耗时很长,而且浏览器主线程做这件事会卡住页面。在半导体场景里,常见的数据文件偏偏又很大。
一次真实的项目中,我前端用 SparkMD5 全量计算一个 4.7GB 的打包文件,花了两分多钟。用户在这两分钟里完全不知道发生了什么,第一反应是“页面卡死了”。
我的建议是分两层校验:
- 第一层,快速指纹:用文件大小、文件名后缀、修改时间组合成初步索引,用于判断是否命中“秒传”或“续传”。如果服务端已有同名同大小同修改时间文件,大概率可以复用已有记录。
- 第二层,强校验:只有未命中快速指纹时才执行完整 MD5。如果文件太大且用户不希望等待,可以采用抽样哈希。但要注意抽样哈希不能完全替代 MD5,它只能作为前端做续传的标记。真正做数据完整性校验应在服务端合并完成后执行全文件哈希。
实际实现时,很多项目直接采用 SparkMD5 计算全文件 MD5。如果不能接受等待时间,也可以在前端显示“正在计算文件指纹”的进度提示,这样至少不会让用户误以为页面卡死。
3.2 一次分片请求该携带哪些参数
在设计接口参数时,我通常会把参数分成三类:
- 文件身份类:
identifier(MD5 或唯一标识)、fileName、fileSize。 - 分片元数据类:
chunkIndex(当前是第几片,从 0 或从 1 开始均可,但要全链路统一)、totalChunks(总分片数)、chunkSize(每片大小)。 - 业务上下文类:
bizType(业务类型,如缺陷图片、日志包)、uploaderId(上传人)、targetPath(希望归档到的相对目录)。
有一点容易出错:不要把 chunkIndex 混入文件名的后缀里让后端自己解析,也不要只传文件名不传文件大小。后端合并时需要用 chunkSize 和 totalChunks 计算最终文件预期大小;如果多个分片来自同一个文件,但文件大小前后不一致,必须当作非法请求拒绝。
一个实用的经验:服务端不要信任前端传的 totalChunks,而应该通过 ceil(fileSize / chunkSize) 重新计算总分片数,防止前端代码有 bug 或被人恶意篡改。
3.3 前端分片循环与失败重试的雏形
这里我给出一个比较精简的前端实现逻辑,不算完整工程代码,但足以说明信息流转:
javascript复制const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB,可根据业务调整
async function uploadLargeFile(file, uniqueId) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let current = 0; current < totalChunks; current++) {
const start = current * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunkBlob = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunkBlob, file.name);
formData.append('identifier', uniqueId);
formData.append('fileName', file.name);
formData.append('fileSize', file.size);
formData.append('chunkIndex', current);
formData.append('totalChunks', totalChunks);
formData.append('chunkSize', chunkBlob.size);
let success = false;
let retryCount = 0;
while (!success && retryCount < 3) {
try {
// 每个分片独立请求,服务器可能因为网络问题返回5xx
await axios.post('/api/upload/chunk', formData, {
timeout: 180000 // 大分片在弱网环境下需要更长超时
});
success = true;
} catch (e) {
retryCount++;
}
}
if (!success) {
throw new Error(`第 ${current + 1} 个分片上传失败`);
}
// 可以通过 (current + 1) / totalChunks 计算出真实进度
updateProgress((current + 1) / totalChunks);
}
// 分片全部上传完成后,通知服务端触发合并
await axios.post('/api/upload/merge', {
identifier: uniqueId,
fileName: file.name,
totalChunks
});
}
前端还应该维护一个“已上传完成的分片索引”列表。如果某个分片重试多次后仍然失败,要将已成功分片的索引提交给后端,让后端返回“哪些分片不存在”,避免用户从头再传一遍。这个细节是断点续传真正落到实处的关键。
4. 核心链路二:SpringBoot 后端的分片接收、落盘与合并处理
前端把分片传过来了,SpringBoot 后端要能可靠接收、验证和合并。我没有使用额外的第三方分片组件,代码主要依赖 Spring MVC 和标准的 Java NIO。
4.1 分片上传接口:参数如何组织更安全
分片上传接口不需要同时接收整个文件的二进制,它只要接收一个分片即可。接口定义为:
java复制@RestController
@RequestMapping("/api/upload")
public class ChunkUploadController {
private final ChunkStorageService chunkStorageService;
public ChunkUploadController(ChunkStorageService chunkStorageService) {
this.chunkStorageService = chunkStorageService;
}
@PostMapping("/chunk")
public ResponseEntity<ApiResult<ChunkUploadResponse>> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("identifier") String identifier,
@RequestParam("fileName") String fileName,
@RequestParam("fileSize") Long fileSize,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("chunkSize") Long chunkSize) throws IOException {
if (identifier == null || identifier.isBlank()) {
return ResponseEntity.badRequest().body(ApiResult.error("缺少文件唯一标识"));
}
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
return ResponseEntity.badRequest().body(ApiResult.error("分片序号不合法"));
}
if (file.isEmpty() || file.getSize() != chunkSize) {
return ResponseEntity.badRequest().body(ApiResult.error("分片大小与声明不一致"));
}
FileModel fileModel = chunkStorageService.saveChunk(
identifier, fileName, fileSize, chunkIndex, totalChunks, file);
return ResponseEntity.ok(ApiResult.success(fileModel));
}
}
很多初学者喜欢把分片序号挂在 MultipartFile 的原始文件名里,比如 abc.zip.part0,后端再解析文件名得到 chunkIndex。这样做很脆弱,因为原始文件名可能包含点号、空格、中文等特殊字符。更规范的做法是保持分片二进制为普通临时文件,所有业务元数据都通过请求参数显式传递。
4.2 分片落盘目录:按 identifier 隔离是关键
服务端需要一个专用的临时存储根目录。每个文件上传任务用 identifier 建立独立的子目录,目录内部只保存 .part 后缀的分片文件。底层逻辑大致如下:
java复制@Component
public class ChunkStorageService {
private final FileStorgeProperties properties;
public ChunkStorageService(FileStorgeProperties properties) {
this.properties = properties;
}
public void saveChunk(String identifier, int chunkIndex, MultipartFile file) throws IOException {
Path taskDir = Path.of(properties.getChunkRootPath(), identifier);
Files.createDirectories(taskDir);
Path target = taskDir.resolve(chunkIndex + ".part");
// 如果已经存在且大小一致,说明该分片已上传成功,直接跳过,实现断点续传
if (Files.exists(target) && Files.size(target) == file.getSize()) {
return;
}
file.transferTo(target);
}
}
这个逻辑是断点续传的后端基础。前端在重新发起上传前,可以先调用“查询已上传分片列表”的接口,后端扫描指定 identifier 目录下的 .part 文件,返回所有已成功写入的分片序号,前端从缺失的第一个分片开始续传,而不是全部重新上传一遍。
需要注意 file.transferTo 有一个使用细节:如果目标文件已经存在,不同 Servlet 容器在处理时可能抛出 FileAlreadyExistsException。稳妥的做法是先删除已存在的目标文件再执行 transferTo;但在断点续传场景中,应先检查已存在分片的大小是否与当前上传分片一致,一致则直接跳过,不一致则覆盖写入。
4.3 分片合并:使用 FileChannel 按偏移量拼接
当最后一个分片上传达成功后,会请求 SpringBoot 的合并接口。此时后端要验证所有分片是否齐全,然后按顺序拼接成完整文件。合并的核心逻辑我建议使用 FileChannel.transferFrom,这样既高效又不容易受跨平台 IO 问题影响。
java复制public File mergeChunks(String identifier, String fileName, int totalChunks, long expectedSize) throws IOException {
Path taskDir = Path.of(storagePath, identifier);
Path mergedFile = Path.of(storagePath, "final", identifier + "_" + fileName);
Files.createDirectories(mergedFile.getParent());
// 校验分片数量
long actualChunkCount;
try (Stream<Path> pathStream = Files.list(taskDir)) {
actualChunkCount = pathStream.filter(p -> p.toString().endsWith(".part")).count();
}
if (actualChunkCount != totalChunks) {
throw new IllegalStateException("分片不完整,期望 " + totalChunks + " 片,实际 " + actualChunkCount + " 片");
}
try (FileChannel outChannel = FileChannel.open(mergedFile,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
for (int i = 0; i < totalChunks; i++) {
Path partPath = taskDir.resolve(i + ".part");
if (!Files.exists(partPath)) {
throw new IllegalStateException("缺少第 " + (i + 1) + " 个分片");
}
try (FileChannel inChannel = FileChannel.open(partPath, StandardOpenOption.READ)) {
long position = (long) i * getChunkSize(totalChunks, expectedSize);
outChannel.transferFrom(inChannel, position, inChannel.size());
}
}
}
// 二次校验文件长度
long realSize = Files.size(mergedFile);
if (realSize != expectedSize) {
throw new IOException("合并后文件大小与预期不一致");
}
return mergedFile;
}
代码中的 getChunkSize 需要注意:最后一个分片可能不足一个标准分片大小。因此更稳妥的做法不是让后端用“文件总大小除以总分片数”的平均值计算每个分片的长度,而是每个分片上传时都记录 chunkSize,合并时逐片按记录偏移写入。实际生产环境里,我会把每个分片的实际大小记录在 file_chunk 表中,合并时优先读取表数据,而不是依赖公式计算。
4.4 合并后的完整性校验
制造业的数据完整性要求远高于普通业务系统。芯片检测图像少一个字节,后端分析程序就可能无法解析。所以在合并后,我会根据业务等级决定是否做高强度校验。
如果只需要快速校验,检查合并后的文件字节大小与前端声明一致就足够;如果文件最终要归档到质量系统或用于追溯,我建议再计算一次 SHA-256 或 MD5,并把校验值写入文件元数据表。
对于超大文件计算完整 SHA-256 也耗时。折中方案是:合并时边写边计算每个分片的 CRC32;全部合并完后,如果业务允许,对最终文件再用 Java 的 DigestInputStream 做一次流式计算。
java复制public String checksum(Path filePath) throws IOException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream is = Files.newInputStream(filePath)) {
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
digest.update(buffer, 0, len);
}
}
return HexFormat.of().formatHex(digest.digest());
}
这一步不可省,尤其是当分片上传来自多个浏览器标签页或并发进程时。
5. 容量与稳定性:SpringBoot、Nginx 与任务状态表该怎么配
代码能跑通是一回事,生产环境能扛住多个工程师同时上传 2GB 文件是另一回事。这里我说几个必须提前配好的点。
5.1 SpringBoot 的 multipart 配置不能只看单个文件大小
SpringBoot 对上传文件的大小限制分为两个层级:
spring.servlet.multipart.max-file-size:单个文件大小上限。spring.servlet.multipart.max-request-size:整个请求体大小上限,通常用于阻止超大请求体攻击。
在分片上传模式下,因为每个分片只有几 MB,这两个值并不需要很大,只要略大于单个分片即可。但如果你同时保留了一个“整文件上传”的兼容入口,那就要仔细计算。生产环境中常见的部署方式,我会在 application.yml 里这样设置:
yaml复制spring:
servlet:
multipart:
max-file-size: 1024MB
max-request-size: 1150MB
file-size-threshold: 10MB
location: ${java.io.tmpdir}/spring_upload_tmp
这里有个很容易踩的坑:max-request-size 必须大于 max-file-size。如果只设置了 max-file-size 而没有同时调整 max-request-size,在一次性上传大文件时仍会被 500MB 或默认 10MB 的请求体限制拦住。
同时要注意,Tomcat 本身也有连接相关参数。在纯本地开发环境可能看不出区别,一旦部署到服务器并通过 Nginx 转发,问题马上就来了。
5.2 Nginx 不只是调大 client_max_body_size 那么简单
很多团队会把 SpringBoot 服务放在 Nginx 后面,而 Nginx 默认只允许 1MB 的请求体。如果不调 client_max_body_size,大文件上传直接返回 413。这也是大文件上传最容易踩的坑。
但仅仅调大 body size 还不够。上传大文件耗时较长,Nginx 默认的 proxy_read_timeout 是 60 秒,如果超过 60 秒没有读取到后端响应,连接就会被 Nginx 断开。分片接口其实还好,因为每片按几 MB 计算通常不会超时;但合并接口可能要处理几 GB 文件的 IO,耗时可能非常长。合并期间如果没有及时返回,Nginx 就会断开连接。
一份比较稳妥的 Nginx 配置片段:
nginx复制server {
listen 80;
server_name your-server-name;
# 上不设限,交由应用层控制
client_max_body_size 0;
location /api/upload/ {
proxy_pass http://springboot-app:8080;
proxy_connect_timeout 60s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# 让 SpringBoot 读取到真实客户端 IP
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
如果是公网传输,前端分片大小建议调到 2MB 到 5MB 之间,Nginx 超时给 300 秒以上;如果是千兆局域网内传输,分片可以用 10MB 或 20MB,超时要求不高。总之,要按实际链路质量决定分片大小,而不是硬编码一个值。
5.3 任务状态表:一个最小可用设计
虽然磁盘上的分片文件可以反映上传进度,但让后端每次扫描磁盘来查询进度并不优雅。更清晰的方式是引入两张表,作为上传任务的元数据。一张是任务主表 upload_task,另一张是分片明细表 upload_chunk。在 MySQL 中的大致结构如下:
sql复制CREATE TABLE upload_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
identifier VARCHAR(128) NOT NULL UNIQUE,
file_name VARCHAR(512) NOT NULL,
file_size BIGINT NOT NULL,
chunk_size BIGINT NOT NULL,
total_chunks INT NOT NULL,
finished_chunks INT DEFAULT 0,
status TINYINT NOT NULL COMMENT '0:上传中 1:合并中 2:完成 3:失败',
target_path VARCHAR(1024),
crc32_value VARCHAR(64),
create_by VARCHAR(64),
create_time DATETIME,
update_time DATETIME
);
CREATE TABLE upload_chunk (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
chunk_size BIGINT NOT NULL,
server_path VARCHAR(1024) NOT NULL,
upload_time DATETIME,
UNIQUE KEY uk_task_chunk (task_id, chunk_index)
);
有了这张表之后,续传查询就变成了数据库索引查询,不需要扫描磁盘;查询进度时可以直接用 finished_chunks/total_chunks 得到百分比;合并完成后把 status 置为 2,并把文件最终路径写进 target_path。
这个完整链路设计起来稍微复杂,但它让整个上传流程是可观测、可审计的。我始终认为制造业系统的核心诉求是“出了问题能够解释清楚”,不是简单把数据塞进数据库。
6. 我在制造业项目里踩过的坑,每条都希望你能避开
下面这部分是为了让后来者少交学费。有几个问题如果不提前预防,就会在项目上线后被用户反复投诉。
6.1 用文件名拼接时间戳做上传目录,结果并发冲突了
第一次做分片上传时,我的临时目录纯粹按文件名生成,结果两个不同批次的工程师同时上传了同名的 Datalog.zip,后端分片目录互相覆盖,合并出的文件错乱。后来把所有链路标识改成了 identifier,也就是文件内容哈希,相同内容的文件可以被合并去重,不同内容即便同名也不会互相干扰。
6.2 合并代码没有用 FileChannel.transferFrom,而是疯狂循环写字节
最早期版本我图省事,直接拿到 InputStream 后循环 read/write。单个 1GB 文件合并花了好几分钟。换用 FileChannel.transferFrom 之后,合并时间下降了至少 80%,而且代码更少。对大文件 IO 来说,一定要用系统级的零拷贝传输机制,而不是用户态逐字节搬运。
6.3 只校验了分片数量,没校验总字节数
有一回前端在某个月较老版本的浏览器上出现了同一个分片重复提交但不递增的问题,导致服务端分片数量够了,但某些分片是旧数据或空数据。合并接口校验的是“分片数量等于 totalChunks”,所以侥幸通过了检查,但最终文件无法被下游图像解析工具打开。后来我在合并完成后增加了“最终文件实际字节数”和“声明 fileSize”的强校验,这才能第一时间发现异常。
6.4 误以为 SpringBoot 换新版本就会自动规避老控件问题
热搜里出现过的“不能装载 NTKO 大文件上传控件,请确保使用 IE 浏览器,并检查浏览器安全设置”这类提示,本质上是被历史遗留的 ActiveX/Ocx 控件绑定住了。现在多数浏览器已经不再支持这类插件机制,靠让用户降低安全设置来运行控件是越来越不可行的做法。我在一个老项目里就遇到用户使用 IE 访问内部系统,频繁弹出安全提示导致上传失败。折腾了很久后,最终方案是放弃旧控件,在前端改用 HTML5 的 File.slice() 分片能力,后端保留老接口兼容老数据,逐步灰度切换。事实证明,分片上传方案完全够用,而且不再依赖任何浏览器插件。
6.5 合并时没有做并发保护
当合并接口被前端重复调用,比如用户点击两次合并按钮,或者某个超时请求被前端自动重试,就可能出现两个合并线程同时写同一个最终文件,导致文件内容错乱。解决方式很简单:用分布式锁或数据库唯一状态位。比如合并前先执行 UPDATE upload_task SET status = 1 WHERE identifier = ? AND status = 0,如果受影响行数为 0,说明已经有其他线程在合并,不再重复处理。
提示:大文件上传最危险的状态不是失败,而是“看起来成功实际数据损坏”。宁可让流程多一个校验步骤,也不要让错误数据流入质量系统。
最后分享一个我的习惯
做这类大文件上传需求,我一般会在联调完成后额外做两次演练:第一次是模拟上传到 90% 时直接断开网络,确认重新上传后能够跳过已有分片;第二次是人为在服务端保留一个残缺分片,确认合并接口会拒绝并提示“分片不完整”。只有这两个场景的测试都通过,我才敢把功能交给用户。这套思路放之四海而皆准,和是不是 SpringBoot、是不是芯片制造业没有关系,它本质上是在帮系统建立对“坏网络、错数据、并发请求”这三个异常变量的防御能力。
