ARTICLE DETAIL

资讯详情

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

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

C#大文件上传方案:分块上传+秒传与断点续传实现详解 大文件上传是个老话题但直到今天我依然会看到有人在C#技术群里提问网页传几百MB的文件为什么总是超时传到一半断网就得重来多个用户传同一个文件服务端收了一遍又一遍我自己做过的文件协作系统里第一版也是普通POST整包上传被1GB级别的工程文件和视频折磨了几周之后我把方案重构成了“分块上传秒传校验”才算真正把这套东西从能用变成好用。这篇文章就把完整思路、C#后端实现、前端配合代码一起拆开讲希望能给正在做文件上传模块的同行一些参考。1. 为什么大文件上传要用分块秒传的组合拳1.1 整包上传的三个致命问题先说说我踩过的最原始方案。浏览器拿到File对象直接用HTTP body把整个文件塞进一次请求里发到服务端。文件小的时候一切正常一旦上了几百MB问题就集中爆发。第一是单请求耗时过长。一次请求可能持续几分钟甚至更久中间任意一次网络抖动、代理超时、用户切网整个请求就失败了而且没有下次——用户只能重新选文件从头传。我遇到过用户传一个1GB的录像卡在98%的时候公司网络闪断那种崩溃我隔着屏幕都能感受到。第二是服务端内存和临时文件的压力。ASP.NET Core虽然默认会把multipart请求写入临时文件但整包上传意味着一个请求全程占用连接、占用文件句柄并发一上来很容易把Kestrel的连接池打穿。第三是完全没有复用能力。两个人传同一个ISO镜像、同一个安装包服务端存了两份一模一样的文件。这不是存储成本的问题是用户花了两倍的等待时间做了一件完全可以避免的事。1.2 分块上传的拆解思路分块上传的做法很直白在浏览器端把大文件用File.slice()切成N个几MB的小分片每个分片独立发一次HTTP请求全部发送成功之后再由服务端把分片按顺序合并成完整文件。这样做的好处是分散了风险和成本。单个请求只有几MB失败重传的范围从“整个文件”缩小到“一个分片”网络恢复后从中断的分片继续传就行。服务端接收小分片的内存开销也完全可控配合并发数限制服务器压力比整包上传小一个量级。还有一个容易被忽略的好处分片之后可以拿到细粒度的上传进度比如“第7/120个分片”而不是一个模糊不清的百分比大圆圈。1.3 秒传的本质用哈希代替流量秒传这个词听起来很有魔法实际上原理非常简单。客户端先计算出整个文件内容的哈希值比如MD5把这个哈希发给服务端。服务端在库里查一下如果相同哈希的文件已经存在直接给当前用户绑定一条文件记录就行文件内容根本不需要再传。用户看到的反馈就是——进度条瞬间到100%。分成块上传是这个机制的物理层秒传是这个机制的逻辑层两者合在一起才是完整方案。处理流程是先算哈希、先查秒传没有命中才走分块上传全部传完后服务端合并并再次校验哈希把文件记录和哈希登记入库为后面所有上传同一个文件的用户提供秒传服务。2. 方案设计三个接口一张表把流程理清楚2.1 一次完整上传的交互流程动手写代码之前先把流程画清楚。我最终敲定的交互时序是这样的用户选择文件前端按固定大小比如5MB把文件切成多个分片。在切分的同时计算整个文件的MD5这个过程会读一遍全部文件内容。调用check接口带上hash、文件名、文件大小。服务端查秒传表如果命中直接返回existstrue前端提示“秒传成功”结束。如果未命中服务端返回existsfalse同时返回该上传任务以hash为identifier已经上传过哪些分片序号。前端过滤掉已上传的分片用并发控制逐个上传剩余分片。全部成功后前端调用merge接口服务端把临时目录里的分片按序号合并、校验哈希、落盘正式目录、写入秒传记录。服务端清理临时分片返回文件访问地址。这套流程把“秒传、分块、断点续传”三种能力全部覆盖了。断点续传本质上就是第5步查询已上传分片前端跳过这些分片继续传剩下的。2.2 接口定义与参数设计接口一共有三个全部走JSON返回统一结构。我用表格列一下后续代码都是围绕这三组接口展开的接口方法作用核心参数/api/upload/checkPOST秒传检查查询分片进度hash, fileName, totalSize/api/upload/chunkPOST上传单个分片file(表单), identifier, chunkIndex, totalChunks/api/upload/mergePOST合并分片并落盘identifier, fileName, totalChunks, hash这里有一个关键设计identifier直接用文件的MD5哈希值。因为同一份文件的哈希一定相同所以断点续传天然具有跨设备、跨会话的恢复能力——用户今天传了一半明天继续传只要前端还保留这个hash就可以询问服务端接续进度。另外文件名不能作为identifier因为重命名文件之后内容还是一样的用文件名会导致重复分块目录。2.3 存储结构与临时目录设计数据库只需要两张表。第一张是秒传记录表FileRecord用来支撑秒传查询public class FileRecord { public int Id { get; set; } public string Hash { get; set; } // 文件内容MD5 public string FileName { get; set; } public string StoredPath { get; set; } // 物理文件路径 public long FileSize { get; set; } public DateTime CreatedAt { get; set; } }第二张是分片记录表UploadRecord记录每个上传会话里已上传的分片序号public class UploadRecord { public int Id { get; set; } public string Identifier { get; set; } // 对应MD5 public int ChunkIndex { get; set; } // 分片序号从0开始 public long ChunkSize { get; set; } public DateTime UploadedAt { get; set; } }服务端磁盘分两个目录temp_upload/{identifier}/存分片uploads/存最终合并完成的文件。所有分片文件名就是序号加.part后缀例如0.part、1.part。这样设计的好处是写入天然无冲突多个并发请求写不同文件不需要加锁。3. C#后端代码逐段拆解检查、分块接收、合并校验3.1 项目基础配置我的示例基于ASP.NET Core 8 Web API先解决一个拦路虎默认的请求体大小限制。比如Kestrel默认MaxRequestBodySize是30MB左右不改的话4个分片就超了。在Program.cs里把限制调大builder.WebHost.ConfigureKestrel(options { // 整包上限放宽到1GB兜底某些绕过前端直接打接口的场景 options.Limits.MaxRequestBodySize 1024L * 1024 * 1024; }); builder.Services.ConfigureFormOptions(options { // 单个分片上限5MB分片放宽到8MB足够 options.MultipartBodyLengthLimit 8L * 1024 * 1024; });同时注册一下静态文件中间件让合并后的文件可以直接通过URL访问并限制上传目录大小防止恶意填充磁盘builder.Services.AddDbContextAppDbContext(); var app builder.Build(); app.UseStaticFiles(new StaticFileOptions { FileProvider new PhysicalFileProvider(uploadRoot), RequestPath /uploads });注意如果服务前面还有Nginx等反向代理请求体大小限制还要在代理层同步放开否则会先被代理拦截返回413。这个坑我在第5节详细讲。3.2 秒传检查接口查询与断点续传二合一这个接口看起来简单但信息量不小。一次请求同时完成两件事判断能否秒传以及返回已上传分片列表供断点续传使用。[HttpPost(check)] public async TaskIActionResult CheckUpload([FromBody] CheckUploadRequest request) { // 同时比对hash和文件大小双重条件降低哈希碰撞带来的误判风险 var exists await _db.FileRecords.AnyAsync(f f.Hash request.Hash f.FileSize request.TotalSize); if (exists) { return Ok(new { exists true }); } var uploadedChunks await _db.UploadRecords .Where(u u.Identifier request.Hash) .OrderBy(u u.ChunkIndex) .Select(u u.ChunkIndex) .ToListAsync(); return Ok(new { exists false, identifier request.Hash, uploadedChunks }); }判断秒传命中必须同时校验哈希和文件大小。虽然MD5碰撞的概率极低但加上文件大小条件能过滤掉99.99%的误判成本几乎为零这个习惯建议保持。已上传分片查询走UploadRecord表前端拿到这个列表就知道该跳过哪些分片。有个细节分片记录里不需要文件内容只存序号因为真正的二进制内容已经落在临时目录里了。这意味着数据库里存的始终是状态元数据不会造成无意义的数据库膨胀。3.3 分块上传接口写入临时分片分块接口接收的是multipart表单IFormFile从请求表单里取。代码不复杂但有几个细节值得注意[RequestSizeLimit(8L * 1024 * 1024)] [HttpPost(chunk)] public async TaskIActionResult UploadChunk([FromForm] ChunkUploadRequest request) { var file Request.Form.Files.FirstOrDefault(); if (file null || file.Length 0) { return BadRequest(new { message 分片内容为空 }); } var tempDir Path.Combine(_env.ContentRootPath, temp_upload, request.Identifier); Directory.CreateDirectory(tempDir); // 分片文件固定命名为 序号.part var chunkPath Path.Combine(tempDir, ${request.ChunkIndex}.part); // FileMode.Create 覆盖写断点续传重传的分片不会叠加出错误内容 await using var stream new FileStream(chunkPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024); await file.CopyToAsync(stream); // 不存在才新增存在则更新时间戳但不重复插记录 var record await _db.UploadRecords.FirstOrDefaultAsync(u u.Identifier request.Identifier u.ChunkIndex request.ChunkIndex); if (record null) { _db.UploadRecords.Add(new UploadRecord { Identifier request.Identifier, ChunkIndex request.ChunkIndex, ChunkSize file.Length, UploadedAt DateTime.UtcNow }); } else { record.ChunkSize file.Length; record.UploadedAt DateTime.UtcNow; } await _db.SaveChangesAsync(); return Ok(new { success true, chunkIndex request.ChunkIndex }); }这里两个细节我特别说一下。第一FileMode.Create是覆盖写正常上传时每个分片只会写一次但断点重传时同一个序号的分片可能被上传两次覆盖写保证最终落盘的是最新一次内容不会因为重传产生脏数据。第二数据库分片记录采用“存在则更新、不存在则新增”的幂等写法配合覆盖写这个接口就是个天然幂等操作前端即便重复调用也不会造成数据错乱。分片大小选择上我建议5MB是个平衡点。太小比如1MB一个1GB文件就要发1024个请求请求往返的开销占比太高太大比如50MB又回到了整包上传的部分问题。5-10MB是比较理想的区间我在生产环境用的5MB。3.4 合并接口流式合并、哈希校验、清理合并是后端最重的操作也是最容易写出性能问题的部分。我的实现要点是全程流式读写绝不把分片一次性读进内存[HttpPost(merge)] public async TaskIActionResult MergeChunks([FromBody] MergeRequest request) { var tempDir Path.Combine(_env.ContentRootPath, temp_upload, request.Identifier); // 1. 校验分片完整性 for (var i 0; i request.TotalChunks; i) { if (!System.IO.File.Exists(Path.Combine(tempDir, ${i}.part))) { return BadRequest(new { message $分片 {i} 缺失请检查上传状态 }); } } // 2. 按序流式合并缓冲4MB var uploadSubDir Path.Combine(_env.ContentRootPath, uploads, request.Identifier); Directory.CreateDirectory(uploadSubDir); var finalPath Path.Combine(uploadSubDir, request.FileName); await using (var output new FileStream(finalPath, FileMode.Create, FileAccess.Write, FileShare.None, 4 * 1024 * 1024)) { for (var i 0; i request.TotalChunks; i) { var chunkPath Path.Combine(tempDir, ${i}.part); await using var input new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4 * 1024 * 1024); await input.CopyToAsync(output); } await output.FlushAsync(); } // 3. 合并完成后校验整体哈希防止分片顺序错乱或内容被篡改 var mergedHash await ComputeFileHashAsync(finalPath); if (!string.Equals(mergedHash, request.Hash, StringComparison.OrdinalIgnoreCase)) { System.IO.File.Delete(finalPath); return BadRequest(new { message 文件哈希校验失败请重新上传 }); } // 4. 写入秒传记录供后续用户秒传 var record new FileRecord { Hash mergedHash, FileName request.FileName, StoredPath finalPath, FileSize new FileInfo(finalPath).Length, CreatedAt DateTime.UtcNow }; _db.FileRecords.Add(record); await _db.SaveChangesAsync(); // 5. 清理临时目录 Directory.Delete(tempDir, true); return Ok(new { success true, url $/uploads/{request.Identifier}/{Uri.EscapeDataString(request.FileName)} }); }合并的IO模式是“顺序读N个文件顺序写一个文件”这是机械硬盘和SSD都最友好的访问模式。在机械硬盘上顺序读写能达到百MB每秒比随机读写快十几倍所以这里的关键就是保持纯顺序流。哈希校验这段务必要保留。前端在切分时计算的哈希和服务器合并后重新计算的哈希比对能发现两类问题一是有分片因为网络问题被写入错误内容但前端没察觉二是某个分片序号缺失导致静默失败。校验不通过时我选择直接删除合并的临时文件让用户重新走上传流程虽然体验差一点但能确保库里落的是正确数据。3.5 临时分片清理机制用户可能上传到一半关掉页面、断网、或者干脆不传了临时目录里的分片就成了垃圾文件。我写了一个简单的后台服务定时清理逻辑不复杂但很实用public class TempUploadCleaner : BackgroundService { private readonly string _tempRoot; private readonly TimeSpan _expire TimeSpan.FromDays(3); public TempUploadCleaner(string tempRoot) { _tempRoot tempRoot; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromHours(6)); while (await timer.WaitForNextTickAsync(stoppingToken)) { if (!Directory.Exists(_tempRoot)) continue; var dirs Directory.GetDirectories(_tempRoot); foreach (var dir in dirs) { var lastWrite Directory.GetLastWriteTimeUtc(dir); if (DateTime.UtcNow - lastWrite _expire) { Directory.Delete(dir, true); } } } } }每隔6小时扫描一次删除3天以上没有更新的会话目录。“3天”这个阈值要按业务场景调如果用户经常隔几天才回来继续传可以放宽到7天如果都是当日传完可以缩短到24小时关键是别误删还在进行的上传任务。4. 前端实现文件切片、哈希计算与并发上传控制4.1 文件切分与哈希计算前端用File.slice()切分非常直接。切分是零拷贝的只是生成对文件某一段的引用不会真正复制数据所以即使文件有1GB瞬间就能完成切分const CHUNK_SIZE 5 * 1024 * 1024; function splitFile(file) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index: index, blob: file.slice(start, end), size: end - start }); start end; } return chunks; }哈希计算推荐用spark-md5这个库。注意一个关键点计算哈希本身需要读取一遍完整的文件内容如果文件有几GB这个计算会花掉十几秒甚至更久而且它发生在主线程上页面会卡住。所以有两个选择用Web Worker把计算放到后台线程或者至少先在界面上给一个“正在计算文件特征值”的提示避免用户以为程序死了。我在生产环境用的是前者async function computeHash(chunks) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); let i 0; function next() { if (i chunks.length) { resolve(spark.end()); return; } const reader new FileReader(); reader.onload e { spark.append(e.target.result); i; next(); }; reader.onerror reject; reader.readAsArrayBuffer(chunks[i].blob); } next(); }); }常规做法是边切分边算哈希这样文件内容只会被读取一遍哈希算完分片列表也准备好了时间成本没有叠加。如果你用Web Worker可以把“切分哈希查询状态”整个交互链路都塞进worker里主线程只负责驱动上传和渲染进度。4.2 并发控制限制请求数而不是一把梭如果一口气把100个分片全部用Promise.all发出去浏览器瞬间建立上百个连接不仅浏览器受不了服务端也会被打满。我用一个固定并发池来控制同时活跃的请求数稳定在3-5个async function uploadWithLimit(items, limit, uploadFn, onProgress) { const queue [...items]; let active 0; let done 0; const total items.length; return new Promise((resolve, reject) { function next() { if (queue.length 0) { if (active 0) resolve(); return; } const item queue.shift(); active; uploadFn(item) .then(() { active--; done; onProgress onProgress(done, total); next(); }) .catch(err { reject(err); }); } for (let i 0; i Math.min(limit, total); i) { next(); } }); }每次完成一个分片就调用onProgress回调进度按分片维度计算。如果需要按字节显示把每个分片的大小累加起来再除以总大小即可const percent Math.round((uploadedBytes / file.size) * 100);还有一个改善体验的小技巧上传分片时用的是XMLHttpRequest或fetch把每个分片的上传进度也反馈给用户。不过分片只有5MB上传一般在几百毫秒内完成分片级进度意义不大按分片数量刷新就够了接口做轻比较好。4.3 主流程编排从选文件到最终URL把这些函数串起来就是完整的上传触发逻辑async function handleFileSelect(file) { // 1. 切分计算哈希 const chunks splitFile(file); const hash await computeHash(chunks); const payload { hash, fileName: file.name, totalSize: file.size }; // 2. 秒传检查 const check await apiCheck(payload); if (check.exists) { showResult(秒传成功文件已存在); return; } // 3. 过滤已上传分片支持断点续传 const uploadedSet new Set(check.uploadedChunks); const pending chunks.filter(c !uploadedSet.has(c.index)); // 4. 并发上传 try { await uploadWithLimit( pending.map(c () apiUploadChunk(c.blob, hash, c.index, chunks.length)), 3, (done, total) updateProgress(done, total) ); } catch (err) { showError(上传中断可重新选择文件续传); return; } // 5. 合并 const mergeResult await apiMerge({ identifier: hash, fileName: file.name, totalChunks: chunks.length, hash }); showResult(mergeResult.url); }这个编排逻辑里秒传检查接口同时充当了断点续传的查询入口所以代码里没有额外再写一个“查询进度”的请求这是我觉得这个方案比较清爽的地方。4.4 失败重试与断点续传的配合上传中某个分片失败最直观的反映是当前分片重试两次比如对同一个分片做最多3次尝试间隔递增。如果重试还失败就中止整体上传让用户选择重新传。用户重选同一个文件时前端再次计算哈希、调用检查接口服务端返回的已上传分片列表会让前端直接跳过已经传成功的那部分这就是断点续传的完整闭环。这里有个前端细节用户中断后已经上传成功的分片对应的临时文件和数据库记录都会保留下次续传时这些分片不会被重复上传所以“重新上传”实际上往往是秒传少量分片补齐体验反而比普通上传快很多。5. 上线之后的坑配置、清理与调优记录5.1 常见问题速查表方案上线后一定会遇到各种各样的问题。我把踩过的典型问题整理成一张表方便直接对照排查现象根源解决方案上传几MB就返回413Kestrel或代理请求体限制修改Kestrel MaxRequestBodySize反向代理加client_max_body_size分片传完但合并失败前端实际发送的分片序号与totalChunks不符后端严格校验每个序号文件存在并检查文件大小一致性秒传偶发误判只用hash匹配未比对文件大小查询条件同时带hash和totalSize缺一不可临时目录磁盘爆满用户中断后分片残留后台定时清理兜底同时合并成功后立即删除目录大并发上传卡顿前端无并发限制请求风暴前端并发池限制3-5个服务端按需做信号量限流合并时内存暴涨把所有分片读入List 再拼接一律用FileStream流式合并禁止整块读入内存5.2 反向代理的超时与大小限制如果服务部署在Nginx后面有两处配置必须同步调整。第一是请求体大小限制Nginx默认client_max_body_size是1MB不改成大值的话后端Kestrel配置得再大也白搭# 全站放开大小限制由后端自行控制 client_max_body_size 0; # 上传和合并接口请求时间长代理超时放宽 proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 60s;第二是超时时间。合并接口在超大文件场景下可能会执行几十秒默认的60秒proxy_read_timeout很容易中途断掉。我建议直接放到300秒因为分块上传接口体量很小、耗时短真正耗时的只有合并和极个别大分片重传场景放宽不会造成明显负面影响。5.3 哈希计算的性能权衡对于2GB以上的文件前端完整读取一遍算MD5可能要30秒以上加上上传时间用户的等待感会很明显。业界有两种做法完整哈希或者采样哈希。采样哈希是只取文件的首段、中段、尾段计算速度快很多但这样做秒传的可靠性会打折扣——两个内容不同的文件可能在采样段相同的情况下被判为相同。我的取舍是文件小于500MB用完整哈希保证可靠性超过500MB的业务场景先允许采样哈希快速秒传但服务端合并校验始终用完整哈希兜底。如果校验失败返回错误让用户重新上传相当于用极低的概率换取了绝大多数场景的速度。5.4 并发上传的服务端限流即使前端限制了并发数依然有绕过前端直接打接口的可能。生产环境我加了一个简单的信号量限流控制全局同时进行的分块写入数量public class UploadSemaphore { private readonly SemaphoreSlim _semaphore new(4); public async TaskIDisposable AcquireAsync() { await _semaphore.WaitAsync(); return new Releaser(_semaphore); } } // 在chunk接口中使用 using (await _uploadSemaphore.AcquireAsync()) { await using var stream new FileStream(...); await file.CopyToAsync(stream); }这里用信号量限制的是“同时打开临时文件写入”的数量本质上是在限制IO并发。如果真遇到恶意并发上传更完整的方案是做用户维度的限流和临时文件配额但这个属于安全范畴篇幅有限不展开。5.5 文件重名与目录结构策略合并后的文件如果直接按用户原始文件名保存会出现重名覆盖问题。我的做法是uploads/{identifier}/{原始文件名}因为identifier是内容哈希同一个文件必然落在同一个目录整个目录级别的文件在逻辑上就是一份天然去重。不同用户上传同名但内容不同的文件不同哈希对应不同目录也不会冲突。5.6 数据库分片记录的清理临时目录清理的同时数据库里的UploadRecord也要清理。我是在后台清理任务里先查过期目录再批量删除对应identifier的分片记录保持表体积可控。毕竟大文件上传场景下一个失败的会话可能残留上百条记录积少成多也会拖慢查询。6. 最后聊几个我反复强调的设计细节分块大小、并发数、超时时间这些参数看似简单实际上每个都影响P99体验。我自己实测下来分块5MB、前端并发3、后端信号量4、代理超时300秒是一个在资源和体验之间比较舒服的配置。如果文件类型主要是大型视频分块可以放到10MB减少请求数量反而更稳。还有一个容易被忽略的点是统一的错误返回结构。三个接口都返回{ success, message, data }这种结构前端才能统一处理错误弹窗。我见过团队因为后端返回风格不统一前端写了一大堆零散判断维护成本极高。接口设计时就把错误码和错误信息规范好后面联调会省很多事。哈希计算建议放在Web Worker里做这个小改动对超大文件的体验提升非常明显。主线程只负责把分片交给上传池进度条依然流畅滚动用户不会觉得页面卡死。我个人在这套方案里最大的体会是分块上传真正解决的其实不是“快”而是“可控”——让上传过程可以被跟踪、被恢复、被重试让每一次失败都有一个清晰的下一步。而秒传则是把去重在产品层面的表达落地让用户感知到“这个系统是有记忆的”。这两个能力组合起来大文件上传从“听天由命”变成了一个可以预判、可以运维的常规功能。最后再分享一个后续扩展方向这套接口天然可以对接OSS等对象存储。只需要把后端合并逻辑替换成按分片序号上传到OSS的Multipart Upload接口秒传逻辑换成检查OSS里是否存在相同哈希的对象前端代码几乎不用改。也就是说这篇文章里的思路完全不绑定本地磁盘未来从自建存储平滑迁移到云存储时架构可以保持不变。
返回列表