做后台系统的这些年,我接手过不少“文件上传”相关的需求,其中最让人头疼的就是大附件。你想想,一个 2GB 的压缩包,让用户挂在网页上传,传了半小时断了,又得重来;服务端那边也好不到哪去,传统的一体化上传,要么直接把内存撑爆,要么被网关拦截,要么请求超时。这还只是“传得过去”的问题,如果文件之前在系统里已经有一份一模一样的,用户还得傻乎乎再传一遍,既浪费带宽又浪费等待时间——这其实就是“分段上传(分片上传)”和“秒传”这两个技术要解决的核心痛点。
这篇文章要聊的,就是怎么用 C# 作为后端语言,在网页端实现大附件的分段上传与秒传。前端用 JavaScript 做文件的切片和哈希计算,后端用 ASP.NET Core Web API 接收分片、校验秒传、合并文件。内容适合正在做网盘、文件管理系统、内部办公系统,或者是对接第三方上传场景的开发者参考,哪怕你是第一次接触这块,按文章里的思路和代码把工程建起来,也能一步步跑通整个流程——这是我在真实项目中验证过的方案,不是理论推演。
1. 从需求出发:为什么大附件上传必须分段
1.1 浏览器和服务器在大文件面前的双重困境
先捋一下传统的“一次上传”方式到底卡在哪儿。浏览器端,如果你直接用一个<input type="file">加上 AJAX,把整个文件塞进请求体,文件稍微大点,内存占用立刻飙升,因为前端要把整个文件读进 Blob 才能发送;而且一旦网络抖动,整个请求失败,你没有任何办法“接着传”,只能从头再来。服务端那边,ASP.NET 的请求体默认有大小限制(比如经典的 Kestrel 默认请求体最大 30MB,IIS 环境下也有 maxAllowedContentLength 的限制),一个 2GB 的文件直接就被拦在外面了;就算你把限制全部放开,服务端把整个文件一次性接收并写入磁盘,中间任何一步出错都会导致进程内存暴涨甚至崩溃。
这就是分段上传的意义:把一个大文件切成若干个小块(分片),每个分片独立上传、独立失败重试、独立记录进度。服务器收到所有分片后,再按顺序把分片合并成完整文件。这样一来,内存占用变成恒定的小值(比如只处理一个 10MB 的分片),而不是整个 2GB;遇到断网,重新传的时候只需上传没完成的那几个分片,而不是整块文件。用一句话概括:用分片换稳定,用记录换断点续传。
1.2 分段上传和秒传到底解决什么问题
分段上传解决的是“传得上去、传得稳定”的问题。而秒传解决的是“能不能不传”的问题——它的思路是:在真正上传之前,前端先算出一个文件唯一特征(一般是计算文件的哈希值,比如 MD5),把这个哈希值连同文件名、大小发给服务端。服务端在数据库里查一下,如果存在一个完全相同哈希和相同大小的文件,就说明这个文件已经上传过了,不需要再传一遍,直接给前端返回“秒传成功”。从用户视角看,几百兆的文件“嗖”一下就上传完成了,像是用了什么黑科技。
这两个机制在企业系统场景里搭配使用效果尤其好。比如一个团队经常互相传递几十上百 MB 的设计稿、安装包,用秒传就能省下大量重复上传的流量。再比如做离线数据备份的场景,单个文件经常超过 1GB,没分段上传这个功能,系统基本就废了。这两个功能一个是“效率”层面的,一个是“稳定”层面的,一次做进去,整个上传模块就完整了。
1.3 整体方案选型:C# Web API + 前端切片 + 特征值校验
技术选型方面,后端用 ASP.NET Core Web API(.NET 6 或 .NET 8 都可以,下面的代码基于 .NET 6 以上风格),原因很实际:跨平台、内置依赖注入、IFormFile 接收文件流非常方便、Stream 操作天然适合分片写入。前端不需要引入重量级框架,原生 JavaScript 就足够完成切片的操作,我习惯配合 SparkMD5 这个轻量库来做文件哈希计算,比纯手写 MD5 高效得多,也支持流式增量计算,不会因为文件大而卡死页面。
这里多说一句,你可能在网上看到过一些老方案,比如用 WebUploader 这种库直接对接,它内部确实封装了分片上传,但问题也不少:库本身已经很多年没有维护了,分片参数的命名方式是chunk、chunks这种泛化名称,对接 .NET Core 的 Controller 时经常要写一堆自定义适配代码,反而比原生实现更麻烦。所以我的建议是,如果项目要求长期维护、逻辑要完全可控,直接基于 File API 的 slice 方法 + C# 接口自己实现,踩坑之后你才知道边界在哪里。
2. 接口设计与数据模型:先把架构搭明白
2.1 四个核心接口,串起整个上传流程
先别急着写代码,把接口设计梳理清楚,后面就不会乱。一个完整的“分段上传 + 秒传”流程,最少需要四个接口:
| 接口 | 方法 | 作用 | 关键参数 |
|---|---|---|---|
/api/upload/check | POST | 上传前预检,判断是否可秒传、是否已有记录 | fileHash(文件总体 MD5)、fileName、fileSize |
/api/upload/chunk | POST | 接收单个分片文件并保存到临时目录 | identifier(上传任务ID)、chunkIndex、totalChunks,文件本身用 FormData 传输 |
/api/upload/merge | POST | 所有分片传完后,服务端合并分片为完整文件 | identifier、fileName、fileHash(用于合并后校验) |
/api/upload/cancel | POST | 取消上传,清理临时分片 | identifier |
整个调用顺序是一个“预检 → 分段传输 → 合并 → 完成”的状态链路。前端拿到文件后第一步调check做秒传判断;如果服务端说need_upload,前端开始切片,按顺序(或并发)调chunk接口;全部成功后调merge;合并成功后再返回一个最终文件的访问路径。中间任何一步失败,前端都能根据已上传的分片列表决定从哪里续传。后面第三节和第四节会展开这些接口的代码实现,这里先把数据模型说清楚。
2.2 数据表设计:文件表、分片表、上传记录表
分段上传涉及的数据量和状态,光靠临时文件目录是不够的,必须用数据库记录“到底传到了什么程度”。我在项目里通常建四张表,核心结构如下。
第一张叫UploadFileInfo,存文件的最终记录:
CREATE TABLE UploadFileInfo ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, FileHash NVARCHAR(64) NOT NULL, -- 文件整体 MD5 FileName NVARCHAR(255) NOT NULL, FileSize BIGINT NOT NULL, FilePath NVARCHAR(500) NOT NULL, -- 合并后的物理路径 UploadId NVARCHAR(50) NOT NULL, -- 秒传/合并时使用的任务标识 UploadTime DATETIME DEFAULT GETDATE(), IsComplete BIT DEFAULT 0 );第二张是ChunkRecord,记录某个上传任务里每个分片是否已经到达:
CREATE TABLE ChunkRecord ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, UploadId NVARCHAR(50) NOT NULL, ChunkIndex INT NOT NULL, -- 分片序号,从 0 开始 ChunkSize BIGINT NOT NULL, IsUploaded BIT DEFAULT 0, UpdateTime DATETIME DEFAULT GETDATE() );在设计时需要注意一个点:UploadFileInfo.FileHash要建唯一索引。这是秒传的基石——只有唯一索引才能保证同一个哈希不会重复存成多份文件。如果你们有“同一个内容但不同文件名”的需求,可以把文件名逻辑拆到另一张表,但物理文件唯一性必须由哈希保证。
2.3 分片大小和并发数的选型逻辑
分片大小是这一步的“定海神针”。我见过有人把分片设成 1MB,结果 1GB 文件传一千个请求,每个请求都有网络握手开销,性能一塌糊涂;也有人设成 100MB,结果一个分片在公网上要传十几分钟,随便一个波动就得重传,体验非常差。我的经验值是5MB 到 20MB之间,办公室内网可以用 20MB,公网环境建议 5MB 或 10MB。
分片大小的选择逻辑其实是“请求次数”和“单次请求耗时”之间的权衡:
- 网络差 / 公网环境:分片小(5MB),单请求时间短,失败重试成本低
- 内网 / 带宽充足:分片大(20MB),请求数量少,减少握手开销
- 用户几乎总是上传大文件:分片稍大,不要造成几千个分片导致服务端文件句柄暴增
另一个关键参数是并发上传数。前端同时发多少个chunk请求?我建议控制在 3 个左右。并发太高的好处是速度快,但坏处是浏览器对同一域名的 TCP 连接数是受限的(HTTP/1.1 一般是 6 个),发再多也是排队;而且服务端在高峰期同时写几十个临时文件,磁盘 IO 会成瓶颈。稳定起见,3-5 个并发是实测性价比最高的区间,如果服务端和客户端都在同一内网且带宽足够大,可以开到 5。
3. 后端 C# 代码实现:接受分片、校验秒传、合并文件
3.1 秒传校验接口:MD5 比对背后的原理
秒传的关键是前端提供一个可靠的文件哈希值。服务端要做的事情很简单:拿着哈希值去UploadFileInfo表里查,有记录就返回“该文件已经存在,直接成功”,没有就返回“可以开始上传”。
我在check接口里同时比对了几项信息,避免仅仅依赖哈希值带来的误判风险:
[HttpPost("api/upload/check")] public async Task<IActionResult> CheckFile([FromBody] CheckFileRequest request) { if (string.IsNullOrEmpty(request.FileHash) || request.FileSize <= 0) { return BadRequest(new { code = 1, msg = "参数不完整" }); } var existed = await _db.UploadFileInfos .FirstOrDefaultAsync(f => f.FileHash == request.FileHash && f.FileSize == request.FileSize); if (existed != null && System.IO.File.Exists(existed.FilePath)) { // 命中:文件已存在,返回秒传成功 return Ok(new UploadCheckResponse { Status = "instant_success", FilePath = existed.FilePath, Message = "秒传成功" }); } // 未命中:创建上传任务,前端开始分段上传 var newUploadId = Guid.NewGuid().ToString("N"); return Ok(new UploadCheckResponse { Status = "need_upload", UploadId = newUploadId }); }为什么要同时比对FileSize?因为虽然 MD5 碰撞的概率极低,但运维上的“误判”更多来自于前端计算错误或者网络传输阶段被截断——如果前端算出 MD5 是abc,文件大小是 0,后端也查不到对应记录,当然没问题;但如果出现某种异常情况导致哈希恰好相同但文件内容不同,加上大小校验可以大大降低这种风险。生产环境我还会记录一下文件扩展名做二次提示,但不作为唯一判定依据。
3.2 分片上传接口:临时文件的写入策略
分片上传接口是核心中的核心。这里最需要注意的不是接收文件本身,而是临时目录的组织方式和分片文件的命名方式。我用的策略是:
- 以
UploadId为文件夹名,每个上传任务一个独立的临时目录,比如/uploads/temp/abc123.../ - 临时目录下存放分片文件,文件名直接用分片序号,比如
0.part、1.part、2.part
这样做的原因有两个。第一,UploadId是唯一的,多个用户同时上传时不会出现分片相互覆盖;第二,用分片序号做文件名,合并时直接按文件名排序就行了,不要用Guid重命名——我见过有人为了防止重名把分片命名成guid.part,结果合并的时候还要把真实序号存在数据库里再查出来排序,纯属自找麻烦。
[HttpPost("api/upload/chunk")] public async Task<IActionResult> UploadChunk( [FromForm] IFormFile file, [FromForm] string uploadId, [FromForm] int chunkIndex, [FromForm] int totalChunks, [FromForm] string fileName) { if (string.IsNullOrEmpty(uploadId) || file == null || file.Length == 0) { return BadRequest(new { code = 1, msg = "分片参数缺失" }); } var tempRoot = Path.Combine(_config.StoragePath, "temp"); var uploadTempDir = Path.Combine(tempRoot, uploadId); Directory.CreateDirectory(uploadTempDir); // 关键点:文件名就是分片序号,后续合并直接按序号排序 var chunkFile = Path.Combine(uploadTempDir, $"{chunkIndex}.part"); await using (var stream = new FileStream(chunkFile, FileMode.Create, FileAccess.Write)) { await file.CopyToAsync(stream); } // 同步分片记录表:已上传的分片数为 +1 var record = await _db.ChunkRecords .FirstOrDefaultAsync(c => c.UploadId == uploadId && c.ChunkIndex == chunkIndex); if (record == null) { _db.ChunkRecords.Add(new ChunkRecord { UploadId = uploadId, ChunkIndex = chunkIndex, ChunkSize = file.Length, IsUploaded = true, UpdateTime = DateTime.Now }); } else { record.IsUploaded = true; record.ChunkSize = file.Length; record.UpdateTime = DateTime.Now; } await _db.SaveChangesAsync(); return Ok(new { code = 0, msg = $"分片 {chunkIndex} 上传完成" }); }这里有两个特别容易被忽略的点,值得单独强调。
第一,FileMode.Create而不是FileMode.OpenOrCreate。如果断点续传场景下前端重试了某个分片,Create模式会把旧分片直接覆盖掉,保证分片内容始终是最新上传的那一份,不会出现同一个序号里混入新旧两份数据的脏情况。
第二,IFormFile.CopyToAsync内部走的是流式复制,服务端内存占用和分片大小相当,而不是与整个文件大小相当。这就是分片架构能扛住超大附件的根本原因——服务端每一刻的内存开销都是 O(分片大小),而不是 O(整个文件大小)。
3.3 合并分片:流式合并与完整性校验
合并分片接口要做的三件事是:检查所有分片是否齐了;按序号合并成一个文件;合并完成后删除临时目录。
合并过程本身不复杂,但有一个性能细节值得说:不要天真地用小文件ReadAllBytes再拼接,那会把临时目录下所有分片一次性读入内存,内存占用直接回到“传统上传”的老路上去。正确做法是用Stream.CopyTo分块复制:
[HttpPost("api/upload/merge")] public async Task<IActionResult> MergeChunks([FromBody] MergeRequest request) { var tempRoot = Path.Combine(_config.StoragePath, "temp"); var uploadTempDir = Path.Combine(tempRoot, request.UploadId); if (!Directory.Exists(uploadTempDir)) { return BadRequest(new { code = 1, msg = "临时目录不存在,请重新上传" }); } var chunkFiles = Directory.GetFiles(uploadTempDir, "*.part") .Select(f => new FileInfo(f)) .OrderBy(f => int.Parse(f.Name.Replace(".part", ""))) .ToList(); var expectedCount = chunkFiles.Count; // 可选:与前端提交的 totalChunks 比对,防止分片缺失 if (request.TotalChunks > 0 && expectedCount != request.TotalChunks) { return BadRequest(new { code = 1, msg = $"分片数量不足,当前 {expectedCount},期望 {request.TotalChunks}" }); } var finalDir = Path.Combine(_config.StoragePath, "files"); Directory.CreateDirectory(finalDir); // 文件名避免路径穿越,简单做一层清洗 var safeName = Path.GetFileName(request.FileName); var finalPath = Path.Combine(finalDir, safeName); // 流式合并核心逻辑 await using (var output = new FileStream(finalPath, FileMode.Create, FileAccess.Write)) { foreach (var chunk in chunkFiles) { await using var input = chunk.OpenRead(); await input.CopyToAsync(output); } } // 合并后校验:重新计算整体 MD5,与前端提交的 fileHash 比对 var mergedHash = await ComputeMd5Async(finalPath); if (!string.Equals(mergedHash, request.FileHash, StringComparison.OrdinalIgnoreCase)) { // 哈希不一致:合并出的文件是损坏的,建议删除并让用户重新上传 System.IO.File.Delete(finalPath); return BadRequest(new { code = 1, msg = "文件完整性校验失败,请重新上传" }); } // 写入文件信息表 _db.UploadFileInfos.Add(new UploadFileInfo { FileHash = request.FileHash, FileName = safeName, FileSize = new FileInfo(finalPath).Length, FilePath = finalPath, UploadId = request.UploadId, UploadTime = DateTime.Now, IsComplete = true }); await _db.SaveChangesAsync(); // 清理临时分片 Directory.Delete(uploadTempDir, true); return Ok(new { code = 0, msg = "合并完成", filePath = finalPath }); }这段代码里的ComputeMd5Async是实现哈希计算的一个辅助方法,它同样用流式方式读取,不会把整个文件加载进内存:
private static async Task<string> ComputeMd5Async(string filePath) { using var md5 = System.Security.Cryptography.MD5.Create(); await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920); var hashBytes = await md5.ComputeHashAsync(stream); return Convert.ToHexString(hashBytes).ToLower(); }这里我要郑重提醒:合并后校验这一步绝不能省。实际项目中,分片上传偶尔会出现前台显示“100%”但合并出来的文件打不开的现象,原因是某个分片在传输过程中静默损坏、或者totalChunks记录有偏差。合并后重新算一次 MD5,和前端提交的哈希做比对,是拦住这类问题的最后一道防线。宁可合并失败让用户重传一次,也不能把损坏文件存进系统里,否则后续打开文件时出现的问题更难排查。
4. 前端实现:切片、传分片、进度与断点续传
4.1 前端切片与 MD5 计算:SparkMD5 还是 Web Worker
前端这边的第一件事,是把文件切成若干 Blob,同时算出文件的整体 MD5。
切片用的是File.slice(start, end),这个 API 在 Chrome、Firefox、Edge 里都支持得很好,不需要额外兼容。以 10MB 分片为例:
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB function createChunks(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, blob: file.slice(start, end), size: end - start }); start = end; index++; } return chunks; }MD5 的计算要特别小心。最直接的做法是读取整个文件再算哈希,但一个 1GB 的文件直接把整个内容读进浏览器内存,页面会卡成幻灯片。正确做法是用 SparkMD5 的增量模式,边读文件边喂数据:
import SparkMD5 from 'spark-md5'; function calculateFileHash(file) { return new Promise((resolve, reject) => { const fileReader = new FileReader(); const spark = new SparkMD5.ArrayBuffer(); // 按 2MB 为一批,分批读取 const partSize = 2 * 1024 * 1024; let currentPos = 0; const readNext = () => { const end = Math.min(currentPos + partSize, file.size); const blob = file.slice(currentPos, end); fileReader.onload = (e) => { spark.append(e.target.result); currentPos = end; if (currentPos < file.size) { readNext(); } else { resolve(spark.end()); } }; fileReader.onerror = reject; fileReader.readAsArrayBuffer(blob); }; readNext(); }); }如果文件特别大,比如超过 500MB,这个哈希计算过程本身也可能给用户带来几秒甚至十几秒的等待。一个体验上的优化方式是把哈希计算放到 Web Worker 里执行,避免阻塞 UI 线程。我自己的习惯是:500MB 以下直接增量计算;超过 500MB 就用一个worker.js处理,前端主线程只接收计算结果。Web Worker 的写法这里不展开,核心思路就是把上面这段calculateFileHash整体挪到 worker 里,通过postMessage返回哈希值。
4.2 并发上传控制:用一个简单队列搞定
分片准备好、MD5 也算完了,接下来就是上传。如果循环一个接一个地传,1GB 文件切 100 个分片,传 100 次,效率太低;如果一次性全发,几十个并发请求同时打过来,服务端磁盘 IO 扛不住。所以需要一个简单的并发控制队列。
下面这个实现里,uploadQueue维护一个并发上限的队列,activeCount记录当前正在传的数量:
const CONCURRENT_LIMIT = 3; let activeCount = 0; const pendingQueue = []; let uploadedChunkIndexes = []; // 已上传分片,用于断点续传 async function uploadChunk(uploadId, chunk) { const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('chunkIndex', chunk.index); formData.append('totalChunks', chunkList.length); formData.append('fileName', file.name); formData.append('file', chunk.blob, `chunk-${chunk.index}`); const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); return resp.json(); } // 并发控制:启动一个任务时计数加一,结束减一,并继续拉取下个任务 async function runTask(uploadId, chunk) { await uploadChunk(uploadId, chunk); uploadedChunkIndexes.push(chunk.index); progressRefresher(); } function enqueue(uploadId, chunk) { pendingQueue.push(() => runTask(uploadId, chunk)); pump(); } function pump() { while (activeCount < CONCURRENT_LIMIT && pendingQueue.length > 0) { const task = pendingQueue.shift(); activeCount++; task().finally(() => { activeCount--; pump(); }); } }这段代码的业务逻辑太简单了,实际项目里还要补几个细节:失败重试、断点续传、上传进度计算。
失败重试的通用做法是给每个分片记录一个重试次数,比如最多重试 3 次,超过 3 次就把这个分片标记为失败,最终在上传列表里把失败的分片提示给用户,由用户决定是否续传。
断点续传的做法是:每次上传一个分片成功时,先把uploadedChunkIndexes存到localStorage(键名用文件 MD5 标记);重新上传时,先调check接口,如果返回need_upload,再对比本地记录跳过已经传过的分片。这里有一个坑:localStorage只适合存几百 KB 以内的数据,分片索引列表通常很小,完全够用;但如果你的分片特别多,建议把已传分片列表放到后端,由后端在check接口里返回已上传的分片范围,前端直接过滤——这是更可靠的方案,因为用户清掉浏览器缓存后本地记录就没了,而后端记录还在。
进度计算就简单了:已上传分片数除以总分片数。合并完再统一变成 100%。
4.3 秒传触发逻辑与 UI 交互衔接
整个上传流程在前端如何串联,我用下面的伪代码展示一下,方便你把各环节黏合起来:
async function handleUpload(file) { const fileHash = await calculateFileHash(file); // 1. 预检:后端判断是否秒传 const checkResp = await fetch('/api/upload/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fileHash }) }); const checkData = await checkResp.json(); if (checkData.status === 'instant_success') { // 2. 秒传命中 showMessage('秒传成功', checkData.filePath); return; } // 3. 开始切片上传 const uploadId = checkData.uploadId; const chunks = createChunks(file); // 断点续传:从后端查询已上传分片,过滤掉 const uploaded = await fetchUploadedChunks(uploadId); // 返回已传分片序号数组 chunks.filter(c => !uploaded.includes(c.index)) .forEach(c => enqueue(uploadId, c)); // 4. 等待队列清空 await waitForQueueIdle(); // 5. 合并分片 const mergeResp = await fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, fileName: file.name, totalChunks: chunks.length, fileHash }) }); const mergeData = await mergeResp.json(); if (mergeData.code === 0) { showMessage('上传成功', mergeData.filePath); } else { showMessage('合并失败', mergeData.msg); } }前端这块还有一个细节经常被忽略:选择文件之后先不要立刻清空<input>的值。如果用户撤回了上传操作(比如点击取消),你可以回去拿到原选中的文件;而一旦清空,用户想继续就得重新找文件。我踩过这个坑,后来改成始终在当前作用域里持有file变量,直到上传流程结束。
5. 常见问题排查与生产环境避坑指南
5.1 高频问题速查表
分段上传一旦到了生产环境,容易出现的问题往往不在功能逻辑本身,而在于各种环境限制和隐式约束。我把踩过的坑整理成一张速查表,方便你直接对应排查。
| 现象 | 直接原因 | 解决办法 |
|---|---|---|
| 请求还没到 Controller 就被返回 404/413 | Kestrel 或 IIS 的请求体大小限制 | 调大MaxRequestBodySize;IIS 下同时调整maxAllowedContentLength |
| 上传速度很慢,总是排队 | 前端并发数设得太低(比如只有 1) | 将并发控制在 3-5,并开启 HTTP/2 或避免跨域代理 |
| 合并后文件打不开 | 分片顺序错乱或分片数据损坏 | 检查合并时的排序规则;合并后做 MD5 校验 |
临时文件夹里残留大量.part文件 | 用户中途取消、浏览器崩溃 | 在后端加定时任务,定期清理多天前的临时文件 |
| 前端提示上传成功,后端没有完整文件 | 未调合并接口或合并接口报错 | 检查前端是否在队列完成后正确触发 merge,查看后端日志 |
| 换了一台电脑,原来传的文件无法续传 | 本地存储记录不可用 | 改用后端返回“已传分片列表”的机制 |
5.2 生产环境必须处理的三个细节
第一个细节:请求体大小限制要改对地方。ASP.NET Core 默认的 Kestrel 请求体限制是 30MB,你的分片如果恰好是 10MB 就没事,但如果你把分片调到 50MB,就必须在Program.cs里显式配置:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100MB });如果部署在 IIS 后面,还需要改web.config里面的maxAllowedContentLength(单位是字节)。两层限制各自独立,只改一层都没用——这两个地方我都踩过。
第二个细节:临时文件必须过期清理。无论用户是主动取消、浏览器崩溃、还是直接关掉页面,断点续传机制都会留下临时分片。后端一定要有一个定期任务(比如每天凌晨跑一次),把超过 48 小时的临时目录删掉。否则上传量一大,磁盘就会被.part文件塞满。
第三个细节:文件名必须清洗。如果你直接用前端传来的fileName拼路径,等于把路径穿越漏洞敞开大门。Path.GetFileName()只取路径最后一段,但还不够,建议再加一层白名单校验,只允许字母、数字、中划线、下划线和点号,其余一律替换成_。
5.3 性能优化:我从真实项目里拿到的数据
我在一个真实项目中做过一次对比测试:单个文件 1.2GB,分片 10MB,并发 3,局域网内上传,整个过程耗时约 1 分 50 秒,服务端内存稳定在 80MB 以内,未出现请求超时或内存溢出。作为对比,传统一体化上传方案在同一环境下,请求直接被网关拦截,根本无法完成。
换成公网环境,受制于上行带宽,1.2GB 文件可能要传近半小时,这时候断点续传的价值就体现出来了:用户断网重连后,只需要补传最后几个分片,而不是从头再来。我建议你在前端把“已传百分比”做得足够显眼,配合断点续传,用户的焦虑感会大幅降低。
如果未来文件量再上一个量级,比如动辄 10GB 以上,可以再考虑引入类似 Tus 协议的开源分片上传标准,或者把存储层切到对象存储(如 MinIO、阿里云 OSS 的分片上传接口)。但基础原理和这篇文章讲的是相通的:前端切片、后端接收、断点续传、秒传校验,核心架构不变,只是把“本地磁盘存储”替换成“对象存储”。
回到我们最初的问题。分段上传和秒传不是两个孤立功能,它们是一套完整的大文件上传方案中的“稳定层”和“效率层”。我在实际项目里最大的感受是:分段上传的代码并不难写,难的是把所有边界场景都考虑到——分片缺失、分片覆盖、请求体限制、临时文件清理、并发控制、断点续传——每个细节在生产环境里都可能变成一个“线上事故”。所以这篇文章里,我把数据和模型设计放在前面,代码实现放在后面,因为架构想清楚之后,代码只是翻译而已。
最后再分享一个小技巧:给这个上传模块做前端反馈时,不要只显示“x%”,可以同时显示“已传 x/y 个分片”。原因很简单,大文件的分片上传往往要持续很久,一个百分比数字看多了会让人觉得进度卡住;而“37/100 个分片”这种基于分片的颗粒度,反而让人明确知道程序还在干活。这个小改动,体验提升非常明显,强烈建议你试一下。