1. 什么是大文件分段上传?它与普通上传的本质区别
先说结论:分段上传不是把一个大文件“拆成几个文件”那么简单,它最核心的价值,是把“一次不确定能不能成功的请求”,变成“多次独立、可重试、可并行的请求”。这个思路在很多场景里都适用,C# 后端只要把握好接口设计,前端配合做切片,整个链路就能顺下来。
1.1 普通上传“一把梭”的问题所在
很多初学者第一次做文件上传,通常是一个IFormFile直接接住,然后SaveAs()或CopyToAsync()写盘。这种做法在小文件(几 MB、几十 MB)下完全没问题,但一旦文件到了几百 MB 甚至几个 GB,问题就全部暴露出来了:
- 浏览器或 HTTP 客户端发出一个巨大的请求体,网络稍有波动,整个请求就断了,而且没有“断了从哪里继续”的能力;
- 服务器端需要一次性把整个请求体接收进内存或临时缓冲区,内存占用直接拉满;
- 如果前端没有做超时控制,一个请求挂着,用户等半小时没有任何反馈,体验非常差;
- 文件越大,网络错误率越高,重传成本也越高。
1.2 分段上传的基本流程
分段上传的核心是把一个文件切成若干个大小相同的分片(比如每片 5 MB),然后逐个上传。后端依次接收、暂存这些分片,等所有分片都传完,再通过一个“合并”动作把它们拼回完整文件。
我习惯把整个流程分成四个阶段:
初始化上传任务
前端把文件名、大小、总分片数、分片大小、文件 MD5 等元数据发给后端,后端生成一个全局唯一的UploadId,并且可以顺便做秒传检查。逐片上传
前端把文件按偏移量切成片,每片附带片序号、当前片的 MD5 或 CRC,后端校验通过后落盘暂存。查询已上传分片
如果中途断网、刷新、关闭页面,重新进入时,前端可以查询“这个文件已经传了哪些分片”,跳过已传的片,只传缺失的片,这就是断点续传。合并分片
当前端确认所有分片都已上传,调用合并接口,后端按片序号逐个读取临时分片数据,写入最终文件。
1.3 秒传:基于指纹的“文件去重”
秒传的原理和分段上传是两码事,它依赖的是内容哈希去重。前端在选完文件后,先计算整个文件的 MD5(或 SHA-1),然后传给后端去数据库里查。如果已经存在一份 MD5 相同的文件,后端直接返回“已存在”,前端瞬间显示上传完成,不再传任何数据。
这里注意一点,MD5 在实际大文件场景下是有碰撞风险的,但从工程角度讲,MD5 加文件大小双重判定,已经能满足绝大多数业务需求。更严谨的做法是 MD5 + 文件大小同时匹配,或者换成 SHA-256 计算整个文件指纹,代价是计算时间会多一点,尤其是 1 GB 以上的文件。
我在实际项目里见过很多团队把“秒传”理解成“把文件传到服务器后立刻合并”,这是不对的。真正的秒传是让用户完全不传,前提是服务器已经有了相同内容的文件。
2. 环境准备与整体架构
做这个功能,不需要引入特别复杂的前端框架,也不需要专门的文件服务中间件。用 C# 写后端,前端用原生 JavaScript 即可跑通全流程,本方案适合零基础复现,也适合已有项目直接参考。
2.1 技术选型:C# 后端 + 前端选择
2.1.1 后端为什么选 ASP.NET Core 6
选型时我对比过 ASP.NET Framework 4.x 和 .NET Core 6,最终推荐 ASP.NET Core 6(现在可以用 8/9)。原因是:
- 跨平台,部署到 Linux + Nginx 也很方便;
- 内置
Kestrel对异步 IO 的支持很好,适合大文件并发传输; - 配置上传大小限制、管道中间件都比较直观;
- 自带依赖注入和日志系统,后续扩展上传队列、任务状态管理都很顺手。
如果团队原本是 .NET Framework 4.7,也能实现分段上传,但IFormFile与模型绑定的机制、异步流的处理都要更小心一些,某些框架版本底层对请求体大小有硬限制,绕起来麻烦。所以能用 Core 就尽量用 Core。
2.1.2 前端:原生 JavaScript 还是 JS 库?
分段上传最核心的切片能力,浏览器原生File.slice()就能实现,不需要引入第三方上传库。我用原生 JS 写过一版,也用plupload和webuploader重构过。对于需要兼容老旧上传控件、拖拽上传、多选上传的场景,第三方库确实方便,但问题也明显:一旦需要自定义进度样式、自定义并发策略、自定义与后端秒传接口对接,库反而成了桎梏。
本方案前端就用原生 JavaScript + XMLHttpRequest 或者fetch。控件的 UI 可以自由发挥,核心逻辑不受限制。如果你对前端不熟悉,也无所谓,后面我把关键代码全贴出来。
2.2 核心目录结构与依赖
为了便于理解,我建议后端项目按以下结构组织:
UploadDemo/ ├── Controllers/ │ └── FileUploadController.cs ├── Services/ │ ├── IUploadStorage.cs │ └── UploadStorage.cs ├── Models/ │ ├── UploadInitRequest.cs │ └── UploadPartRequest.cs ├── wwwroot/ │ ├── index.html │ └── js/ │ └── upload.js └── UploadDemo.csproj后端需要引入的包其实很少,核心就一个:
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.20" />这里我用了 SQLite 做演示,方便本地起项目。如果你公司已有 SQL Server、MySQL、PostgreSQL,对应替换DbContext的 Provider 配置即可。数据库里只需要保存“文件记录”和“分片上传状态”。
2.3 数据库表设计
我设计了两张表:一张记录文件元数据,一张记录分片暂存状态。
文件表(UploadedFile)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | Guid | 文件唯一标识 |
| UploadId | string | 上传任务 ID |
| FileName | string | 原始文件名 |
| FileSize | long | 文件总字节数 |
| FileHash | string | 整文件 MD5 |
| ChunkSize | int | 单个分片大小 |
| TotalChunks | int | 总分片数 |
| Status | int | 0 等待上传,1 上传中,2 已完成 |
| StoragePath | string | 合并后的完整文件路径 |
| CreatedAt | DateTime | 创建时间 |
分片表(UploadChunk)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | Guid | 主键 |
| UploadId | string | 关联上传任务 |
| ChunkIndex | int | 分片序号(从 0 开始) |
| ChunkHash | string | 当前分片 MD5 |
| TempPath | string | 临时分片保存路径 |
| UploadedAt | DateTime | 上传完成时间 |
分片表的核心作用就是支撑“断点续传”。每次上传分片前,前端先去查询表中已有的片序号,然后过滤掉,只传缺失部分。
3. 后端核心接口实现
后端我需要四个接口配合完成这个功能:创建上传任务、上传分片、合并分片、查询已上传分片。秒传检查可以合并到创建任务里,也可以用单独的接口。
3.1 第一个接口:创建上传任务(InitiateUpload)
前端在切片之前,先要计算完整文件的 MD5。这个 MD5 是为了秒传检测,也用于最终合并时校验完整性。后端收到初始化请求,参数长这样:
public class UploadInitRequest { public string FileName { get; set; } public long FileSize { get; set; } public string FileHash { get; set; } // 整个文件的 MD5 public int ChunkSize { get; set; } // 单分片大小,单位字节 public int TotalChunks { get; set; } }后端处理逻辑:
- 先用
FileHash + FileSize查数据库里是否已经有内容完全相同的文件,如果已存在,直接返回{ Exists = true, UploadId = "" },前端立即跳到“秒传成功”; - 如果不存在,生成一个
UploadId(一般用 GUID),同时创建一条文件记录,状态设为“上传中”; - 返回
{ Exists = false, UploadId = "xxx", ChunkSize = 2097152 }。
这里要注意,ChunkSize虽然由前端传入,但后端不能完全信任前端,必须自己在服务端校验,或者直接以后端配置为准,否则恶意用户可以传一个巨大的切片绕过限制。
3.2 第二个接口:上传分片(UploadPart)
每个分片走同一个接口,后端依据UploadId + ChunkIndex区分归属。接口签名如下:
[HttpPost("chunk")] public async Task<IActionResult> UploadChunk([FromForm] UploadPartRequest request) { // request.UploadId // request.ChunkIndex // request.ChunkHash // request.Form.Files[0] // 真正的分片数据 }核心步骤:
- 根据
UploadId找到上传任务,校验任务状态是“上传中”; - 校验当前分片序号是否在
[0, TotalChunks - 1]范围内; - 将分片数据写入临时目录,文件名建议采用
{UploadId}_{ChunkIndex}.part,这样后续合并和查询都很方便; - 计算该分片的 MD5,与前端传来的
ChunkHash比对,不一致则返回错误码; - 在分片表里插入一条记录,标记该片已完成;
- 返回“分片上传成功”。
注意临时目录要按UploadId分子目录,避免成千上万个.part文件混在一个目录里,文件系统在单目录数量过万后性能下降明显。
3.3 第三个接口:合并分片(CompleteUpload)
前端在所有分片上传成功后,调用合并接口。后端流程:
- 校验该任务所有
0 ~ TotalChunks-1的分片都已在分片表里有记录; - 按
ChunkIndex升序遍历,逐个读取临时分片文件,追加写入最终目标文件; - 全部合并完成后,删除临时目录里的所有
.part文件; - 更新文件记录的状态为“已完成”,写入最终存储路径;
- 返回文件的访问 URL 或唯一标识。
合并阶段有一个容易忽略的点:不能只靠临时文件是否存在判断“所有分片都已上传”,因为并发上传时可能存在“分片文件正在写入、还没写完整”的情况。所以最好是在分片上传接口完成后,才在分片表中插入记录;合并前以分片表记录为准,而不是以文件存在为准。
另外,合并的时候可以考虑不用File.ReadAllBytes直接把每个分片读进内存,应该用流式读取,边读边写,避免大文件合并时内存爆炸。我的示例代码:
await using (var output = new FileStream(finalPath, FileMode.Create, FileAccess.Write)) { foreach (var chunk in chunks) { await using var input = new FileStream(chunk.TempPath, FileMode.Open, FileAccess.Read); await input.CopyToAsync(output); } }3.4 第四个接口:查询已上传分片(QueryUploadedChunks)
这个接口服务于断点续传,请求参数只有UploadId,返回的是已上传成功的分片序号列表:
{ "uploadedChunks": [0, 1, 2, 3, 5, 6, 9] }前端拿到这个列表后,会自动跳过已经传过的分片,只把缺失的分片上传。若分片 5 传失败了,没关系,前端可以重新传这一片。这也是分段上传区别于普通上传的核心优势:失败的成本很小。
4. 前端分段上传与进度展示实现
前端是实现体验的关键。本方案用原生 JavaScript,核心是把整个上传流程串起来。下面我会分模块讲解,并给出可以直接用的代码骨架。
4.1 选择文件后立即计算 MD5
秒传第一步是拿文件的 MD5。浏览器不能直接读文件内容的 MD5,但可以通过FileReader配合crypto.subtle.digest实现。不过crypto.subtle只能算 SHA-256,算 MD5 需要引入一个纯 JS 库(比如spark-md5)。
大文件不能一次性全读进内存,需要切片分块计算。以 spark-md5 为例,典型做法是每次读 2 MB:
function computeFileMd5(file) { return new Promise((resolve, reject) => { const chunkSize = 2 * 1024 * 1024; const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; function loadNext() { const slice = file.slice(offset, offset + chunkSize); reader.readAsArrayBuffer(slice); } reader.onload = (event) => { spark.append(event.target.result); offset += chunkSize; if (offset < file.size) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = (err) => reject(err); loadNext(); }); }注意,文件越大,整个文件的 MD5 计算越耗时。一个 1 GB 的文件,即使分块计算,也需要几秒到十几秒。我建议界面先显示“正在计算文件指纹”,让用户有心理预期;如果用户选择的是超大文件,甚至可以考虑先把切片上传,边传边算整文件摘要,但这个方案复杂度较高,初期不必强求。
如果你的项目对“秒传准确性”要求很高,建议用 SHA-256。但由于浏览器的crypto.subtle只支持 SHA-256,花点时间改造即可。MD5 在业务场景下足够用,但要注意,如果同一个 MD5 被伪造(比如用户手动修改哈希值),就可能拦截到错误文件,导致秒传后下载文件损坏。稳妥做法是服务器端合并完成后异步重算一次 MD5,与前端上报不一致则标记失败。
4.2 动态切片与并发控制
切片的大小一般建议 2 MB ~ 10 MB,太小会导致请求数过多、HTTP 握手开销大;太大会退化回“大请求单次传”的体验。我通常取 5 MB 或 2 MB。以 5 MB 为例,100 MB 文件分成 20 片,200 MB 分成 40 片,比较划算。
切片本身用file.slice(start, end)即可,得到的是Blob对象。然后表单提交:
const formData = new FormData(); formData.append('UploadId', uploadId); formData.append('ChunkIndex', index); formData.append('ChunkHash', chunkMd5); formData.append('file', chunkBlob, file.name); const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData });并发控制是必须的。不能一次性把所有分片全部发起,否则浏览器和服务器都容易崩。我常用“滑动窗口”思路:固定同时只能有 3 个或 5 个请求在飞,每完成一个,就从待上传队列里取出下一个分片补上。
const concurrency = 3; let pendingQueue = listOfChunkIndexes.slice(); let activeCount = 0; function next() { if (pendingQueue.length === 0 && activeCount === 0) { // 全部完成,进入合并阶段 mergeFile(); return; } while (activeCount < concurrency && pendingQueue.length > 0) { const idx = pendingQueue.shift(); activeCount++; uploadSingleChunk(idx).finally(() => { activeCount--; next(); }); } }这种滑动窗口的好处是代码简单、可控性强,比“每片单独 promise + await Promise.all”更好的一点是:它可以随时暂停、恢复。暂停时只要把pendingQueue保留,取消当前 in-flight 请求即可。
4.3 断点续传与失败重试
断点续传在前端的落地策略是:上传未完成时,把 uploadId、文件信息、已传分片列表都缓存到 localStorage 或 IndexedDB。用户刷新或重新打开页面时,先检查本地是否有未完成任务,如果有,就调用QueryUploadedChunks获取服务器真正存了哪些分片,然后接着传。
这里有个关键细节:localStorage 存储的是上传任务元数据,不是文件内容。文件内容仍然从本地重新读取File对象。由于浏览器安全限制,页面刷新后无法自动恢复之前的File对象引用,所以断点续传通常依赖用户“重新选择同一文件”。页面加载后,用户再次选择文件,前端比对文件大小、文件名、MD5,如果与缓存中的任务一致,就自动恢复断点。
失败重试方面,我建议给每个分片设置最大重试次数(比如 3 次),每次重试间隔可以指数退避(500ms、1s、2s)。如果连续失败,就停止整个任务,提示用户网络异常。
4.4 合并状态轮询与秒传跳转
所有分片上传完成后,前端调用合并接口。合并接口如果处理耗时较长(比如文件很大、磁盘较慢),不能让它在一两个请求内无限等待,我一般会让合并接口同步处理,但对超大文件,也可以改成异步:
- 合并接口立即返回 202 +
taskId; - 前端每 2 秒轮询一次合并状态;
- 合并完成再返回最终 URL。
演示项目里我用的是同步合并,因为数据量不大。生产环境如果要对 10 GB 以上文件合并,建议异步化。至于秒传跳转,是在初始化阶段,后端返回Exists = true后,前端直接调用一个“秒传成功”分支即可,把文件的下载链接或详情展示出来。
5. 容易踩的坑:并发、临时文件、MD5 与后端配置
技术方案看起来不难,但真正上线时,踩坑点全在细节里。我把这几年同学、同事和大佬们反复掉进去的坑集中整理一下。
5.1 并发数与后端压力之间的平衡
前端并发太高会把服务器连接数打满。以默认 Kestrel 为例,如果同时 50 个分片请求并发,每个请求都占用一个 TCP 连接和一个异步 IO 任务,小服务器很容易 CPU 或网络瓶颈。
我通常的实践经验:
- 普通文件(100 MB 以内):前端并发 3 就够;
- 大文件(1 GB 以上)且网络带宽好:并发 5 ~ 8;
- 不要盲目把并发调到 20+,吞吐量未必线性增长,反而增加失败率和服务器压力。
另外,后端如果部署在 Nginx 后面,要确认client_max_body_size和proxy_request_buffering配置。Nginx 默认对 request body 有 1 MB 限制,虽然分片很小,但如果某个分片恰好超过 1 MB,就会直接被 Nginx 拒绝。
client_max_body_size 50m; proxy_request_buffering off;proxy_request_buffering off可以让 Nginx 边收边转给后端,避免把分片先全缓存到 Nginx 再转发,内存占用更小。
5.2 临时分片文件的生命周期
临时分片文件是所有分段上传方案里最容易被忽视的部分。两个典型问题:
上传中断后残留垃圾文件
用户上传了一半就关闭页面,服务器上的临时分片会一直留在磁盘。必须有一个清理机制。我采取两种方式:一是合并完成后立即删除临时目录;二是定期清理超过 24 小时未完成的任务及对应临时文件。临时目录的磁盘配额
如果允许多个大文件同时分段上传,临时目录的占用会快速膨胀。建议按用户或任务目录隔离,并做容量监控。我见过有的项目上线一星期,/tmp 目录被几百 GB 的.part文件塞满,直接导致服务器不可用。
5.3 MD5 计算与文件内容匹配
前端计算整个文件 MD5 时,如果文件被用户修改(比如第二次选择时换成同名但内容不同的文件),MD5 就会不同,这时候要丢弃之前的断点任务,重新初始化。
实际生产中遇到过这样一个坑:用户选择了一个 2 GB 的文件,前端切片用了 5 MB,总共 400 片,但浏览器读取文件时出现读取异常或内存问题(Safari 某些版本对 Blob 切片的大小有限制),导致某一片的Blob内容缺失。这时后端校验该分片 MD5,会校验失败,需要正确返回错误码,让前端重试。如果后端图省事不校验分片 MD5,坏数据就会被合并进最终文件,文件静默损坏,非常坑。
5.4 务必调整 ASP.NET Core 上传大小限制
默认 ASP.NET Core 的请求体上限是30,000,000 字节(约 28.6 MB),也就是单个请求不能超过这个值。如果我们设置了每片 5 MB,那没问题;但有人偷懒把ChunkSize设置为 50 MB,就会莫名收到 413 错误。需要显式设置:
builder.Services.Configure<FormOptions>(options => { options.MultipartBodyLengthLimit = 50 * 1024 * 1024; });同时 Kestrel 也有限制:
builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; });即使你把分片设成 2 MB,也建议把这两个值调高,为后续分片大小调整留出空间。
6. 实测效果、性能数据与升级思路
文章最后聊聊实测情况和优化方向。我给不少内部项目做过这套方案,下面列一组典型数据,供参考。
6.1 本地与局域网实测数据
测试条件:ASP.NET Core 6 + SQLite,前端 Chrome 120,上传文件 500 MB,切片 5 MB,并发 3。
| 指标 | 本地回环 | 局域网(千兆) |
|---|---|---|
| 总分片数 | 100 | 100 |
| 平均单分片上传耗时 | 30~50ms | 10~20ms |
| 总上传耗时 | 约 9~13s | 约 4~6s |
| CPU 占用 | 较低 | 较低 |
| 内存峰值 | 小于 100MB | 小于 100MB |
这个数据说明,分段上传的瓶颈通常不在后端处理,而在网络和浏览器读取文件速度。如果带宽有限,增大切片大小、降低并发数,反而更稳。
6.2 可以继续优化的方向
- 服务端分片重组优化:如果磁盘性能不够,可以把分片合并改成“并行读取 + 顺序写入”,或者直接跳过合并步骤,保存分片目录结构,提供分布式文件索引。但这样会复杂化读取逻辑,一般不需要。
- 文件秒传范围扩大:可以考虑把秒传从“同用户同文件”扩展到“全站用户同文件”,这时数据库表就要记录引用计数,删除时要谨慎。
- 上传进度平滑:不要按“已上传分片数/总分片数”来算百分比,最好按“已上传字节数/文件总字节数”来计算,防止某一片重传导致进度倒退。
- 接入云存储:如果最终存储目标是 OSS、S3 这类对象存储,可以用服务端预签名 URL,让前端分片直传对象存储,后端只负责记录状态。这与本方案的核心思路完全一致,只是落盘位置从本地换成了云端。
6.3 相关资源与 FAQ
我把实现这段功能时最常被问到的几个问题放在这里:
Q1:为什么我的分片上传返回 413?
先查 Kestrel 和 Nginx 的请求体限制,再看MultipartBodyLengthLimit是否设置。413 十有八九是请求体超过配置上限。
Q2:合并时发现缺少某几个分片,怎么办?
前端在调用合并接口之前,最好再调用一次QueryUploadedChunks,如果发现缺失分片立刻补传。后端合并接口也应该做校验,缺片就返回明确错误,而不是尝试合并,否则会生成损坏文件。
Q3:秒传校验通过后,如果文件被后台删除,怎么办?
这要看秒传语义。如果允许秒传的前提是“服务器上已存在该哈希文件”,那么文件被删除后,秒传自然失效,需要重新上传。建议在初始化接口里顺便返回文件是否存在、引用计数是否正常,否则用户秒传成功后下载文件会 404。
Q4:分片上传要不要加密?
如果走 HTTPS,数据加密由 TLS 层完成,业务层不需要额外加密。内网 http 环境下则可以考虑敏感数据二次加密,但加密会带来性能和密钥管理的复杂度,一般非敏感文件不必做。
Q5:有没有必要用 SignalR 实时上报进度?
没必要。HTTP 轮询或前端本地累加进度已经足够。SignalR 适合服务器主动推送场景,比如合并任务在后台执行时通知前端结果;但需要用实时进度条可以,不需要为了“进度条”强行上 WebSocket。
我在实际开发里的体会是:分段上传最难的并不是写接口,而是把“前端切片、后端落盘、断点恢复、合并校验”这四件事的边界想清楚。只要每个环节都做好状态记录与异常处理,文件上传体验就能做到很流畅,大附件也能接近“秒传”的体感。希望这份代码和踩坑记录能帮你少走弯路。