一、引言:问题背景

在医疗问诊等需要高可靠性数据记录的场景中,我们的一项关键功能是:医生与患者问答填写问卷时,系统需要实时录制音频并与问卷绑定,以保证数据的真实性和有效性。然而,直接上传完整的录音文件(尤其是长时间问诊产生的大文件)面临两个严峻的技术挑战:

  1. 网络稳定性:医院网络环境复杂,长时间上传大文件极易因网络波动而中断,导致整个上传失败,用户体验极差。
  2. 文件与服务器压力:大文件上传占用服务器连接时间过长,接口响应慢,且服务端一次性处理大文件对内存和IO压力巨大,容易导致文件损坏或上传失败。

为了解决这些问题,我们放弃了传统的一次性上传方案,转而采用 分块上传与断点续传 相结合的技术方案。本文将详细介绍如何基于 Spring Boot 3.0MyBatis-PlusMinIO 对象存储来实现这一稳健的上传流程。

二、技术选型与项目架构

  • 后端框架: Spring Boot 3.0
  • ORM 框架: MyBatis-Plus 3.5
  • 对象存储: MinIO 8.2.2
  • 状态缓存: Redis (用于存储上传状态和分块索引)
  • 核心思路: 将大文件分割成多个小块,分别上传。利用 MinIO 的分块上传能力和 Redis 的记录功能,实现上传中断后可从中断点继续上传,而非重新开始。

三、核心设计与实现

我们通过四个清晰的 RESTful 接口来串联整个上传流程:

  1. init: 初始化上传,获取本次上传的唯一ID。
  2. chunk: 上传单个文件分块。
  3. complete: 通知服务端所有分块已上传完毕,执行合并操作。
  4. progress: 查询当前上传进度。

**上传任务状态流转图:**本图描述了一个上传任务可能处于的各种状态及其转换条件。任务从“初始化”状态进入“上传中”子状态,并随着每个分块的上传成功而逐步推进。核心在于,当任务因异常进入“已中断”状态后,可以通过查询进度重新回到“上传中”状态,继续上传剩余分块,从而实现“续传”。超时未完成的任务会被系统清理。


上传流程
开始
调用/init
清理状态,结束
超时未完成
清理状态,结束
开始上传分块
所有分块成功
调用/complete
超时未完成
网络中断等异常
查询进度后
续传未完成分块
Idle
Initialized
Uploading
上传成功
上传成功
...
上传成功
Chunk_0
Chunk_1
Chunk_2
...
Chunk_N
Completed
Aborted
Interrupted
1. 初始化上传 (/init)

客户端在上传前,首先需要调用初始化接口。

Controller:

@Operation(summary = "初始化分片上传")
@PostMapping("/init")
public CommonResult<FileUploadDTO.UploadInitResponse> initUpload(@RequestParam("fileName") String fileName,@RequestParam("fileSize") long fileSize) {return CommonResult.SUCCESS(minioSysFileServiceImpl.initUpload(fileName, fileSize));
}

Service:

@Override
public FileUploadDTO.UploadInitResponse initUpload(String fileName, long fileSize) {// 1. 生成唯一上传ID,用于标识本次上传任务String uploadId = UUID.randomUUID().toString();// 2. 根据预设的分块大小,计算总分块数int totalChunks = (int) Math.ceil((double) fileSize / chunkSize);// 3. 创建上传状态对象并存入Redis,设置过期时间以防僵尸任务UploadStatus status = new UploadStatus(uploadId, fileName, fileSize, totalChunks);redisTemplate.opsForValue().set(RedisKeyUtil.getUploadKey(uploadId), status, 24, TimeUnit.HOURS);// 4. 返回给客户端:uploadId 和 总分块数return new FileUploadDTO.UploadInitResponse(uploadId, totalChunks, chunkSize);
}

此接口的核心是生成一个全局唯一的 uploadId,并将文件元信息(名称、大小、分块数)存入 Redis,为后续的分块上传和续传奠定基础。
在这里插入图片描述

2. 上传分块 (/chunk)

