
做过网页上传功能的朋友应该都有过这种体验文件稍微大一点比如几百MB甚至几个GB用传统的input typefile加后端一把梭要么浏览器卡死要么后端报超时要么网络抽风一下整个文件白传。后来我接触了分片上传和断点续传才真正把大文件上传这个坑填平。这个方案的核心思路并不复杂把一个大文件按固定大小切成很多个小块逐个传到服务器服务器收齐后再拼回去。配合续传机制网络断开后不需要重头再来只补传丢失的分片就行。如果你正被大文件上传折磨这篇内容应该能给你一套直接能落地的C#实现思路前端用JavaScript配合后端用ASP.NET Core代码和踩坑记录都会贴出来。1. 分片上传和断点续传的整体设计思路1.1 为什么不能用传统方式上传大文件很多初接触的人会问为什么不能用传统的表单上传因为传统上传是把整个文件作为请求体一次性发送。浏览器需要把文件读入内存服务器也需要在内存里缓冲整个请求导致两个致命问题。第一个问题是内存。一个500MB的文件前端读入内存就占了500MB服务器端如果同步处理也得占用对应内存。如果同时有几十个人上传服务器内存直接爆炸。ASP.NET Core虽然对IFormFile有本地临时文件缓冲机制但大文件的请求体默认是不允许超过30MB的具体取决于配置而且这类上传对网络稳定性要求极高任何一点波动都会导致整个请求失败。第二个问题是用户体验。传统上传没有“进度”的概念要么一直转圈要么等很久才有反应。一旦失败用户只能重新选择文件再传一遍这种体验放在后台管理系统或者企业内部网盘上基本会被骂死。分片上传恰好把大文件拆成很多小请求每个请求只携带一小块数据。小请求对内存友好对超时不敏感即使某一个分片失败只需要重传这个分片而不影响其他已经传完的分片。这就是它能解决大文件上传的根本原因。1.2 分片上传和断点续传的核心原理分片上传的核心动作很简单前端拿到文件对象后利用File对象的slice方法把文件按指定字节数切段。比如一个100MB的文件按2MB切可以得到50个分片。然后对每个分片单独发起HTTP请求后端每收到一个分片就存成一个临时文件。等所有分片都到齐了后端按顺序把临时文件合并成最终文件。断点续传做的事情更细节。它需要解决一个关键问题怎么知道哪些分片已经传到服务器了最简单粗暴的做法是前端把所有分片一个个传过去遇到传失败的记录下来重试。但更好的做法是让前端先询问服务器“这个文件已经有哪些分片”然后跳过这些分片只传缺失的部分。要实现这个询问就得给文件一个唯一标识。这个标识不能是随机的因为用户刷新页面后还要能重新找到这个文件的已传分片列表。实践中常用文件内容的摘要哈希作为标识或者用一个前端生成的GUID配合文件名、大小、最后修改时间组成一个签名。这样即使页面刷新、网络断开重新上传同一个文件时只要标识一致就能快速确认进度。1.3 为什么选 C# ASP.NET Core 做后端市面上有很多现成的分片上传方案比如tus协议、各种云存储SDK但很多企业内部系统还是希望自己掌控上传服务尤其是后端起服务端和桌面端同时跑的场景。C#在.NET生态里做这种上传服务非常顺手。ASP.NET Core提供了完善的中间件、模型绑定和文件流处理能力写一个分片接收接口并不复杂。而且C#的FileStream合并分片效率很高用起来也直观。如果你的项目是搭建在Windows服务器上配合IIS部署处理这种文件操作逻辑非常稳定。相比Java或PHPC#在处理二进制IO、异步任务、并发控制方面写起来更省心这也是很多公司内部管理系统偏爱.NET技术栈的原因之一。2. 关键细节分片策略、标识设计与接口约定2.1 分片大小怎么定分片大小不是一个拍脑袋定的数字它直接影响上传的成功率和效率。太小了比如100KB分片数量会特别多HTTP请求开销太大前端并发能力再强也会被大量小请求拖垮。太大了又失去了分片的意义失去一个分片就需要重传很大的数据块。我的经验是常规大文件100MB2GB分片大小设为1MB5MB比较合适。以2MB为例1GB的文件只需要512个分片每个请求几秒钟内就能完成不会触发网关超时。如果网络环境比较差可以适当调小如果内网带宽充裕可以调到10MB。另外要考虑请求体限制如果服务器配置了单请求上限分片大小必须小于这个上限。下面是一个典型的分片参数设计参考文件大小范围建议分片大小说明100MB以下1MB上传稳定失败成本低100MB1GB2MB5MB兼顾速度与稳定性1GB以上5MB10MB大文件优先减少请求数还需要注意前端切分时最后一个分片通常会小于分片大小这是正常的。后端合并时不能假设每个分片大小相等只能根据分片索引顺序写最后以实际文件流结束为准。2.2 如何生成稳定的文件唯一标识断点续传的难点不是“传”而是“识别”。前后端必须对同一个文件使用相同的标识且这个标识在不同时间、不同会话中保持稳定。最简单的方案是使用文件的MD5值但大文件计算MD5会非常慢。我之前遇到过一个接近2GB的文件前端用MD5算一次在普通笔记本上跑了十几秒用户等得很恼火。实用的方案是前后端约定一个“快速指纹”取文件前2MB、中间2MB、最后2MB的内容分别计算MD5再拼上文件大小和文件名组成一个字符串再对这个字符串取一次MD5得到32位标识。这样计算量小冲突几率也很低能满足绝大多数场景。后端不要依赖这个标识作为文件最终名称因为可能会有重名或非法字符。正确做法是后端为每个上传任务创建一个目录目录名用这个标识临时分片放在目录里最终合并出来的文件名单独保存。这样既保证标识稳定又不会污染文件系统。2.3 上传接口的参数设计和校验机制分片上传接口的参数设计直接影响续传和合并的复杂度。建议至少包含以下字段fileIdentifier文件唯一标识fileName原始文件名合并时需要或者单独存起来chunkIndex当前分片的索引从0开始totalChunks总分片数chunk当前分片的文件内容除此之外可选字段是fileSize和chunkMd5用于校验分片是否完整。后端接收分片时不要只依赖chunkIndex还应该校验totalChunks的一致性。有些前端因为并发等原因可能先发送后几个分片这时后端不能合并只能先存临时文件。合并的触发最好由前端在所有分片都上传完成后调用一个独立的合并接口这样后端逻辑更清晰也便于统计进度。校验机制上每个分片应该带一个MD5值分片大小不大计算很快后端收到后重新计算分片MD5不匹配就直接返回错误让前端重传该分片。这一步看起来多此一举但实际中很管用。网络传输偶尔会丢字节或者代理服务器可能截断请求没有校验的话合并出来的文件可能损坏。3. 实操演示前端分片 C#后端接收与合并3.1 前端JS实现分片和断点续传前端部分我用原生JavaScript写不依赖第三方库方便你看清原理。如果你项目里用了Vue或React改成相应组件写法就行。核心思路先把文件切成预设大小的分片然后循环上传。上传前先询问后端哪些分片已经存在存在就跳过。上传过程用一个AbortController控制实现暂停功能。这是一个串行上传版本的示意async function uploadFile(file, chunkSize 2 * 1024 * 1024) { const fileIdentifier await computeQuickFingerprint(file); const totalChunks Math.ceil(file.size / chunkSize); // 先查询服务器已有的分片列表 const checkUrl /api/upload/check?fileIdentifier${fileIdentifier}; const checkResp await fetch(checkUrl); const checkData await checkResp.json(); const uploadedSet new Set(checkData.uploadedChunks); let uploadedBytes uploadedSet.size * chunkSize; for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) { continue; // 这个分片之前传过了跳过 } const start i * chunkSize; const end Math.min(file.size, start chunkSize); const chunk file.slice(start, end); const formData new FormData(); formData.append(fileIdentifier, fileIdentifier); formData.append(fileName, file.name); formData.append(chunkIndex, i); formData.append(totalChunks, totalChunks); formData.append(chunk, chunk, chunk-${i}); // 发送上传请求 await fetch(/api/upload/chunk, { method: POST, body: formData }); uploadedBytes chunk.size; updateProgress(uploadedBytes / file.size * 100); } // 所有分片传完通知后端合并 await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileIdentifier, fileName: file.name, totalChunks }) }); }注意几个细节computeQuickFingerprint需要异步因为要读取文件部分内容做MD5我建议用crypto.subtle或者SparkMD5库来实现。这个函数是整个续传机制的关键如果返回的标识不稳定后续所有逻辑都白搭。暂停功能可以通过一个全局变量控制循环比如设置一个isPaused标记循环里每传完一个分片就检查一次如果暂停就break。串行上传虽然慢一点但稳定。如果希望提高速度可以改成并发35个分片但需要处理分片乱序、进度统计和失败重试。我建议先把串行跑通再优化并发。3.2 后端C#实现分片接收、查询和合并后端我用ASP.NET Core Web API实现。先定义几个配置项临时目录、最终文件存储目录。public class UploadSettings { public string TempDirectory { get; set; } upload_temp; public string StorageDirectory { get; set; } upload_files; }接收分片的接口[HttpPost(upload/chunk)] public async TaskIActionResult UploadChunk( [FromForm] string fileIdentifier, [FromForm] string fileName, [FromForm] int chunkIndex, [FromForm] int totalChunks, IFormFile chunk) { if (string.IsNullOrEmpty(fileIdentifier) || chunk null) return BadRequest(参数不完整); // 一个文件对应一个临时目录 var chunkDir Path.Combine(_settings.TempDirectory, fileIdentifier); Directory.CreateDirectory(chunkDir); // 分片文件命名chunkIndex.part var chunkPath Path.Combine(chunkDir, ${chunkIndex}.part); // 如果文件已存在说明重复上传直接返回成功 if (System.IO.File.Exists(chunkPath)) return Ok(new { received chunkIndex }); // 保存分片 await using (var stream new FileStream(chunkPath, FileMode.Create)) { await chunk.CopyToAsync(stream); } // 每次上传都更新一下元数据记录总数 var metaPath Path.Combine(chunkDir, meta.json); await System.IO.File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(new { FileName fileName, TotalChunks totalChunks })); return Ok(new { received chunkIndex }); }查询已传分片列表[HttpGet(upload/check)] public IActionResult CheckUploadedChunks([FromQuery] string fileIdentifier) { var chunkDir Path.Combine(_settings.TempDirectory, fileIdentifier); if (!Directory.Exists(chunkDir)) return Ok(new { uploadedChunks new int[0] }); var uploadedChunks Directory.GetFiles(chunkDir, *.part) .Select(f Path.GetFileNameWithoutExtension(f)) .Select(int.Parse) .OrderBy(x x) .ToArray(); return Ok(new { uploadedChunks }); }合并接口[HttpPost(upload/merge)] public IActionResult MergeChunks([FromBody] MergeRequest request) { var chunkDir Path.Combine(_settings.TempDirectory, request.FileIdentifier); var metaPath Path.Combine(chunkDir, meta.json); if (!System.IO.File.Exists(metaPath)) return BadRequest(上传信息不存在); var meta JsonSerializer.DeserializeUploadMeta(await System.IO.File.ReadAllTextAsync(metaPath)); // 检查分片是否齐了 for (int i 0; i meta.TotalChunks; i) { var partPath Path.Combine(chunkDir, ${i}.part); if (!System.IO.File.Exists(partPath)) return BadRequest($分片 {i} 缺失); } var finalPath Path.Combine(_settings.StorageDirectory, ${Guid.NewGuid().ToString(N)}_{request.FileName}); Directory.CreateDirectory(_settings.StorageDirectory); // 按顺序合并 await using (var output new FileStream(finalPath, FileMode.Create)) { for (int i 0; i meta.TotalChunks; i) { var partPath Path.Combine(chunkDir, ${i}.part); await using var input System.IO.File.OpenRead(partPath); await input.CopyToAsync(output); } } // 清理临时目录 Directory.Delete(chunkDir, true); return Ok(new { filePath finalPath }); }合并的时候我特意用了await input.CopyToAsync(output)而不是直接读入一个byte数组避免在合并过程中产生大内存对象。这个细节对大文件很重要一个2GB的文件如果直接读进内存很容易让服务器GC卡顿甚至内存溢出。3.3 合并完成后的清理与异常补偿合并完成后一定要清理临时目录否则磁盘很快会被残留分片占满。但是如果合并失败比如校验分片缺失临时目录不该立刻删而应该保留等待前端重新上传缺失分片后再调合并接口。所以上面代码里只有确认分片齐全后才会执行Directory.Delete。还需要处理一个边界情况如果用户上传到一半关闭浏览器临时目录会一直留在服务器上。这时候需要一个后台清理任务比如定期扫描临时目录删除超过24小时没有更新的分片文件。下面是一个简单的清理思路public class TempCleanupService : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var cutoff DateTime.UtcNow.AddHours(-24); foreach (var dir in Directory.EnumerateDirectories(upload_temp)) { if (Directory.GetLastWriteTimeUtc(dir) cutoff) { Directory.Delete(dir, true); } } await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } }这个后台服务虽然简单但很实用。没有它你上线半个月后磁盘会莫名其妙多出一堆几十GB的临时文件尤其是用户总上传失败的情况。4. 常见问题与避坑实录4.1 上传超时和请求体大小的坑分片上传后每个请求都不大但有些服务器配置仍然会导致失败。最常见的情况是在Kestrel或IIS上默认请求体大小限制是30MB如果分片大小超过这个限制接口会返回413。你可以给上传接口加特性[RequestSizeLimit(20 * 1024 * 1024)] [HttpPost(upload/chunk)] public async TaskIActionResult UploadChunk(...)但更推荐直接在Startup或Program.cs里统一配置builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 20 * 1024 * 1024; });前端也要注意代理层限制。如果你用Nginx代理默认client_max_body_size是1MB分片请求一多很容易被拦截。必须把这个值调大或者设置成0不限制。我之前排查过一次诡异问题本地调试正常部署到Nginx后上传超过2MB就失败就是这个原因。4.2 分片乱序与并发冲突处理分片上传的请求是无序的并发上传时第3个分片可能比第1个分片先到达。所以后端绝不能依赖分片到达顺序来合并只能靠索引号。另外如果前端并发传同一个分片可能两个请求同时写同一个.part文件导致文件内容损坏。解决并发冲突的方法很简单分片文件名用索引命名写入时加一个文件锁或者如果文件已存在就直接跳过保留第一次写入的结果。我倾向于“已存在跳过”的策略因为它天然支持重复上传还能避免并发写问题。但要注意如果某次写入只写了一半文件虽然存在但内容是坏的这时跳过就会出问题。所以在跳过之前最好校验分片大小是否等于预期大小或者用分片MD5校验。4.3 断点续传失效和临时文件清理策略有些网友反馈断点续传不管用排查下来大多是文件唯一标识不稳定。前端计算指纹时如果使用了文件最后修改时间而用户修改文件后修改时间变了标识也变了服务器就认为这是一个新文件之前的进度全部无效。解决办法是标识尽量只依赖文件内容不要依赖元数据。如果你为了速度用了“快速指纹”也要保证它的计算逻辑稳定。比如固定取前中后三个2MB片段加上文件大小做MD5文件内容变化后MD5会变但修改时间变化不会影响指纹这样续传就能生效。另外上面提到的24小时清理服务虽然避免磁盘浪费但也会把用户的续传进度清掉。所以清理时间要设置得合理建议按文件大小分配过期时间大文件给更长有效期。4.4 服务端的校验和安全性细节上传接口非常容易被恶意利用。攻击者可以构造任意fileIdentifier和分片塞满服务器磁盘。所以在生产环境必须做几件事。第一限制上传者身份。接口要加认证授权至少得验证登录状态。第二限制分片大小和总大小。后端检查每个分片的Content-Length是否超过了约定值超了就拒绝。第三合并前检查最终文件大小是否合理比如超过配置的1GB就拒绝合并。第四在合并后的文件保存到正式目录之前最好做文件类型检测避免用户传一个伪装成图片的可执行文件。这些安全措施不会让代码复杂多少但能避免很多线上事故。我见过一个内部管理系统没做任何限制结果被刷了几百GB垃圾文件直接把服务器磁盘打满业务全部停摆。另外还要说一下跨域问题。如果前端页面和后端接口不在同一个域名下需要在后端配置CORS允许你的前端域名访问。fetch默认会发起预检请求所以要允许POST、GET方法和Content-Type: multipart/form-data等头。回到开头说的那个痛点我现在的做法基本固定了前端用快速指纹算标识分片大小根据网络动态调整后端用异步流保存分片最后按序合并。这个方案支持最多几个GB的文件上传测试过在弱网环境下断线重连后进度能准确续上不用从头再来。如果你也要做类似功能建议先把最简单的串行版本跑通再加并发和断点续传每一步验证后再往下走。分片上传不是代码多复杂而是边界情况太多真正上手踩过一遍就会觉得大文件其实也没那么难处理。