
最近处理医疗影像系统的上传模块时被DICOM文件折腾了一轮。一个CT序列的DICOM文件动辄几百MB超声或者内镜的动态影像更是轻松上GB。最初用普通POST方式一次性上传结果要么连接超时要么传到一半断了就得重头再来。后来重构成了“分片上传 完整性校验”的流程才把这个问题彻底解决。这套方案用Java做服务端既保证了分片合并后和原始DICOM文件完全一致又能在弱网环境下断点续传。这篇文章就把完整的实现思路、校验算法取舍和代码细节整理出来给正在做医疗影像上传模块的同学一个可直接落地的参考。1. DICOM 文件为什么会在上传环节卡住1.1 DICOM 文件的体积和结构特征DICOMDigital Imaging and Communications in Medicine不是简单的图片格式它是一个医学影像通信标准。一个DICOM文件由三部分组成128字节的导言Preamble、4字节的DICM前缀、以及一组按Tag排列的数据元素。数据元素里除了患者姓名、检查号、设备参数这些元数据最核心的是像素数据Tag7FE0,0010这一部分通常占据文件体积的90%以上。理解了结构就能明白为什么DICOM文件这么大了。一张512x512、16位深的CT平扫图像原始像素数据就是5125122字节约512KB。一个断层序列动辄两百帧算下来就是100MB起步。如果是不做压缩的动态影像单文件达到GB级别很正常。这种量级的文件放到Web系统里靠一次HTTP请求传输从根上就不现实。1.2 传统一次性上传的三个死穴很多系统早期处理DICOM上传用的是最直接的方式前端把整个文件塞进MultipartFile服务端直接读入内存再落盘。这个方案在小文件时代没问题但遇到DICOM这种大文件会暴露三个致命问题连接超时几十秒甚至几分钟的长连接中间任意一秒网络抖动整个请求就断了。公立医院和基层医院之间的专线、跨地域机房的上传场景超时概率极高。内存溢出Spring MVC 解析MultipartFile时如果文件过大默认先落临时目录再转流但代码里一旦用了file.getBytes()或byte[]接收整个文件就直接进堆内存。500MB的文件同时进来三四个GC先崩溃。没有断点续传传了80%断掉下次重新开始前面的传输时间全部浪费。1.3 分片上传带来的新问题分片上传看起来简单前端把文件切成若干块逐块上传服务端收齐后按顺序合并。但真正做起来会引入一个新的核心问题——完整性。这里说的完整性分两层每个分片在网络传输过程中有没有损坏所有分片按顺序合并之后得到的文件是否和原始DICOM文件一模一样医疗影像和普通文档不一样一份CT影像如果某个像素数据字节错位可能导致诊断信息偏差这是不能接受的。所以分片上传方案里完整性校验不是可选项是整个架构的基石。2. 分片上传与完整性校验的整体流程设计2.1 一次完整上传的生命周期我落地这套方案时把整个上传过程拆成了六个阶段每个阶段都有明确的状态记录阶段操作状态1前端读取DICOM文件基本信息生成uploadIdINIT2前端后台线程计算全文件SHA-256同时开始切片HASHING3按顺序逐片上传服务端逐片校验并落盘UPLOADING4前端通知服务端“所有分片已传完”MERGING5服务端合并分片边写边计算合并文件哈希VERIFYING6哈希比对通过解析DICOM结构登记入库COMPLETED设计上有个关键点前端切分和哈希计算是并行的而不是先算完哈希再切分。大文件算一次SHA-256是需要时间的500MB的文件在普通机器上要用一两秒如果先算完再上传就白白浪费了这段时间。我们可以边读文件边切分同时把读过的字节喂给哈希计算器。得保证这个流程和分片上传不互相阻塞。2.2 前端切片与元数据携带前端用浏览器的File.slice()切分分片大小我选的是5MB。为什么不用更大的分片两个考虑5MB分片在弱网上传失败后重新传的代价小服务端合并时使用的是文件流分片太小比如1MB会导致合并时的系统调用次数太多性能下降明显。每个分片上传时除了分片本身的二进制内容还需要携带一批元数据。这些元数据就是完整性校验的依据// 前端分片上传时携带的元数据示例 const formData new FormData(); formData.append(file, chunkBlob); formData.append(uploadId, uploadId); // 上传任务唯一ID formData.append(chunkIndex, index); // 分片序号从0开始 formData.append(totalChunks, total); // 总分片数 formData.append(fileName, dicomFileName); // 原始文件名 formData.append(sopInstanceUid, sopUid); // DICOM 实例UID用于命名 formData.append(fullFileHash, fullFileHash); // 全文件SHA-256前端预先算好这里有个容易忽视的点前端算全文件哈希时必须基于原始文件字节流不能对文件做任何预处理。DICOM文件末尾可能有填充字节Padding如果在切分或读取过程中被某个库自动“优化”掉了最终哈希必然不一致。前端直接用File对象的原始字节流去计算别用任何自带的图像解码器。2.3 服务端的三个核心角色服务端我分了三块逻辑上传接口接收单个分片校验分片哈希、写入临时目录更新上传任务状态。任务登记表记录每个uploadId对应的完整文件信息包括文件名、总大小、总分片数、预期全文件哈希、当前已接收分片的索引集合。数据库里用一张upload_task表字段见下面的设计。合并校验任务当所有分片都到齐后触发合并操作合并完成后计算整文件哈希并与预期值比对。任务表的核心字段设计CREATE TABLE upload_task ( upload_id VARCHAR(64) PRIMARY KEY, sop_instance_uid VARCHAR(128) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, total_chunks INT NOT NULL, chunk_size BIGINT NOT NULL, full_file_hash VARCHAR(64) NOT NULL, -- 前端提交的预期SHA-256 current_chunks INT DEFAULT 0, -- 已接收分片数 status VARCHAR(16) NOT NULL, -- INIT/UPLOADING/MERGING/COMPLETED/FAILED create_time DATETIME DEFAULT CURRENT_TIMESTAMP );current_chunks是计数冗余真正判断分片是否齐全时需要查分片表比对索引集合是否连续覆盖0到totalChunks-1。3. 全文件哈希与分片哈希医疗场景下的校验算法取舍3.1 SHA-256 为什么比 MD5 稳妥网上很多分片上传教程用的是MD5因为MD5计算速度快。但医疗数据场景我强烈建议用SHA-256。MD5已经被证明存在碰撞攻击也就是说存在两个不同文件算出相同MD5值的可能性。虽然概率极低但医疗影像涉及诊断依据完整性校验的抗碰撞性必须拉满。Java的MessageDigest原生支持SHA-256计算速度在DICOM这种场景下完全够用没理由去省这点性能。3.2 三种校验策略对比我梳理过三种完整性校验策略它们的深度和成本各不相同策略说明优点缺点仅整文件哈希前端提交全文件SHA-256合并后比对实现简单性能开销最小分片传输错误要等合并后才能发现无法定位哪片出错分片哈希 整文件哈希每个分片上传时携带分片SHA-256服务端落盘时校验合并后再校验整文件能快速定位坏分片失败重传只传坏的那片多一次哈希计算和比对逻辑分片哈希 整文件哈希 DICOM结构解析在前者基础上合并后解析DICOM Tag验证元数据和Pixel Data可读文件级校验语义级校验最稳妥实现复杂引入DICOM解析库我最终的实现选择了第二种并且把第三种作为可选的“深检”开关。原因很简单分片哈希能保证传输层无损整文件哈希能保证合并逻辑无误DICOM结构解析则能捕捉那些“文件字节一致但结构已损坏”的极端情况比如文件被截断但恰好哈希未匹配虽然概率极低。医疗场景下多花一点解析时间换取诊断数据的可靠性是值得的。3.3 边合并边算哈希的性能优化合并和哈希计算不要分成两步做否则等于把整个文件读两遍。正确做法是合并过程中同时计算哈希。我的做法是自定义一个OutputStream在写文件的同时喂给MessageDigestpublic class DigestOutputStream extends FilterOutputStream { private final MessageDigest digest; public DigestOutputStream(OutputStream out, MessageDigest digest) { super(out); this.digest digest; } Override public void write(byte[] b, int off, int len) throws IOException { digest.update(b, off, len); out.write(b, off, len); } public String getDigestHex() { return HexFormat.of().formatHex(digest.digest()); } }合并时用FileChannel.transferTo()把每个分片写入最终文件通过这个包装流最终文件写完的瞬间哈希值也同时算完。4. 核心 Java 代码拆解从接收分片到合并校验4.1 实体与接口设计先定义一个分片信息的DTOpublic class DicomChunkUploadRequest { private String uploadId; private String sopInstanceUid; private Integer chunkIndex; private Integer totalChunks; private String fileName; private String chunkHash; // 本分片的SHA-256 private String fullFileHash; // 全文件SHA-256 // getters / setters 省略 }Controller层接口接收分片请求RestController RequestMapping(/api/dicom) public class DicomUploadController { PostMapping(/chunk) public ResultString uploadChunk( RequestParam(file) MultipartFile file, DicomChunkUploadRequest request) { // 1. 校验uploadId是否存在且状态为UPLOADING // 2. 校验分片索引范围 // 3. 校验分片哈希 // 4. 落盘到 chunk 文件 // 5. 更新已接收分片集合 return Result.success(分片接收成功); } PostMapping(/merge) public ResultString mergeChunks(RequestParam(uploadId) String uploadId) { // 触发合并校验流程 return Result.success(合并完成); } GetMapping(/uploaded-chunks) public ResultSetInteger uploadedChunks(RequestParam(uploadId) String uploadId) { // 返回已上传的分片索引集合供前端断点续传 return Result.success(uploadedChunkService.getUploadedIndexes(uploadId)); } }4.2 接收分片安全落盘服务端接收分片后不能直接拼文件名要防止路径穿越和恶意文件名。我建议用uploadId做目录隔离分片文件统一命名为chunk_{index}public void saveChunk(MultipartFile file, DicomChunkUploadRequest request) throws IOException { Path chunkDir uploadRoot.resolve(request.getUploadId()).resolve(chunks); Files.createDirectories(chunkDir); // 分片文件名只认索引忽略前端传入的fileName Path chunkFile chunkDir.resolve(chunk_ request.getChunkIndex()); // 1. 先算分片哈希 String actualHash sha256(file.getInputStream()); if (!actualHash.equalsIgnoreCase(request.getChunkHash())) { throw new IllegalStateException(分片哈希不一致index request.getChunkIndex()); } // 2. 落盘用transferTo不用getBytes转内存 try (InputStream in file.getInputStream()) { Files.copy(in, chunkFile, StandardCopyOption.REPLACE_EXISTING); } // 3. 更新任务状态记录该分片已上传 uploadedChunkService.markUploaded(request.getUploadId(), request.getChunkIndex()); }这里有个细节判断分片是否已经存在时要优先用哈希去重而不是看文件是否存在。如果同一分片重复上传且内容一致直接返回成功即可幂等如果内容不一致但索引相同说明是脏数据必须覆盖并告警。4.3 合并文件的同时计算 SHA-256所有分片到齐后进入合并逻辑。这里的核心是保证合并顺序和分片索引完全一致并且边合并边算哈希public String mergeAndVerify(String uploadId, String expectedFullHash) throws Exception { Path chunkDir uploadRoot.resolve(uploadId).resolve(chunks); Path targetFile uploadRoot.resolve(uploadId).resolve(merged_ uploadId .dcm); // 检查分片索引是否连续完整 SetInteger uploaded uploadedChunkService.getUploadedIndexes(uploadId); int total uploadedChunkService.getTotalChunks(uploadId); for (int i 0; i total; i) { if (!uploaded.contains(i)) { throw new IllegalStateException(缺少分片索引 i); } } // 边合并边算SHA-256 MessageDigest digest MessageDigest.getInstance(SHA-256); try (OutputStream fileOut Files.newOutputStream(targetFile); DigestOutputStream digestOut new DigestOutputStream(fileOut, digest)) { for (int i 0; i total; i) { Path chunkPath chunkDir.resolve(chunk_ i); try (InputStream in Files.newInputStream(chunkPath)) { in.transferTo(digestOut); } } } // 合并完成比对完整文件哈希 String actualFullHash HexFormat.of().formatHex(digest.digest()); if (!actualFullHash.equalsIgnoreCase(expectedFullHash)) { Files.deleteIfExists(targetFile); throw new IllegalStateException(完整文件哈希校验失败); } return targetFile.toString(); }注意这里的DigestOutputStream和上面的包装流要配合使用。InputStream.transferTo()是Java 9引入的方法用起来非常简洁避免了手写缓冲区循环。合并完成并校验通过后文件流关闭时才算完。因为DigestOutputStream的digest()方法必须在digestOut.close()之后调用吗其实不是filter包装流的close()会触发底层流的更新但哈希值是实时更新的所以合并完直接取digest.digest()即可。不过建议在try-with-resources块外部拿哈希避免流关闭顺序引发问题。4.4 校验通过后用关键 Tag 再确认一次 DICOM 结构文件哈希一致不代表DICOM结构一定合法。我引入了一个可选的深检层用轻量级方式解析文件头确认DICM前缀位置正确再读取几个关键Tag。Java生态中dcm4che是比较常用的DICOM库但加入了它项目体积会膨胀不少。我这里的做法是做一个极简的“DICOM结构感知”检查只验证最关键的信息public void verifyDicomStructure(Path dcmFile) throws IOException { try (RandomAccessFile raf new RandomAccessFile(dcmFile.toFile(), r)) { // 跳过128字节导言 raf.seek(128); byte[] magic new byte[4]; raf.readFully(magic); if (!Arrays.equals(magic, new byte[]{D, I, C, M})) { throw new IllegalStateException(非DICOM文件缺少DICM前缀); } } }如果想要更完整的结构校验可以用dcm4che库读取DataSet确认SOPInstanceUID、StudyInstanceUID、SeriesInstanceUID这些元数据Tag存在且能正确解析。此时再做一次语义验证// 深检开关只有文件哈希校验通过后才执行 try { DicomInputStream dis new DicomInputStream(dcmFile.toFile()); Attributes attrs dis.readDataset(-1); String sopUid attrs.getString(Tag.SOPInstanceUID); if (sopUid null || sopUid.isEmpty()) { throw new IllegalStateException(DICOM结构异常缺少SOPInstanceUID); } dis.close(); } catch (Exception e) { throw new IllegalStateException(DICOM结构解析失败); }这一步会把“上传的文件不是完整DICOM”的问题拦截在入库前避免把坏文件推进PACS系统。4.5 清理临时分片目录合并校验通过后临时分片目录要清理。这个步骤常被忽略等磁盘满了才后悔。清理逻辑很简单Path uploadDir uploadRoot.resolve(uploadId); FileUtils.deleteDirectory(uploadDir.toFile());但如果要支持审计留痕可以把分片目录归档到冷存储或者保留24小时再删除看实际业务要求。5. 稳定性与断点恢复工程落地中的细节5.1 分片丢失与补传查询已上传分片分片上传场景下网络中断是常态不能指望前端一次把所有分片都传完。服务端要提供一个查询接口告诉前端哪些分片已经安全落盘。前端拿到结果后只重新上传缺口部分即可。接口实现时要关注两个关键点用ConcurrentHashMap缓存已上传分片集合避免每次查询都查数据库。返回给前端的是SetInteger索引集合前端直接基于这个集合过滤缺失分片逻辑非常简单。public SetInteger getUploadedIndexes(String uploadId) { return uploadedChunkCache.getOrDefault(uploadId, Collections.emptySet()); }缓存更新时机是分片落盘成功之后。如果进程崩溃缓存丢失就从临时目录扫描chunk_*文件重建缓存这里又体现了分片文件命名规范的价值。5.2 并发与幂等同一分片重复上传怎么办高并发场景下前端可能因为重试机制同时发送两个相同的分片请求。服务端要做幂等处理。我的做法是写一个带锁的markUploaded方法同一个uploadId chunkIndex 的写操作要串行化。public synchronized void markUploaded(String uploadId, int chunkIndex) { SetInteger set uploadedChunkCache.computeIfAbsent(uploadId, k - ConcurrentHashMap.newKeySet()); set.add(chunkIndex); // 同步更新数据库中的current_chunks计数 }synchronized粒度虽然粗但这里的操作只是内存集合写入加一次数据库更新性能开销可接受。更精细的方案是给每个uploadId分配一把锁或者用分布式锁根据部署规模权衡。5.3 进程崩溃后的恢复扫描目录重建状态服务跑着跑着崩溃了怎么办重启后正在上传中的uploadId状态会丢失。恢复策略是启动时扫描上传根目录对每个uploadId子目录做三件事统计chunks/下分片文件数量与任务表里的total_chunks比对。如果分片没齐把任务状态重置为UPLOADING前端下次携带相同uploadId可以继续补传。如果分片齐了自动触发合并。5.4 磁盘空间和目录规划这个坑我踩过。合并过程中需要“分片 合并后的完整文件”双份存储一个2GB的上传任务峰值磁盘占用近4GB。目录规划上要注意上传根目录和PACS归档目录最好分盘挂载避免相互影响。定期清理状态为FAILED或超时未完成的临时目录。给上传目录加独立的磁盘空间监控达到阈值直接拒绝新上传任务别等着磁盘满了整个服务瘫痪。5.5 弱网环境下的重试策略医疗院区网络环境参差不齐有的基层机构上传带宽只有几兆。前端重试策略我推荐“指数退避”第一次失败等2秒重试第二次失败等4秒最大重试间隔不超过30秒同一分片最多重试5次超过后提示用户检查网络。配合服务端幂等设计这个重试策略可以放心交给前端无限重试反正重复分片不会产生脏数据。6. 实操心得与注意事项这套方案上线运行后DICOM上传成功率从最初的不到70%提升到了99.6%。最后分享几个亲自踩过的细节。前端计算哈希的时机如果拿到的DICOM文件特别大比如超过1GB前端计算SHA-256的线程一定要用Web Worker否则主线程卡死用户以为页面崩了。哈希计算完后再更新UI中的上传状态。分片大小不是越大越好我用5MB跑了一阵后试过10MB弱网环境下失败重传成本明显增加回退到5MB。如果是内网千兆环境可以放宽到10MB。服务端合并时一定要校验分片索引的连续性有些实现只检查分片文件数量数量够了就合并结果某个分片重复上传、另一个缺失合并出来的文件大小不对哈希校验失败才暴露问题。提前在合并入口做完整性断言能省下很多排查时间。DICOM文件末尾的填充字节不要动有的同学为了让“文件更干净”会截掉末尾的空白字节这就破坏了字节流的一致性。DICOM标准允许文件末尾有填充位但这些填充位也是文件的一部分哈希计算必须原样覆盖。合并阶段从分片流式合并时务必使用“纯字节流搬运”不要做任何格式感知的处理。归档文件命名用sopInstanceUid.dcm合并后的文件如果用前端原始文件名保存两个不同系统上传同名文件会互相覆盖。改用DICOM实例UID作为文件名天然全局唯一还能和PACS系统的索引对接。如果后续要做多节点部署把分片元数据从本地缓存迁移到Redis上传接口做成无状态整个架构就可以横向扩展了。现在这个版本单机已经能稳定支撑日常业务优先把完整性校验做扎实再考虑分布式的事。