医疗系统里,提到“大文件上传与下载”,通常就是指那么几类体积惊人的数据:CT/MR影像、病理数字切片、基因测序原始数据,一个大体积DICOM文件轻松超过几百MB,多序列合起来几个GB也不罕见。我接触过的某医院医学影像平台项目,早期直接用常规表单上传,结果文件一超过200MB,前端等半天、后端内存暴涨,传输失败还得重头再来,体验非常糟糕。
这篇内容主要围绕后端Java(Spring Boot)实现大文件上传与下载的示例步骤展开,会把分片上传、断点续传、合并校验、Range范围下载这几个核心环节拆开讲清楚。适用范围很明确:做医疗信息化、影像归档、报告附件的开发者,或者在大文件传输上吃过亏的后端同学,都可以直接参考。
1. 医疗大文件上传与下载的核心挑战与设计方案
1.1 医疗数据的真实体量:一个病例动辄几个GB
医疗行业的数据模型和普通互联网业务差别很大。普通业务里“大文件”可能是几十MB的附件,但到了医疗场景,边界要往上拉高几个量级:MR一次扫描可能包含几百个序列,每个序列几十到几百MB;病理切片扫描图像分辨率极高,一张切片压成金字塔格式后轻松超过1GB;基因测序下机数据甚至直接按TB计算。
要命的是,医疗文件不是一次性写出来就完事,它需要长期保存、回传、二次调阅。很多场景下医生希望快速预览影像或报告附件,不想等整个文件下载完。这就要求我们不是简单做一个大文件传输工具,而是要综合考虑内存占用、网络波动、断点续传、合法合规的访问审计。如果一开始就用传统的MultipartFile一把梭,后面必然要返工。
1.2 常规上传方式为什么扛不住大文件
刚接手这个需求时,团队里有人提议直接用Spring MVC的MultipartFile接收,代码写起来简单,测试小文件也没问题。等到真传1GB文件时,问题立刻暴露:
- Tomcat默认对请求体、上传文件大小有限制,直接报MaxUploadSizeExceededException。
- 如果走内存暂存,服务器内存可能被撑爆;就算配置了临时文件路径,大文件上传过程中任何一个抖动,都会导致整段数据作废。
- 没有进度反馈。大文件上传动辄几分钟甚至更久,用户不知道卡在哪一步,体验极差。
- 没有断电或网络中断后的恢复能力。一旦请求失败,只能从头再来。
所以在大文件场景,标配做法是:把大文件切成若干小分片,逐个上传;后端记录每个分片的状态;所有分片传完后由服务端合并;下载时则通过HTTP Range协议支持断点续传和内容分段读取。
1.3 核心方案:分片上传 + 断点续传 + 秒传 + Range下载
这套方案的核心理念是“化整为零、校验兜底、可断可续”。整体流程可以拆成这样:
| 环节 | 方案 | 主要目的 |
|---|---|---|
| 上传 | 前端将大文件切分为固定大小的分片,逐片上传 | 降低单次请求内存压力,避免请求时间过长 |
| 状态记录 | 后端以文件MD5为标识,记录已上传分片索引 | 实现断点续传,失败后只补传失败分片 |
| 合并 | 全部切片完成后,按索引顺序合并为完整文件 | 获得原始文件 |
| 校验 | 合并完成后重新计算MD5,与原始MD5比对 | 保证传输内容完整无损 |
| 下载 | 服务端支持Range范围请求,返回字节范围 | 支持断点续传、音视频/医学影像在线预览 |
| 安全审计 | 文件操作统一走鉴权和审计日志 | 医疗数据合规要求 |
这套方案在大型网盘和云存储里很成熟,我们只是把它落地到医疗内部系统,并且特别强化了校验和审计环节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:不要一上来就上重型框架
2.1 HTTP还是专用传输协议?
做医疗项目时,很多人会纠结要不要上FTP、是否使用对象存储SDK。我的判断是:内部医疗系统优先走HTTP协议,原因很简单:
- 医院网络环境复杂,防火墙策略严格,HTTP(S)的443/8080端口通常默认可用,FTP反而容易被拦。
- 医疗Web系统普遍基于Spring Boot,HTTP接口统一鉴权更方便,可以直接复用现有的登录态和权限体系。
- 不需要额外部署FTP服务器或引入重客户端,维护成本低。
- 对象存储(如MinIO)性能确实好,但部分医院对数据出境和第三方依赖非常敏感,本地化部署时HTTP仍然是无污染、最稳妥的选择。
当然,如果是跨院区传输超大基因数据,专用传输协议或对象存储更合适。但针对常规的院内影像、报告文件,用HTTP把分片做好已经足够了。
2.2 分片大小怎么定才合理
分片大小没有绝对标准,和网络环境、服务器内存、并发量都有关系。我自己的经验是先定一个基础范围,再去压测调整:
- 局域网内(院内网络),分片可以稍微大一点,比如5MB~10MB,减少请求次数。
- 广域网/远程调阅场景,分片建议小一点,比如1MB~4MB,网络抖动时丢失成本更低。
- 后端Spring Boot内置的multipart限制至少要大于单个分片大小,不能出现分片还没传就被拒绝的情况。
以最常见的1GB文件为例,如果分片大小定为5MB,会产生约205个分片。并发上传3~5个分片,整体速度可控,失败重试成本也可控。太小的分片会导致请求数量爆炸,太大则失去了分片的意义。
2.3 断点续传的状态管理
状态管理的核心是以文件MD5作为唯一标识。前端选择文件后会先计算出整个文件的MD5,这个值一方面用于后端判断是否“秒传”,另一方面用于标识同一文件的分片集合。后端用Redis或数据库记录该MD5对应的已上传分片索引列表。
我建议状态管理剥离成两部分:
- 持久化存储记录,保存到数据库表,用于文件元数据、上传状态、最终存储路径的维护。
- 实时状态记录,保存到Redis,用于快速查询哪些分片已上传、是否允许合并、当前上传进度。
两张数据配合使用,查询效率高,即使Redis数据丢失,也能从数据库恢复基础状态,不会出现分片全部丢失的极端问题。
2.4 下载侧的Range请求设计
下载方向,医疗场景主要有两种需求:一是完整下载原文件,比如医生把DICOM影像包导出为压缩包;二是在线预览,比如Web端直接看病理切片、PDF报告、MP4视频。第二种需求对断点下载能力要求很高。
HTTP的Range请求就是为此存在的。客户端请求头带上Range: bytes=0-1023,服务端解析后只返回该字节范围,同时回复状态码206和Content-Range。这个方法既支持视频拖动播放,也支持下载工具断点续传。做完这个功能后,Web端医学影像预览的加载速度明显改善,医生打开影像不再需要等完整文件下载。
3. 后端完整示例:上传、合并、下载
3.1 基础工程与存储目录规划
技术栈以Spring Boot 3.x + Java 17为例。先规划存储目录:
text复制/data/medical_file/
upload_tmp/ # 分片临时目录,按文件标识分目录存放
final/ # 合并完成的最终文件目录
archive/ # 归档目录,按日期分层
避免把所有文件直接扔在同一目录,文件量大了以后目录性能会急剧下降。使用UUID作为内部文件名,防止非法的路径穿越,也避免中文文件名直接暴露在文件系统上。
3.2 分片上传接口实现
分片上传的核心是:接收一个分片,把分片内容写到临时目录,并记录分片索引。下面是Controller代码:
java复制@RestController
@RequestMapping("/api/file")
public class FileUploadController {
private final FileUploadService uploadService;
public FileUploadController(FileUploadService uploadService) {
this.uploadService = uploadService;
}
/**
* 分片上传
*
* @param identifier 整个文件的MD5标识
* @param chunkIndex 当前分片索引,从0开始
* @param totalChunks 总分片数
* @param chunk 分片文件
*/
@PostMapping("/chunk")
public Result<String> uploadChunk(@RequestParam("identifier") String identifier,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestPart("chunk") MultipartFile chunk) {
uploadService.saveChunk(identifier, chunkIndex, totalChunks, chunk);
return Result.ok();
}
}
Service层需要做三件事:校验分片参数、写入临时文件、更新已上传分片状态。
java复制@Service
public class FileUploadService {
private static final String TMP_ROOT = "/data/medical_file/upload_tmp/";
private final FileMetaMapper metaMapper;
private final StringRedisTemplate redisTemplate;
public FileUploadService(FileMetaMapper metaMapper, StringRedisTemplate redisTemplate) {
this.metaMapper = metaMapper;
this.redisTemplate = redisTemplate;
}
public void saveChunk(String identifier, int chunkIndex, int totalChunks, MultipartFile chunk) {
// 1. 校验分片索引范围
if (chunkIndex < 0 || chunkIndex >= totalChunks) {
throw new IllegalArgumentException("非法的分片索引");
}
// 2. 如果该文件已完成合并,直接拒绝重复上传
FileMeta meta = metaMapper.findByFileKey(identifier);
if (meta != null && "MERGED".equals(meta.getStatus())) {
return;
}
// 3. 保存分片文件,目录按identifier隔离
try {
Path chunkDir = Paths.get(TMP_ROOT, identifier);
Files.createDirectories(chunkDir);
Path chunkPath = chunkDir.resolve(chunkIndex + ".part");
chunk.transferTo(chunkPath);
} catch (IOException e) {
throw new RuntimeException("分片保存失败", e);
}
// 4. 更新Redis中的已上传分片集合
String redisKey = "file:chunks:" + identifier;
Long added = redisTemplate.opsForSet().add(redisKey, String.valueOf(chunkIndex));
// 写入数据库元数据,用于持久化状态恢复
uploadService.ensureMeta(identifier, totalChunks);
}
}
这里有个很关键的点:分片写入前一定要判断该分片是否已存在且校验通过。如果网络重试导致同一分片反复上传,对应的.part文件会被覆盖,只要分片内容一致,覆盖其实无妨,但合并前要对分片数量和大小做最终校验,防止少片或坏片混进去。
3.3 合并与完整性校验
当所有分片上传完成后,调用合并接口。合并前先查Redis中分片数量是否达到总数,然后按索引顺序读取分片写入最终文件。
java复制public String mergeChunks(String identifier, int totalChunks, String originalFilename) throws IOException {
Path chunkDir = Paths.get(TMP_ROOT, identifier);
Path finalPath = Paths.get(FINAL_ROOT, UUID.randomUUID().toString() + "_" + identifier);
String redisKey = "file:chunks:" + identifier;
Long uploadedCount = redisTemplate.opsForSet().size(redisKey);
if (uploadedCount == null || uploadedCount != totalChunks) {
throw new IllegalStateException("分片未上传完整,请检查缺失分片");
}
try (FileChannel outChannel = FileChannel.open(finalPath,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
for (int i = 0; i < totalChunks; i++) {
Path partPath = chunkDir.resolve(i + ".part");
if (!Files.exists(partPath)) {
throw new IllegalStateException("缺失分片: " + i);
}
try (FileChannel inChannel = FileChannel.open(partPath, StandardOpenOption.READ)) {
long remaining = inChannel.size();
long position = 0;
while (remaining > 0) {
long transferred = inChannel.transferTo(position, remaining, outChannel);
if (transferred <= 0) {
break;
}
position += transferred;
remaining -= transferred;
}
}
}
}
// 合并完成后重新计算MD5
String md5 = DigestUtils.md5Hex(Files.newInputStream(finalPath));
if (!identifier.equalsIgnoreCase(md5)) {
Files.deleteIfExists(finalPath);
throw new IllegalStateException("文件校验失败,MD5不匹配");
}
// 更新数据库元数据状态
metaMapper.updateStatus(identifier, "MERGED", finalPath.toString(), originalFilename);
// 清理临时分片
deleteRecursively(chunkDir);
redisTemplate.delete(redisKey);
return finalPath.toString();
}
使用FileChannel.transferTo合并,比用普通字节流复制更快,尤其在千兆网环境下的本地磁盘合并非常明显。整个合并过程是流式操作,内存中只保留缓冲,不会因为文件巨大导致OOM。
3.4 Range下载接口实现
下载功能的核心是支持Range请求。Controller代码需要处理两种场景:不带Range返回完整文件;带了Range返回指定字节范围。
java复制@GetMapping("/download")
public ResponseEntity<byte[]> download(@RequestParam("fileId") String fileId,
@RequestHeader(name = "Range", required = false) String range,
@RequestHeader(value = "User-Agent", defaultValue = "") String ua) throws IOException {
FileMeta meta = metaMapper.findByFileId(fileId);
if (meta == null) {
return ResponseEntity.notFound().build();
}
Path path = Paths.get(meta.getStoragePath());
long fileSize = Files.size(path);
long start = 0;
long end = fileSize - 1;
if (range != null && range.startsWith("bytes=")) {
String[] parts = range.substring("bytes=".length()).split("-");
try {
start = Long.parseLong(parts[0]);
if (parts.length > 1 && !parts[1].isEmpty()) {
end = Long.parseLong(parts[1]);
}
if (start > end || start >= fileSize) {
return ResponseEntity.status(416)
.header("Content-Range", "bytes */" + fileSize)
.build();
}
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().build();
}
}
long rangeLength = end - start + 1;
// 读取字节范围,注意大文件不适合把整个文件读入内存
byte[] data = readRange(path, start, rangeLength);
HttpStatus status = (range != null && range.startsWith("bytes=")) ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK;
return ResponseEntity.status(status)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", String.valueOf(rangeLength))
.header("Accept-Ranges", "bytes")
.header("Content-Disposition", "attachment; filename*=UTF-8''" + UrlEncoder(meta.getOriginalName()))
.body(data);
}
private byte[] readRange(Path path, long start, long length) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) {
raf.seek(start);
byte[] buf = new byte[Math.toIntExact(length)];
raf.readFully(buf);
return buf;
}
}
这个示例使用RandomAccessFile直接按偏移读取。如果是超大文件,更优做法是使用FileChannel.map或直接基于流返回,避免一次性加载太多内容。一般医疗文件预览只需要读开头几MB,所以按Range读取足够。
3.5 进度查询与状态管理
上传进度查询接口也很必要,前端每隔几百毫秒拉一次进度,用户可以看到“正在上传第X/N片”的反馈。后端只需要从Redis取set大小或者数据库字段即可。
java复制@GetMapping("/progress")
public Result<UploadProgress> progress(@RequestParam("identifier") String identifier,
@RequestParam("totalChunks") int totalChunks) {
String redisKey = "file:chunks:" + identifier;
Long uploaded = redisTemplate.opsForSet().size(redisKey);
long count = uploaded == null ? 0 : uploaded;
UploadProgress progress = new UploadProgress();
progress.setTotalChunks(totalChunks);
progress.setUploadedChunks(count);
progress.setProgress(count * 100.0 / totalChunks);
return Result.ok(progress);
}
这个接口还能顺手做“秒传”判断:如果用户在别的终端上传过同一个MD5文件,后端发现元数据中已经存在同一identifier且状态为MERGED,就可以直接返回原文件标识,前端提示“秒传成功”,避免重复上传。
4. 医疗场景下绕不开的合规与安全细节
4.1 权限校验不能只靠前端
医疗文件不像普通论坛附件,可能涉及患者隐私。上传和下载都必须做身份认证与权限校验,并且服务端必须再次校验用户是否有权访问某个文件。比如医生A只能下载自己科室的影像,不能越权访问其他科室文件。在接口层加一个文件归属校验,数据模型里记录上传者、所属科室,下载时过滤。
不能只在前端按钮上做隐藏,后端必须做同样的校验。我在实际项目里遇到过抓包直接调接口绕过前端菜单的情况,如果没有后端权限校验,数据很容易泄露。
4.2 敏感字段脱敏与加密落盘
理想状态下,文件存储系统不应该直接以患者姓名、身份证号命名文件。可以用系统内部生成的文件标识,比如UUID,作为落盘文件名,原始元数据单独存到数据库,并且把姓名、病案号等字段做脱敏展示。
如果涉及特别敏感的数据,建议对存储目录启用透明加密,或者对文件流做AES加密。注意加密不能影响下载性能,可以考虑只对关键元数据加密,或者使用磁盘层加密方案。至少不能把患者手机号、身份证直接拼在路径里,这是底线。
4.3 文件操作的审计日志
医疗系统被各种检查盯得很紧,文件上传、下载、删除都必须可追溯。我建议每个关键操作都记录审计日志,至少包含:
| 审计项 | 记录内容 |
|---|---|
| 操作人 | 用户ID、姓名、角色 |
| 操作时间 | 精确到毫秒 |
| 操作类型 | 上传分片、合并、下载、删除 |
| 文件标识 | MD5、文件ID、原始文件名哈希 |
| 客户端信息 | IP、设备UA、浏览器信息 |
| 结果 | 成功/失败、错误原因 |
审计日志不要只打到应用日志里,要单独存到独立的审计表,并定期归档。这样一旦发生数据泄露或违规调阅,可以很快定位到人、到时间点。
5. 实战踩坑记录与排查指南
5.1 上传到大半突然断开
这是最常见的坑。文件传到60%或70%时连接断开,前端报网络错误,后端发现分片只保存了一部分。排查方向有几个:
- 反向代理的超时时间太短,比如Nginx默认proxy_read_timeout是60秒,大分片或弱网环境下容易超时。
- 服务器Tomcat的connectionTimeout设置不合理。
- 前端XHR或Fetch没有开启重试机制。
我建议在生产环境调整Nginx配置:
bash复制proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
client_max_body_size 20m;
注意client_max_body_size至少比单个分片大,否则分片请求直接被拒。这不是让你放宽整个请求体限制,而是放宽到单个分片大小即可,整体文件大没有关系,因为它是拆小后多次请求传上来的。
5.2 合并后文件打不开
文件传完了,合并也提示成功,但下载下来发现图片无法打开或者PDF损坏。这类问题通常出在合并环节或分片写入:
- 分片保存时没有flush/close,文件内容还在缓冲区。
- 多线程并发上传同一个分片,导致文件互相覆盖。
- 合并时没有按顺序读取,索引排序用了字符串排序,导致第2分片排在10之后。
- 合并完成后没有重新计算MD5,坏文件没有被及时发现。
建议强制在合并完成后做MD5比对,因为分片索引错位很难用肉眼检测。还有一点,分片目录下如果产生了0字节文件,说明某次上传时文件没有真正写入成功,排查时可以加一个“分片大小必须大于0”的校验。
5.3 下载时在线预览失败,响应头缺少Accept-Ranges
有的系统里,去医院网络环境稍微差一点,医生点击影像预览时,Web播放器卡死或无法拖进度。打开浏览器看到响应头发现根本没有Accept-Ranges,说明后端没有返回206部分内容,而是把整个文件一次性发过去,前端解析失败。
解决方法是确保下载接口一定是返回206状态并且设置Content-Range。如果使用Nginx作为静态文件代理,还需要在location里加:
bash复制add_header Accept-Ranges bytes;
否则即使后端设置了响应头,也可能被代理层剥掉。
5.4 中文文件名与URL编码问题
有些医院系统用的是老业务系统,下载文件名是中文,直接拼在Content-Disposition里,轻则显示乱码,重则被浏览器拦截。推荐使用RFC 5987标准格式,把文件名编码为UTF-8的百分号形式:
java复制String encodedName = URLEncoder.encode(meta.getOriginalName(), StandardCharsets.UTF_8.name())
.replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedName)
.build();
这个细节往往不影响程序运行,但直接影响医生使用体验。
6. 后续优化方向与个人体会
6.1 并行分片与自动重试
基础分片上传可以工作,但医疗文件动辄几GB,逐个分片上传速度太慢。真正上线时可以加入并发控制:前端同时发送3~5个分片请求,充分利用带宽。同时为每个分片添加自动重试机制,失败分片指数退避重试。后端合并逻辑不受影响,因为合并只看分片是否齐全,不管它们上传顺序。
6.2 对象存储和本地存储怎么选
如果医院内部有条件部署对象存储,比如MinIO,可以把最终文件丢给它,获得更好的扩容和迁移能力。但要注意:对象存储不等于一劳永逸,分片上传、断点续传、MD5校验这些逻辑仍然需要服务端实现,上传接口依然要面对同样的挑战。对象存储主要解决的是存储侧高可用和扩展性问题,传输侧协议本身仍然要处理。
6.3 真实项目上线后的个人体会
这套方案上线后,我再也没收到过“文件传不上去”的工单。以前医生反馈说上传2GB数据等半天最后失败重来,现在即使断网,重新登录后也能从断点继续传。最大的体会是:大文件传输没有魔法,核心就是分片、断点、校验、Range这几个基本功。
如果只能给一个建议,那就是先做好MD5校验闭环。分片上传可以丢,网络可以断,但合并前必须检查分片是否齐全、合并后必须验证文件完整。医疗数据容不得任何损坏,校验这一步省不掉的。
另外,简单方案往往最可靠。我见过一些项目一上来就引入分布式任务调度、消息队列,反而把问题搞复杂。先把分片上传和Range下载做扎实,再逐步优化并发和重试,才是医疗项目求稳的正确路线。
