大文件上传这问题,做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 Task<IActionResult> 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 Task<IActionResult> 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 List<int>(); 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判断,分片多了就很慢,用HashSet<int>或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在跑,心里就不慌了。这个小功能虽然简单,但客户满意度提升非常明显。