能源化工企业的管道巡检,经常是几个人扛着设备沿着管线走十几公里,手机里攒了几十条现场视频、热成像图、高清照片,回到营地连上内网想把日志传回调度中心。结果一点上传,十分钟后进度条卡死在87%,再传一遍又是从头开始,一个200多MB的附件折腾一晚上。这个问题我在好几个化工园区的项目里都撞见过,最终的解法都是用Java做一套大文件分片+断点续传的上传服务。这篇内容就把我在能源化工行业做巡检日志附件上传的完整方案拆开讲,从分片策略、接口设计到合并校验、异常恢复,涵盖可以直接落地的代码思路和参数计算方式。
这篇适合正在做设备巡检、管道监测、危险作业管理这类系统的Java后端开发,也适合在化工企业信息中心负责系统集成、被网络和附件问题反复折磨的同行参考。内容不是讲理论,而是讲我在真实项目中怎么选型、怎么实现、怎么排障的心得。
1. 管道巡检日志的附件,到底难在哪
1.1 化工现场的网络环境与文件特征,和办公室完全两码事
很多没去过现场的开发容易把“上传大文件”等同于“写个MultipartFile接一下”。但在能源化工企业的真实场景里,管道巡检日志的附件有几个特别刁钻的特征:单文件体积大、产生频率高、拍摄设备杂、回传网络弱。
一条次高压天然气管线的日常巡检,标准作业要求每个巡检点至少拍三张照片,遇到疑似泄漏、防腐层破损还要录一段现场视频,单条视频随便就是200MB以上。如果是红外热成像仪采集的数据文件,单文件能上GB。这些附件和普通办公文档不一样,它承载的是安全合规证据,丢失一个字节的问题都可能被追溯到责任事故。而巡检人员又不可能站在原地等文件传完,他们往往是在移动过程中间歇性有网,甚至在管廊桥架下面信号直接归零。
所以这个场景的核心矛盾是:超大文件遇上了极不稳定的弱网环境。一次性上传在这种条件下几乎必死,唯一靠得住的方案就是分片传输加断点续传。
1.2 一次性上传方案为什么在化工现场必然翻车
我见过好几个项目一开始都图省事,让前端直接Post一个文件对象到后端,结果上线第二天就开始出事故。原因非常典型:
首先是HTTP连接超时。常规的Spring Boot内置Tomcat默认的连接超时时间并不长,一个200MB的文件在没有特别配置的情况下,上传过程中只要任何一段网络抖动超过几秒,整个请求就断了,一切推倒重来。
其次是容器内存压力。如果直接以MultipartFile接收,Spring会把文件先缓冲到本地临时目录再转存,这看起来没问题,但一旦多个巡检员同时上传,Tomcat的并发连接数和磁盘临时目录都可能被打爆。文件越大,缓冲时间越长,线程池被占满后整个系统接口全部瘫痪。
还有一个常被忽略的问题:移动网络切换。巡检人员在现场经常处于4G基站之间漫游的状态,IP会变,TCP连接会被重置。如果仅依赖同一个HTTP连接传完整个文件,断一次就废一次。
所以这不是代码风格问题,而是方案架构本身就不匹配业务场景。要解决它,必须把“一个文件的一次请求”拆成“一个文件的多次请求”,让每一次请求都是独立、可重试、可校验的原子操作。
1.3 断点续传解决的不只是“断点”,而是三个问题
很多人对断点续传的理解就是“断了之后从断的地方继续传”。实际上在设计一个真正能用的断点续传服务时,要同时解决三个层面的问题:
第一是传输层的续传能力。文件被切分成若干分片,客户端记录已经上传成功的分片序号,失败时只重传未成功的分片,而不需要重新上传整个文件。这是“续传”字面意义上的核心。
第二是数据层的完整性校验。每个分片在服务端要校验大小和散列值,所有分片合并之后还要做一次全文件校验。这样即使某一个分片在传输过程中被损坏,也能立刻发现并触发重传,而不是等到文件合成了才发现打不开。
第三是业务层的幂等性。巡检人员可能点了两次上传、App可能自动重试,服务端必须能识别“同一个文件的上传任务”,即便收到重复的分片请求也不能报错或产生脏数据。
把这三个问题想清楚了,才算真正理解了断点续传的设计目标。下面我按这个思路逐步展开具体方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点续传的技术原理与服务端选型
2.1 HTTP Range协议是分片续传的基石
要理解断点续传,必须先理解分片是怎么定位的。网上很多教程一上来就讲前端削峰、文件分片,忽略了最底层的协议支撑。实际上所有基于HTTP的大文件续传方案,骨子里都依赖同一个机制:Range头。
RFC 7233定义了HTTP Range请求。客户端可以在请求中携带Range头,告诉服务端“我只想要这个文件的某一段字节”。服务端收到之后返回206 Partial Content状态码,回传对应的字节区间。传统的文件下载断点续传就是靠这个实现的,PDF阅读器翻页、播放器拖动进度条,底层都是Range。
而在我们讨论的上传场景里,Range同样适用,只是方向反了。上传场景中通常不用HTTP的Range请求语义,而是直接把文件切成固定大小的分片,每个分片用独立的请求上传,并在请求参数中携带分片序号和文件唯一标识。服务端根据这两个信息决定把字节写入哪个位置。虽然是“自定义分片”,但原理仍是把字节流分割成有明确边界的段落,这一点和Range的哲学是一致的。
理解这个基础之后,再去看各家云存储SDK里的断点续传实现,就会发现它们都只是在这个原理上加了业务包装:记录了哪些分片上传成功、哪些失败,失败就从第一个丢失的分片开始重传。
2.2 分片大小怎么定,我看中的三个约束
分片大小的选择是整个方案里最关键的参数。定得太小,请求次数过多,事务开销大;定得太大,又回到一次性上传的老问题上。我一般按三个约束条件来算:
第一是弱网环境下的单分片成功率。在化工园区的移动网络下,保持10秒内传完一个分片是比较稳妥的目标。假设现场有效上传带宽只有500KB/s,那单分片大小控制在5MB上下比较合理。如果现场有WiFi覆盖,带宽能到5MB/s,分片可以放大到20MB甚至50MB,减少请求数量和合并压力。
第二是服务端并发和临时磁盘IO的承受能力。假设同时有20个巡检终端在传文件,每个分片5MB,接收20个分片产生的临时写入就是100MB,如果合并频率还高,磁盘IO压力就很明显。所以分片大小要结合服务器磁盘类型和网络带宽综合衡量。
第三是分片序号的数据类型范围。用int存储分片序号,最大支持21亿个分片,对常见G级别文件完全够用,不需要用long,这一点在实际设计时不必过度设计。
我自己的经验值:弱网场景默认5MB一个分片,在有WiFi的场景通过客户端初始化上传时传入期望带宽,动态把分片调整为20MB。这里给一个简单的估算例子:一个1.2GB的视频文件,5MB分片就是240个分片,每次请求携带一个分片的字节数组加元数据,即使中途断网五次,每次重传的也只是失败的那一小段,整体成功率比一次上传高了一个数量级。
2.3 临时文件目录与上传状态记录方案
分片到达服务端后,不能直接拼成原文件,因为分片可能会乱序到达。我的做法是设计一个清晰的文件暂存目录结构:
text复制/data/upload-tmp/
{fileMd5}/
0.part
1.part
2.part
...
其中{fileMd5}是客户端计算出的整个文件的MD5值,作为一次上传任务的唯一标识。每个分片落盘成独立的.part文件,文件名为分片序号。这样即使客户端重复上传同一个分片,服务端只需要判断同名分片文件是否已存在且校验值一致,直接返回成功即可,天然支持幂等。
上传任务本身的元数据,我用一张数据库表来记录,核心字段包括:
sql复制CREATE TABLE upload_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_md5 VARCHAR(64) NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
chunk_size INT NOT NULL,
chunk_total INT NOT NULL,
chunk_uploaded INT DEFAULT 0,
status TINYINT DEFAULT 0,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这里我不推荐用Redis存分片上传进度,原因后面在问题排查章节详述。数据库表虽然看起来重,但胜在可靠,而且在企业内网环境下访问开销完全可以接受。每次分片上传成功,更新chunk_uploaded计数,当计数等于chunk_total时触发合并,逻辑非常清晰。
3. Java后端核心实现:从接收分片到合成文件
3.1 上传服务的三个核心接口设计
我把上传服务拆成三个接口,分工明确,每个接口的职责单一,这样前端也好理解,后端也好扩展:
第一个是初始化上传接口。客户端在上传前调用,传入文件MD5、文件大小、文件名、分片大小,服务端检查是否存在已完成或进行中的上传任务。如果已完成,直接返回“已存在”并附上文件访问地址;如果进行中,返回已有分片列表,客户端就能知道哪些分片需要跳过。
第二个是上传分片接口。这是核心接口,接收一个分片的字节流,携带文件MD5、分片序号、分片大小、分片MD5,服务端保存分片文件并更新进度。
第三个是合并文件接口。客户端在所有分片上传完成后调用,服务端检查分片完整性,按序号顺序合并生成最终文件,校验整个文件MD5,更新业务状态并返回文件URL。
我见过有些团队的方案把三个接口合并成一个,用参数区分,结果前端状态管理混乱,后端日志也无法按阶段追踪。分三个接口最大的好处是职责清晰,每个阶段失败之后重试的路径都很明确。
3.2 服务端接收分片时,文件名与路径隔离怎么处理
在能源化工企业的内网环境里,上传服务虽然不直接暴露公网,但也不能假设客户端永远可靠。路径穿越问题是必须处理的:如果文件名是客户端传过来的,那fileName字段完全可能是../../etc/passwd这种值。
我的做法是双保险:
第一层,文件存储路径只使用服务端自己生成的fileMd5目录,客户端传入的原始文件名只作为一条业务记录写入数据库,不作为文件系统路径的组成部分。
第二层,在合并文件时,最终落盘的文件名使用服务端生成的UUID加原始文件扩展名,例如:
java复制String ext = StringUtils.getFilenameExtension(uploadTask.getFileName());
String targetName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
这样既保留了原始文件的扩展名,方便在线预览和下载,又彻底杜绝了客户端的路径注入风险。
另外,分片文件也要做大小上限校验。每个分片大小必须严格等于配置值,除非是最后一个分片允许小于分片大小。如果收到的大小异常,直接返回错误,防止恶意客户端通过分片接口无限写磁盘。
3.3 分片合并与MD5校验的Java实现
合并是整个流程里最容易写错的地方。很多新手会用ByteArrayOutputStream把所有分片读入内存再一次性写文件,遇到GB级别文件直接OOM。正确做法是流式读写,按分片序号顺序逐块写入。
我给出一个可直接参考的合并方法:
java复制public File mergeChunks(String fileMd5, int chunkSize, int chunkTotal, String targetDir)
throws IOException {
// 先校验所有分片是否完整
File chunkDir = new File(tmpRoot, fileMd5);
for (int i = 0; i < chunkTotal; i++) {
File chunkFile = new File(chunkDir, i + ".part");
if (!chunkFile.exists() || chunkFile.length() == 0) {
throw new IllegalStateException("缺少分片: " + i);
}
// 如果配置了分片级MD5,在这里先校验每个分片的MD5
}
File target = new File(targetDir, UUID.randomUUID().toString().replace("-", "") + ".dat");
try (FileOutputStream fos = new FileOutputStream(target);
FileChannel outChannel = fos.getChannel()) {
for (int i = 0; i < chunkTotal; i++) {
File chunkFile = new File(chunkDir, i + ".part");
try (FileInputStream fis = new FileInputStream(chunkFile);
FileChannel inChannel = fis.getChannel()) {
// 使用零拷贝或transferTo,避免大块字节在用户态和内核态之间反复拷贝
long position = 0;
long size = chunkFile.length();
while (position < size) {
position += inChannel.transferTo(position, size - position, outChannel);
}
}
// 合并完一个分片就删除,及时释放磁盘空间
chunkFile.delete();
}
// 最后统一做整体MD5校验
String mergedMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(target));
if (!expectedMd5.equalsIgnoreCase(mergedMd5)) {
target.delete();
throw new IOException("合并文件MD5校验失败");
}
return target;
}
}
这里面有几个容易踩的坑。第一个是transferTo返回值未必等于请求传输的字节数,必须用循环判断是否传完,否则偶发情况下会漏字节。第二个是合并过程中如果某个分片文件被其他线程删除会抛异常,所以合并时最好在任务状态上加一个分布式锁或者用数据库行锁。第三个是整个文件MD5校验在G级别的大文件上也比较耗时,但这一步不能省,安全合规数据即使多用几秒钟校验也是值得的。
3.4 与Spring Boot、Tomcat、Nginx的整合要点
即使你的Java服务代码写得再正确,如果容器层配置不对,大文件请求依然会在进入Controller之前就被拒之门外。
第一处必须改的是Tomcat的maxSwallowSize。Spring Boot内置Tomcat默认的maxSwallowSize是2MB,也就是当请求体超过2MB而应用又直接返回错误时,Tomcat会强制丢弃剩余的请求体。如果客户端已经发出了分片请求而服务端因某种原因快速返回异常,连接会被断开,影响整个上传链路的稳定性。这个参数建议调大:
properties复制server.tomcat.max-swallow-size=100MB
第二处是spring.servlet.multipart相关配置。使用分片上传后,单个请求的body大小就是一个分片的大小,建议设置:
properties复制spring.servlet.multipart.max-file-size=50MB
spring.servlet.multipart.max-request-size=60MB
这里的阈值要比常见分片大小留出约20%的冗余,因为分片请求里还有元数据字段和Base64包装的开销。
第三处是Nginx的client_max_body_size。如果系统前面还有一层Nginx反向代理,默认限制是1MB,不调整的话分片请求直接在Nginx层被拒。一定要同时检查Nginx的配置:
nginx复制location /upload/ {
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
}
proxy_request_buffering off很关键。它让Nginx在收到客户端请求体时直接透传给后端,而不是先缓冲到临时文件。如果不关,Nginx会把整个body缓存到磁盘,再加一次磁盘IO,影响性能。同时proxy_read_timeout调到300秒,给后端合并文件一个充足的时间窗口。
4. 工程落地中的常见问题与排查实录
4.1 上传任务状态丢失与幂等设计
前面提到不推荐用Redis存分片上传进度,主要原因就是状态丢失。巡检员在野外的网络环境差,可能一个晚上反复重试几十次,一旦Redis内存淘汰把key清了,客户端再查询上传进度时会拿到“全部分片未上传”的答复,等于前面的活白干。
即便用MySQL,也可能遇到任务进程重启导致的中间状态丢失。我的处理思路是:客户端每次调用初始化接口时,服务端不要新建任务,而是根据fileMd5查库。如果状态是已完成,直接返回文件地址;如果状态是上传中,返回已接收的分片序号列表。
分片上传接口同样要做幂等处理:如果对应序号的.part文件已经存在并且分片MD5一致,直接返回成功,不重复写入。这个设计保证客户端无论重试多少次,都不会导致数据错乱。
4.2 分片顺序错乱与重复上传的应对策略
分片请求在网络传输中不是严格按顺序到达的,尤其是客户端并发发送多个分片时,服务端接收顺序可能是2、0、1。所以合并时绝对不能基于“最后修改时间”或“到达顺序”来拼接文件,必须以分片序号为准。
还有一个容易疏忽的场景:客户端在弱网下把同一个分片重发了两次,第二次重发时服务端应立即识别并跳过存储。判断依据就是分片文件的MD5是否一致。如果两次内容不一致,说明客户端侧文件发生了变化(比如重新生成了文件),这种情况不应该覆盖原分片,而应返回错误提示客户端重新初始化上传任务。
4.3 临时分片文件的磁盘空间与清理策略
分片越多,临时目录占用越大。一个1GB文件拆成5MB分片,在全部上传完成前,临时目录里最多会躺着240个分片文件,合计1GB。如果20个任务并发,就是20GB。所以在项目上线前我一般会做一个临时空间评估,并把临时目录和最终文件目录放在不同磁盘上,避免合并时的写入竞争。
清理策略也不能简单粗暴删目录。有些上传任务可能只传了一半就放弃了,这种僵尸任务会把磁盘慢慢吃满。我建议加一个定时任务,扫描最后更新时间超过24小时且状态不是“已完成”的临时目录,直接整目录删除。删除前检查目录下文件的最后修改时间,防止误删正在上传的任务。
4.4 移动网络切换与弱网重试机制
巡检人员从管廊一端走到另一端,网络信号会经历“有信号-断线-恢复”的过程。TCP连接一旦断开,当前分片请求就会失败。这时客户端的重试逻辑不能是单纯的循环重试,更不能用固定间隔重试,否则信号还没恢复时所有分片请求全部超时,把服务端连接池打满。
我采用的是指数退避加最大重试次数:第一次重试等2秒,第二次等4秒,第三次等8秒,最多重试5次。如果重试后仍然失败,标记当前分片失败,继续尝试下一个分片,最后再来一轮“补传”阶段,只补传失败列表中的分片。
客户端还需要处理移动网络切换带来的连接重置,这时只需要重新发起失败分片的请求即可。因为每个分片请求本身是独立HTTP连接,不依赖于之前的连接状态,这样就把网络切换的影响范围限制在了一个分片之内。
4.5 另一种思路:数据库行锁与合并并发控制
合并操作是分片上传中最容易产生并发问题的环节。两个客户端同时触发同一个文件任务的合并请求,可能导致分片目录被重复读取、文件被重复写入、甚至一个请求在删除分片时另一个请求还在读取。
我的做法是在数据库任务记录上利用更新操作的自旋锁来串行化合并过程。合并前先执行一条带条件的更新:
java复制int affected = uploadTaskMapper.compareAndSetStatus(taskId,
UploadStatus.UPLOADING, UploadStatus.MERGING);
if (affected == 0) {
// 说明已经有其他线程在合并,直接返回当前产物信息
return currentFileInfo;
}
这样能保证同一时间只有一个消费者执行合并逻辑。等合并完成后再把status更新为FINISHED,其他线程查询到FINISHED就直接返回文件地址。
这个方案在单体应用里足够可靠。如果以后拆分微服务、多实例部署同一套上传服务,再考虑引入Redis分布式锁,但在当前能源化工内网场景下,数据库CAS方案是最简单也最稳的。
最后说几句我踩过的坑
这几年做能源化工企业的数字化项目,我最大的感受是:这类系统最不缺的就是业务逻辑代码,真正考验人的反而是那些不起眼的非功能需求——大文件怎么传、弱网怎么容错、磁盘怎么清理、并发怎么控制。断点续传这功能听起来不大,但要把边界情况全部处理好,涉及的知识面其实相当广,从HTTP协议到容器配置,从流式IO到并发控制,每一环都可能成为线上事故的导火索。
如果要给正在做类似系统的同行一个建议,我会说:优先把分片大小、临时目录策略、幂等设计和MD5校验这四件事想清楚,它们比任何花哨的框架都重要。框架可以换,这四件事想漏了,系统上线之后一定会以某种方式狠狠教训你。希望这篇内容能帮你在设计自己的上传服务时多避开几个暗坑。
