C#實現(xiàn)大文件分片上傳完整指南
大文件分片上傳的核心思路是:前端將大文件切割成多個小分片,逐個發(fā)送到服務(wù)端暫存,全部接收完成后服務(wù)端按順序合并還原。下面從前后端實現(xiàn)、數(shù)據(jù)庫設(shè)計、斷點續(xù)傳、合并邏輯、并發(fā)優(yōu)化和避坑指南六個維度來介紹。
一、核心原理
分片上傳不是HTTP協(xié)議的內(nèi)置特性,需要業(yè)務(wù)層自行實現(xiàn)。前端使用File.slice()(瀏覽器)或FileStream.Read()(桌面端)將文件按固定大小切片,單片大小建議2~5 MB——太小增加HTTP請求開銷,太大降低失敗重傳效率。每次請求攜帶三個關(guān)鍵字段:fileId(全文件唯一標(biāo)識)、chunkIndex(從0開始的片序號)、totalChunks(總片數(shù)),服務(wù)端按fileId + chunkIndex冪等寫入,不能依賴請求順序。
二、前端實現(xiàn)(C# 桌面端 / WinForms / WPF)
/// <summary>
/// 大文件分片上傳客戶端(使用 HttpClient)
/// </summary>
public class ChunkUploader
{
private static readonly HttpClient _httpClient = new HttpClient();
private const int CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 每片
private const string UPLOAD_URL = "https://localhost:5001/api/upload/chunk";
private const string MERGE_URL = "https://localhost:5001/api/upload/merge";
public async Task<bool> UploadLargeFileAsync(string filePath, string fileId)
{
using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read,
FileShare.Read, 81920, FileOptions.Asynchronous);
long fileSize = fileStream.Length;
int totalChunks = (int)Math.Ceiling((double)fileSize / CHUNK_SIZE);
// 1. 查詢服務(wù)端已上傳的分片(斷點續(xù)傳)
var uploadedChunks = await GetUploadedChunksAsync(fileId);
for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
{
if (uploadedChunks.Contains(chunkIndex)) continue; // 跳過已上傳的分片
// 2. 讀取分片數(shù)據(jù)
int offset = chunkIndex * CHUNK_SIZE;
int currentChunkSize = (int)Math.Min(CHUNK_SIZE, fileSize - offset);
byte[] chunkData = new byte[currentChunkSize];
fileStream.Seek(offset, SeekOrigin.Begin);
await fileStream.ReadAsync(chunkData, 0, currentChunkSize);
// 3. 計算當(dāng)前分片的哈希值(用于完整性校驗)
string chunkHash = ComputeSha256Hash(chunkData);
// 4. 上傳分片
bool success = await UploadChunkAsync(fileId, chunkIndex, totalChunks,
chunkData, chunkHash);
if (!success)
{
// 失敗重試(帶指數(shù)退避)
success = await RetryUploadAsync(fileId, chunkIndex, totalChunks, chunkData, chunkHash);
if (!success) return false;
}
}
// 5. 所有分片上傳完成,觸發(fā)合并
return await MergeChunksAsync(fileId, Path.GetFileName(filePath), fileSize);
}
private async Task<bool> UploadChunkAsync(string fileId, int chunkIndex, int totalChunks,
byte[] chunkData, string chunkHash)
{
using var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(chunkData), "file", $"chunk_{chunkIndex}");
content.Add(new StringContent(fileId), "fileId");
content.Add(new StringContent(chunkIndex.ToString()), "chunkIndex");
content.Add(new StringContent(totalChunks.ToString()), "totalChunks");
content.Add(new StringContent(chunkHash), "chunkHash");
var response = await _httpClient.PostAsync(UPLOAD_URL, content);
return response.IsSuccessStatusCode;
}
private async Task<HashSet<int>> GetUploadedChunksAsync(string fileId)
{
var response = await _httpClient.GetAsync($"{UPLOAD_URL}/status?fileId={fileId}");
if (!response.IsSuccessStatusCode) return new HashSet<int>();
var json = await response.Content.ReadAsStringAsync();
var uploaded = JsonSerializer.Deserialize<List<int>>(json);
return new HashSet<int>(uploaded ?? new List<int>());
}
private async Task<bool> MergeChunksAsync(string fileId, string fileName, long fileSize)
{
var mergeData = new { fileId, fileName, fileSize };
var content = new StringContent(JsonSerializer.Serialize(mergeData),
Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(MERGE_URL, content);
return response.IsSuccessStatusCode;
}
private static string ComputeSha256Hash(byte[] data)
{
using var sha256 = SHA256.Create();
byte[] hash = sha256.ComputeHash(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
關(guān)鍵要點:
HttpClient必須復(fù)用單例實例或用IHttpClientFactory,否則會導(dǎo)致 socket 耗盡;- 超時時間需要顯式配置為較大值(如 30 分鐘),默認(rèn) 100 秒不足以完成大文件上傳;
- .NET 5+ 中
StreamContent默認(rèn)不會自動 Dispose 底層流,建議改用ByteArrayContent以確保安全。
三、服務(wù)端實現(xiàn)(ASP.NET Core)
3.1 服務(wù)配置(Program.cs)
var builder = WebApplication.CreateBuilder(args);
// 禁用默認(rèn)請求體大小限制(兩層都要配置)
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = long.MaxValue; // 禁用 Kestrel 層限制
});
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = long.MaxValue; // 禁用 MVC 層限制
});
var app = builder.Build();
ASP.NET Core 中有兩層請求體限制:Kestrel 自身的 MaxRequestBodySize(默認(rèn) 30MB)和 MVC 層的 MultipartBodyLengthLimit,兩層必須同時調(diào)整才能生效。
3.2 分片上傳 API(UploadController)
[ApiController]
[Route("api/[controller]")]
[DisableRequestSizeLimit] // 禁用請求大小限制
public class UploadController : ControllerBase
{
private readonly IUploadService _uploadService;
public UploadController(IUploadService uploadService)
{
_uploadService = uploadService;
}
/// <summary>
/// 上傳單個分片(繞過 IFormFile,避免 OOM)
/// </summary>
[HttpPost("chunk")]
public async Task<IActionResult> UploadChunk([FromForm] ChunkUploadRequest request)
{
// 驗證參數(shù)
if (string.IsNullOrEmpty(request.FileId) || request.ChunkIndex < 0)
return BadRequest("Invalid parameters");
// 驗證分片哈希
using var ms = new MemoryStream();
await request.File.CopyToAsync(ms);
byte[] chunkData = ms.ToArray();
string computedHash = ComputeSha256Hash(chunkData);
if (!computedHash.Equals(request.ChunkHash, StringComparison.OrdinalIgnoreCase))
return BadRequest("Chunk hash mismatch");
// 冪等保存:如果已存在則直接返回成功
bool saved = await _uploadService.SaveChunkAsync(request.FileId, request.ChunkIndex,
chunkData, request.ChunkHash);
if (!saved)
return Conflict(new { message = "Chunk already exists", index = request.ChunkIndex });
return Ok(new { success = true, index = request.ChunkIndex });
}
/// <summary>
/// 查詢已上傳的分片索引(斷點續(xù)傳核心)
/// </summary>
[HttpGet("chunk/status")]
public async Task<IActionResult> GetUploadedChunks([FromQuery] string fileId)
{
var uploadedChunks = await _uploadService.GetUploadedChunkIndicesAsync(fileId);
return Ok(uploadedChunks);
}
/// <summary>
/// 合并所有分片
/// </summary>
[HttpPost("merge")]
public async Task<IActionResult> MergeChunks([FromBody] MergeRequest request)
{
// 加鎖防止并發(fā)合并
bool merged = await _uploadService.MergeChunksAsync(request.FileId, request.FileName);
if (!merged)
return Conflict(new { message = "Merge failed or already in progress" });
return Ok(new { success = true, filePath = $"/uploads/{request.FileName}" });
}
}
public class ChunkUploadRequest
{
public string FileId { get; set; }
public int ChunkIndex { get; set; }
public int TotalChunks { get; set; }
public string ChunkHash { get; set; }
public IFormFile File { get; set; }
}
public class MergeRequest
{
public string FileId { get; set; }
public string FileName { get; set; }
public long FileSize { get; set; }
}
關(guān)鍵要點:
- 不要使用
IFormFile直接處理 GB 級文件,它會觸發(fā)完整文件讀取和內(nèi)存緩沖,導(dǎo)致 OOM。但分片上傳場景下單片只有 2-5 MB,用IFormFile是可行的; - 每片保存后必須校驗哈希,網(wǎng)絡(luò)傳輸中單片出錯很常見,僅靠文件大小無法判斷內(nèi)容正確性;
- 接口必須支持冪等寫入——重復(fù)上傳同一片應(yīng)直接返回成功,而非報錯。
四、數(shù)據(jù)庫設(shè)計(跟蹤上傳狀態(tài))
為支持?jǐn)帱c續(xù)傳和狀態(tài)恢復(fù),需要設(shè)計兩張核心表:
上傳會話表(UploadSession)
| 字段 | 類型 | 說明 |
|---|---|---|
| SessionId | GUID PK | 文件上傳會話唯一標(biāo)識 |
| FileName | VARCHAR(255) | 原始文件名 |
| FileSize | BIGINT | 文件總大?。ㄗ止?jié)) |
| FileHash | VARCHAR(128) | 整個文件的 SHA256 值(秒傳校驗) |
| ChunkSize | INT | 分片大?。ㄗ止?jié)) |
| TotalChunks | INT | 總分片數(shù) |
| UploadedChunksCount | INT | 已上傳分片數(shù) |
| Status | TINYINT | 狀態(tài):0-上傳中,1-合并中,2-已完成,3-失敗 |
| CreatedAt | DATETIME2 | 創(chuàng)建時間 |
| UpdatedAt | DATETIME2 | 更新時間 |
分片記錄表(UploadedChunk)
| 字段 | 類型 | 說明 |
|---|---|---|
| ChunkId | BIGINT PK | 自增主鍵 |
| SessionId | GUID FK | 關(guān)聯(lián)到 UploadSession |
| ChunkIndex | INT | 分片序號(從 0 開始) |
| ChunkSize | INT | 該分片大小(最后一片可能較?。?/td> |
| ChunkHash | VARCHAR(128) | 該分片的 SHA256 值 |
| StoredPath | VARCHAR(500) | 分片在磁盤上的存儲路徑 |
| UploadedAt | DATETIME2 | 上傳時間 |
狀態(tài)持久化策略:
內(nèi)存維護(hù)活躍會話可以提升性能,但進(jìn)程崩潰會丟失狀態(tài)。生產(chǎn)環(huán)境應(yīng)在關(guān)鍵節(jié)點落庫:首次上傳時插入記錄,每個分片成功后更新 UploadedChunksCount 和 lastChunkIndex,合并完成后將 Status 改為 Completed 并清理臨時文件。
五、分片合并實現(xiàn)
/// <summary>
/// 安全合并分片(使用 Seek 定位寫入,避免內(nèi)存溢出)
/// </summary>
public async Task<bool> MergeChunksAsync(string fileId, string finalFileName)
{
var chunks = await GetChunksOrderedAsync(fileId);
if (chunks.Count == 0) return false;
// 檢查是否所有分片都已到達(dá)
int totalChunks = await GetTotalChunksCountAsync(fileId);
if (chunks.Count != totalChunks) return false;
string tempDir = Path.Combine(_config["Storage:ChunkPath"], fileId);
string finalPath = Path.Combine(_config["Storage:FinalPath"], finalFileName);
// 使用 FileStream 配合 Seek 定位寫入,而非全量加載
using var finalStream = new FileStream(finalPath, FileMode.Create, FileAccess.Write,
FileShare.None, 81920, useAsync: true);
int chunkSize = _config.GetValue<int>("ChunkSize", 5 * 1024 * 1024);
foreach (var chunk in chunks)
{
long offset = chunk.ChunkIndex * (long)chunkSize;
finalStream.Seek(offset, SeekOrigin.Begin);
string chunkPath = Path.Combine(tempDir, $"{fileId}_{chunk.ChunkIndex}.tmp");
using var chunkStream = new FileStream(chunkPath, FileMode.Open, FileAccess.Read);
await chunkStream.CopyToAsync(finalStream);
}
await finalStream.FlushAsync();
// 合并完成后校驗全文件哈希(可選)
string finalHash = await ComputeFileSha256Async(finalPath);
if (!finalHash.Equals(await GetExpectedFileHashAsync(fileId), StringComparison.OrdinalIgnoreCase))
{
File.Delete(finalPath);
return false;
}
// 清理臨時分片文件和目錄
foreach (var chunk in chunks)
{
File.Delete(Path.Combine(tempDir, $"{fileId}_{chunk.ChunkIndex}.tmp"));
}
Directory.Delete(tempDir);
return true;
}
合并要點:
- 不要用
File.AppendAllBytes()或File.ReadAllBytes()+File.WriteAllBytes(),大文件會內(nèi)存溢出; - 必須使用
FileStream.Seek()按分片編號計算偏移量后寫入,確保寫入位置精確; - 合并前必須校驗三個條件:分片哈希完整、全部分片已到達(dá)、加鎖防止并發(fā)合并;
- 合并成功后立即清理臨時文件,失敗時也要清理并標(biāo)記任務(wù)為失敗狀態(tài);
- 建議設(shè)置后臺定時任務(wù)(如每 30 分鐘執(zhí)行一次),掃描并清理超過 2 小時未完成上傳的臨時分片。
六、斷點續(xù)傳實現(xiàn)
斷點續(xù)傳的核心是 客戶端在開始上傳前先向服務(wù)端查詢已接收的分片索引,跳過這些索引再上傳剩余分片。
流程如下:
- 客戶端計算
fileId(通常為文件名_文件大小_最后修改時間或文件內(nèi)容的 MD5); - 客戶端發(fā)送 HEAD/GET 請求
GET /api/upload/chunk/status?fileId=xxx,獲取服務(wù)端已接收的chunkIndex列表; - 客戶端比對本地分片列表,跳過已上傳的分片,僅上傳缺失部分;
- 每上傳成功一個分片,服務(wù)端立即持久化狀態(tài)到數(shù)據(jù)庫;
- 所有分片上傳完成后,調(diào)用
/merge接口觸發(fā)合并。
注意事項:
- 不要用本地文件修改時間或 MD5 做續(xù)傳依據(jù),服務(wù)端可能清理過臨時文件;
- 每個分片上傳后必須檢查 HTTP 狀態(tài)碼和響應(yīng)體中的明確確認(rèn)信息,遇到 409 Conflict(分片已存在)可直接跳過,遇到 500 錯誤則采用指數(shù)退避重試策略(最多 3 次);
- 斷點續(xù)傳需要服務(wù)端持久化狀態(tài),僅依賴磁盤臨時文件是不夠的——IIS 或 Kestrel 重啟后已上傳的分片會丟失。
七、并發(fā)上傳優(yōu)化
多個分片可以并發(fā)上傳以提升效率,但需控制并發(fā)數(shù)避免帶寬搶占:
// 使用 SemaphoreSlim 控制最大并發(fā)數(shù)
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(3); // 最多 3 個并發(fā)
public async Task UploadWithConcurrencyAsync(string filePath, string fileId, int totalChunks)
{
var tasks = new List<Task>();
for (int chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
{
await _semaphore.WaitAsync();
int index = chunkIndex; // 捕獲變量
tasks.Add(Task.Run(async () =>
{
try
{
await UploadSingleChunkAsync(filePath, fileId, index, totalChunks);
}
finally
{
_semaphore.Release();
}
}));
}
await Task.WhenAll(tasks);
}
八、避坑指南
1. 服務(wù)端默認(rèn)限制問題
ASP.NET Core 有兩層請求體限制,必須同時調(diào)整才生效。Kestrel 默認(rèn) MaxRequestBodySize 為 30MB,MVC 層也有自己的限制,兩層都要配置為 long.MaxValue。
2. Stream 行為差異
.NET Framework 中 StreamContent 會自動 Dispose 底層流,而 .NET 5+ 默認(rèn)不會。建議統(tǒng)一使用 ByteArrayContent 避免兼容性問題。
3. HTTP 順序不可靠
HTTP 請求不保證順序到達(dá),服務(wù)端必須以 fileId + chunkIndex 為準(zhǔn)進(jìn)行冪等寫入,不能依賴請求到達(dá)順序進(jìn)行合并。
4. 大文件哈希計算
計算整個文件的 SHA256 時,不要用 SHA256.Create().ComputeHash(fileStream) 一次性讀入內(nèi)存,而應(yīng)使用 TransformBlock / TransformFinalBlock 增量分塊計算,避免 OOM。
5. 合并時的并發(fā)控制
合并操作必須加鎖防止并發(fā)多次觸發(fā)??墒褂梦募i(FileStream.Lock())或分布式鎖(如 Redis SETNX)實現(xiàn)。
6. 臨時文件清理
必須設(shè)置自動清理機制:用后臺定時任務(wù)掃描 lastModified 超過設(shè)定時間(如 2 小時)的臨時分片并刪除,避免磁盤被殘留文件占滿。
九、方案選擇建議
| 方案 | 適用場景 | 優(yōu)點 | 缺點 |
|---|---|---|---|
| 自建分片上傳 | 需要完全掌控、自定義業(yè)務(wù)邏輯 | 靈活可控、無外部依賴 | 開發(fā)成本高、需要處理所有邊界情況 |
| WebUploader + ASP.NET MVC | Web 端大文件上傳,歷史項目 | 成熟穩(wěn)定、社區(qū)資源多 | 前端依賴外部組件 |
| 阿里云 OSS / 騰訊云 COS | 直接對接云存儲 | 分片上傳已內(nèi)置、高可靠、支持?jǐn)帱c續(xù)傳 | 需要云服務(wù)賬號、有流量費用 |
| Azure Blob Storage | 微軟生態(tài)項目 | 與 .NET 集成好、原生支持塊上傳 | 僅限 Azure 環(huán)境 |
建議:如果項目已經(jīng)使用云存儲,優(yōu)先使用云廠商的 SDK(如阿里云 OSS、Azure Blob、騰訊云 COS),它們內(nèi)置了分片上傳、斷點續(xù)傳和錯誤重試機制。如果需要完全自建,請務(wù)必關(guān)注上述的數(shù)據(jù)庫設(shè)計、冪等性、并發(fā)控制和臨時文件清理等生產(chǎn)環(huán)境要點。
以上就是C#實現(xiàn)大文件分片上傳完整指南的詳細(xì)內(nèi)容,更多關(guān)于C#大文件分片上傳的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
為IObservable實現(xiàn)自己的運算符(詳解)
下面小編就為大家?guī)硪黄獮镮Observable實現(xiàn)自己的運算符(詳解)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-05-05
C#中將dateTimePicker初始值設(shè)置為空
本文主要介紹了C#中將dateTimePicker初始值設(shè)置為空,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-02-02
C# readnodefile()不能讀取帶有文件名為漢字的osg文件解決方法
這篇文章主要介紹了C# readnodefile()不能讀取帶有文件名為漢字的osg文件解決方法,需要的朋友可以參考下2015-09-09

