资讯动态

DICOM大文件上传难题:Java分片上传与断点续传实战解析

发布时间:2026/10/22 7:52:40 来源:尧图企业网站定制
近年来做医院集成平台的项目最让我头疼的往往不是业务逻辑而是上传影像。某三甲医院放射科一天的DICOM影像数据量轻轻松松超过几十GB网速一旦波动整个上传过程就废掉重来。后来我们在医疗系统里设计了一个Java插件专门解决浏览器端上传DICOM影像文件时的断点续传与分片上传问题效果立竿见影。这篇博文就是把这套方案从头到尾拆开揉碎从原理讲到落地从踩坑讲到优化希望给同样在处理大文件上传的同行一些参考。1. 先讲清楚浏览器端上传DICOM影像到底难在哪1.1 DICOM文件本身的“巨无霸”属性医疗影像领域有一套标准叫DICOM一个序列可能包含几百张CT切片每张切片原始数据轻轻松松20—50MB一个序列算下来就是几个GB到几十GB。这些文件不像普通文档没法压缩也不能丢了某个片段再补因为关乎诊断。更麻烦的是医院场景里这些影像基本都存在服务器上医生或者技师在浏览器端查看时经常需要把某个患者的全序列上传到云端供后续处理。我见过最极端的案例一个头部CTA序列768张切片单张25MB总共接近20GB。这种情况下如果直接用传统方式上传浏览器要么超时要么内存溢出要么断一次网就得从头再来。医院网络环境可不像互联网机房那么理想院内带宽有限高峰期还经常拥塞断断续续是家常便饭。1.2 普通上传方式为什么撑不住传统做法通常是浏览器把文件一次性读进内存然后通过HTTP请求发到后端。这里有两个致命问题一是服务器接不住。Spring Boot的默认并发上传大小限制、Tomcat的连接超时都会成为瓶颈。一旦文件超过1GBhttp请求很可能在传输途中被网关或负载均衡器掐断。二是浏览器等不起。这个“等不起”不是用户心态问题而是浏览器的XMLHttpRequest或fetch机制在处理大文件时内存占用会飙升尤其在低配工作站上直接卡死页面。更隐蔽的问题是普通上传没有“断点”概念。只要连接断开已经传了99%的数据全部作废。影像科最反感的就是“我传了一个小时结果告诉我重新传”。所以后来我设计断点分片上传时第一原则就是让上传过程可中断、可恢复不依赖网络稳定。2. 分片上传与断点续传的核心把大文件“切碎”再“拼回”2.1 分片的基本思想分片上传的原理其实一句话就能讲明白浏览器端把文件按固定大小切成多个片段chunk然后一片一片地传给后端。后端收完所有分片后再按照顺序把分片合并成完整文件。这么做的好处非常直观每片只有几MB或几十MB单个请求耗时短不容易触发超时。上传过程中断了只需重传失败的那一片之前传好的分片还在服务器临时目录里躺着。能够实现并发上传同时传5—10个分片充分利用带宽。在医疗系统里我通常把分片大小设成5MB到10MB。这个值不是随便拍的太小的话分片数量过多请求开销大太大的话单个分片上传时间过长又回到了“大文件”的老问题。5MB在千兆局域网环境下大约0.04秒在10Mbps的远程链路下大约4秒实验下来都还算稳定。2.2 断点续传的两个关键状态记录位置“断点续传”听起来高大上实际就是回答两个问题已上传了哪些分片下一个该传哪一个这两个状态信息可以放在两个地方前端记录浏览器端用localStorage或IndexedDB保存已上传的分片索引。这样可以做到用户关掉浏览器重新打开还能继续传不用从头再来。后端记录Java插件维护一个临时目录记录该上传任务对应的分片序号和字节数。前端上传前先向后端询问“我还缺哪几片”后端根据临时目录的实际情况返回缺失列表。我实践下来的结论是两者都要。前端记录是为了让用户立刻看到进度、给出友好的续传交互后端记录才是真正的数据依据因为前端可能被清缓存、换浏览器、甚至崩溃但后端临时目录还在。2.3 分片上传在医疗场景中的好处很多人问“DICOM文件那么大分片上传能快多少”其实分片上传本身并不会让总文件大小变小它改变的是失败的成本模型。普通上传失败一次等于全量重传分片上传失败一次只重传单片。医疗系统里网络抖动频率高尤其是在远程医疗、基层医院互联互通场景下这种“降低失败惩罚”的价值反而比提升速度更大。举个例子20GB的数据在1Gbps局域网下理论只需要160秒但实际可能因为医院HIS/PACS系统并发高、交换机限速导致整体速度只有50Mbps。一次中断就白白浪费几十分钟。分片上传后中断重传的成本降到几秒到几十秒整体效率提升完全不是数量级的问题。3. 浏览器端实现切片、进度记录与续传的完整设计3.1 核心API选择与分片逻辑浏览器端要做的事情很直白读取文件、切片、逐个上传。不需要引入重量级框架我用原生JavaScript的File API就能搞定一切。最关键的API是Blob.prototype.slice()它允许我们从文件对象中提取任意字节区段比如file.slice(0, 5MB)、file.slice(5MB, 10MB)。分片对象本身就是Blob可以直接作为FormData的字段提交到后端。示例代码如下const file document.querySelector(input[typefile]).files[0]; const chunkSize 5 * 1024 * 1024; // 5MB let chunks []; let offset 0; while (offset file.size) { chunks.push(file.slice(offset, Math.min(offset chunkSize, file.size))); offset chunkSize; }这样得到的分片数组配合一个计数器就可以决定哪一片是当前要发送的。有人会问“为什么不直接用FileReader.readAsArrayBuffer()把整个文件读出来”原因很简单大文件动辄几个GB读进内存不是炸内存就是卡页面。Blob.slice是零拷贝操作返回的是文件引用不会占用太多内存。这一点对影像浏览器来说非常重要因为浏览器往往同时打开了多个影像序列内存非常紧张。3.2 上传状态机与断点记录上传过程不能简单写成一个循环因为要支持暂停、失败重试、续传。我给每个上传任务维护一个状态机let uploadState { currentChunk: 0, totalChunks: chunks.length, uploadedChunks: [], uploadId: null }; // 从localStorage恢复状态 const savedState localStorage.getItem(uploadState_ file.name); if (savedState) { uploadState JSON.parse(savedState); }每次成功上传一个分片后立即把分片索引保存到localStorage。这样页面刷新或浏览器关闭后用户再次选择同一个文件时程序会先从localStorage里找回上次记录的索引再向后端请求“缺失分片列表”。需要注意的是localStorage不适合存大对象但存几个数字索引绰绰有余。实际上我通常存的是“已上传分片序号数组”最多几百个整数完全不担心存储限制。3.3 并发上传与暂停恢复实现并发上传可以显著提升整体速度但并发数不能太高否则服务器和浏览器都容易出问题。我实测下来浏览器端一般开3~5个并发最佳。给一个简单的并发控制逻辑const concurrency 3; let active 0; let currentChunk uploadState.currentChunk; function uploadChunk(index) { const formData new FormData(); formData.append(file, chunks[index]); formData.append(uploadId, uploadState.uploadId); formData.append(chunkIndex, index); return fetch(/api/chunk/upload, { method: POST, body: formData }).then(res res.json()); } async function uploadAll() { while (currentChunk chunks.length) { if (active concurrency) { active; uploadChunk(currentChunk).then(() { active--; uploadState.uploadedChunks.push(currentChunk); localStorage.setItem(uploadState_ file.name, JSON.stringify(uploadState)); // 更新进度条 updateProgress(uploadState.uploadedChunks.length / chunks.length); }); currentChunk; } else { await new Promise(resolve setTimeout(resolve, 100)); } } }暂停操作更简单设置一个isPaused标志上传函数检测到该标志就停止循环。恢复时重新拉取后端缺失分片列表从第一个缺失处继续即可。这里踩过的坑如果前端不检查后端返回的chunkIndex容易重复上传同一个分片造成网络浪费。所以后端最好返回一个“缺失分片列表”前端以此为准而不是只信本地记录。4. Java插件后端从接收分片到合并文件的完整链路4.1 设计一个REST API接收分片Java插件作为后端的核心我用Spring Boot写了一个ChunkUploadController专门负责分片上传接口。接收分片时每个请求都包含四个关键参数uploadId全局唯一ID通常用UUID标识一次上传任务。chunkIndex当前分片序号。totalChunks总分片数。fileMd5整个文件的MD5值前端在切片前用SparkMD5计算在合并时校验。后端接收到分片后不会立刻合并而是把分片写入临时目录并记录分片元数据到数据库或内存中。关键接口设计PostMapping(/api/chunk/upload) public ResponseEntity? uploadChunk(RequestParam(file) MultipartFile file, RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex) { // 存储分片到临时目录 File chunkFile new File(STORAGE_DIR uploadId / chunkIndex); chunkFile.getParentFile().mkdirs(); file.transferTo(chunkFile); // 记录这个分片已上传可以用Map或数据库 chunkRecordService.addChunk(uploadId, chunkIndex); return ResponseEntity.ok().body(Map.of(status, ok)); }这里有两个设计要点一每个分片在磁盘上单独作为一个文件存储。缺点是产生大量小文件但优点是合并时可以直接按文件名排序拼接简单可靠。文件数量总量可控因为上传任务完成后会清理。二用uploadId作为目录名这样即使不同用户同时上传同名文件也不会冲突。4.2 查询缺失分片与断点续传支持为了支持断点续传后端必须提供一个接口让前端在“恢复上传”时能查到已接收了哪些分片。GetMapping(/api/chunk/status) public ResponseEntity? chunkStatus(RequestParam(uploadId) String uploadId, RequestParam(totalChunks) int totalChunks) { ListInteger uploadedChunks chunkRecordService.getChunkList(uploadId); ListInteger missingChunks IntStream.range(0, totalChunks) .filter(i - !uploadedChunks.contains(i)) .boxed() .collect(Collectors.toList()); return ResponseEntity.ok().body(Map.of(missingChunks, missingChunks)); }前端拿到缺失列表后只重传缺失分片即可。这里的难点是如果本地ChunkRecord存储在内存中服务重启就丢了。所以生产环境我建议把分片元数据写入数据库表例如chunk_upload_record(upload_id, chunk_index, created_time)这样即使Java插件重启也能继续之前的上传任务。另外要设置临时文件的过期清理机制。上传任务可能因为用户离开而终止如果临时分片永不清理硬盘会爆掉。我用一个定时任务每隔一小时检查一次临时目录将超过24小时未完成合并的任务清空并把数据库记录删除。4.3 合并分片与完整性校验最后一个分片上传完成后前端会调用合并接口PostMapping(/api/chunk/merge) public ResponseEntity? mergeChunk(RequestParam(uploadId) String uploadId, RequestParam(totalChunks) int totalChunks, RequestParam(fileMd5) String fileMd5) { File outputFile new File(COMPLETE_DIR System.currentTimeMillis() .dcm); try (FileOutputStream fos new FileOutputStream(outputFile)) { for (int i 0; i totalChunks; i) { // 确保所有分片都存在 ptr new BufferedOutputStream(fos); for (int i 0; i totalChunks; i) { File chunkFile new File(STORAGE_DIR uploadId / i); if (!chunkFile.exists()) { throw new IllegalArgumentException(缺失分片: i); } FileUtils.copyFile(chunkFile, fos); } } } catch (IOException e) { // 处理异常 } // 校验MD5 String mergedMd5 DigestUtils.md5Hex(new FileInputStream(outputFile)); if (!mergedMd5.equals(fileMd5)) { return ResponseEntity.status(500).body(文件校验失败请重新上传); } // 清理临时文件 FileUtils.deleteDirectory(new File(STORAGE_DIR uploadId)); return ResponseEntity.ok().body(Map.of(fileUrl, outputFile.getAbsolutePath())); }这里有几个必须注意的细节分片不能缺合并前必须检查所有分片都存在否则合并出的文件一定有空洞。MD5校验不能省网络传输过程中有可能出现分片损坏虽然TCP协议会检测但磁盘故障等仍可能发生。MD5是整个文件级的校验能有效防止合并后文件不完整。临时文件清理必须在合并成功后进行如果合并失败临时分片还得保留让前端能重新上传缺失分片。4.4 并行合并与流式写入合并操作的性能瓶颈在磁盘IO。如果直接用一个for循环把所有分片写入同一个OutputStream其实比一次性写入原文件还慢。原因在于频繁的流切换和小文件读取。优化方案是先把所有分片读入一个字节数组列表然后一次性写入不行这样内存会爆掉。正确做法是采用缓冲流将每个分片的输入流和目标输出流都包装成Buffer减少系统调用次数。如果分片数量较大例如上千可以考虑用Java 8的并行流逐片合并但要注意磁盘IO的并发竞争一般一个文件合并用单线程即可。我实测过1000个5MB分片合并成一个5GB文件单线程流式合并大约需要12秒而并行流合并因为磁盘磁头来回切换反而更慢所以别迷信并行。5. 医疗系统里的特殊“规矩”完整性、权限与审计5.1 DICOM数据完整性校验不能只在文件层面通用文件上传只要确保字节一致对医疗系统来说还不够。DICOM文件内部有严格的标签结构包含患者ID、检查实例UID、序列号等元数据。如果标签结构损坏即使字节MD5一致影像工作站也无法解析。因此合并完成后Java插件会额外做一个DICOM标签层校验。我用dcm4che库读取DICOM文件头确认文件头前128字节为空DICOM标准Part 10规定。紧接着是“DICM”字符串。至少能正确解析出一个必填标签比如SOP Instance UID。如果解析失败就直接判定文件无效要求重新上传。这一步虽然增加了一些CPU开销但能避免“传上去了却无法使用”的尴尬。5.2 权限控制与Token机制医疗数据是敏感数据上传接口绝不能裸奔。我的Java插件在分片上传接口上强制校验TokenToken来自统一的认证中心。每个上传请求都在Header中携带Authorization: Bearer token后端拦截器解析Token并校验患者的访问权限。有一个细节分片上传接口的请求频率很高每片都要校验Token会带来额外开销。所以我采用“首次创建uploadId时绑定Token之后分片上传统一校验该uploadId是否合法”的方式避免频繁解析JWT。具体实现是在创建上传任务时把Token和uploadId关联存入Redis校验分片时只查询Redis。5.3 多重日志与审计追踪医院系统上线后随时可能面临合规审计。Java插件必须记录完整的日志包括每次上传操作的uploadId、患者ID、操作者ID、IP、时间。每个分片的接收状态是否有缺失、是否有重试。合并完成后的文件URL、大小、MD5、耗时。我用Logback按天滚动并把这些关键信息同时以结构化日志写入数据库。遇到故障时运维能快速定位到具体是哪个操作者、哪个文件上传失败。5.4 并发冲突与重复上传处理医院里不同医生可能同时上传同一个患者的检查数据。如果不做处理会出现分片互相覆盖的情况。解决办法是把uploadId设计成包含某种“唯一会话”标识例如包括患者ID和检查实例UID后端在创建上传任务时检查是否存在尚未完成的同ID任务如果有就返回已有的uploadId前端续传即可。6. 上线半年后我总结的优化方案与踩坑清单6.1 大文件合并时的内存溢出最初上线时合并接口直接报OutOfMemoryError。原因源自FileUtils.copyFile的封装排查后发现是在合并后的MD5计算阶段我用的是DigestUtils.md5Hex(new FileInputStream(outputFile))看起来没问题但底层实现会把整个文件加载到内存不DigestUtils.md5Hex是流式读取不会直接进内存。真正的坑是某些低配服务器上FileUtils.copyFile默认缓冲区大小只有1KB导致IO极慢但没内存问题。后来发现是另一个代码问题我在合并前先读取了所有分片的File对象到ArrayList并保留每个分片的InputStream句柄导致文件句柄耗尽。教训是不要试图一次性打开所有分片输入流只在合并到某个分片时临时打开并关闭。6.2 浏览器并发上传数过大导致服务器连接数打满前端开8个并发医院网关只支持6个长连接导致大量请求排队超时。我把并发数改成可配置项在页面启动时通过接口获取recommendConcurrency局域网内给5公网给3。这点对远程医疗场景尤其重要。6.3 断点续传时localStorage数据与后端状态不一致用户换了一台电脑或者浏览器无痕模式localStorage丢失但后端临时目录还在。此时前端拿着旧的uploadId去询问就应该直接返回缺失列表。如果uploadId已过期后端清理了临时文件接口返回“上传任务不存在”前端必须重新开始上传。这时UI上要有提示不要静默重传。6.4 分片丢失或损坏的补充策略虽然单个分片极小但也有概率在传输中损坏。前端上传成功后后端对每个分片也计算一个MD5分片MD5前端在收到响应时对比返回的分片MD5与本地计算值如果不一致就重传该分片。这个双校验策略在远程传输场景中非常值得投资。6.5 针对MRI、CT等多序列文件的批量上传优化我们最终版插件支持多个DICOM文件同时上传即一个文件列表批量处理。前端会对每个文件创建独立的上传任务后端用线程池分别处理。这里要特别注意控制任务队列长度否则内存和线程都会爆掉。我做了一个简单的信号量限流假设最大并发为5那么队列里最多同时处理5个文件其他人排着。7. 最后分享几个实操细节说了这么多原理和代码最后分享三个我每次实施这套方案时都会检查的点。第一确认服务器和中间件支持分片请求。有时候Nginx或Spring Boot的配置把请求体大小限制得太死比如max-file-size默认只有1MB就会导致分片上传直接报413。一定要调大同时考虑前端并发带来的瞬时带宽冲击。第二分片大小的设置不是死的。医院内网千兆而且稳定10MB分片完全没问题如果是远程会诊场景1—2MB分片更能抗抖动。我的经验是写一个配置中心按科室或网络段动态下发分片大小比写死一个值灵活得多。第三千万要记得给临时上传目录做权限隔离。因为分片文件虽然被切开但存储的仍然是患者影像数据如果服务器上其他人员能随意访问这些临时目录也会泄露隐私。DICOM数据合规要求很高宁可把目录权限设细一点也别图方便。断点分片上传在通用行业已经不算新鲜方案但放到医疗系统里难度会成倍增长。因为一边要面对超大文件、不稳定的网络一边还要守住患者数据安全和DICOM格式的完整性。这套Java插件方案我们上线运行了半年累计上传了超过200万份影像数据真正因为网络问题需要重传的案例下降到之前的十分之一。希望把这些实践写出来能给做医疗大数据平台、PACS升级或影像互联的同行省点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