客户端根据初始化接口返回的 totalChunks,将文件切分,并循环调用此接口上传每一个分块。

Controller:

@Operation(summary = "上传分片")
@PostMapping("/chunk")
public CommonResult<Void> uploadChunk(@RequestParam("uploadId") String uploadId,@RequestParam("chunkNumber") int chunkNumber,MultipartFile chunk) {minioSysFileServiceImpl.uploadChunk(uploadId, chunkNumber, chunk);return CommonResult.SUCCESS();
}

Service:

@Override
public void uploadChunk(String uploadId, int chunkNumber, MultipartFile chunk){// 1. 为当前分块生成在MinIO中的唯一对象名称// 格式如:chunks/{uploadId}/{chunkNumber}String objectName = chunkObjectName(uploadId, chunkNumber);try (InputStream inputStream = chunk.getInputStream()) {// 2. 调用MinIO SDK上传分块PutObjectArgs args = PutObjectArgs.builder().bucket(minioConfig.getBucketName()).object(objectName).stream(inputStream, chunk.getSize(), -1).contentType(chunk.getContentType()).build();minioClient.putObject(args);} catch (Exception e) {log.error("上传分块 {} 失败: {}", chunkNumber, e.getMessage());throw new BaseException(ResultCode.CHUNK_UPLOAD_FAILED); // 抛出自定义异常,由全局异常处理器处理}// 3. 上传成功后,在Redis中标记该分块已完成markChunkUploaded(uploadId, chunkNumber);
}private void markChunkUploaded(String uploadId, int chunkNumber) {// 使用Set结构存储已上传的分块编号redisTemplate.opsForSet().add(RedisKeyUtil.getUploadKey(uploadId) + ":chunks", chunkNumber);
}

每个分块都是独立上传的,一个分块的失败不会影响其他分块。成功上传后,其编号会被记录到 Redis 的 Set 中,用于后续的进度查询和完成校验。

在这里插入图片描述
在这里插入图片描述

3. 完成上传与合并 (/complete)

当所有分块都上传完毕后,客户端调用此接口。

Controller:

@Operation(summary = "完成上传并合并文件")
@PostMapping("/complete")
public CommonResult<String> completeUpload(@RequestParam("uploadId") String uploadId){return CommonResult.SUCCESS(minioSysFileServiceImpl.completeUpload(uploadId));
}

Service:

@Override
public String completeUpload(String uploadId) {// 1. 从Redis获取上传状态,并检查所有分块是否已上传完成UploadStatus status = getUploadStatus(uploadId);if (!status.isComplete()) {throw new BaseException(ResultCode.CHUNK_UPLOAD_NOT_COMPLETE);}// 2. (业务逻辑)准备文件记录String originalFilename = status.getFileName();FileRecord record = ... // 创建或更新文件记录逻辑try {String finalObjectName = ... // 生成最终在MinIO中存储的文件名// 3. 核心:合并分块// 构建一个源分块列表List<ComposeSource> sources = IntStream.range(0, status.getTotalChunks()).mapToObj(i -> ComposeSource.builder().bucket(minioConfig.getBucketName()).object(chunkObjectName(uploadId, i)) // 指向每个分块.build()).collect(Collectors.toList());// 调用MinIO的composeObject API合并文件// 此操作在MinIO服务端进行,高效且不耗费应用服务器资源minioClient.composeObject(ComposeObjectArgs.builder().bucket(minioConfig.getBucketName()).object(finalObjectName).sources(sources).build());// 4. 合并成功后,清理临时分块文件for (int i = 0; i < status.getTotalChunks(); i++) {minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioConfig.getBucketName()).object(chunkObjectName(uploadId, i)).build());}String fullUrl = minioConfig.getUrl() + "/" + minioConfig.getBucketName() + "/" + finalObjectName;record.setUrl(fullUrl);} catch (Exception e) {log.error("文件合并失败:{}", e.getMessage());throw new BaseException("文件合并失败");} finally {// 5. 清理Redis状态redisTemplate.delete(RedisKeyUtil.getUploadKey(uploadId));redisTemplate.delete(RedisKeyUtil.getUploadKey(uploadId) + ":chunks");}fileRecordService.saveOrUpdate(record);return record.getUrl(); // 返回最终文件的访问地址
}

