news 2026/10/9 3:25:07

C#实现大文件分段上传与秒传:前端切片到后端合并全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现大文件分段上传与秒传:前端切片到后端合并全解析

做后台系统的这些年,我接手过不少“文件上传”相关的需求,其中最让人头疼的就是大附件。你想想,一个 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/checkPOST上传前预检,判断是否可秒传、是否已有记录fileHash(文件总体 MD5)、fileName、fileSize
/api/upload/chunkPOST接收单个分片文件并保存到临时目录identifier(上传任务ID)、chunkIndex、totalChunks,文件本身用 FormData 传输
/api/upload/mergePOST所有分片传完后,服务端合并分片为完整文件identifier、fileName、fileHash(用于合并后校验)
/api/upload/cancelPOST取消上传,清理临时分片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/413Kestrel 或 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 个分片”这种基于分片的颗粒度,反而让人明确知道程序还在干活。这个小改动,体验提升非常明显,强烈建议你试一下。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 3:23:59

内网私有化部署实战:用Sealos离线交付K8s云平台

上个月接了一个内网交付&#xff0c;客户要求把研发团队的 PaaS 环境整体搬到隔离网络里&#xff0c;公网不碰&#xff0c;半天之内要能跑业务。以前遇到这种需求&#xff0c;我第一反应是 OpenStack&#xff0c;或者老老实实手动拉 K8s 集群。但这次我直接用 Sealos 做私有化部…

作者头像 李华
网站建设 2026/10/9 3:23:59

多任务学习在空气质量预测中的工程实践与避坑指南

简介&#xff1a;基于深度学习的多任务空气质量预测模型设计与实现项目包&#xff0c;完整覆盖数据预处理、模型搭建、训练验证与预测推断的深度学习应用全流程&#xff0c;面向环境数据分析、智慧城市和深度学习交叉领域的开发者与学习者。压缩包共46个文件&#xff0c;以36个…

作者头像 李华
网站建设 2026/10/9 3:23:40

创始人退休动态何以刷屏?揭秘“交班不交权”的治理逻辑与观察方法

1. 为什么头部创始人的退休动态永远是热搜体质我长期关注大型互联网企业的人事变动&#xff0c;发现一个很有意思的现象&#xff1a;不管这位退休企业家是去做了公益分享、还是被拍到在某个小镇喝茶&#xff0c;只要消息传出来&#xff0c;必然是一轮刷屏。2026年这个时间窗口尤…

作者头像 李华
网站建设 2026/10/9 3:23:19

OpenHarmony上跑Flutter:猫咪喂食计算器从0到1

做 Flutter 开发这几年&#xff0c;我一直在关注它在非传统平台上的落地情况。去年手上接了一个宠物类 App 的案子&#xff0c;目标平台是搭载 OpenHarmony 的国产设备&#xff0c;客户点名要 Flutter 技术栈&#xff0c;需求里最核心也最吸引我的一个模块就是"猫咪管家&q…

作者头像 李华
网站建设 2026/10/9 3:23:17

钓鱼邮件识别指南:从发件人地址到邮件头的攻防拆解

如何识别钓鱼邮件&#xff1a;一封“HR邮件”的攻防拆解 上个月我们公司一位入职半年的运营同事&#xff0c;收到一封标题为“全员薪资调整方案”的邮件&#xff0c;发件人写的是公司HRVP的名字&#xff0c;附件是一个看起来很正常的工作簿&#xff0c;里面是全员薪资Excel表。…

作者头像 李华
网站建设 2026/10/9 3:23:11

域名投资新老顶级域怎么选?避开续费陷阱与变现困局

1. 从一次后悔的抢注说起&#xff1a;我为什么重新审视新老顶级域2016年我还在玩域名投资&#xff0c;那时候正是新顶级域疯狂上线的时候&#xff0c;.club、.vip、.top、.xyz这些后缀铺天盖地做促销&#xff0c;首年注册价甚至只要几块钱人民币。我是个老域名投资人&#xff0…

作者头像 李华