这段时间被医疗影像大文件上传这事折腾得够呛,从最开始小白式的单次整传,到后面分片并发、断点续传、秒传一路重构,总算是把一条能落到生产环境的Java大文件上传下载链路跑通了。所以这篇东西不是教程式的罗列,而是想把我在医疗场景里踩过的坑、试出来的方案、还有那些文档上根本不写的参数计算逻辑一次性讲清楚。如果你正在做医院影像归档、病理切片、超声动图这类动不动就几百MB到几个GB的文件传输,这篇文章应该能让你少走不少弯路。
1. 医疗领域大文件上传的真实困境
1.1 医疗大文件到底大在哪
先说个很容易被低估的事实:医疗行业里的“大文件”和普通业务系统里的“大文件”完全不是一个量级。普通系统里的几MB附件,放到影像科根本不够看。一张数字病理切片(WSI)动辄5GB到20GB,一份CT断层重建序列可能几十GB,哪怕是单张DICOM医学影像,解压后也常常超过100MB。
但麻烦不只是体积大,还有两个非常头疼的特征:一是文件数量多,一个检查可能对应几百上千张序列帧;二是网络环境差,医院的内网虽然带宽看着高,但科室到机房往往隔着交换机和防火墙,很多老院区的网络丢包率相当感人,上传到一半断掉是家常便饭。这意味着,你要是还抱着浏览器一次POST完一个2GB文件的思路做,那基本就是给自己埋雷。
1.2 传统上传方案为什么必挂
传统的做法一般是两种:一种是前端读文件后用FormData整体POST,另一种是把文件Base64编码后塞进JSON字符串里。这两种放在几MB的小文件上没毛病,但放到医疗影像上就是灾难。
整体POST最先崩。文件越大,请求体越长,网络抖动一次TCP重传,整个请求就得重来。而且服务器端为了接收这个请求,通常会把InputStream读到内存或临时文件里,一旦文件达到GB级别,Tomcat的maxPostSize、Nginx的client_max_body_size、内存缓冲区这些全部会被顶穿。
Base64方案更别说了,Base64编码会把文件体积再扩大约33%,一个10GB的切片传上去光编码后的字符串就有13GB多,JSON解析时内存直接爆掉,前端也会卡到白屏。这还不算中间网关对请求体大小的限制。
所以核心问题不是“上传文件”这个动作本身,而是“怎么样让一个超大文件的传输过程可在失败后恢复、可并发出力、可校验完整”。要解决这个,就必须把大任务拆成小任务,把无状态请求变成有状态流程。
1.3 核心矛盾:内存、带宽、稳定性
做之前先想清楚三件事:
- 内存:不管文件多大,服务端都不能把整个文件塞进内存,必须边收边落盘,尽量保持内存占用恒定。
- 带宽:单连接上传跑不满带宽,需要靠多分片并发把带宽利用起来。
- 稳定性:传输中断不可避免,设计上必须让任何一步中断后都能从上一步继续,而不是从头再来。
这三件事就是整个方案的设计出发点。后面每一行代码、每一个参数,都是为了在这三个约束之间找平衡。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构设计:分片、断点、秒传
2.1 分片上传:把大文件拆成小任务
分片上传的逻辑其实很直白:前端把文件切成固定大小的块,比如10MB一片,然后一片一片(或并发多片)传到服务端。服务端每收到一片,就落一个临时文件,等到所有分片都到齐后,再按顺序把分片合并回完整文件。
为什么要这么折腾?有三点好处:
- 每个分片都是独立的HTTP请求,即使某片失败,只需重传这一片,而不是整个大文件。
- 多个分片可以并发上传,充分利用带宽。
- 服务端每片占用的内存是固定的(比如10MB缓冲区),不会因为文件大而暴涨。
分片之后,系统把“传输一个10GB文件”变成“传输1000个10MB文件”,虽然总量没变,但每一小步都变得可控、可跟踪、可重试。
2.2 断点续传:任务状态化
断点续传的关键是“状态”二字。传统上传是无状态的,请求发出去,服务器处理完就忘了。分片上传则要求服务端记住这笔上传任务的进度:总共有多少片,已经传了哪些片,下一片从哪儿开始。
所以第一步往往是“初始化上传”,由前端告诉服务端文件名、大小、总片数,服务端返回一个唯一的fileId。之后每次传分片都带上这个fileId,服务端就能知道当前进度。
前端启动时先查一下这个fileId的进度,发现某几片已经有了,就跳过上传,这就是断点续传。用户万一下午传了80%关掉浏览器,第二天打开还是从80%开始,而不是重新传一遍。
2.3 秒传:用哈希换流量
秒传的核心是“内容寻址”。同样是那张病理切片,可能A医生已经上传过一份一模一样的了,那B医生再传的时候,系统先算一下文件的MD5,去数据库里查一下,如果发现相同MD5的文件已存在,就不用真正传输文件了,直接把引用路径指过去就行。
听起来很魔法,但前提是MD5计算得准。前端要在文件被切分之前就算出整个文件的MD5,这个计算对4GB的文件一般几秒到十几秒,是可以接受的。后端合并完成后也会算一次整体MD5,两边对上了才认为文件完整。
需要注意:秒传只对“内容完全相同”的文件有效,医学影像通常不会有人为改动,所以同一份导出的DICOM文件重复上传的场景确实存在,做秒传很值。
2.4 存储介质选型对比
分片上传流程确定后,还得选一个存储方案。我在项目里对比过几种:
| 存储方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 本地磁盘 | 实现简单、带宽可控、无额外成本 | 扩容困难、单点风险高 | 内网小规模单机部署 |
| 分布式文件系统(如某通用分布式存储) | 扩容方便、多副本可靠 | 部署运维复杂 | 大型医院集团 |
| 对象存储(如MinIO兼容S3) | API简单、生态成熟、支持直传 | 需要额外部署中间件 | 对内对外混合场景 |
| 云对象存储 | 弹性扩容、免维护 | 网络出口带宽受限 | 远程会诊多云部署 |
我做的那套系统最终用的是内网部署的MinIO兼容对象存储,因为医院通常对数据出境非常敏感,不允许随便上公有云,而MinIO这类私有化对象存储既能提供S3标准接口,又能把数据留在内网。Java生态对接也方便,直接走SDK上传下载即可。
但我必须提醒一点:如果只是想快速落地分片上传,本地磁盘也能跑通,别一上来就上复杂分布式架构,先让业务跑起来,再把存储层替换成对象存储。
2.5 前端配合要点
前端不是只把文件切好发请求就行,还要做这几件事:
- 计算文件整体MD5。
- 把文件均匀切成指定大小分片。
- 控制并发数,比如同时最多发4个分片请求。
- 监听每个分片的上传进度,汇总成总进度条。
- 断网或失败时,对失败分片做3次重试。
- 全部完成后再调用合并接口。
前端用Web Workder去切分和计算MD5,避免主线程卡死。进度条的计算公式是:已完成分片数 / 总分片数,而不是字节数/总字节数,这样更直观也更稳定。
3. 核心接口设计与Java实现
3.1 数据表设计
先看表结构,所有上传状态都记在这里。实际项目里可能还会加更多字段,但核心这些不能少:
sql复制CREATE TABLE file_upload_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
original_name VARCHAR(255) NOT NULL,
storage_path VARCHAR(512),
file_size BIGINT NOT NULL,
chunk_size INT NOT NULL,
total_chunks INT NOT NULL,
uploaded_chunks INT DEFAULT 0,
file_md5 VARCHAR(64),
status TINYINT NOT NULL COMMENT '0初始化 1上传中 2合并中 3完成 4失败',
user_id VARCHAR(64),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_file_id (file_id)
);
另外还需要一张分片明细表,用来记录每一片的上传状态,方便断点续传时快速定位缺失分片:
sql复制CREATE TABLE file_chunk_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id VARCHAR(64) NOT NULL,
chunk_index INT NOT NULL,
chunk_size BIGINT,
status TINYINT DEFAULT 0 COMMENT '0未上传 1已上传',
upload_time DATETIME,
UNIQUE KEY uk_file_chunk (file_id, chunk_index)
);
两张表配合使用:file_upload_record负责整体进度,file_chunk_record负责细节进度。上传分片时先更新分片表,再更新总表的uploaded_chunks。
3.2 上传初始化接口
初始化接口要做的很简单:生成唯一fileId,记录元信息,返回给前端。
java复制@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
@Autowired
private FileUploadService fileUploadService;
@PostMapping("/init")
public Result<UploadInitResponse> init(@RequestBody UploadInitRequest request) {
String fileId = UUID.randomUUID().toString().replace("-", "");
FileUploadRecord record = new FileUploadRecord();
record.setFileId(fileId);
record.setOriginalName(request.getFileName());
record.setFileSize(request.getFileSize());
record.setChunkSize(request.getChunkSize());
record.setTotalChunks((int) Math.ceil(request.getFileSize() / (double) request.getChunkSize()));
record.setStatus(0);
fileUploadService.save(record);
// 预创建所有分片记录,状态为0
fileUploadService.initChunkRecords(fileId, record.getTotalChunks());
return Result.ok(new UploadInitResponse(fileId, record.getTotalChunks()));
}
}
这里的totalChunks不能直接整数相除,因为最后一片通常小于chunk_size,必须向上取整。比如一个35MB文件按10MB分,应该是4片,而不是3片。
3.3 分片上传实现
分片上传接收的是multipart文件,每个分片都是一个临时文件,落到磁盘上的临时目录,用{fileId}_{chunkIndex}.part命名。
java复制@PostMapping("/chunk")
public Result<String> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") int chunkIndex) {
String tempDir = fileUploadService.getTempDir(fileId);
File partFile = new File(tempDir, fileId + "_" + chunkIndex + ".part");
// 已存在则直接跳过,支持断点
if (partFile.exists()) {
return Result.ok("已存在");
}
try {
file.transferTo(partFile);
fileUploadService.markChunkUploaded(fileId, chunkIndex);
} catch (IOException e) {
// 失败清理残留分片
partFile.delete();
throw new BizException("分片保存失败: " + chunkIndex);
}
return Result.ok("成功");
}
几点经验总结:
transferTo比手动读流再写文件省事,但底层如果文件跨盘符会触发复制,所以临时目录和目标存储目录最好在同一个磁盘分区。- 分片文件一写到磁盘就得立刻更新分片表,避免后续状态对不上。
- 前端重试机制是必要的,因为网络闪断时可能请求实际发出去了但响应超时,如果没有重试,分片会缺。
3.4 合并文件与校验
当所有分片都上传完成后,前端调用合并接口。合并逻辑看着简单,但很容易出问题,主要是顺序和校验。
java复制@PostMapping("/merge")
public Result<String> merge(@RequestParam("fileId") String fileId) {
FileUploadRecord record = fileUploadService.getByFileId(fileId);
if (record == null || record.getStatus() != 1) {
throw new BizException("文件状态不正确");
}
// 校验分片完整性
List<File> partFiles = fileUploadService.listPartFiles(fileId);
if (partFiles.size() != record.getTotalChunks()) {
throw new BizException("分片缺失,已上传 " + partFiles.size() + "/" + record.getTotalChunks());
}
// 按索引排序,再顺序写入
partFiles.sort(Comparator.comparing(f -> extractIndex(f.getName())));
File target = new File(record.getStoragePath());
try (OutputStream out = new BufferedOutputStream(new FileOutputStream(target))) {
for (File part : partFiles) {
Files.copy(part.toPath(), out);
}
} catch (IOException e) {
throw new BizException("合并失败: " + e.getMessage());
}
// 计算整体MD5并校验
String mergedMd5 = DigestUtils.md5Hex(new FileInputStream(target));
if (!mergedMd5.equalsIgnoreCase(record.getFileMd5())) {
target.delete();
throw new BizException("文件MD5校验失败");
}
// 清理分片文件和临时目录
fileUploadService.cleanupTemp(fileId);
record.setStatus(3);
fileUploadService.update(record);
return Result.ok("合并完成");
}
要注意的坑:合并时必须按分片索引升序写入,不能依赖listFiles的返回顺序,因为文件名的排序不是自然排序。比如file_10.part会在file_2.part之前,如果直接用文件名排序就会乱。所以我在命名时把索引格式化成固定宽度,比如fileId_00001.part,或者写代码里用正则提取索引排序。
3.5 断点续传下载实现
下载的断点续传本质是HTTP Range请求。服务器读到请求头里的Range字段,定位到文件对应位置开始输出,状态码返回206,而不是200。
java复制@GetMapping("/download/{fileId}")
public ResponseEntity<Resource> download(@PathVariable String fileId,
@RequestHeader(value = "Range", required = false) String range) {
FileUploadRecord record = fileUploadService.getByFileId(fileId);
if (record == null || record.getStatus() != 3) {
throw new BizException("文件不存在或未完成");
}
File file = new File(record.getStoragePath());
long fileLength = file.length();
long start = 0;
long end = fileLength - 1;
if (StringUtils.hasText(range)) {
// 解析如 "bytes=0-99" 或 "bytes=100-"
String rangeValue = range.replace("bytes=", "");
String[] parts = rangeValue.split("-");
start = Long.parseLong(parts[0]);
if (parts.length > 1 && StringUtils.hasText(parts[1])) {
end = Long.parseLong(parts[1]);
}
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.set("Content-Disposition", "attachment; filename=\"" + record.getOriginalName() + "\"");
headers.setContentLength(end - start + 1);
headers.set("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
headers.set("Accept-Ranges", "bytes");
try {
InputStream inputStream = new FileInputStream(file);
inputStream.skip(start);
InputStreamResource resource = new InputStreamResource(inputStream);
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.headers(headers)
.body(resource);
} catch (IOException e) {
throw new BizException("文件读取失败");
}
}
这里有个容易被忽略的点:如果前端传了Range,后端必须返回206,否则下载工具会认为服务器不支持断点,只能从头下载。实现时要注意流式输出,不要一次性把整个文件读到内存里。
3.6 前端并发上传要点
前端用axios加Promise控制并发,核心就是一个并发池:
javascript复制async function uploadChunks(fileId, file, chunkSize, concurrency) {
const totalChunks = Math.ceil(file.size / chunkSize);
const results = [];
const pool = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
const task = axios.post('/api/upload/chunk', {
fileId,
chunkIndex: i,
file: chunk
}, {
onUploadProgress: (e) => updateProgress(i, e)
}).then(() => markDone(i)).catch(() => retryChunk(i));
pool.push(task);
if (pool.length >= concurrency) {
await Promise.race(pool);
pool.splice(pool.findIndex(p => p === taskRef), 1);
}
}
await Promise.all(pool);
}
这里的Promise.race实现有些粗糙,但核心思路是:保持并发池里有固定数量的请求,任何一个完成后就补充下一个。真正生产环境推荐用p-limit或async-pool这类库,代码会干净很多。
4. 参数怎么定:分片大小、并发数、超时
4.1 分片大小的计算逻辑
分片大小不是随便拍脑袋定的。我试过从2MB到50MB各种值,最终在日常项目里定在10MB到20MB之间,核心考虑有四条:
- 分片太小:HTTP请求数量爆炸,服务端要处理大量小请求,吞吐反而下降,而且分片记录表膨胀。
- 分片太大:单个分片传输时间长,失败重试成本高,断点续传的粒度变粗。
- 内存约束:服务端如果每片都在内存缓冲后再落盘,那分片大小基本等于内存占用量,要控制在JVM堆能承受的范围内。
- 网络质量:医院内网质量好可以放大到20MB,远程会诊网络抖动大就缩到5MB。
一个简单的估算公式:假设目标总上传时间可接受,想并发4路,单路有效带宽为B(MB/s),一片传输时间=T秒,那么分片大小≈B*T/2左右比较合适,因为还要留一半时间给请求处理和重传。比如单路带宽10MB/s,希望一片传完不超过1秒,分片就是5MB到10MB。
4.2 并发数与线程池
后端接收分片时,如果用同步处理,则并发数取决于前端发起的请求数。但如果前端4路并发,后端Tomcat默认线程池是200,完全扛得住。真正需要注意的是后端合并和分片保存时的线程池:
- 分片保存是纯IO操作,用I/O密集型线程池,核心线程数设两倍的CPU核心数比较合理。
- 合并文件操作不要并发执行同一个fileId的合并,要给每个fileId加锁,否则会重复合并导致数据错乱。
- 前端并发数控制在3到5比较稳。并发太高,网络层先吃不住,分片乱序重试也很折磨人。
4.3 超时与重试策略
超时时间的设置容易踩坑。如果分片10MB,在2MB/s的链路上需要5秒,你给Nginx和网关的代理超时只设30秒看似够了,但遇到网络拥塞或服务端GC停顿,x传输时间可能膨胀到十几倍。所以我习惯把分片接口的读取超时设成60秒,连接超时10秒;给合并接口设成更长,比如5分钟,因为合并是本地IO,一般快,但遇到RAID性能差或磁盘满时会很慢。
重试策略采用“退避重试”:第一次失败隔1秒重试,第二次隔3秒,第三次隔10秒,最多重试3次。无脑快速重试反而会让服务端雪上加霜。
4.4 医疗网络环境的适配
医疗场景的实际网络比想象中要复杂。院内网络虽然内网速度快,但很多影像科室的设备集中在特定网段,业务服务器在信息科机房,这中间的防火墙如果启用了流量审计,大流量持续上传可能被限流甚至拦截。远程医院访问时,往往走的是专线,专线带宽恒定但延迟较高,高档数字切片从几十公里外传回来要几个小时。
我在这种场景下做了个妥协方案:把分片大小设置为8MB,并发数降到2到3,同时在分片请求上加了压缩头,虽然压缩对已经压缩过的DICOM来说意义不大,但对纯文本报告、波形文件有效,能省一点带宽。
5. 医疗数据安全与合规的落地
5.1 权限控制和审计
医疗影像属于敏感数据,这块不能应付。所有上传下载请求必须经过权限校验,不能用裸接口。我这边统一在网关层加了token鉴权,同时用拦截器记录操作日志:谁、什么时间、上传/下载了哪个文件、文件大小、来源IP。这些审计日志至少要保留半年以上,医院信息科检查时都要看。
5.2 传输与存储加密
传输层面,系统内部全部走HTTPS协议,这个没商量。虽然内网部署也会有被恶意嗅探的可能,尤其跨部门网络,加密传输是最低要求。存储层面:
- 敏感影像文件落盘时要考虑应用层加密还是磁盘加密。全盘加密实现简单,但密钥管理在外面的云环境容易出问题;应用层加密可以做到细粒度,但会牺牲一点性能。
- 我用的是AES-256-CBC对文件内容加密,密钥存储在独立的密钥管理服务里,而不是和文件放一起。上传后合并到最终文件时先解密再合并,下载时再边解密边输出。
5.3 文件类型和完整性校验
上传接口不能只认扩展名,医疗影像的格式五花八门,但都需要校验头部魔数。比如DICOM文件头有固定的128字节预留加上"DICM"标识,PDF文件头是"%"号。合并完成后除MD5校验外,最好再做一次业务层校验:调用医学影像解析服务读取文件头,确认文件可以被识别。
这个步骤能卡住很多乱传的文件。我遇到过一个现场传了个改名成.dcm的普通图片,MD5没问题,但影像工作站就是打不开,最后就是在合并校验阶段识别的。
5.4 日常运维与清理
分片上传会产生大量临时文件,一旦上传中断且前端没有重试,临时分片就会一直躺在磁盘上。我在服务端加了定时任务,扫描超过24小时的临时目录并清理。还有一条经验:临时目录和最终文件目录分开放,清理临时目录时直接用“按目录前缀删除”,操作安全,不怕误删已完成文件。磁盘水位超过80%时,合并接口直接拒绝新任务,避免合并写到一半磁盘满了导致文件损坏。
6. 常见问题和排查实录
6.1 Nginx默认限流导致上传413
第一次联调就撞上了Nginx的client_max_body_size默认1MB限制,结果是任何超过1MB的请求直接返回413。排查办法是抓请求响应,看到413就知道是Nginx层拦截了。解决方式:client_max_body_size 100m;,注意这个值要大于实际最大的单个分片大小,而不是大于完整文件大小——这是很多人容易踩的坑。
另外Nginx还有个隐藏问题:proxy_read_timeout默认60秒,如果分片上传在某个环节卡住超过60秒,Nginx会主动断开。这个要和业务超时时间配合,我设的是proxy_read_timeout 180s;。
6.2 合并时文件顺序错乱
这个坑我在前面提醒过一次,但还是要展开讲。用Java File.listFiles()返回的数组顺序在底层文件系统上是不确定的,尤其在Linux ext4上,它按目录哈希顺序返回,完全不可控。当你依赖文件名排序时,必须自己提取索引数值排序,而不是直接调用Arrays.sort()比较字符串。否则合并后影像打开后是乱序帧,虽然文件大小没错,但影像序列错位,诊断直接受影响。
6.3 分片丢失与磁盘占满
分片上传过程中,如果前端已经显示了全部上传成功,但服务端合并时提示“分片缺失”,多半是某一两个分片文件没有真正落到磁盘。常见原因是transferTo时临时目录所在磁盘已满,虽然返回了成功,但实际写入产生了IOException被吞掉了。排查思路:先看分片表的已上传记录和磁盘上的.part文件数量是否一致,如果不一致,看下磁盘空间和文件权限。
磁盘满还会引发另一个连锁问题:合并时FileOutputStream打开失败,但已创建的目标文件是0字节空文件,如果没做处理,这个空文件会一直占用文件名,后续重传被秒传逻辑跳过就尴尬了。所以合并接口检测到目标文件存在且大小为0时,必须先删除再合并。
6.4 下载进度条跳变
下载进度条跳变不是后端问题,而是前端没有处理好Content-Length。如果下载接口没有设置Content-Length,前端只能根据已接收字节估算,导致进度条忽快忽慢。修复方法就是明确设置Content-Length为end - start + 1,并且对Range请求返回206状态码。还有一个细节:用InputStreamResource时不要设置整体文件大小,而要设置本次响应体的大小,否则下载工具会认为文件没传完。
6.5 线程池被打满
多用户同时上传时,如果每个任务都占用一个线程,线程池很容易被打满,其余的业务请求全部排队。我给上传分了单独的线程池,和常规业务线程池隔离。上传线程池里不执行耗时业务,只做落盘和状态更新,最大线程数限制在CPU核数的2倍内。做压测时发现线程数再多,磁盘IO先成了瓶颈,线程数翻倍带来的收益不到10%,反而增加了上下文切换成本。
把并发前线放在前端,后端保持轻量化处理,整个系统会稳定很多。前端限制并发数,比后端限制线程更直接也更可控。
我个人在实际操作中的体会是:大文件上传下载方案,真正难的不是某一两个接口怎么写,而是要把分片、断点、并发、校验、安全这些环节串成一条可靠的流水线。医疗领域更是如此,影像数据不能丢、不能乱、更不能泄露。上面这套方案我从最早的单请求上传一点一点演进到现在,每一步都是在真实故障里长出来的经验,你照着搭建可以少走很多弯路。如果后续你还需要处理多节点上传、跨机房传输或者边缘设备到中心端的推流,其实都可以在这一套“分片+状态+校验”的框架上扩展,核心思想始终是一样的。