这是最精妙的一步。合并操作 (composeObject) 是在 MinIO 服务端完成的,它只是将各个分块文件的元数据组合起来,形成一个逻辑上的完整文件,而不需要在应用服务器上进行耗时的二进制流合并,因此速度极快,资源消耗极低。
在这里插入图片描述
在这里插入图片描述

4. 查询上传进度 (/progress)

在上传过程中,客户端可以定时调用此接口来获取上传进度,用于前端显示进度条。

Service:

@Override
public FileUploadDTO.UploadProgressResponse getUploadProgress(String uploadId) {UploadStatus status = getUploadStatus(uploadId);return new FileUploadDTO.UploadProgressResponse(status.getUploadedChunks().size(), // 已上传数status.getTotalChunks(),           // 总数status.getUploadedChunks()         // 已上传的编号集合);
}private UploadStatus getUploadStatus(String uploadId) {// 从Redis获取基础状态UploadStatus status = (UploadStatus) redisTemplate.opsForValue().get(RedisKeyUtil.getUploadKey(uploadId));if (status == null) {throw new BaseException(ResultCode.CHUNK_ID_NOT_EXIST);}// 从Redis Set中获取已上传的分块编号,并设置到状态对象中Set<Object> uploadedChunks = redisTemplate.opsForSet().members(RedisKeyUtil.getUploadKey(uploadId) + ":chunks");if (uploadedChunks != null) {status.setUploadedChunks(uploadedChunks.stream().map(o -> Integer.parseInt(o.toString())) // 注意类型转换.collect(Collectors.toSet()));}return status;
}

在这里插入图片描述

四、断点续传工作流程

此方案如何实现断点续传?流程如下:

  1. 客户端首次上传文件前,调用 /init 获取 uploadId
  2. 开始上传分块。假设在上传到第 50 个分块时网络中断。
  3. 网络恢复后,客户端可以先调用 /progress?uploadId=xxx 查询进度。
  4. 接口返回 {uploadedChunks: 49, totalChunks: 100, uploadedChunkNumbers: [0,1,2,...,48]}
  5. 客户端得知前 50 个块(0-49)中,第 49 块(索引从0开始)还未成功上传,于是从第 49 块开始继续上传,而不需要重传 0-48 块。
  6. 所有分块上传完成后,调用 /complete 完成合并。

五、方案优势总结

  1. 提升稳定性:网络中断后可从断点继续,避免重复劳动和流量浪费。
  2. 减轻服务器压力:分块上传变小请求,降低服务器内存和IO压力。MinIO 服务端合并,效率极高。
  3. 提升用户体验:前端可以实时显示精确的上传进度条。
  4. 清晰的责任分离:四个接口职责单一,逻辑清晰,易于维护和扩展。

六、拓展优化

  1. 分块大小:需要根据实际网络情况和文件大小调整 chunkSize(例如 5MB 或 10MB),在减少请求次数和降低单次失败成本之间取得平衡。
  2. 异常处理与重试:在 uploadChunk 方法中,可以实现更强大的重试机制,例如最多重试 3 次。
  3. 过期清理:需要有一个定时任务,清理 Redis 中超过一定时间(如 24 小时)仍未完成的 UploadStatus 以及 MinIO 中对应的临时分块文件,避免存储资源浪费。
  4. 安全性:可以对 uploadId 进行校验,确保用户只能操作自己发起的上传任务。

七、方案优势对比

传统方案 vs 分块断点续传方案

方面传统单次上传 (Traditional)分块断点续传 (Chunked & Resumable)
网络中断完全失败,需从头开始重传从中断点继续,仅需传剩余分块
进度反馈难以实现精确的进度条实时精确的进度反馈
服务器压力长时间占用连接,内存IO压力大分块小请求,压力分散,服务端合并高效
用户体验差,失败成本高,可控且可靠

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/news/921051.shtml
繁体地址,请注明出处:http://hk.pswp.cn/news/921051.shtml
英文地址,请注明出处:http://en.pswp.cn/news/921051.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

表达式语言EL

表达式语言EL 1.EL表达式的作用 可以说&#xff0c;EL&#xff08;Expression Language&#xff09;表达式语言&#xff0c;就是用来替代<% %>的&#xff0c;EL比<%%>更简洁&#xff0c;更方便。 2.与请求参数有关的内置对象 1.使用表达式&#xff1a;<%request…

pycharm无法添加本地conda解释器/命令行激活conda时出现很多无关内容

本文主要解决以下两种问题&#xff1a;1.pycharm在添加本地非base环境时出现无法添加的情况&#xff0c;特征为&#xff1a;正在创建conda解释器--->弹出一个黑窗口又迅速关闭&#xff0c;最终无法添加成功2.在conda prompt中进行activate 指定env&#xff08;非base&#x…

LeetCode 844.比较含退格的字符串

给定 s 和 t 两个字符串&#xff0c;当它们分别被输入到空白的文本编辑器后&#xff0c;如果两者相等&#xff0c;返回 true 。# 代表退格字符。 注意&#xff1a;如果对空文本输入退格字符&#xff0c;文本继续为空。 示例 1&#xff1a; 输入&#xff1a;s “ab#c”, t “a…

什么是涌浪电压

涌浪电压&#xff08;浪涌电压&#xff09;是电路或设备在运行时突然出现的、超出额定电压的瞬时过电压。它通常由雷击、电感性负载的断开、电力系统的故障切换或大型电容性负载的接通等原因引起。涌浪电压是一种高能量的瞬变干扰&#xff0c;可能损坏电子设备&#xff0c;如击…

uniapp 优博讯k329蓝牙打印机,设置打印机,一键打印

设置页面&#xff1a;<template><view class"pageBg"><u-navbar leftIconColor"#fff" :leftIconSize"28" title"打印设置" bgColor"#3c9cff" :placeholder"true"leftClick"$navigateBack&quo…

pikachu之sql注入

目录 XX型注入 insert/update注入 delete注入 "http header"注入 基于boolian的盲注 基于时间的盲注 宽字节注入&#xff08;wide byte注入&#xff09; pikachu靶场的字符型注入中xx or 11#可以得到所有用户的信息。 XX型注入 首先输入1探测一下。 然后返回…

TLS(传输层安全协议)

文章目录一、核心概念二、为什么需要 TLS/SSL&#xff1f;三、工作原理与详细流程握手步骤详解&#xff1a;1.ClientHello & ServerHello&#xff1a;2.服务器认证 (Certificate, ServerKeyExchange)&#xff1a;3.客户端响应 (ClientKeyExchange, Finished)&#xff1a;4.…

【SpringMVC】SSM框架【二】——SpringMVC超详细

SpringMVC 学习目标&#xff1a; 1.SpringMVC简介 1&#xff09;web访问流程1.web服务器通过浏览器访问页面2.前端页面使用异步提交的方式发送请求到后端服务器3.后端服务器采用&#xff1a;表现层—业务层—数据层的架构进行开发4.页面请求由表现层进行接收&#xff0c;获取用…

PostgreSQL表膨胀的危害与解决方案

PostgreSQL 的 表膨胀&#xff08;Table Bloat&#xff09; 是数据库中由于 MVCC&#xff08;多版本并发控制&#xff09;机制导致的一种常见性能问题&#xff0c;表现为物理存储空间远大于实际有效数据量。以下是详细解释及其危害&#xff1a;一、表膨胀的产生原因 1. MVCC 机…

Elasticsearch面试精讲 Day 5:倒排索引原理与实现

【Elasticsearch面试精讲 Day 5】倒排索引原理与实现 在“Elasticsearch面试精讲”系列的第五天&#xff0c;我们将深入探讨搜索引擎最核心的技术基石——倒排索引&#xff08;Inverted Index&#xff09;。作为全文检索系统的灵魂&#xff0c;倒排索引直接决定了Elasticsearc…

【小白笔记】基本的Linux命令来查看服务器的CPU、内存、磁盘和系统信息

一、 核心概念与命令知识点英文名词&#xff08;词源解释&#xff09;作用与命令CPU (中央处理器)Central Processing Unit&#xff1a;<br> - Central&#xff08;中心的&#xff09;&#xff1a;来自拉丁语 centralis&#xff0c;意为“中心的”。<br> - Process…

51c大模型~合集177

自己的原文哦~ https://blog.51cto.com/whaosoft/14154064 #公开V3/R1训练全部细节&#xff01; 刚刚&#xff0c;DeepSeek最新发文&#xff0c;回应国家新规 AI 生成的内容该不该打上“水印”&#xff1f;网信办《合成内容标识方法》正式生效后&#xff0c;De…

CA根证书的层级关系和验证流程

CA根证书的层级关系和验证流程&#xff1a;1. 证书层级结构&#xff08;树状图&#xff09; [根证书 (Root CA)] │ ├── [中间证书 (Intermediate CA 1)] │ │ │ ├── [网站证书 (example.com)] │ └── [邮件证书 (mail.example.com)] │ └── [中间证书 (In…

液态神经网络(LNN)1:LTC改进成CFC思路

从液态时间常数网络&#xff08;Liquid Time-Constant Networks, LTC&#xff09;到其闭式解版本——闭式连续时间网络&#xff08;Closed-form Continuous-time Networks, CfC&#xff09; 的推导过程&#xff0c;可以分为以下几个关键步骤。我们将基于你提供的两篇论文&#…

【图像处理基石】图像预处理方面有哪些经典的算法?

图像预处理是计算机视觉任务&#xff08;如目标检测、图像分割、人脸识别&#xff09;的基础步骤&#xff0c;核心目的是消除图像中的噪声、提升对比度、修正几何畸变等&#xff0c;为后续高阶处理提供高质量输入。以下先系统梳理经典算法&#xff0c;再通过Python实现2个高频应…

MySQL 多表查询方法

MySQL 多表查询方法MySQL 多表查询用于从多个表中检索数据&#xff0c;通常通过关联字段&#xff08;如外键&#xff09;实现。以下是常见的多表查询方式&#xff1a;内连接&#xff08;INNER JOIN&#xff09;内连接返回两个表中匹配的行。语法如下&#xff1a;SELECT 列名 F…

网络断连与业务中断的全链路诊断与解决之道(面试场景题)

目录 1. 网络链路的“命脉”:从物理层到应用层的排查逻辑 物理层:别小看那一根网线 数据链路层:MAC地址和交换机的“恩怨情仇” 工具推荐:抓包初探 2. 网络层的“幕后黑手”:IP冲突与路由迷雾 IP冲突:谁抢了我的地址? 路由问题:数据包的“迷路”之旅 3. 传输层与…

英伟达Newton与OpenTwins如何重构具身智能“伴随式数采”范式

具身智能的“数据饥荒”&#xff1a;行业痛点与技术瓶颈的深度剖析1.1 具身智能的现状与核心挑战Embodied AI的落地之路面临着多重严峻挑战。在算法层面&#xff0c;实现通用智能仍需人类的持续介入&#xff0c;并且从感知到行动的认知映射尚未完全打通。在硬件层面&#xff0c…

STM32HAL 快速入门(十六):UART 协议 —— 异步串行通信的底层逻辑

大家好&#xff0c;这里是 Hello_Embed。在前几篇中&#xff0c;我们通过环形缓冲区解决了按键数据丢失问题&#xff0c;而在嵌入式系统中&#xff0c;设备间的数据交互&#xff08;如单片机与电脑、传感器的通信&#xff09;同样至关重要。UART&#xff08;通用异步收发传输器…

使用 C 模仿 C++ 模板的拙劣方法

如下所示&#xff0c;准备两个宏&#xff0c;一个定义类型&#xff0c;一个定义容器大小。 使用时只要先定义这两个宏&#xff0c;然后再包含容器头文件就能生成不同类型和大小的容器了。但是这种方法只允许在源文件中使用&#xff0c;如果在头文件中使用&#xff0c;定义不同类…