1. 能源化工场景下为什么非要断点续传不可1.1 先认清现实工厂里的网络环境没你想的那么美好我接触过不少能源化工企业的数字化项目这类企业的典型特征是什么网络环境极其复杂生产网、办公网、管理网三区隔离跨厂区走专线远程站点靠普通互联网通道。带宽看着不小但延迟高、抖动大、丢包率高尤其赶上雷雨天或生产高峰期一个几十GB的大文件传到一半断掉那是家常便饭。更麻烦的是文件本身就很大。DCS/SCADA系统的历史趋势数据包动辄几十GBGG地质勘探产生的三维地震数据几百GB很正常还有监控视频归档、MES批次记录、ERP系统备份、质量检验报告附件随便一个都是传统网页上传组件根本扛不住的类型。我见过太多企业还在用老旧的FTP服务或者共享文件夹导数据要么传一半挂了重来要么凌晨加班守着传完才敢走效率低到离谱。这时候网页端的JAVA大文件断点续传方案就成了刚需。它解决的核心问题不是把上传做得多炫而是让不可靠的网络变得可靠文件传到80%断了重连后从80%继续而不是从头再来多分片并发传输充分利用带宽哪怕中间断网、断电、服务器重启也能在恢复后接着传。这篇文章就是围绕这个主题把我在能源化工企业网页开发项目里落地JAVA断点续传方案的全过程掰开揉碎讲清楚适合正在做同类系统的后端工程师、项目负责人以及被大文件传输折磨过的运维和研发同事参考。1.2 断点续传的本质分治思想和幂等设计很多人一提断点续传第一反应是“断了我能续”但真正落地时发现没这么简单。核心要理解两件事。第一是分治。把一个大文件切成若干个小分片每个分片独立上传、独立校验。这样任何一个分片失败只需要重传这个分片本身而不用动其他分片。这和“把大象装进冰箱”是一个道理一次搬不动整头大象就切块搬搬到哪算哪剩下没搬的继续搬。第二是幂等。同一个分片无论传多少次结果都是一样的。服务端接到的分片要么写入临时文件要么丢弃重复数据不会产生脏数据。这是判断一个断点续传实现是否合格的关键标准。如果这两点没想明白代码写得再花哨生产环境一跑就露馅。2. 两种“断点续传”必须分清楚选错方案等于白做2.1 HTTP Range续传和分片上传不是一回事先说一个很容易踩的坑HTTP协议本身有一种断点续传能力靠Range请求头实现。服务器返回206 Partial Content客户端从指定字节位置继续拉数据。这个方法用在下载场景是标准解法浏览器、下载工具都原生支持。但上传场景HTML表单和传统的multipart/form-data没有原生的断点续传能力。浏览器从头到尾就是一个完整的POST请求网络一断除非服务端能处理部分请求体内容基本不可行否则只能重传。所以网页端上传大文件要实现断点续传必须靠自己在前端切片、后端重组这本质上是“分片上传”方案和HTTP的Range续传是两条路线。搞清楚这个区别你就不会被网上各种混杂的说法带偏了。维度HTTP Range续传前端分片上传适用场景文件下载文件上传实现位置浏览器/下载器处理请求头前端JS切片后端接收组装断点恢复粒度单连接连续字节任意分片可独立重传并发能力单连接弱多分片并发强复杂度低中高2.2 为什么我最终选了分片上传 临时文件合并能源化工的项目里我做过一次方案评审备选方案有三个。第一种直接用HTTP Range配合WebSocket或者截断重传。听着简单但前端没法可靠控制中断后的续传点WebSocket在大文件传输上也没有性能优势放弃。第二种分片直接落到对象存储比如MinIO最后触发合并。这套适合云原生架构但企业内网环境下对象存储不一定存在且额外引入一套系统对传统化工企业来说部署和维护成本偏高。放弃。第三种也就是我最终采用的前端切片、后端接收分片写入临时目录、全部上传完后触发异步合并。这套方案不依赖任何第三方中间件纯JAVA加普通文件系统就能跑对已有老系统改造最小也最容易让用户接受。配合MinIO可以把合并后的文件托管到对象存储但那是后话不影响核心链路。2.3 方案选型中的几个关键决策点这里提几个容易被忽视的决策点直接影响方案的成败。分片大小定多少合适我习惯设5MB到20MB之间。分片太小比如512KB网络请求数量暴增服务端每秒钟要处理上百个请求会产生大量磁盘IO和线程切换开销分片太大比如100MB一旦网络波动单个分片重传成本太高。5MB到20MB是一个成本平衡区间。根据项目带宽计算假设内网带宽50Mbps理论传速每秒6MB左右10MB分片大约1.7秒传完体感很好如果带宽只有5Mbps建议把分片降到2MB到5MB。服务端选RandomAccessFile还是普通FileOutputStream这里有个细节。多个分片如果并发上传用FileOutputStream追加写入append模式会有并发覆盖风险需要用锁保护。RandomAccessFile则可以直接按分片序号seek到指定位置写入天然支持乱序写入合并前不需要排序。前提是你能预知文件总大小提前创建一个占位文件。具体做法我下一章节用代码说明。要不要引入消息队列处理合并不建议为了“显得高大上”引入MQ。文件合并本身是IO密集型操作不是异步解耦的核心需求。同步处理超时控制足够除非文件量大到合并时间超过用户等待上限比如超过1分钟才考虑异步合并回调通知。3. JAVA后端核心实现分片接收、落盘、合并3.1 接口设计总览后端我设计了三个接口职责单一也方便前端对接POST /upload/chunk # 上传单个分片 POST /upload/merge # 通知服务端合并全部分片 GET /upload/check # 查询文件已上传分片支持秒传和断点恢复为什么需要一个check接口因为断点续传的核心能力不是“从断的位置继续”而是“知道哪些分片已经传过了跳过它们”。前端在每次上传前先调check拿到已上传分片列表直接跳过这就是用户体验上的“续传”。3.2 服务端环境准备和配置项目用Spring Boot 2.xJDK 8/11都行不需要太高版本。真正要关注的是磁盘空间和文件句柄。建议单独挂载一个数据盘作为上传临时目录不要放在系统盘。为什么分片临时文件加上合并后的成品文件峰值可能占用原始文件两倍空间临时分片 合并文件。系统盘通常就几十GB分分钟撑爆。在application.yml里配一下app: upload: temp-dir: /data/upload/tmp merge-dir: /data/upload/merge chunk-size: 10MB max-file-size: 100GB还有个容易被忽略的点Spring Boot默认的spring.servlet.multipart.max-file-size是1MBmax-request-size是10MB。如果不调整分片请求会被直接拒掉。注意这里单位用MB不要写10MB以外的字母spring: servlet: multipart: enabled: true max-file-size: 20MB max-request-size: 25MB3.3 分片接收核心代码分片上传的Controller层代码非常简单简单到很多新手会怀疑“这就完了”是的核心逻辑全在Service层。RestController RequestMapping(/upload) Slf4j public class UploadController { Resource private UploadService uploadService; PostMapping(/chunk) public ResultBoolean uploadChunk(RequestParam(file) MultipartFile file, RequestParam(identifier) String identifier, RequestParam(chunkNumber) Integer chunkNumber, RequestParam(totalChunks) Integer totalChunks) { uploadService.saveChunk(file, identifier, chunkNumber, totalChunks); return Result.success(true); } PostMapping(/merge) public ResultString merge(RequestParam(identifier) String identifier, RequestParam(fileName) String fileName, RequestParam(totalSize) Long totalSize) throws IOException { String path uploadService.mergeChunks(identifier, fileName, totalSize); return Result.success(path); } GetMapping(/check) public ResultCheckResp check(RequestParam(identifier) String identifier) { return Result.success(uploadService.checkExists(identifier)); } }注意几个参数的设计意图identifier文件的唯一标识一般用前端计算的文件MD5值。它是整个断点续传链路的核心锚点所有分片、临时目录都靠它关联。chunkNumber分片序号从1开始服务端靠它实现乱序写入。totalChunks总片数服务端在合并前校验完整性用。接下来是saveChunk的实现我把关键部分拆开讲。public void saveChunk(MultipartFile file, String identifier, Integer chunkNumber, Integer totalChunks) throws IOException { // 1. 校验文件大小防止恶意请求 if (file.getSize() chunkSize 1MB) { throw new BizException(分片大小超限); } // 2. 分片临时文件存储路径 Path chunkDir Paths.get(tempDir, identifier); Files.createDirectories(chunkDir); // 原子目录创建 // 3. 占位文件先创建与最终文件总大小相同的空白文件 // 这样每个分片可以按偏移量直接写入 Path placeholder Paths.get(tempDir, identifier .tmp); if (!Files.exists(placeholder)) { // 注意这里需要在 check 接口或第一次上传分片时 // 传入 totalSize 来创建占位文件 } // 4. 分片写入 try (RandomAccessFile raf new RandomAccessFile(placeholder.toFile(), rw)) { long offset (long) (chunkNumber - 1) * chunkSize; raf.seek(offset); raf.write(file.getBytes()); } // 5. 记录已上传分片元数据用于check接口 saveChunkMeta(identifier, chunkNumber, totalChunks); }这里RandomAccessFile的seek定位是关键它保证任意分片都能直接写到正确位置不受上传顺序影响。chunkSize不一定和前端切片大小完全一样可以按ceil(totalSize / totalChunks)动态计算对齐。3.4 合并流程与一致性校验合并接口做的事情说穿了就三步校验分片完整性、按顺序组装文件、清理临时文件。public String mergeChunks(String identifier, String fileName, Long totalSize) throws IOException { Path chunkDir Paths.get(tempDir, identifier); Path placeholder Paths.get(tempDir, identifier .tmp); // 1. 检查占位文件大小是否等于 totalSize if (Files.size(placeholder) ! totalSize) { throw new BizException(文件大小不一致请重新上传); } // 2. 将占位文件移动为最终文件 // 因为之前所有分片都是按偏移量写入同一个占位文件 // 合并本质上只是改名不需要再IO组合。 String ext fileName.substring(fileName.lastIndexOf(.)); Path finalPath Paths.get(mergeDir, identifier ext); Files.move(placeholder, finalPath, StandardCopyOption.ATOMIC_MOVE); // 3. 清理分片目录 deleteQuietly(chunkDirFile); return finalPath.toString(); }这就是用RandomAccessFile占位文件方案最大的优势合并动作只是文件重命名几乎瞬间完成。如果按传统思路先把每个分片存成独立临时文件合并时再逐个读取写入最终文件一个20GB的文件合并过程可能要几分钟占用的磁盘IO也会拖垮其他服务。注意Files.move要指定ATOMIC_MOVE避免移动过程中服务宕机导致最终文件出现半写状态。生产环境强烈建议把临时目录和最终目录放在同一个文件系统内跨文件系统的move会退化成复制删除不仅慢还失去了原子性。3.5 秒传逻辑和元数据设计check接口怎么实现秒传呢两个维度如果最终文件已经存在说明这个文件之前传完了前端直接提示“上传成功”秒传。如果最终文件不存在但临时占位文件存在则根据元数据返回已上传的分片序号列表前端跳过这些分片。元数据我建议用一个简单的Map存到内存或Redis里key是identifiervalue是已上传分片序号集合。中等规模企业文件并发量没那么夸张内存Map足够如果怕重启丢失写成JSON文件放到临时目录也可以反正丢了最多让用户重新传一遍已经上传的分片。4. 前端配合File.slice切片、并发控制、进度展示4.1 前端能做的事别让用户去浏览器“刷新重传”后端接口设计好以后前端不用写什么高大上的框架原生JS加一个Web Worker就能完成核心功能。前端选型我用的File.slice()方法这是断点续传的基石const CHUNK_SIZE 10 * 1024 * 1024; // 10MB function createChunks(file, chunkSize CHUNK_SIZE) { const chunks []; let cur 0; while (cur file.size) { chunks.push({ file: file.slice(cur, cur chunkSize), chunkNumber: chunks.length 1 }); cur chunkSize; } return chunks; }这个切片动作不是复制文件而是类似“视图”的引用内存占用很低。拿到分片数组后计算整个文件的MD5作为identifier。注意计算超大文件的MD5不能一次性把文件读进内存要用FileReader分块读取、增量计算否则浏览器可能直接内存溢出。4.2 并发控制是性能关键分片全部一起上传是不现实的浏览器和服务端都扛不住。我采用固定并发数控制比如同时上传3~5个分片。实现方式可以用一个简单的“池子”async function uploadChunks(chunks, identifier, poolSize 4) { const tasks chunks.map(chunk () uploadOneChunk(chunk, identifier)); let i 0; async function worker() { while (i tasks.length) { const task tasks[i]; await task().catch(err retryWithBackoff(task)); } } const workers Array.from({ length: poolSize }, worker); await Promise.all(workers); }并发数怎么定分片大小10MB、并发4路理论上占用带宽40MB/批。内网千兆环境没问题但如果总部到厂区只有20Mbps专线这个并发会把链路打满其他业务全卡死。所以实际项目里我会做个自适应先上传一个分片测速根据测速结果动态调整并发数。重试机制是断点续传的另一个核心。我利用axios的onUploadProgress记录已上传进度如果某个分片失败重试时先调check接口跳过已经传完的分片。这样即使前端刷新、关闭页面重新打开时依然能从已传位置继续。4.3 Web Worker别让页面卡成PPT切片、计算MD5、上传重试这些逻辑如果在浏览器主线程执行大文件处理会严重阻塞页面渲染。尤其计算10GB文件的MD5好几秒的时间页面根本点不动。用Web Worker把计算和上传逻辑扔到后台线程主线程只负责进度条更新// 主线程 const worker new Worker(/js/uploadWorker.js); worker.postMessage({ file, chunkSize: 10MB }); worker.onmessage (e) { if (e.data.type progress) { updateProgressBar(e.data.percent); } if (e.data.type done) { callMergeApi(e.data.identifier); } };Worker内不能直接操作DOM但可以做文件切片、计算Hash、发请求。注意一点大文件的File对象传给Worker时浏览器使用结构化克隆不是复制文件内容性能没问题。4.4 进度计算的坑upload.onprogress不够用XMLHttpRequest或axios上传onUploadProgress给的是当前这个分片的进度不是整个文件进度。所以前端进度条要自己算// 总进度 已发送字节数 / 总大小 const totalSent uploadedChunks * chunkSize currentChunkSentBytes; const percent Math.min(100, Math.round((totalSent / file.size) * 100));这个“当前分片的进度累加”很容易写错建议单独封装一个ProgressTracker保证累计无误。5. 下载场景的断点续传与大型文件配套5.1 上传能续传下载也一样重要能源化工场景里大文件不只有上传需求。厂区监控录像的调取、地质数据包的下载、ERP大报表的导出动辄几十GB到上百GB。我给老系统做过一个下载断点续传接口用Spring的ResourceRegion实现GetMapping(/download/{identifier}) public ResponseEntityResourceRegion downloadRange( PathVariable String identifier, RequestHeader(value Range, required false) String rangeHeader) throws IOException { Path filePath Paths.get(mergeDir, identifier); long fileSize Files.size(filePath); // 解析Range请求头返回206 Partial Content ResourceRegion region ResourceRegionUtils.parseRange(rangeHeader, fileSize); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(region); }浏览器自带的下载管理器、IDM等工具都支持Range请求用户下载断了以后直接用下载工具点“继续”就行不需要前端做任何额外处理。这是HTTP协议自带能力实现成本极低。5.2 生成超大测试文件的土办法需要验证100GB级别文件是否真的能正常下载时Windows可以用fsutilLinux一条命令# Linux 生成100GB稀疏文件不占实际空间但读写行为接近完整文件 truncate -s 100G /data/upload/test_100g.dat # 或者生成实际占据磁盘空间的100GB文件用于极限测试 dd if/dev/zero of/data/upload/test_100g_real.dat bs1M count102400我测试时通常先用truncate快速验证接口逻辑再用dd做真实IO压力测试。注意磁盘空间和IO负载别把生产环境拖垮。5.3 MinIO接入大文件存储层的扩展方向如果企业已经有对象存储分片上传的最终落点可以换成MinIO。JAVA用MinIO SDK很简单MinioClient client MinioClient.builder() .endpoint(http://minio.internal:9000) .credentials(admin, password) .build(); // 分片上传时每个分片独立PUT最后用composeObject合并 client.putObject(PutObjectArgs.builder() .bucket(production) .object(String.format(%s/chunk_%d, identifier, chunkNumber)) .stream(file.getInputStream(), file.getSize(), -1) .build());用MinIO有一个大坑分片合并时会受maxPartsCount和单个part大小限制合并大量小分片可能触发资源上限。所以用MinIO时分片建议合并成较大的part再上传或者直接依赖MinIO自己的分段上传APISDK里有getS3Object相关支持不要把它当普通网盘硬怼。5.4 git clone这类研发场景的断点续传处理能源化工企业的研发部门也要拉大仓库。git clone断了重来在弱网环境下特别痛苦。几个实用技巧# 浅克隆只拉取最新一层提交 git clone --depth 1 repository_url # 如果已经 clone 到一半失败了不要删进入目录后补齐 git fetch --unshallow # 或者分步拉取逐步加深 git fetch --depth100 origin master:master这些和JAVA断点续传没有直接关系但在同一个技术体系下经常被一起问到顺手记录一下给研发同事们参考。6. 能源化工企业落地时最容易踩的坑6.1 常见问题与排查速查表现象可能原因解决方案上传分片报413Nginx的client_max_body_size没调大在nginx.conf中设置client_max_body_size 25m注意要大于分片大小分片上传成功但合并后文件损坏占位文件大小和总分片实际写入大小不一致检查分片大小计算是否一致尤其是最后一个分片大小可能不足一个chunkSize页面刷新后“续传”变成“重新传”前端只把identifier存在内存里用localStorage或IndexedDB持久化上传记录并发上传时服务端CPU飙升到100%每个分片都独立占用连接和线程线程池耗尽Spring Boot配置server.tomcat.max-threads限制并发前端降低并发数合并报“文件被占用”Windows/Linux文件系统锁冲突NFS挂载时尤其严重确保临时目录和最终目录在同一本地文件系统不要在NAS/NFS上做ATOMIC_MOVE垃圾分片占满磁盘用户传一半不传了临时分片文件永久残留定时任务扫描临时目录清理超过24小时未被合并的文件6.2 几个实操层面的独家经验最后分享几条我踩过几次坑之后沉淀下来的经验。第一临时目录和最终目录一定要分开并监控磁盘水位。我之前遇到过一次磁盘写满的情况不是上传逻辑问题而是运维把日志和上传文件放到了同一块盘日志把空间吃光了。可以把临时目录挂在/data/upload/tmp最终目录挂在/data/upload/merge然后配一个定时任务检查剩余空间低于20%就告警。第二别让分片临时文件裸奔记得做并发控制。前端不设并发限制1000个分片同时打过来Tomcat默认线程池只有200线程池满了以后新的请求直接排队用户看着“转圈圈”以为卡死了。我建议前端并发数控制在4~6服务端再通过Semaphore限制上传处理线程数不超过20。控制好并发系统不会无故“假死”。第三能量化工企业常有多网段访问注意前端上传地址的URL不能太长。有些老的网络设备比如上网行为管理、负载均衡会截断超长URL导致上传请求被重置。把分片参数尽量放在请求体内而不是URL里可以规避这个问题。第四MD5计算不要用服务端重新计算整个文件。大文件传到服务端后如果服务端又整体算一遍完整文件MD5IO和CPU开销翻倍。我通常只在merge时用总大小和分片数量做完整性校验不做全文件哈希真要防篡改在分片级别做校验就够风险可接受。第五安全权限要跟上。上传接口如果没做好鉴权任何人都可以往服务器写文件分分钟把磁盘打满。至少做到上传前校验登录态对fileName做白名单检查禁止../../路径穿越用UUID重命名最终文件对外下载时用映射ID而不是原始文件名。6.3 从一版能跑到稳定上线还差什么初版框架跑通后我通常会再做三件事。一是压测用前文提到的dd生成大文件通过脚本模拟100个用户同时上传观察服务端线程、磁盘IO、内存占用二是做异常演练模拟断网、重启服务、磁盘写满验证断点续传逻辑能不能正确恢复三是写操作文档尤其是临时目录清理策略、磁盘扩容步骤、服务端重启后前端续传的恢复方案交给运维同事。做完这三件事这套断点续传方案才算真正能扛住生产环境的考验。我个人在实际项目里的体会是断点续传的技术栈并不复杂真正的难度在于把各种边界情况想全——网络闪断、服务重启、磁盘满了、恶意请求、并发突增任何一个点没兜住生产环境就会给你颜色看。如果你准备在下一个项目里落地JAVA大文件断点续传建议直接从分片思路切入先跑通主干链路再一层层把并发控制、安全校验、异常恢复补上。这样既稳也不会被各种花哨方案带偏。
