ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#大文件分块上传实战:断点续传与秒传方案详解

C#大文件分块上传实战:断点续传与秒传方案详解 做后台管理系统这几年我最怕遇到的需求不是权限、不是报表而是“上传大文件”。尤其当业务方轻飘飘地来一句“视频也就 1 个G传就完了呗”而前端用的是传统的 multipart/form-data 整包提交时结局通常是请求超时、浏览器卡死、用户骂街。后来我老老实实用 C# 写了一套大文件分块上传接口配合前端切片才把这块硬骨头啃下来。这篇文章就把这套“分块秒传”方案完整拆开从核心思路到 C# 后端实现、前端切片逻辑、合并细节、断点续传再到各种坑的排查方法全部讲明白。适合用过 ASP.NET Core 写 Web API、但没系统性做过上传模块的开发者也适合前端想了解后端怎么配合的兄弟。先说清楚分块秒传不是黑魔法。它的核心就三件事——把大文件切成多个小块、逐块上传、全部传完后再在后端合并成完整文件。而“秒传”则是锦上添花如果文件之前已经上传过后端通过哈希值直接告诉你“我这儿已经有了”前端就不需要再传一遍整个过程看起来就像秒传一样。这套方案做好之后别说 1G几十个 G 的文件也能稳定跑。下面我从设计思路开始讲再给完整代码。1. 分块秒传的核心思路与选型分析1.1 为什么传统上传在大文件面前一定会失败传统上传的方式很简单前端把一个 File 对象塞进 FormData一次性 POST 给后端。文件小的时候几 MB这条路走得通。但文件一旦上了几百 MB 甚至几个 G问题就会接踵而来浏览器和服务器之间的网络连接随时可能断开一旦断开整个请求就废了用户得从头再来。大文件在传输过程中需要占用大量的内存和网络带宽服务器端如果用IFormFile直接接收ASP.NET Core 会把整个文件缓冲到内存或临时文件里并发一高内存直接告急。很多代理服务器、网关对单次请求体大小有限制比如默认 Nginx 的client_max_body_size可能是 1mIIS 也可能有maxAllowedContentLength限制。文件大了请求还没到后端就被网关拦下了。所以大文件上传的第一原则就是不要一次性把整包丢给网络。分块传输把一个大请求拆成多个小请求单个请求失败不会影响其他块重传成本极低。1.2 分块、断点续传、秒传之间的关系这三个概念经常被混着说实际是不同层次的东西分块上传把文件按固定大小切开每块独立上传。解决的是“单次请求过大”的问题。断点续传在分块的基础上已上传的块在服务端被记录下来。下次上传时前端先查一下哪些块已存在只传缺失的块。解决的是“传了一半断网”的问题。秒传利用文件内容哈希比如 MD5判断服务端是否已经存过相同文件。如果存在直接返回成功连分块都不用传。解决的是“重复文件白白占用带宽”的问题。三者的关系可以这样理解断点续传是分块的必备配套秒传是分块之上的能力和后端存储策略的配合。在小文件场景秒传价值不大但对几百 MB 的大小遇到两个用户上传同一个安装包秒传能省下大量时间和流量。1.3 为什么用 C# 做后端前端怎么配合选择 C#具体来说是 ASP.NET Core来做后端并不是因为它比 Java、Go 更强而是有现实考量很多企业内部系统本来就是 .NET 技术栈运维、部署、权限体系全都现成。C# 写这种带文件流处理的接口非常顺手FileStream、Stream这些类天然适合做二进制数据处理。加上 ASP.NET Core 的[FromForm]模型绑定和自定义IActionFilter的扩展能力做上传接口可以很优雅。前端这块我建议尽量用原生XMLHttpRequest或者fetch自己做切片控制而不是依赖某个大而全的上传组件。原因有两个一是上传组件的抽象层太厚出了问题难排查二是分块上传本身逻辑并不复杂核心就是File.slice()加循环发送自己写反而更可控。如果追求效率可以用 Web Worker 在后台线程做哈希计算避免计算大文件 MD5 时把页面主线程卡死。这个后面代码里都会给到。2. 环境准备与项目结构搭建2.1 后端环境与基础接口设计我用的是ASP.NET Core 6.0创建一个空的 Web API 项目即可。如果用的是 .NET 8代码基本能直接移植。不需要额外的 NuGet 包因为文件流处理都是 BCL 自带的。项目结构建议这样分FileUploadDemo/ ├── Controllers/ │ └── UploadController.cs ├── Services/ │ └── FileMergeService.cs ├── Storage/ │ └── chunks/ // 临时分块目录 │ └── uploads/ // 合并后的文件目录 └── Program.cs后端需要提供这么几个接口POST /api/upload/check前端上传前调用传入文件哈希和文件名返回是否已存在以及已上传的分块编号列表。POST /api/upload/chunk接收单个分块。参数包含文件标识可以用文件哈希或一个上传会话ID、分块序号、分块总数和分块二进制数据。POST /api/upload/merge所有分块传完后后端按序号合并临时文件生成最终文件。POST /api/upload/cancel可选取消上传时清理临时分块。关于文件标识我推荐用“文件 MD5 文件大小 用户ID”组合成一个会话 key而不是直接用文件名。因为文件名不可靠两个不同用户可能传同名的不同文件同一个人也可能传两个同名的文件。用内容哈希可以保证同一个文件复用同一套分块记录天然支持断点续传和秒传。2.2 前端页面与上传组件的选型前端不需要框架单纯一个 HTML 页面加原生 JS 就能完成。但为了演示方便我会在后文用纯 HTML JavaScript 写一个完整的 demo依赖只有两个axios或直接用 fetch和SparkMD5用于计算文件 MD5。如果你不想引第三方库也可以用crypto.subtle.digest计算哈希但它是异步的而且不同浏览器实现有差异算大文件不如 SparkMD5 稳。SparkMD5 支持增量计算配合 FileReader 分片读取能算大文件的 MD5。上传核心逻辑用 XMLHttpRequest 写反而更方便因为它有upload.onprogress事件可以拿上传进度而且取消请求也更顺手。fetch 虽然更现代但进度事件支持不够好我用 XHR 多一点。前端需要实现的功能点选择文件后计算 MD5用 Worker 或异步分片读取避免卡界面。调用后端 check 接口判断是否秒传。根据文件大小设置分块大小比如 5MB、10MB用file.slice(start, end)切块。查询已上传分块列表过滤掉已存在的块。并发上传剩余分块控制并发数比如 3 或 5。全部成功后调用 merge 接口。上传过程中如果某块失败自动重试。3. 核心代码实现从分片到秒传的完整链路3.1 前端文件分片与上传控制先看前端最关键的分片与上传逻辑。我建议把分片大小定为5MB这个值对大部分网络环境和服务器配置都比较友好。如果分片太小比如 1MB请求数量会爆炸HTTP 握手开销太大如果太大比如 50MB又失去了分块的意义单个分片失败重传成本高。下面是一个可以直接跑通的前端核心代码const CHUNK_SIZE 5 * 1024 * 1024; // 5MB async function handleUpload(file) { // 1. 计算文件MD5生产环境建议放Web Worker里做 const fileMd5 await calculateMd5(file); // 2. 检查是否可以秒传、以及断点信息 const checkResp await fetch(/api/upload/check, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fileMd5: fileMd5 }) }); const checkData await checkResp.json(); if (checkData.uploaded) { alert(秒传成功文件已存在无需重复上传。); return; } // 3. 计算总分块数 const totalChunks Math.ceil(file.size / CHUNK_SIZE); const uploadedChunks new Set(checkData.uploadedChunks || []); // 4. 并发上传未传的分块 const concurrency 3; const tasks []; let current 0; // 使用一个简单的并发池 async function uploadNext() { while (current totalChunks) { const index current; if (uploadedChunks.has(index)) { continue; } const start index * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const chunk file.slice(start, end); const formData new FormData(); formData.append(fileMd5, fileMd5); formData.append(chunkIndex, index); formData.append(totalChunks, totalChunks); formData.append(file, chunk, file.name); await fetch(/api/upload/chunk, { method: POST, body: formData }).then(res { if (!res.ok) throw new Error(Chunk ${index} upload failed); }); } } // 启动并发任务 for (let i 0; i concurrency; i) { tasks.push(uploadNext()); } await Promise.all(tasks); // 5. 调用合并接口 const mergeResp await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileMd5: fileMd5, totalChunks: totalChunks }) }); const mergeData await mergeResp.json(); if (mergeData.success) { console.log(上传完成:, mergeData.filePath); } }需要注意的一个细节file.slice()在不同浏览器上兼容性没问题但File对象的 slice 方法如果在之前被调用过某些浏览器会拷贝一份 Blob 数据导致内存上升。所以尽量在循环里直接基于原始file切片不要先const f file.slice(0)然后再切。MD5 计算函数我用 SparkMD5典型写法如下async function calculateMd5(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; // 2MB const reader new FileReader(); const spark new SparkMD5.ArrayBuffer(); let offset 0; function loadNext() { const slice file.slice(offset, offset chunkSize); reader.readAsArrayBuffer(slice); } reader.onload e { spark.append(e.target.result); offset chunkSize; if (offset file.size) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror reject; loadNext(); }); }如果文件很大超过 2GB上面这种 FileReader 分片读取依然可能造成内存压力因为ArrayBuffer会一次性把分片载入内存。为了更稳可以改用FileReader.readAsArrayBuffer配合Blob.arrayBuffer()或者直接把计算逻辑放到 Web Worker 里让主线程不卡顿。这里我不过度展开代码层面以上面的够用。3.2 后端接收分片与合并文件后端我用一个UploadController来实现。先看接收分片的接口它接收IFormFile按会话 key 存储到临时目录。using Microsoft.AspNetCore.Mvc; using System.Text.RegularExpressions; [ApiController] [Route(api/upload)] public class UploadController : ControllerBase { private readonly IWebHostEnvironment _env; private readonly string _chunkDir; private readonly string _uploadDir; public UploadController(IWebHostEnvironment env) { _env env; _chunkDir Path.Combine(env.ContentRootPath, Storage, chunks); _uploadDir Path.Combine(env.ContentRootPath, Storage, uploads); Directory.CreateDirectory(_chunkDir); Directory.CreateDirectory(_uploadDir); } [HttpPost(chunk)] public async TaskIActionResult UploadChunk( [FromForm] string fileMd5, [FromForm] int chunkIndex, [FromForm] int totalChunks, IFormFile file) { // 使用MD5作为目录名隔离不同文件的分块 var chunkFolder Path.Combine(_chunkDir, fileMd5); Directory.CreateDirectory(chunkFolder); var chunkPath Path.Combine(chunkFolder, chunkIndex.ToString()); using (var stream new FileStream(chunkPath, FileMode.Create)) { await file.CopyToAsync(stream); } // 可以顺带记录一个进度元信息也可以用文件系统天然的文件列表代替 return Ok(new { success true }); } }这里有一个安全细节fileMd5是客户端传上来的字符串直接用做路径拼接会有路径穿越风险。比如客户端传一个../../Windows/System32作为 fileMd5就会覆盖任意目录。所以必须对 MD5 做合法性校验最简单的办法是正则只允许小写字母和数字if (!Regex.IsMatch(fileMd5, ^[a-f0-9]{32}$)) { return BadRequest(new { error invalid fileMd5 }); }这是一个非常关键的坑我文末会再强调。写生产代码时任何客户端的输入都不能直接拼路径。合并接口稍微复杂一点。它需要读取某文件的所有分块按序号顺序写入最终文件[HttpPost(merge)] public IActionResult Merge([FromBody] MergeRequest request) { // 同样校验md5 if (!Regex.IsMatch(request.FileMd5, ^[a-f0-9]{32}$)) { return BadRequest(new { error invalid fileMd5 }); } var chunkFolder Path.Combine(_chunkDir, request.FileMd5); if (!Directory.Exists(chunkFolder)) { return NotFound(new { error no chunks found }); } // 检查分块是否齐全 var chunkFiles Directory.GetFiles(chunkFolder) .Select(Path.GetFileName) .Select(int.Parse) .OrderBy(x x) .ToList(); if (chunkFiles.Count ! request.TotalChunks) { return BadRequest(new { error $chunk count mismatch, expected {request.TotalChunks}, got {chunkFiles.Count} }); } // 生成最终文件名避免文件名冲突和路径问题 var safeFileName Path.GetFileName(request.FileName); var ext Path.GetExtension(safeFileName); var finalPath Path.Combine(_uploadDir, ${request.FileMd5}_{Guid.NewGuid():N}{ext}); using (var finalStream new FileStream(finalPath, FileMode.CreateNew)) { foreach (var index in chunkFiles) { var chunkPath Path.Combine(chunkFolder, index.ToString()); using var chunkStream new FileStream(chunkPath, FileMode.Open); chunkStream.CopyTo(finalStream); } } // 合并完成清理临时分块目录 Directory.Delete(chunkFolder, true); return Ok(new { success true, filePath finalPath }); } public class MergeRequest { public string FileName { get; set; } public string FileMd5 { get; set; } public int TotalChunks { get; set; } }合并的时候有几个点值得注意FileMode.CreateNew可以防止因并发重复调用 merge 而产生的覆盖问题。如果文件已存在会抛 IOException我们可以捕获后直接返回已存在的结果。分块文件名我直接用index.ToString()在排序时需要转为数字再排序否则10会排在2前面导致合并出来的文件错乱。这是一个非常经典的坑。合并是 IO 密集操作如果文件很大CopyTo会占用大量磁盘 IO。生产环境建议给合并接口加一个简单的状态标记避免同一个文件的合并请求被并发触发多次。3.3 秒传逻辑文件特征码与快速跳过秒传的实现依赖 check 接口。它的逻辑特别简单根据文件 MD5 查找存储目录中是否已有对应文件。如果存在返回uploaded: true。同时它也负责返回已上传的分块列表以实现断点续传。[HttpPost(check)] public IActionResult Check([FromBody] CheckRequest request) { if (!Regex.IsMatch(request.FileMd5, ^[a-f0-9]{32}$)) { return BadRequest(new { error invalid fileMd5 }); } // 秒传判断当完整文件已经存在时直接告诉前端不用传了 var finalFile Directory.GetFiles(_uploadDir, ${request.FileMd5}_*) .FirstOrDefault(); if (finalFile ! null) { return Ok(new { uploaded true, uploadedChunks new Listint() }); } // 断点续传判断返回已上传的分块序号 var chunkFolder Path.Combine(_chunkDir, request.FileMd5); var uploadedChunks new Listint(); if (Directory.Exists(chunkFolder)) { uploadedChunks Directory.GetFiles(chunkFolder) .Select(Path.GetFileName) .Select(int.Parse) .OrderBy(x x) .ToList(); } return Ok(new { uploaded false, uploadedChunks }); } public class CheckRequest { public string FileName { get; set; } public long FileSize { get; set; } public string FileMd5 { get; set; } }这里有一个思路要明确秒传不是真的不传而是通过后端已有的文件副本直接返回成功。如果你的业务要求每个用户都有一份独立副本比如私有文件空间那就不能直接复用物理文件而应该复制一份或者做引用映射。我这里为了演示简单直接用“已存在完整文件”做秒传生产环境要结合自己的存储策略决定是硬链接、复制、还是记录引用关系。另外断点续传的“已上传分块列表”除了用文件系统扫描目录之外更稳妥的方式是维护一张数据库表记录 fileMd5、chunkIndex、上传时间、大小、状态。文件系统扫描在分块数少时没问题但分块很多比如 1000 块时每次 check 都扫目录会有一些 IO 开销。用数据库表还能顺便支持多用户隔离、过期清理等功能。4. 关键参数调优与常见问题排查4.1 分片大小、并发数与超时设置的权衡这块没有标准答案但有几个经验值可以聊。参数建议值原因分块大小5MB ~ 20MB太小则请求数多握手开销大太大则失去分块意义失败重传成本高上传并发数3 ~ 5太大容易打满带宽和后端连接也可能触发浏览器连接数限制太小则速度上不去单接口超时2分钟以上上传接口耗时取决于网络和分块大小超时设置太短会误杀正常请求哈希计算分片2MB计算 MD5 时每片 2MB 可以在进度和内存占用之间取得平衡比如一个 1GB 的文件用 5MB 分块一共 2048 块。并发数设为 3如果每块需要 0.5 秒上传完那么理论最快时间是2048 * 0.5 / 3 ≈ 341秒也就是 5 分多钟。如果单块上传速度不稳定重试机制就非常重要。前端对失败的块做重试时我推荐“连续失败 3 次就终止整个任务”避免网络故障时无限重试浪费资源。后端也要设置请求体大小限制。ASP.NET Core 默认的FormOptions.MultipartBodyLengthLimit是 128MB虽然分块大小只有 5MB但为了保险可以在Program.cs中调大builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 60 * 1024 * 1024; // 60MB });同时如果部署在 IIS 或 Nginx 后面这些反向代理也要调大maxAllowedContentLength/client_max_body_size。很多人后端代码没问题结果被代理层拦截表现为“大文件分块前几块正常传了几块后突然 404 或 413”。4.2 合并时容易踩的坑与解决方案分块序号排序错误。前面提过文件名的字符串排序与自然排序不同。OrderBy(x x)会把10排在2前面。解决方案是OrderBy(int.Parse)。这个问题最常见的表现是合并后的文件播放器打开卡顿、图片后半部分花屏、压缩包提示损坏。磁盘空间不足。分块上传期间临时目录占用的空间约等于原文件大小。合并时又需要一份完整文件空间。所以磁盘至少要预留 2 倍文件大小。否则合并到一半会报IOException。提前做磁盘空间检测是非常有必要的。临时文件残留。如果用户传了一半就关闭页面分块目录会一直留在服务器上。所以需要定期清理“上传时间超过 24 小时且未合并”的目录。清理逻辑可以写一个后台任务扫描分块根目录下每个子目录的最近写入时间超时直接删除。4.3 实操中遇到的典型问题速查表现象可能原因排查与解决办法上传几块后请求直接 413反代或后端请求体大小限制调大 Nginxclient_max_body_size、IISmaxAllowedContentLength、ASP.NET CoreMultipartBodyLengthLimit合并后的文件比原文件小分块数不足某个块上传失败但前端没有检查前端必须收集所有块的上传结果有失败块就重试后端 merge 校验分块数量合并后的文件内容乱序分块排序用了字符串排序改成int.Parse后排序check 接口返回秒传成功但文件不存在秒传判断只看了文件目录可能被清理任务删了秒传前检查文件是否真实存在或者用数据库记录文件状态大文件计算 MD5 时浏览器卡死主线程被同步哈希计算阻塞把哈希计算放到 Web Worker 或使用异步增量算法并发上传时分块覆盖前端意外启动了多个上传任务共用同一个 MD5 目录前端在上传开始前增加“任务锁”后端写入分块时用独占文件流合并接口抛出“文件正在被占用”同一个文件并发触发了多次 merge 请求合并时加锁或用FileMode.CreateNew重复请求返回已有文件路径在实际项目里我还遇到过一种糟糕情况用户上传的文件名带了特殊字符比如../evil.docx。在合并时直接拼路径会出大问题。我的做法是永远不信任文件名只用后端生成的 GUID 作为最终文件名如果业务需要保留原名可以单独存在数据库字段里并在下载时通过Content-Disposition转义输出。这是很多初学 C# 的开发者容易忽略的安全漏洞。5. 进阶扩展让这套方案更接近生产可用5.1 用数据库记录分块状态与文件元数据上面这套临时目录方案在 demo 阶段没问题但如果上传规模和用户量上来我建议把分块记录、文件记录放进数据库。表设计可以参考CREATE TABLE UploadSessions ( SessionId VARCHAR(64) PRIMARY KEY, -- fileMd5 userId 组合 FileName NVARCHAR(255) NOT NULL, FileSize BIGINT NOT NULL, FileMd5 VARCHAR(32) NOT NULL, TotalChunks INT NOT NULL, UploadedChunks TEXT NULL, -- JSON数组如 [0,1,2,3] Status INT NOT NULL, -- 0上传中 1已合并 2已取消 CreateTime DATETIME NOT NULL, UpdateTime DATETIME NOT NULL )有了这张表check 接口直接查库不再扫描目录上传每个分块后更新UploadedChunks。配合事务可以做到比较精确的并发控制。合并成功后再把完整文件路径、文件大小、MD5 写入 FileInfo 表。清理任务也更简单查找UpdateTime超过 N 小时的会话删除关联分块文件和记录。5.2 基于 Web Worker 的哈希计算与断点恢复计算大文件 MD5 时会卡 UI很多人就随便放个 loading 动画让用户等。实际上更好的方案是用 Web Worker。把 SparkMD5 脚本放进worker.js主线程通过postMessage传入 File 对象Worker 分片读取并计算最后把结果传回主线程。这样用户拖动页面、点击按钮都不会卡顿。断点刷新恢复的做法是刷新页面后先从后端拿到已上传分块列表再继续传缺失块。要做到这一点前端需要把文件对象的lastModified、size、name等信息缓存到localStorage或 IndexedDB。页面加载时检测到之前有未完成的上传任务提示用户“是否继续上次的上传”。如果文件已经被用户修改过lastModified或size变了就直接判定为不是同一个文件重新开始。5.3 异步合并与进度推送如果文件特别大比如 20GB合并操作也可能耗时较长不能一直让 HTTP 请求挂着。更稳妥的做法是merge 接口接到请求后只创建一个合并任务并写入队列立刻返回“合并中”。后台有一个 HostedService 或 Hangfire 任务去真正执行合并。前端轮询另一个查询接口或者使用 SignalR 推送“合并进度”。用 SignalR 推进度的体验最好。在UploadHub里定义一个Progress方法合并任务每写入一定字节就向客户端推送一次进度。用户能看到“合并中 45%”而不是干等。不过 SignalR 会增加复杂度如果不是特别大的文件同步合并几十秒内能完成就没必要上这一套。我个人在实际项目中的体会是上传模块没有“绝对正确”的实现只有“符合当前业务规模”的实现。分块大小、并发数、用文件系统还是数据库都要依据文件平均大小、用户量、服务器配置来调整。我最初做的版本用 1MB 分块文件一大会产生几千个请求后来改成 5MB整体稳定很多。所以建议你做出来之后先用自己的测试包压一压看看分块目录数量和合并不合并失败的概率再决定要不要调参。最后再分享一个我踩过的痛彻心扉的坑前端计算完 MD5 之后一定要校验 MD5 是否为标准的 32 位小写十六进制格式。如果某次后端正则在 debug 环境失效比如正斜杠被转义整个路径就暴露了。所以路径相关的输入永远要白名单校验而不是黑名单过滤。这算是做文件上传功能时最不能省的一条安全底线。这套代码虽然称不上完美但足以支撑大部分中小型系统的大文件上传需求。你把它按照自己的存储、并发、权限场景稍作调整就能直接上线用。如果后面遇到更复杂的场景比如分片加密、秒传与物理文件隔离、跨服务器合并欢迎再交流。
返回列表