
大文件上传这问题做Web开发的早晚都会撞上。我之前带过一个项目客户非要直接传几个GB的数据库备份文件结果每次传到一半就断用户那边急得跳脚我们这边查日志查到怀疑人生。后来彻底换成C#后端配合前端分片上传加断点续传的方案才算把这个坑填平。这篇文章就集中写写怎么在网页上用C#实现大文件分片上传并且支持续传。从设计思路、接口定义、前后端代码到参数配置、常见问题排查全是我自己踩过坑之后沉淀下来的东西。适合正在做文件上传功能、被大文件超时和断连折磨的C#后端开发者也适合前端同学想了解怎么配合后端做分片。不管你是用传统Web Forms还是.NET Core Web API思路都一样代码基本能平移。1. 项目背景与整体设计思路1.1 为什么大文件直接上传总是翻车很多人一开始都用最直接的方式前端拿整个文件塞进FormData后端一个IFormFile接住完事。小文件当然没问题一旦文件上了几百MB甚至几个GB问题就接踵而来。首先是HTTP连接的超时。默认情况下浏览器和服务器之间的连接不可能为一个请求保持几十分钟甚至几个小时。就算你把Web服务器的超时时间调到很大用户的网络环境也不可控WiFi抖动、手机切网、公司代理重启任何一个环节出问题整个上传就废了。其次是服务器内存压力。如果你在代码里直接把整个大文件读到内存或者框架层面缓冲了请求体多来几个并发用户服务器内存直接爆掉。再者就是用户体验问题传到99%断掉用户就得从头再来这在业务上是不可接受的。分片上传的思路说白了就是把一个大文件切成若干小块一块一块传。每一块都是独立的HTTP请求服务器收到后先存成临时文件。全部传完后后端再把这些临时文件按顺序合并成完整的文件。核心逻辑就这么简单但真正落地的时候细节决定成败。1.2 分片上传加续传的核心思路分片上传解决了“单次请求时间过长”和“服务器内存压力”两个问题但还没解决“断了就得重来”的问题续传才是体验的关键。续传的实现其实也不复杂每个分片在服务器上独立保存前端上传前先问一下服务端哪些分片已经传过了已存在的直接跳过只传缺失的。这里有个很容易忽略的点文件切块后你怎么知道哪些块已经传过所以必须为每个文件生成一个唯一标识。最常用的方式是对文件内容做哈希比如MD5或SHA1。前端计算整个文件的哈希作为文件标识传给后端。后端以这个标识为目录名存放所有分片文件。前端再次上传时请求这个标识对应的分片列表接口就能拿到已上传分片的下标。在实际项目中我为这个功能专门设计了几个接口查询文件是否已存在、查询已上传分片列表、上传单个分片、通知后端合并分片。整套流程跑通之后再大的文件都能传而且中途断了也不怕重新点一下上传按钮就能接着传用户根本感知不到断线的痛苦。2. 方案选型与接口设计2.1 关键技术点拆解分片、标识、合并先拆分核心概念搞清楚这三个东西整个方案就立住了一半。第一个是分片。前端用File对象的slice方法可以把大文件按指定的字节数切成多个Blob每个Blob就是一个分片。比如一个1GB的文件按5MB一块切就是2048个分片。切完之后前端逐个把分片通过XMLHttpRequest或fetch上传。这里记得给每个分片编号后端靠这个编号决定合并顺序。编号从0开始还是从1开始无所谓但一定要连续且唯一。第二个是文件标识。刚才说了要对文件内容做哈希。算整个大文件的MD5确实耗时1GB的文件可能要几十秒但这是值得的。有了这个标识就能实现“秒传”的变体——如果服务端发现这个标识的文件已经完全存在就直接告诉前端不用传了。在实际项目里我用过MD5也用过SHA1两者的区别不大。如果你的安全要求更高用SHA256但计算时间会更长。另外要注意的是千万不要用文件名加文件大小做标识同名同大小的不同文件内容完全可能不一样会出大问题。第三个是合并。这是后端最容易写bug的地方。合并的过程就是把临时目录下按编号排好序的分片文件按照顺序写入最终文件。合并不需要复杂的技术用FileStream逐个分片打开、拷贝到目标流即可。但这里有个性能陷阱如果你每写入一个分片就重新打开目标文件流会造成频繁的磁盘寻道合并几个GB的文件会慢得让人崩溃。正确做法是打开一个目标文件流然后循环打开每个分片文件流用CopyTo写入目标流全部写完后再关闭中途不要关闭目标流。2.2 接口定义与参数约定接口设计是整个功能的地基。我在项目里用的这套接口结构经过多个版本迭代稳定性和扩展性都不错分享出来供大家参考。第一个接口是预请求接口前端上传前先调用用来查询文件当前的状态。请求参数是文件的哈希值和文件名响应结果包含文件是否已存在、已上传的分片编号列表。如果文件已存在前端直接提示“秒传成功”如果不存在但已有部分分片前端就知道从哪个分片开始续传。第二个接口就是分片上传接口接收三个核心参数文件标识、分片序号、分片文件本身。后端收到请求后把分片文件保存到指定目录下。这里要注意如果同一个分片因为网络抖动被重复上传后一个请求应该覆盖前一个所以写入临时文件时直接用覆盖模式或者写之前先检查、存在就删除再写。第三个接口是合并接口前端传完所有分片后调用。后端收到合并请求后执行上面说的合并逻辑合并完成后把最终文件移到业务指定的目录同时清理临时分片文件。合并接口还需要返回最终文件的访问路径、大小等信息方便前端跳转下载。接口参数约定要统一我这里列一下我常用的字段命名identifier代表文件标识chunkIndex代表分片序号chunkSize代表分片大小后端校验用totalChunks代表总分片数fileName代表原始文件名。全部用大驼峰或小驼峰统一风格别一会儿下划线一会儿驼峰前后端联调的时候会把自己坑了。3. 服务端核心代码实现3.1 分片接收与临时文件管理服务端的核心是ChunkUpload接口的写法。这里我给出一个经过生产验证的简化版本框架用的ASP.NET Core Web API。[HttpPost(api/upload/chunk)] public async TaskIActionResult UploadChunk( [FromForm] string identifier, [FromForm] int chunkIndex, [FromForm] int totalChunks, [FromForm] string fileName, IFormFile file) { if (string.IsNullOrEmpty(identifier) || file null) return BadRequest(参数不完整); // 用文件标识作为分片存放目录名避免不同文件之间相互干扰 var chunkDir Path.Combine(_uploadPath, chunks, identifier); if (!Directory.Exists(chunkDir)) Directory.CreateDirectory(chunkDir); // 分片文件名统一为 index.part例如 0.part、1.part var chunkPath Path.Combine(chunkDir, ${chunkIndex}.part); // 保存分片覆盖写模式保证重复上传同一分片时后到者胜 using (var stream new FileStream(chunkPath, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { chunkIndex, uploaded true }); }这套代码看着简单里面有几个实际经验我要特别说一下。第一分片保存目录结构最好用“根目录/chunks/文件标识/序号.part”这种层级好处是清理和管理都很方便而且同一个文件的所有分片集中在一个目录里合并的时候直接枚举目录就能拿到全部分片。第二FileMode.Create是覆盖模式如果分片文件名已存在会自动覆盖。为什么不用CreateNew因为如果网络超时导致前端重发同一个分片CreateNew会直接抛异常前端还得重新处理很麻烦。覆盖模式则天然支持幂等。第三IFormFile接收分片时框架会把文件内容缓冲到临时文件不会直接压进内存所以大文件分片上传对服务器内存是友好的。3.2 合并逻辑与完整性校验合并接口是整个后端最容易出问题的地方我刚开始写的时候也翻过车。核心代码如下[HttpPost(api/upload/complete)] public async TaskIActionResult CompleteUpload( [FromForm] string identifier, [FromForm] string fileName) { var chunkDir Path.Combine(_uploadPath, chunks, identifier); if (!Directory.Exists(chunkDir)) return NotFound(未找到分片目录); var chunkFiles Directory.GetFiles(chunkDir, *.part) .Select(f new { Index int.Parse(Path.GetFileNameWithoutExtension(f)), Path f }) .OrderBy(x x.Index) .ToList(); if (chunkFiles.Count 0) return BadRequest(没有可合并的分片); // 避免文件名路径穿越 var safeFileName Path.GetFileName(fileName); var finalDir Path.Combine(_uploadPath, files); if (!Directory.Exists(finalDir)) Directory.CreateDirectory(finalDir); var finalPath Path.Combine(finalDir, ${identifier}_{safeFileName}); // 这里只是常用的判断逻辑分片数是否齐全 var totalChunks await GetTotalChunksFromStorage(identifier); if (chunkFiles.Count ! totalChunks) return BadRequest($分片不完整已上传 {chunkFiles.Count} / {totalChunks}); // 合并核心逻辑注意目标流只在最后释放 using (var targetStream new FileStream(finalPath, FileMode.Create, FileAccess.Write)) { foreach (var chunk in chunkFiles) { using (var sourceStream System.IO.File.OpenRead(chunk.Path)) { await sourceStream.CopyToAsync(targetStream); } } } // 合并完成后清理临时分片目录 Directory.Delete(chunkDir, true); var fileInfo new FileInfo(finalPath); return Ok(new { fileUrl $/files/{identifier}_{safeFileName}, fileSize fileInfo.Length }); }这段代码有几点值得拿出来讲。第一OrderBy(x x.Index)是关键合并顺序必须严格按分片序号排否则文件内容就乱套了。我在代码里用int.Parse(Path.GetFileNameWithoutExtension(f))把文件名转成int这样排序才能按数字大小而不是字符串大小排。如果你用字符串排序10.part会排在2.part前面合并出来的文件直接损坏。这个问题我真的见过。第二目标文件流不要放在循环内部反复打开否则几百个分片就会打开几百次目标流磁盘性能会明显下降。第三合并完成后一定要清理临时分片目录否则磁盘空间会被垃圾占满。我一般在天不亮跑一个定时任务把超过24小时没合并的临时目录清一遍。关于完整性校验我一般会在合并后在服务端对最终文件算一次哈希跟客户端上传前算好的哈希对比。如果不一致说明合并过程中出了问题直接删除文件并返回错误。这块代码在合并逻辑后面加几行即可using var md5 System.Security.Cryptography.MD5.Create(); using var finalFileStream System.IO.File.OpenRead(finalPath); var hash Convert.ToHexString(md5.ComputeHash(finalFileStream)).ToLower(); if (hash ! identifier) // 这里假设identifier就是MD5值 { System.IO.File.Delete(finalPath); return BadRequest(文件完整性校验失败); }3.3 续传状态查询接口续传接口的核心是返回“哪些分片已经上传过了”。实现不复杂枚举分片目录下的文件名即可。[HttpGet(api/upload/status)] public IActionResult GetUploadStatus(string identifier) { var chunkDir Path.Combine(_uploadPath, chunks, identifier); var uploadedChunks new Listint(); if (Directory.Exists(chunkDir)) { uploadedChunks Directory.GetFiles(chunkDir, *.part) .Select(f int.Parse(Path.GetFileNameWithoutExtension(f))) .ToList(); } // 检查是否已经有完整文件如果有就直接通知前端走秒传 var finalDir Path.Combine(_uploadPath, files); var existingFile Directory.GetFiles(finalDir, ${identifier}_*).FirstOrDefault(); if (existingFile ! null) { return Ok(new { uploaded true, fileUrl $/files/{Path.GetFileName(existingFile)}, uploadedChunks }); } return Ok(new { uploaded false, uploadedChunks }); }这里有一点需要注意续传状态下返回的uploadedChunks是已经保存的分片序号列表前端拿到之后通过Set集合快速判断哪些分片可以跳过。我见过有人用List.Contains判断分片多了就很慢用HashSetint或Set能快一个数量级。关于接口性能我也踩过坑。如果分片数量巨大比如几万片每次都枚举目录拿文件名IO开销不小。优化方式是缓存文件名列表或者在分片上传成功后维护一个内存中的状态。但对多数业务场景来说直接枚举目录毫秒级就能完成根本不是什么瓶颈不要一开始就过度设计。等真出了问题再加缓存也不迟。4. 前端配合与分片并发控制4.1 前端分片与上传实现后端接口就绪后前端要做的事情就清晰了。我用的纯前端JavaScript加HTML没有引额外的框架方便大家理解。核心步骤是选择文件后先计算MD5然后查询上传状态再根据已上传分片决定从哪里开始传最后逐个或并发上传分片。分片的核心代码非常简单const CHUNK_SIZE 5 * 1024 * 1024; // 5MB一个分片 let currentChunk 0; let uploadedChunks []; async function uploadFile(file) { // 计算文件MD5作为文件标识 const identifier await calculateFileMd5(file); // 查询上传状态获取已上传的分片列表 const statusRes await fetch(/api/upload/status?identifier${identifier}); const statusData await statusRes.json(); if (statusData.uploaded) { // 文件已存在直接走秒传逻辑 console.log(文件已存在秒传成功); return; } uploadedChunks new Set(statusData.uploadedChunks || []); const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 只上传缺失的分片 for (let i 0; i totalChunks; i) { if (uploadedChunks.has(i)) continue; const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); const formData new FormData(); formData.append(identifier, identifier); formData.append(chunkIndex, i); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); formData.append(file, blob, chunk-${i}); await fetch(/api/upload/chunk, { method: POST, body: formData }); // 更新进度 updateProgress(i 1, totalChunks); } // 所有分片上传完成通知后端合并 const completeForm new FormData(); completeForm.append(identifier, identifier); completeForm.append(fileName, file.name); await fetch(/api/upload/complete, { method: POST, body: completeForm }); console.log(上传成功); }这段代码里calculateFileMd5用了spark-md5这个库处理大文件时通过FileReader分批读取文件内容不断往MD5对象里追加数据最后得到整个文件的哈希值。这里要提醒一下计算大文件MD5是耗时的操作1GB的文件可能要几十秒页面会卡住。我实际项目里是用Web Worker做的把MD5计算放在后台线程界面照常响应配合进度条显示“正在计算文件指纹”体验会好很多。这个做法也是热搜词里提到的“前端使用worker上传大文件”的应用场景。4.2 并发数、分片大小怎么定串行上传分片虽然简单但速度太慢。一个200MB的文件按5MB分片就是40个请求串行跑起来要等很久。我在生产环境里加了并发控制限制同时最多3到5个上传请求。为什么不是越大越好因为同时发太多请求会占满浏览器的HTTP连接数还会给服务器带来并发压力网络拥塞时反而更慢。并发控制的实现可以用简单的计数器async function uploadWithConcurrency(chunks, limit 3) { const queue [...chunks]; const workers []; for (let i 0; i limit; i) { workers.push((async () { while (queue.length 0) { const task queue.shift(); await uploadSingleChunk(task); } })()); } await Promise.all(workers); }分片大小怎么定要根据网络和服务器情况权衡。分片太小的话比如1MB本来一个大文件就要切上千片请求数量太多HTTP握手开销会拖慢整体速度。分片太大比如50MB单次请求时间太长又回到超时问题上了。我实践中用的比较舒服的配置是5MB到10MB。公网上5MB比较稳内网或服务器在同一机房可以调到10MB甚至20MB。1GB的文件按5MB切就是200片并发3个线程速度中等的网络大约几分钟就传完了。这里还要注意一个细节前端计算totalChunks用Math.ceil(file.size / CHUNK_SIZE)但最后一个分片往往小于CHUNK_SIZE这没问题。关键是前端传给后端的totalChunks和后端实际保存的分片数要匹配我在合并接口里就校验了这个数不一致就直接报错防止文件不完整还被误判成功。5. 常见问题与排查思路5.1 高频问题速查表在实际开发中经常被问到的问题我整理成下面这张表都是我自己真实遇到过的问题现象可能原因解决方案合并后文件损坏大小对不上分片排序时用了字符串比较10.part排在2.part前面分片序号转成int再排序上传过程中断重新上传时部分分片丢失临时目录被服务器回收或进程重启时清理了调整临时目录回收策略或上传前先查状态接口大文件MD5计算非常慢浏览器卡死在主线程同步计算整个文件哈希改用Web Worker后台计算并发上传时服务器报内存不足分片没限制并发数几十个请求同时到达前端限制并发服务端也做限流或调整请求体大小限制上传完成后下载文件时名称乱码文件名含中文或特殊字符URL没有编码上传时记录原始文件名下载时用UrlEncode处理续传后文件内容顺序错乱前端跳过已上传分片后后续分片序号没有拼接正确统一用分片序号作为文件名跳过时不要改变序号逻辑5.2 现场排查实录与避坑经验我印象最深的一个线上事故是这样的某个内部系统升级后用户反馈上传超过2GB的文件总是失败而且失败点随机有时候在10%有时候在90%。查了很久最后发现是Web服务器对请求体大小默认限制是2GB超过这个值直接断开连接前端就表现为“传着传着断了”。这个问题的排查思路值得分享一下。第一步看后端日志会发现在某个时间点之后完全没有新的分片上传日志说明请求根本没到达后端是前面某个环节断掉了。第二步查服务器配置发现请求体大小限制被默认配置卡住。这里特别注意你以为改了web.config或appsettings.json里的MaxRequestBodySize就完事了不Nginx层面的client_max_body_size也要看而且如果前面还有网关或代理每一层都要改。我后来用了更稳的办法不依赖服务器的全局配置后端每个分片上传接口都显式设置[RequestSizeLimit(15 * 1024 * 1024)]15MB只允许分片大小级别的请求体从根上避免了“巨无霸请求”打到服务器上。另一个容易被忽略的坑是临时分片目录的权限问题。程序以服务账户运行时默认可能没有权限在某个路径下创建目录和写文件。本地调试好好的一部署到服务器就报401或500。排查时可以写个简单的接口测试目录读写权限或者干脆把分片目录和最终文件目录隔离配置到配置文件中部署时专门确认这两个目录的权限。这个看似不起眼的问题我在生产环境被坑过整整一个下午。前端也有个常见坑就是使用fetch上传分片时取消请求的问题。分片上传应该支持用户主动取消取消时AbortController要逐一终止未完成的请求否则浏览器会继续把队列里的分片传完跟用户看到的“已取消”不一致。还有刷新页面时正在上传的分片会被浏览器中断服务端已接收的分片保留下次上传时靠状态接口跳过这套续传逻辑要跟产品讲清楚续传不是“无缝续传”而是“重新点上传已传的不用再传”这样用户的预期管理就做好了。6. 后续扩展与工业场景思考6.1 秒传、误传识别与自适应分片分片上传框架搭好后往上加功能非常顺手。熵一度让我头疼的就是“秒传”这个功能其实原理很简单前端算完文件MD5后先调用状态接口。如果后端发现这个MD5对应的完整文件已经存在就直接返回上传成功前端连分片都不用传。这在多人上传相同大文件的场景下特别有用比如培训视频、安装包、固件包第一个人传完后后面的人秒传大大节省带宽和存储压力。还有一个场景容易忽略就是文件误传的识别。有时候用户传了文件发现传错了又在界面上传了同一个文件名的另一个版本。如果只用文件名做标识系统可能会傻傻地认为文件已存在。但我们的标识是文件内容哈希内容变了哈希就变了系统就认为这是一个新文件不会和旧文件混淆。同时还要考虑如果用户想删除旧文件释放空间接口里要能按标识清理对应目录下的分片文件和最终文件否则会积累大量垃圾数据。自适应分片大小是个更高级的话题。我见过有的实现会根据网络测速结果动态调整分片大小网络好时用10MB分片网络差时降到2MB分片。这个功能写起来不复杂但实用性很强尤其是面对移动端弱网环境。而且注意分片大小变化后已经上传的分片序号不受影响因为分片序号是以原始文件偏移计算的不依赖具体某一刀切在哪儿只要偏移量一致后端合并时就正确。用start i * CHUNK_SIZE这个公式算出来的偏移天然适应动态分片。6.2 与上位机、Web Worker等场景结合从热词里能看到很多人搜索“前端使用worker上传大文件”“C#上位机”这些其实跟分片上传都能串起来。比如上位机工业控制上位机通常用C#开发采集到的数据文件动辄数百MB想要上传到Web服务端做分析。如果上位机本身就是C#写的你可以直接用HttpClient在C#桌面端实现分片上传代码逻辑跟前端JavaScript几乎一一对应只是从浏览器换到了桌面端。客户端少了浏览器的CORS限制处理起来更顺滑。还有一种是上位机通过内嵌WebView或HTTP接口把数据给Web前端由浏览器负责上传。这种情况下大文件原始路径可能在上位机本地WebView里的JS拿不到本地文件系统路径就需要上位机通过本地HTTP服务把文件内容暴露给前端或者干脆让上位机自己完成上传。我见过不少做得好的方案是在C#上位机里直接用分片上传逻辑同时在界面上显示进度条后台服务端逻辑完全一致复用度很高。Web Worker在这里的作用就是让浏览器不卡顿。大文件上传时前端要执行计算MD5、切割文件、逐个上传、计算进度、处理重试等一系列操作这些如果全挤在主线程页面滚动都会卡。把耗时操作扔到Worker里配合postMessage把进度数据发回主线程更新UI整体体验能有明显提升。我在做某个数据管理平台时就用了这个方案用户上传几个GB的压缩包时照样能流畅操作其他页面后台默默传着传完弹个通知就完事。热词里还有“C#读power focus 6000扭矩值”“C#生成Word文档插入变量”这些工业办公场景本质上都是在处理某种“数据文件”当文件体积变大、数量变多时分片上传的通用方案就能直接平移到这些领域。所以说把分片上传这套底层能力做好价值会辐射到很多业务面上。写在最后的经验我做过的上传相关需求前后也有七八个了从最早的整文件上传到现在的分片续传最大的体会是分片上传这个方案本身不是技术难点难的是把各种边界条件处理干净。文件太大超时怎么办、断网了怎么办、用户传到一半关了浏览器怎么办、并发太高服务器受不了怎么办、文件名和路径规范怎么约定这些问题如果没有一开始就想清楚后面会不断返工。从经验角度看我建议你第一次实现就严格按“前端查状态、跳过分片、并发上传、后端合并校验、清理临时文件”这条主线先把全链路跑通再考虑并发控制、秒传、动态分片这些优化项。不要一上来就堆技术先把最基础的分片合并做对比什么都重要。最后分享一个小技巧分片上传完成后建议后端返回每个分片的接收耗时或服务端时间戳前端可以用来估算当前网络上传速度。我做过一个简单的网速计算每上传完一个分片就记录endTime - startTime再乘以分片大小得出实时速率显示在界面上。用户看着20MB/s在跑心里就不慌了。这个小功能虽然简单但客户满意度提升非常明显。