这个问题几乎每隔一段时间就会有人问我一次:“ASP.NET到底能不能做大文件断点续传?有没有现成的示例项目?”我最初接触的时候也以为需要引入什么重量级框架或商业控件,后来自己从零搭了一个基于ASP.NET Core Web API的示例,发现核心思路并不复杂,关键是把分片、并发、状态记录和合并逻辑理清楚,再配好服务器和IIS的请求限制。下面我就把整个项目的设计思路、核心代码和踩过的坑完整拆一遍,包括前端用File API切片、Web Worker优化、服务端接收分片与合并,以及最终发布到IIS时那些绕不开的配置问题。
这篇文章重点面向两类人:一是还在用传统ASP.NET Web Forms、被“不能装载NTKO大文件上传控件,请确保使用IE浏览器”这类提示折磨的朋友,二是想从零实现或移植断点续传功能、但不确定后端怎么设计的.NET开发者。我会尽量把每一步为什么这样做讲清楚,而不是只丢一段能跑的代码。
1. 先搞清楚大文件上传为什么会失败
1.1 三个拦路虎:请求体限制、服务器超时、网络抖动
很多人第一次做大文件上传,都会发现一个诡异的现象:小文件随便传,一超过几百MB就报错,或者传到一半连接被重置。这不是ASP.NET的错,而是所有HTTP上传方案都会遇到的问题,只是不同技术栈暴露出来的错误形式不同。
第一个拦路虎是请求体大小限制。在传统ASP.NET中,web.config里的httpRuntime maxRequestLength默认只有4MB,也就是说超过4MB的请求直接被拒绝。就算你把这个值调大了,IIS层还有一个maxAllowedContentLength,默认30MB,它属于requestFiltering模块,会提前拦截超大请求,返回HTTP 404.13错误。这个错误很有迷惑性,光看状态码会以为是路由找不到,实际上是请求内容超限被IIS拦截了。
第二个拦路虎是超时。ASP.NET的executionTimeout默认是90秒,如果上传一个较大文件在90秒内没有完成,服务器会主动掐断请求。即使你把客户端超时设得很长,服务器也会在底层切断连接,表现为“已收到请求头但迟迟没有响应”。
第三个拦路虎是网络抖动。即便你把大小限制和超时都调到了“无限大”,只要上传过程中路由器重启、WiFi闪断、笔记本合盖,整个文件就得从头传。4G/5G环境下,一个2GB的文件很可能连续传十几次都失败。这个问题靠调参数解决不了,必须从传输模型上改。
1.2 示例项目要解决什么:分片、并发、断点续传
既然瓶颈在于“把一个完整的文件当作单个HTTP请求体发送”,那解决办法就是不让这个请求体太大、耗时太长。我们把文件切开,一片一片传,每一片都是一个独立的小请求,即使一片失败也只需要重传这一片。
这带来三个好处。第一,单次请求体大小可控,不用担心服务器限制和超时。第二,可以并发发送多个分片,利用带宽,上传速度比串行快不少。第三,天然支持断点续传——已经传过的分片不需要再传,只要服务端记录了哪些分片已经到达,下次继续传剩余分片即可。
这个示例项目就围绕这三件事展开:前端负责切片、并发控制、进度统计;服务端负责接收分片、记录已上传分片的索引、在所有分片都到达后完成合并。不依赖任何商业控件,不需要IE的ActiveX插件,一个现代浏览器就够了。如果你还在用老旧的NTKO控件,我建议尽快脱离,那套方案在如今的前端环境里简直寸步难行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点续传的设计思路:不是玄学,是“分而治之”
2.1 完整文件切成N片,记录每片状态
分片其实很简单,前端用File.slice(start, end)就能把文件切成若干Blob。真正需要设计的是“如何知道哪些分片已经上传成功”。我用的方案是这样的:
- 客户端读取文件内容,计算出一个唯一标识
fileId(通常用文件MD5或者“文件大小+文件名”的组合,但MD5更可靠)。 - 把文件按固定大小(比如2MB)切成
totalCount片。 - 每一片在服务端的标识为
{fileId}_{index}.part。 - 客户端上传前先请求服务端查询
fileId已经存在哪些分片索引。 - 上传某一片时,如果服务端发现该分片已经存在且大小一致,就直接返回成功,前端跳过。
这个方案里,fileId是断点续传的抓手,index是合并时的顺序依据。只要这两个信息不丢,无论浏览器刷新了多少次、网络断了几次,都可以接着传。
2.2 前端如何记录已上传分片
有人会问:断点续传需要把“已上传分片”存在哪里?我推荐以后端记录为准,前端只负责查询和展示。因为前端存储在localStorage里容易被清掉,而且换一台电脑就完全失效。后端在接收分片时,已经在磁盘上写入了对应的.part文件,这些文件本身就是上传状态。查询接口只需扫描目录,把存在的分片编号返回给前端。
这样设计还有一个好处:多端同步。同一个用户在公司传了一半,回家打开浏览器,只要fileId相同(比如文件名+大小+内容算出的MD5一致),就能继续上传剩余分片。当然,实际项目里把fileId和用户账号绑定更严谨,但示例项目可以暂不引入权限。
2.3 上传完成后的合并策略
当客户端收到所有分片都成功的响应后,调用一个merge接口触发服务端合并。合并流程很简单:先检查所有0到totalCount-1的分片文件是否存在且完整,然后按索引顺序读出来写入同一个目标文件。
合并时最容易踩的坑是内存爆炸。如果每片都读成byte[]再拼接,100GB的文件会在合并瞬间打爆服务器内存。正确做法是用固定大小缓冲区循环读取分片并写入目标流,比如每次读4MB,写4MB。示例项目里为了简洁直接用了File.ReadAllBytes,但实际生产环境务必改成流式读写。
3. 服务端实现:基于ASP.NET Core Web API的分片接收与合并
3.1 接口设计:UploadChunk / QueryStatus / Merge
我按REST风格设计了3个接口:上传分片、查询状态、触发合并。这几个接口足够支撑断点续传和秒传的完整逻辑。
code复制POST /api/upload/chunk // 上传单个分片,multipart/form-data
GET /api/upload/status/{fileId} // 查询某个文件已上传的分片索引
POST /api/upload/merge?fileId=xx&fileName=xx&totalCount=xx // 合并
分片上传接口接收的字段必须包含:FileId、Index、TotalCount、FileName和Data文件流。Index从0开始,TotalCount用于后面校验完整性和初始化进度展示。上传成功后返回该分片是否已存在,触发“秒传”效果。
3.2 分片数据写入临时文件,避免内存爆炸
服务端接收分片时,不要把所有字节累积在内存里,而是直接写入临时目录。下面是接收分片的核心代码:
csharp复制[HttpPost("chunk")]
public async Task<IActionResult> UploadChunk([FromForm] ChunkModel model)
{
var saveDir = Path.Combine(_env.ContentRootPath, "Uploads", model.FileId);
Directory.CreateDirectory(saveDir);
var partPath = Path.Combine(saveDir, $"{model.Index}.part");
// 如果分片已存在且大小相同,直接返回成功,表示不需要重新上传
if (System.IO.File.Exists(partPath) &&
new FileInfo(partPath).Length == model.Data.Length)
{
return Ok(new { uploaded = true, exists = true });
}
await using (var stream = new FileStream(partPath, FileMode.Create))
{
await model.Data.CopyToAsync(stream);
}
return Ok(new { uploaded = true });
}
这段代码有一个关键细节:判断分片是否已存在时,不能只靠文件存在与否,还要比对大小。如果上一次上传因为网络中断只写了一半,这个文件大小不对,前端应该重新传这一片。我通常建议再叠加一个MD5校验,生产环境可以做,示例中为了清晰先省略。
ChunkModel定义如下:
csharp复制public class ChunkModel
{
public string FileId { get; set; }
public int Index { get; set; }
public int TotalCount { get; set; }
public string FileName { get; set; }
public IFormFile Data { get; set; }
}
IFormFile是ASP.NET Core里处理multipart/form-data的标准类型,底层是流,不是把整个文件读入内存。只要配合FormOptions限制,就不会因为单个文件太大而内存溢出。
3.3 查询接口:让前端知道“该传哪些”
查询接口很简单,遍历分片目录下所有.part文件,把文件名(不包含扩展名)解析成整数索引,排序后返回。
csharp复制[HttpGet("status/{fileId}")]
public IActionResult GetStatus(string fileId)
{
var dir = Path.Combine(_env.ContentRootPath, "Uploads", fileId);
var uploaded = new List<int>();
if (Directory.Exists(dir))
{
foreach (var file in Directory.GetFiles(dir, "*.part"))
{
if (int.TryParse(Path.GetFileNameWithoutExtension(file), out var index))
{
uploaded.Add(index);
}
}
}
return Ok(new { uploaded = uploaded.OrderBy(x => x).ToList() });
}
前端拿到这个列表后,就可以跳过已存在的分片。如果所有分片都已存在,前端可以直接调用合并接口,这就是“秒传”。实际项目里这里还可以加入“服务端是否已有完整文件”的判断,如果整个文件已经存在,直接返回“文件已存在”,完全可以不传任何分片。
3.4 合并接口:顺序校验与流式写入
合并接口接收fileId、fileName、totalCount三个参数。首先检查目录下所有分片是否齐全,然后按顺序写入目标文件。注意不要使用File.ReadAllBytes拼接,要使用流式写入,否则大文件合并会占用大量内存。
csharp复制[HttpPost("merge")]
public async Task<IActionResult> Merge(string fileId, string fileName, int totalCount)
{
var dir = Path.Combine(_env.ContentRootPath, "Uploads", fileId);
var finalPath = Path.Combine(_env.ContentRootPath, "Uploads", fileName);
for (var i = 0; i < totalCount; i++)
{
var partPath = Path.Combine(dir, $"{i}.part");
if (!System.IO.File.Exists(partPath))
{
return BadRequest($"缺少分片: {i}");
}
}
await using (var output = new FileStream(finalPath, FileMode.Create, FileAccess.Write))
{
for (var i = 0; i < totalCount; i++)
{
var partPath = Path.Combine(dir, $"{i}.part");
await using var input = new FileStream(partPath, FileMode.Open);
await input.CopyToAsync(output);
}
}
Directory.Delete(dir, true);
return Ok(new { fileName, size = new FileInfo(finalPath).Length });
}
合并完成后建议删除临时分片目录,否则日积月累会留下大量垃圾文件。如果合并失败,保留临时文件还有机会重试,这一点可以根据业务要求权衡。我通常会先把分片目录保留24小时,由一个后台任务定期清理。
4. 前端实现:File API + 并发控制 + Web Worker
4.1 从File.slice到FormData:分片的核心代码
前端切片非常简单,不需要任何库:
javascript复制const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
function createChunks(file) {
const totalCount = Math.ceil(file.size / CHUNK_SIZE);
const chunks = [];
for (let index = 0; index < totalCount; index++) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({
index,
blob: file.slice(start, end)
});
}
return chunks;
}
上传每个分片时,用FormData把元数据和Blob一起发送:
javascript复制function uploadChunk(fileId, chunk, fileName, totalCount) {
const formData = new FormData();
formData.append('FileId', fileId);
formData.append('Index', chunk.index);
formData.append('TotalCount', totalCount);
formData.append('FileName', fileName);
formData.append('Data', chunk.blob, `${fileName}.part${chunk.index}`);
return fetch('/api/upload/chunk', { method: 'POST', body: formData });
}
这里有一个容易踩的坑:FormData.append('Data', chunk.blob, ${fileName}.part${chunk.index})的第三个参数是文件名,很多浏览器会根据扩展名设置Content-Type,虽然服务端用IFormFile接收时通常不关心这个,但还是值得保持一致,方便排查问题。
4.2 并发控制:不能把浏览器和服务器同时打死
分片上传如果不做并发控制,一次性把所有分片请求发出去,浏览器会建立大量TCP连接,很容易触发浏览器或者服务器的连接数限制,反而导致上传变慢甚至失败。我测试过的最优并发数通常在3到6之间,具体取决于服务器带宽和客户端网络。
实现并发控制可以使用简单的任务队列:
javascript复制async function uploadWithConcurrency(tasks, concurrency) {
const results = [];
let index = 0;
async function worker() {
while (index < tasks.length) {
const current = index++;
results[current] = await tasks[current]();
}
}
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, worker);
await Promise.all(workers);
return results;
}
这里把每个分片上传包装成一个返回Promise的函数,然后启动多个“线程”同时从任务队列里取任务。这样做不会让某个分片请求一直占用线程,整体吞吐量反而更高。
4.3 Web Worker优化:不阻塞UI的切片与哈希计算
对于超大文件,切片本身不是瓶颈,但计算文件的MD5是。如果直接在主线程用SparkMD5读取整个文件,页面会长时间卡住,用户可能以为浏览器崩溃了。更好的方案是把文件句柄传给Web Worker,在后台计算MD5,同时还能在Worker里完成文件切片,主线程只负责进度渲染。
一个简单的Worker脚本架构如下:
javascript复制// upload-worker.js
self.importScripts('/lib/spark-md5.min.js');
self.onmessage = function (e) {
const { file, chunkSize } = e.data;
const totalCount = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
let current = 0;
// 逐段读取并累加MD5
function next() {
const start = current * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const fileReader = new FileReader();
fileReader.onload = function (ev) {
spark.append(ev.target.result);
current++;
if (current < totalCount) {
next();
} else {
self.postMessage({
fileId: spark.end(),
totalCount,
chunks: createChunkMeta(file, chunkSize)
});
}
};
fileReader.readAsArrayBuffer(file.slice(start, end));
}
next();
};
主线程中只需要在input变化时把文件对象postMessage给Worker,然后监听Worker的message事件拿到fileId和分片列表。注意,Web Worker不能直接操作File对象的所有属性,但是可以接收File对象并进行slice,这是浏览器允许的。
如果你的文件不需要精确的MD5,也可以用“文件大小+最后修改时间”作为fileId,这样可以跳过哈希计算,但存在极低概率的冲突。我这边觉得MD5还是值得算的,毕竟断点续传最重要的就是文件唯一标识,万一冲突了,合并出来的文件就错了。
4.4 断点续传与秒传的完整客户端流程
完整的上传流程可以这样组织:
- 用户选择文件后,立即计算文件的
fileId(在Worker中完成)。 - 调用
GET /api/upload/status/{fileId},获取已上传分片索引。 - 生成所有分片任务,跳过已上传的索引。
- 用并发控制队列上传缺失分片,每传完一片更新进度条。
- 全部完成后调用
POST /api/upload/merge,由服务端合并文件。
如果服务端返回“所有分片都已存在”,那前端几乎不需要等待,直接进入合并步骤,用户看起来就是“秒传”。这正是断点续传与秒传结合的典型体验。
5. 最常见的配置坑:web.config与IIS部署
5.1 传统ASP.NET需要调整的参数
如果你还在用ASP.NET Web Forms或MVC 5,要让大文件上传生效,需要修改web.config里的两处配置:
xml复制<configuration>
<system.web>
<httpRuntime executionTimeout="3600" maxRequestLength="2097151"
requestValidationMode="2.0" />
</system.web>
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="2147483648" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
maxRequestLength单位是KB,2097151KB约等于2GB,这里按需设置。maxAllowedContentLength单位是字节,2147483648正好也是2GB。两者必须同时设置,因为前者是ASP.NET运行时限制,后者是IIS请求过滤限制,任何一个不满足都会被拒。
很多人忽略executionTimeout,导致上传超过90秒后报“超时”。注意,这个属性只对ASP.NET运行时管理的请求有效,如果请求卡在IIS层或其他模块,属性和它没有关系。设置大一点总没错,但更好的方案是分片上传,因为每个分片请求都不会超过几十秒。
5.2 ASP.NET Core发布到IIS时的限制配置
ASP.NET Core应用发布到IIS后,请求处理链路变成了“IIS -> AspNetCoreModuleV2 -> Kestrel”,三层各自有请求体限制。IIS的maxAllowedContentLength仍然生效,Kestrel默认不限制请求体大小,而ASP.NET Core的FormOptions.MultipartBodyLengthLimit默认约1.28亿字节(约128MB),这个决定了上传表单体的大小。
发布到IIS时,web.config通常由SDK生成,但你要手动加入requestLimits:
xml复制<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\YourApp.dll"
stdoutLogEnabled="false" hostingModel="inprocess" />
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="2147483648" />
</requestFiltering>
</security>
</system.webServer>
</location>
</configuration>
同时,在Program.cs里配置Kestrel和表单限制:
csharp复制builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 2L * 1024 * 1024 * 1024; // 2GB
});
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024;
});
这里有个细节:IIS的maxAllowedContentLength如果设置得比Kestrel小,请求会先被IIS拦截,所以两边要一起调。如果是反向代理部署(Nginx等),还要看代理层的上传大小限制,这里不展开。
5.3 那个“检测到有潜在危险的Request.QueryString值”是怎么回事
热词里出现的“webconfig检测到有潜在危险的 request.querystring 值”,这是很多老ASP.NET开发者都见过的错误。它的本质是ASP.NET的请求验证机制——默认会对查询字符串、表单、Cookie进行XSS检查,如果发现值看起来像HTML标签或脚本,就抛异常。
这个错误和大文件上传的直接关系不大,但它是分片上传时很容易踩的坑:假设你的分片查询接口使用GET /api/upload/status/{fileId},而fileId是文件内容的MD5,正常是十六进制字符串,不会触发验证。但如果你的fileId里有Base64字符(比如+、/、=),URL编码后某些反恶意软件工具可能会误解,或者在老式ASP.NET页面里被请求验证拦截。
解决方式分两种:如果是.NET Framework,可以在web.config里对特定页面设置validateRequest="false",并且把requestValidationMode设为2.0;如果是ASP.NET Core,已经没有这种全局请求验证,默认不会拦截这类字符串。但无论如何,不要把敏感参数放在URL里,用POST请求体传递是最稳妥的。
5.4 关于“不能装载NTKO大文件上传控件,请确保使用IE浏览器”
这个热词很典型,是中了老式ActiveX控件的毒。NTKO是一个只能在IE里运行的第三方上传控件,它的原理是浏览器插件直接操作文件系统,所以现代浏览器基本都不支持。而且微软自己都在逐步淘汰IE,继续依赖这种控件等于把自己的功能绑在一台古董机器上。
如果你的系统还在用NTKO,我的建议是尽快替换成HTML5方案。IE10及以上支持File.slice,IE11支持FormData和fetch(虽然fetch支持不完整,可以用XMLHttpRequest代替)。如果你必须兼容IE9,那只能退回到iframe+Flash方案,但那是另一个时代的产物了。本文这套分片上传方案在IE11上跑没问题,再老的IE就得打补丁了。
6. 实测后的经验:并发数与分片大小怎么选、断点续传的边界情况
6.1 分片大小与并发数推荐
这个东西没有绝对标准,我测试过几十组参数,下面是比较稳的参考值:
- 局域网/内网环境:分片大小5MB,并发数4~6。内网带宽高,分片大可以减少请求次数,减少服务端IO压力。
- 公网环境:分片大小1MB~2MB,并发数3。公网不稳定,分片小一些,失败重传成本低;并发太高会占满连接,反而容易触发运营商限速。
- 弱网环境(例如移动网络):分片大小512KB~1MB,并发数2。宁可慢一点,也要保证每个请求都能在较短时间内完成。
分片大小和并发数可以做成前端可配置参数,甚至动态调整。我见过有项目根据最近的网络延迟自动调整,效果很好,但示例项目不需要那么复杂。
6.2 覆盖各种中断场景:刷新、断网、关闭浏览器
断点续传最怕的不是服务端崩溃,而是客户端“假死”。我实测过的场景包括:
- 上传过程中按F5刷新:没问题,刷新后重新走一遍查询流程,已上传的分片直接跳过。
- 上传过程中断网:浏览器抛网络错误,前端捕获后提示重试。用户恢复网络后再次选择同一个文件,从头执行上传流程,已传分片会跳过。
- 上传过程中关闭浏览器标签页:已传分片保留在服务端,下次打开页面选择同一文件,继续传。
- 服务端在合并前重启:分片文件在磁盘上,重启后依然存在。只要查询接口能读到,续传不受影响。
这里要注意,如果你的前端刷新后丢失了fileId,就只能重新计算MD5。所以建议把fileId暂存到sessionStorage或localStorage,和文件最后修改时间绑定,下次选文件时如果发现相同大小、相同修改时间,直接复用缓存的fileId,省去重复计算。
6.3 效率对比:一次2GB文件上传的实测数据
我在一台2核4G的云服务器上做了测试,客户端是百兆家庭宽带。整包上传(假设强行关闭所有限制)到第1.7GB时连接被运营商掐断,耗时约8分钟。用方案A:2MB分片、并发3,总耗时约5分40秒,中间模拟断网30秒,恢复后继续传,最终完成。用方案B:5MB分片、并发5,总耗时约4分50秒,但过程中有两次分片失败需要重试,重试机制扛住了。
结论很清晰:分片并发不仅能断点续传,还能利用并行连接提高吞吐量。但并发数和分片大小需要权衡,不是越大越好。有一点特别重要:一定要在服务端做分片大小校验。比如前端设置2MB一片,但如果某个分片上传时被代理截断,服务端收到的可能不足2MB。此时如果继续合并,文件就损坏了。我通常在合并前检查每个分片的大小,如果大小不在预期区间(除了最后一片),就返回错误并让前端重传。
6.4 给生产环境的额外建议
如果你准备把这个方案用到生产环境,还有几件事不能省:
- 给分片上传接口加身份认证,否则任何人都可以往你的Uploads目录里传垃圾文件。
- 对
fileName做安全处理,不能直接使用用户传入的文件名拼接到路径中,避免路径穿越。 - 合并完成后及时清理临时目录,并用定时任务清理超过24小时未合并的孤儿分片。
- 如果文件超大(超过10GB),建议加入校验码机制(比如对每个分片记录MD5,合并后对完整文件做一次校验)。
- 上传进度不要只看“已上传字节数”,因为并发请求会导致顺序错乱,最好用“已上传分片数/总分片数”来显示百分比。
IIS发布时,如果发现上传进度条一直不动,优先查看IIS日志和事件查看器里的“AspNetCore Module”日志,确认请求是否到达了应用层。很多时候问题出在代理层或防火墙,而不是ASP.NET代码。
这个示例项目里的代码并不是最优解,但它是清晰、可运行、能帮你理解断点续传核心原理的版本。我自己后来在这个基础上加入了分片MD5校验、多租户隔离、断点合并,甚至把分片存储迁移到了云存储,但核心逻辑仍然是“分片、查询、合并”这三板斧。如果让我给你一句忠告:不要迷信任何商业控件,先把分片模型想明白,剩下的都是工程细节。
