大文件穩定上傳:Spring Boot + MinIO 斷點續傳實踐

一、引言:問題背景

在醫療問診等需要高可靠性數據記錄的場景中,我們的一項關鍵功能是:醫生與患者問答填寫問卷時,系統需要實時錄制音頻并與問卷綁定,以保證數據的真實性和有效性。然而,直接上傳完整的錄音文件(尤其是長時間問診產生的大文件)面臨兩個嚴峻的技術挑戰:

  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;定義不同類…