写这篇之前,先交代下背景。我在央企信息化部门干了快十年,最近刚把单位里一套老旧的ASP.NET文件上传系统从“IE控件时代”拉到了“跨平台断点续传时代”。这套系统主要是用来上传工程设计图纸、监理视频、勘探数据这类大文件,单文件动不动就是几GB。以前全靠ActiveX控件撑着,后来单位逐步淘汰Windows+IE的组合,一批人换了国产操作系统,还有人用macOS办公,控件方案直接崩了。于是“如何在不重写整个系统的情况下,给现有ASP.NET上传方案加入跨平台、可断点续传的能力”就成了我们必须解决的问题。
这篇文章我不讲虚的,把我从需求分析、技术选型、前后端改造,到内网压测、踩坑修复的真实过程都梳理一遍。里面所有方案、配置、代码片段都是实际跑过的,你可以直接当参考。
1. 老系统为什么会卡在“跨平台”这道坎上
很多在央企做过信息化的人应该都有同感:老一代的上传系统,十有八九是ASP.NET Web Forms + ActiveX控件或者Silverlight控件。控件能直接读写本地文件路径、能拿到文件大小、能监控上传进度,所以做断点续传很自然——控件事先把文件切开,一片一片传到服务器,传完的片记录下来,中断后从断点继续。
这套玩法在Windows + IE时代没毛病。但到了2024年前后,三个现实问题直接堵死了老路:
- 浏览器不再支持ActiveX。Edge、Chrome、Firefox、以及国产操作系统自带的浏览器,全部不支持ActiveX,甚至IE本身都走到生命周期尽头了。用户打开系统,直接提示“请使用IE浏览器并检查安全设置”,然后就卡死了。
- 办公终端生态在变。我们单位现在有一半以上的新机器预装的是国产操作系统(麒麟、统信UOS),办公软件和浏览器都要走信创路线。老控件在Linux内核上根本无法运行。
- 服务端也要考虑Linux。虽然目前生产环境还是Windows Server + IIS,但后续新部署的节点很可能采用Linux + Nginx + Docker。如果方案只能在Windows上跑,迟早还要再改一遍。
所以我们对新方案的定义一开始就很明确:必须走纯B/S + 标准HTTP协议,前端不依赖任何操作系统特性,后端不依赖IIS专有能力。换句话说,就是放弃“控件直连本地文件”的老思路,改成“浏览器JS读文件分片 + 服务端临时目录收片 + 合并回原始文件”。这个思路和是WebForms还是MVC还是ASP.NET Core关系不大,关键是HTTP交互和文件IO,理论上一套方案能吃遍所有ASP.NET版本。
这里多说一句,很多人在设计阶段容易被“断点续传”这个词带偏,以为要像下载工具那样精确记录每个字节的偏移量。实际上Web端做断点续传,主流且好维护的方式是“分片续传”:把文件切成N个分片,每个分片独立上传,服务端记录哪些分片已经收到,下次继续传缺失的分片即可。它不追求字节级续传,但效果完全够用,而且天然支持并发、支持失败重试、支持校验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分片续传的整体设计:一次把逻辑链路理清楚
我先把整套系统的模块结构画出来讲清楚,因为很多人的方案最后写成了东拼西凑的补丁,根本原因是链路没想透。
一个完整的断点续传设计,从前端到后端,至少要包含以下模块:
| 模块 | 职责 | 关键点 |
|---|---|---|
| 前端分片模块 | 读取文件、按大小切片、计算每片标识 | 用File.slice(),不能直接读整个文件进内存 |
| Worker并发控制 | 同时上传多个分片,避免线程阻塞主页面 | 用Web Worker,让UI不卡 |
| 本地断点记忆 | 记录已成功上传的分片索引 | localStorage/IndexedDB,刷新页面后能恢复进度 |
| 服务端分片接收API | 接收单个分片、落盘、校验 | 每片一个临时文件,命名规则可追溯 |
| 分片状态管理 | 记录文件与会话的所有分片状态 | 可以用Redis或DB,建议落盘可恢复 |
| 合并模块 | 所有分片到齐后合并成完整文件 | 按索引顺序写入,合并后计算整个文件MD5 |
| 清理模块 | 删除过期临时文件 | 防止磁盘被半截文件耗尽 |
| 安全审计模块 | 记录上传人、上传时间、文件哈希、业务标识 | 央企系统审计是硬需求 |
然后说交互流程。我把一次完整的大文件上传拆成四个阶段:
- 预创建上传会话:前端先把文件名、文件大小、分片大小、分片总数、每个分片的MD5值(或者至少最后一片的MD5)提交给服务端。服务端生成一个uploadToken,同时在这个Token下初始化一个上传状态记录。
- 查询断点状态:前端拿着uploadToken去问服务端“哪些分片我已经传过了”,服务端返回已收到的分片索引列表。这一步就是断点续传的核心,前端据此决定从哪个分片继续传,不需要重新传。
- 并发上传分片:前端通过Worker并发上传没有传过的分片,每个分片请求都带上uploadToken和分片索引。
- 合并文件:全部传完后,前端调一次合并接口。服务端校验分片完整性后,顺序合并成完整文件,然后做整体MD5校验,通过后返回最终文件路径和下载地址。
这套链路里,前端每次上传分片之前先调用“查询断点”接口,是因为用户可能换了一台电脑、清了浏览器缓存、或者隔了好几天才回来继续传。我们不能只依赖localStorage里的本地记录,服务端的状态才是权威。
从“为什么这样设计”的角度看,有几条经验值得分享:
- 分片大小建议设置在2MB~20MB之间。我们内网环境带宽好,用的8MB一片;如果公网传输,建议4MB或2MB。分片太小会导致HTTP请求数量过多,服务端IO压力大;分片太大又失去断点续传的粒度意义。
- MD5校验不能省。大文件传输过程中,偶尔会有网络层静默损坏,尤其是在负载均衡设备或多跳链路上。每个分片算一次MD5,服务端校验通过才落盘,可以有效防止最后合并出来的文件是坏的。
- 服务端状态必须能恢复。如果服务端只把状态放在内存里,IIS一回收进程、或者应用池重启,所有临时文件和状态全部丢失,用户得从头传。所以状态要么落数据库,要么落磁盘文件,或者用Redis持久化。
下面我用一个简化的数据模型说明状态如何管理:
code复制UploadSession
{
UploadToken string // 全局唯一,GUID即可
FileId string // 业务系统分配的文件ID,也可能是附件ID
FileName string // 原始文件名
FileSize long
ChunkSize int
TotalChunks int
UploadedChunks List<int> // 已完成的分片索引
FileMD5 string // 前端算好的整体MD5,合并后校验
Status int // 0=初始化 1=传输中 2=已合并 3=失败
CreateTime DateTime
UpdateTime DateTime
CreatedBy string // 哪个用户发起的,审计用
}
这个模型在当前系统里我用了一张Oracle表存储,放在现有业务库里。每次上传分片就更新UploadedChunks,用乐观锁防止并发写冲突。索引就建在UploadToken上,查询很快,完全没必要用Redis,但如果你所在单位有现成的Redis,用它也行,只是要考虑持久化策略,别把状态搞丢了。
3. ASP.NET服务端实现:从Web.config到分片接收逻辑
这一节开始写代码层面的东西。我们老系统主体是ASP.NET Web Forms(.NET Framework 4.5)跑的,这次我把新增的上传模块做成了独立的ASP.NET Core Web API项目,部署在同一个IIS站点下的子应用里。这样做的好处是不动老代码,坏处是需要处理子应用的路径配置,我会在踩坑部分展开。
先说传统ASP.NET(.NET Framework)下最容易踩的配置文件问题。如果你不想把新模块拆成Core项目,而是直接在老的Web Forms工程里加页面或一般处理程序(ashx)来处理分片上传,那么Web.config必须做三处调整:
xml复制<configuration>
<system.web>
<!-- 这个值是ASP.NET管线的请求体大小限制,默认4096KB(4MB)。单位是KB,GB级别需要写大 -->
<httpRuntime targetFramework="4.5" maxRequestLength="2097152" executionTimeout="3600" />
</system.web>
<system.webServer>
<security>
<requestFiltering>
<!-- 这是IIS层面的限制,默认30000000(约28MB)。单位是字节 -->
<requestLimits maxAllowedContentLength="2147483647" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
注意这里有个很隐蔽的问题:如果请求被IIS层的requestFiltering拦了,返回的是404.13错误,不会走到你的代码里。如果你看到日志里根本没有请求进来,十有八九就是这层没过。调试的时候可以先打开IIS的失败请求追踪(Failed Request Tracing),可以看到具体是哪个模块拦截的。
然后是分片接收接口。我用ASP.NET Core Web API实现,代码核心逻辑如下:
csharp复制[HttpPost("api/upload/chunk")]
public async Task<IActionResult> UploadChunk(
[FromForm] string uploadToken,
[FromForm] int chunkIndex,
[FromForm] string chunkMd5,
IFormFile chunkFile)
{
// 1. 校验会话是否存在
var session = await _uploadSessionRepo.GetByTokenAsync(uploadToken);
if (session == null) return BadRequest("upload session not found");
// 2. 创建分片临时目录,按UploadToken分文件夹
var chunkDir = Path.Combine(_config.TempRoot, uploadToken);
Directory.CreateDirectory(chunkDir);
// 3. 分片落盘,文件名直接叫 000001.chunk 这种
var chunkPath = Path.Combine(chunkDir, $"{chunkIndex:D6}.chunk");
await using (var stream = new FileStream(chunkPath, FileMode.Create, FileAccess.Write, FileShare.None, 8 * 1024 * 1024))
{
await chunkFile.CopyToAsync(stream);
}
// 4. 校验分片MD5,不一致就删掉分片并报错
var md5 = CalculateMD5(chunkPath);
if (!string.Equals(md5, chunkMd5, StringComparison.OrdinalIgnoreCase))
{
System.IO.File.Delete(chunkPath);
return BadRequest($"chunk {chunkIndex} md5 mismatch, actual={md5}");
}
// 5. 更新会话状态
await _uploadSessionRepo.MarkChunkUploadedAsync(uploadToken, chunkIndex);
return Ok(new { received = true, chunkIndex });
}
这段代码看着简单,实际有几个细节要强调:
- 临时目录不能放在站点目录内。一是站点目录如果配置了静态文件服务,分片可能会被直接下载;二是目录刷新生效时可能会锁IO。我建议放独立的目录,比如
D:\UploadTemp,并且定期清理超过72小时没合并的临时目录。 - 写分片时不要用FileMode.Append。虽然每个分片只写一次,但万一前端超时重试同一个分片,Append会把数据重复写进去。用FileMode.Create,即使重试也是覆盖写,幂等。
- MD5校验放落盘之后还是之前?我实测在千兆内网上,校验一个8MB分片的MD5大约耗时20~50ms,完全可接受。所以先落盘再校验,省内存,逻辑也清晰。如果你用Stream方式边读边校验,要注意FileStream的Position复位。
- 并发更新会话状态时防冲突。多个分片同时上传成功,会同时去更新UploadedChunks这一行记录。如果你用
UPDATE ... SET UploadedChunks = UploadedChunks || :new这种追加模式,没问题;但如果你用“先读出来、内存加、再写回”,在并发下就会丢数据。我用的是Oracle的CLOB字段+追加一个分隔符,实际测试比较稳。
接下来是合并接口。合并逻辑是分片上传里最容易写错的地方之一。很多人直接遍历文件按顺序写入,但没考虑分片缺失、顺序错乱、磁盘空间不足等情况。我的实现是先校验分片齐全再合并:
csharp复制[HttpPost("api/upload/merge")]
public async Task<IActionResult> MergeChunks([FromBody] MergeRequest request)
{
var session = await _uploadSessionRepo.GetByTokenAsync(request.UploadToken);
if (session == null) return BadRequest("session not found");
if (session.Status != 1) return BadRequest("session status invalid");
var chunkDir = Path.Combine(_config.TempRoot, request.UploadToken);
// 检查分片数量是否齐全
for (int i = 0; i < session.TotalChunks; i++)
{
var chunkPath = Path.Combine(chunkDir, $"{i:D6}.chunk");
if (!System.IO.File.Exists(chunkPath))
return BadRequest($"chunk {i} missing");
}
// 输出目录
var finalDir = _config.FileRoot;
Directory.CreateDirectory(finalDir);
var finalPath = Path.Combine(finalDir, $"{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}_{SanitizeFileName(session.FileName)}");
await using (var finalStream = new FileStream(finalPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 8 * 1024 * 1024))
{
for (int i = 0; i < session.TotalChunks; i++)
{
var chunkPath = Path.Combine(chunkDir, $"{i:D6}.chunk");
await using var chunkStream = new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read);
await chunkStream.CopyToAsync(finalStream);
}
}
// 整体MD5校验
var fileMd5 = CalculateMD5(finalPath);
if (!string.Equals(fileMd5, session.FileMD5, StringComparison.OrdinalIgnoreCase))
{
System.IO.File.Delete(finalPath);
return BadRequest("file md5 mismatch, merging failed");
}
// 更新状态、删除临时目录
session.Status = 2;
session.FinalPath = finalPath;
await _uploadSessionRepo.UpdateSessionAsync(session);
Directory.Delete(chunkDir, true);
return Ok(new { filePath = finalPath, fileMd5 });
}
合并这一步我特别建议做成异步任务,因为几个GB的文件合并要几十秒甚至几分钟,HTTP请求等不了那么久。更稳妥的做法是:合并接口只负责把Session状态改成“合并中”,然后立即返回;后台有一个HostedService定时扫描“合并中”状态的会话并执行合并逻辑,合并完再回调业务系统或者由前端轮询查询状态。我们现在的实现就是后台合并+前端每2秒轮询一次状态。
至于传统ASP.NET Web Forms下怎么处理,思路也一样:用一个一般处理程序(.ashx)或Page方法接收multipart/form-data,把HttpPostedFile保存到临时目录。核心逻辑完全通用,只是接收文件的部分写法有差异。如果你用的是老工程,建议单独为上传建一个无Session的页面,避免Session锁导致并发阻塞。
4. 前端工程化:Web Worker分片、断点记忆与并发控制
前端这一半,我踩的坑比后端还多。最核心的问题是:直接用主线程读文件、切片、上传,GB级文件会把浏览器UI卡死。用户的电脑配置参差不齐,必须把文件读取和分片上传放到Web Worker里。
我用的方案是主线程 + 一个Worker + 多个XHR上传请求。主线程负责File对象切片,把每个分片的ArrayBuffer通过postMessage送给Worker,Worker负责计算MD5并上传。但这里有个坑:MD5计算需要读整个分片的ArrayBuffer,如果你在Worker里用SparkMD5或者hash-wasm,注意不能把ArrayBuffer直接传过去然后再传回来,那样会触发结构化克隆,内存翻倍。更好的实践是:主线程直接切Blob传给Worker,Worker内部用FileReader读取并计算MD5,然后Worker自己发起XHR上传。这样主线程只负责UI和调度,不碰大块内存。
前端核心代码结构大致是:
javascript复制// 主线程
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const chunkSize = 8 * 1024 * 1024; // 8MB
const totalChunks = Math.ceil(file.size / chunkSize);
// 1. 创建上传会话
const sessionRes = await fetch('/api/upload/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
chunkSize,
totalChunks
})
});
const session = await sessionRes.json();
// 2. 查询已上传分片
const statusRes = await fetch(`/api/upload/status?uploadToken=${session.uploadToken}`);
const status = await statusRes.json();
const uploadedChunks = new Set(status.uploadedChunks);
// 3. 把未传分片派发给 Worker
const worker = new Worker('/js/uploadWorker.js');
worker.postMessage({
action: 'start',
uploadToken: session.uploadToken,
file: file,
chunkSize: chunkSize,
totalChunks: totalChunks,
uploadedChunks: Array.from(uploadedChunks)
});
worker.onmessage = (e) => {
const { type, index, progress, md5 } = e.data;
if (type === 'chunkProgress') {
// 更新进度条
updateProgress(index, progress);
} else if (type === 'chunkComplete') {
// 标记某个分片完成
markChunkDone(index, md5);
} else if (type === 'allComplete') {
// 全部传完,调合并接口
mergeFile(session.uploadToken);
}
};
});
Worker内部的逻辑也有讲究。我一开始为了省事,让Worker串行上传分片,结果一个8GB文件、8MB一个分片就是1024个请求,串行传千兆网也要传半天。后来改成Worker内部维护一个简单的并发池,同时最多传4个分片。再多不建议,因为服务端并发写磁盘、IIS连接数、以及运营商/网关并发限制都会成为瓶颈。实测4并发上传大文件时,单文件吞吐能跑到300MB/s以上,把千兆内网快跑满了。
然后是断点记忆。我们要支持“关掉浏览器、重启电脑后,重新打开页面还能继续传”。这个单靠内存里的Set是不够的。我用IndexedDB把每个文件的已传分片列表存下来,结构很简单:
code复制upload_records
{
uploadToken: string, // 主键
fileId: string,
fileName: string,
fileSize: number,
chunkSize: number,
totalChunks: number,
uploadedChunks: number[],
lastUpdate: number
}
用户重新打开页面时,先读IndexedDB里的upload_records,然后调服务端status接口,两边取并集(以服务端为准,因为用户可能在另一个终端已经传了一部分)。最后用这个并集生成新的上传计划。
这里有一个容易忽略的产品细节:同一个uploadToken如果重新发起上传,分片覆盖写就行了,但一定要允许用户“选择继续上传”或者“重新上传”。 有些用户就是要清掉重传,我们不能强制续传。所以界面上要有两个按钮:“继续上传”和“重新上传”。重新上传就是调一个新的create接口,生成新的uploadToken。
Worker的上传请求部分,我直接用的XMLHttpRequest,没走fetch。原因有两个:一是XHR有现成的upload.onprogress事件可以拿分片上传进度;二是fetc在请求取消和进度事件上支持不如XHR成熟。其实用axios封装也行,但Worker里引入axios有点重,我直接用原生XHR,代码也不复杂:
javascript复制function uploadChunk(uploadToken, chunkIndex, fileBlob) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('uploadToken', uploadToken);
formData.append('chunkIndex', chunkIndex);
formData.append('chunkMd5', ''); // 稍后计算完再填
formData.append('chunkFile', fileBlob, `chunk_${chunkIndex}`);
xhr.open('POST', '/api/upload/chunk');
xhr.timeout = 120000; // 2分钟超时,内网其实用不了这么久
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
self.postMessage({ type: 'chunkProgress', index: chunkIndex, progress: e.loaded / e.total });
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`upload chunk ${chunkIndex} failed: ${xhr.status} ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error(`network error on chunk ${chunkIndex}`));
xhr.send(formData);
});
}
注意一点:chunkMd5 这个字段,你可以让Worker在postMessage之前先算好,也可以在服务端接收后回传MD5但前端不校验。我最后是让Worker先算MD5,再组装FormData,把MD5填进去。这样服务端校验失败时可以精确定位是哪个分片损坏。
前端还有一个很重要的交互细节:进度条的计算口径。别只用“已传字节/总字节”来表示,因为每个分片上传时的瞬时速度差异很大,进度条来回跳会让人焦虑。我的做法是:全局进度=已传完分片数/总片数,另加一个子进度条显示当前正在上传的4个分片的实时速度。这样整体进度是单调递增的,看着舒服,也能反映真实状态。
最后,断点续传还有一个“假成功”场景要防:所有分片都上传成功了,但合并接口因为服务端临时故障没调成功。前端要保证,分片全部传完后,如果合并请求失败,自动重试合并,最多重试三次。如果三次都失败,再提示用户联系管理员,并保留uploadToken和本地记录,不要清空。否则用户以为传完了,其实文件根本不可用。
5. 央企业务环境下的安全审计、资源清理与多机高可用
为什么专门拉一章讲央企环境,是因为这个场景和互联网公司的方案有本质区别。我们系统对安全审计、文件合规、资源清理的要求比常规To B系统严得多,而且内网环境往往还有各种奇怪的限制,不做足功课上线后会出很多幺蛾子。
5.1 权限校验与审计日志
服务端每个上传分片的接口里,都必须校验当前登录用户是否有该业务文件的上传权限。这不能只在前端做,因为分片请求完全可以被人为构造。我在自定义的ActionFilter里统一做权限校验,读取Header里的用户票据,再查一次业务系统的文件权限表。因为分片数量很多(8GB文件1075个分片),每片都查数据库会带来压力,所以我做了一层简单的内存缓存:同一个uploadToken第一次校验后,五分钟内不再重复查库。这个缓存粒度就够了,因为在系统里5分钟内已经能传上百个分片。
审计日志必须记录的信息包括:用户ID、业务文件ID、上传时间、文件名、文件大小、分片总数、最终MD5、服务器IP、客户端IP。我们的做法是每收到一个分片就写一条日志到日志表,但为了不让日志表膨胀得太多,我把分片日志放在集成日志里(NLog文件日志),只有合并成功后写一条正式的审计记录。审计记录要保留至少三年,这条是硬要求。
5.2 文件类型白名单与病毒扫描
大型央企的文档管理系统,往往对接了防病毒网关或者离线杀毒服务。大文件上传进来之后不能直接入库,合并完成后必须过一遍杀毒扫描。如果你们有现成的杀毒API,直接在合并后调一次,扫描通过再更新状态;如果没对接,最次也要做文件类型白名单校验。
文件类型校验这一点我要特别说:不要只看扩展名,要看文件头(magic number)。我们系统里有一类文件是必须严格限制为DWG图纸的,以前有人把其他文件改了扩展名传进来,导致下游系统解析崩溃。所以我加了一个“文件头校验”功能,对DWG、PDF、MP4、ZIP等常见格式,读取文件前16字节,和预设的magic number比对,不一致直接拒收。对于DWG这种变种比较多的格式,还要额外解析一下AutoCAD版本信息,不过这个工作可以放到合并完成后异步做,不必阻塞上传流程。
5.3 临时资源清理
央企系统上线后往往运维人手不足,资源清理机制一定要自动化。我在临时目录的根目录下创建了一个CleanupMarker.txt文件,里面写清理策略;后台服务每30分钟扫描一次TempRoot下的所有子目录,如果子目录最后修改时间早于72小时,直接删除整个目录。这个策略对“用户传了一半就放弃”的场景特别管用。说句不好听的,曾经上线第一周,因为前端测试时反复创建会话,临时目录迅速积累了40多GB的半截文件,如果不是有自动清理,磁盘早就报警了。
清理的触发时机还有一点技巧:上传会话创建时如果发现临时目录所在磁盘剩余空间不足文件大小的150%,直接拒绝创建会话。这个判断能提前拦住90%的磁盘打满事故。
5.4 多机部署时的分片一致性
随着单位上云,系统开始做多节点负载均衡,这就带来了一个棘手的问题:用户传到A机器的分片和传到B机器的分片,最后怎么合并?
分片上传天然具备“不同请求可能被打到不同后端”的特性。最简单的解法:合并时让后端从共享存储上读分片。如果分片目录本身就在NAS或云盘上,那不管哪个节点收的分片,最终都在同一个共享存储目录里,合并节点只要把整个目录下的分片读出来即可。但如果后端没有共享存储、只有本机磁盘,那就必须做“节点亲和”:在创建上传会话时,按一定的路由规则把token绑定到某台节点,后续所有分片请求都路由到同一台节点。实现方式有几种,包括在负载均衡上按Header里的uploadToken做一致性哈希、或者客户端在拿到会话信息时记录节点信息、后续直接请求该节点。
我推荐能用共享存储就用共享存储,因为节点亲和方案在节点故障时会更麻烦——A节点挂了,用户的所有分片在B节点上找不到,续传就断了,只能重新传。共享存储虽然性能会受一点影响,但对GB级文件来说,一般都在可接受范围内。
5.5 内网网关上传大小限制
最后提醒一个经常被忽略的检查项:内部网络经常有WAF或者应用网关,它们默认也会限制HTTP请求体大小。我们有一套测试环境,走了APISIX网关,默认请求体限制是100MB,改了后端所有配置也没用,分片一超过100MB就被网关返回413。最后是把网关的client_max_body_size调到了和分片大小匹配的值才算通。任何分层网络设备——包括但不限于Nginx、APISIX、F5、深信服WAF——都可能默认限制请求体大小,排查这类问题时一定要按链路逐层看,不要只盯着后端IIS和ASP.NET配置。
6. 压测表现与参数调优:一分一毫都是实测出来的
压测这块,我不谈那种PPT式的“性能满足要求”,直接给出我们真实的测试环境、数据和我调参的过程。
测试环境:
- 客户端:Windows 10 + Chrome 115,千兆网卡
- 服务端:Windows Server 2019 + IIS 10 + ASP.NET Core 6.0,Dell R740,16核64G,SSD RAID5
- 文件类型:一个5.6GB的MP4文件、一个2.8GB的DWG图纸压缩包
- 分片大小:先用8MB,后对比4MB和16MB
- 并发分片数:4
实测数据:
| 分片大小 | 并发数 | 总耗时 | 有效吞吐 | 服务端CPU峰值 | 备注 |
|---|---|---|---|---|---|
| 4MB | 4 | 约2分钟 | 约48MB/s | 35% | 请求数量最多,IO并发高 |
| 8MB | 4 | 约1分35秒 | 约63MB/s | 28% | 综合表现最好 |
| 16MB | 4 | 约1分50秒 | 约51MB/s | 22% | 分片数量少了,但每片传输和校验时间变长 |
| 8MB | 2 | 约2分30秒 | 约38MB/s | 20% | 并发少,吞吐明显下降 |
| 8MB | 6 | 约1分30秒 | 约62MB/s | 40% | 吞吐提升有限,CPU反而高了不少 |
最后我选择8MB分片+4并发,因为在这个配置下吞吐最高且CPU负载可控。
现在聊聊调参的几个教训:
- 分片大小不是越小越好。4MB分片会使文件产生1400多个HTTP请求,每个请求都有建连和序列化的开销,服务端IO线程和文件句柄压力巨大。8MB分片综合最优,但如果你走公网且网络质量一般,建议退回2MB或4MB,减少单分片传输时间,降低超时概率。
- 并发数也不是越多越好。我们试过8并发,吞吐几乎没涨,CPU反而涨了一倍。原因可能在于ASP.NET Core的Kestrel线程池和文件落盘IO共同限定了瓶颈。推荐从4开始,观察瓶颈再增减。
- 大文件合并耗时:5.6GB文件合并大概需要15秒。这个时间说长不长,但HTTP请求等不了,所以合并必须后台做,前端轮询。
- IIS的TLS? 大部分内网应用走了HTTPS,分片上传时会增加TLS握手开销和CPU加解密开销。因为我们用互联网大厂经验的思路——长连接复用——能有效降低TLS开销。XHR每次请求都连到同一台服务器,大多数浏览器会复用TLS连接,实测在HTTP/2下1.5GB文件的上传SSL握手次数很少,损耗可接受。
另外,我们还专门测了弱网续传场景。用Clumsy工具模拟了丢包和延迟,在传输到38%时断开网络,等网络恢复后重新打开页面,调status接口发现未传分片全部重新排队,已传分片秒跳过,最后文件整体MD5与本地一致。这个验证很关键,因为断点续传功能如果只是看起来能用,到最后合并出来坏文件,还不如不做。
7. 最终落地:一个让所有人都能用的上传体验
在业务层面,我最终的落地界面没有搞复杂的科技感,而是做了三件事:
第一,把“续传”能力透明化。当用户在同一个浏览器再次选择同一个文件上传时,系统直接在界面上提示“检测到您上次上传未完成,是否继续上传?”点“继续”后,几乎瞬间就跳过所有已传分片。点“重新上传”则走全新流程。这个交互逻辑对用户来说零学习成本。
第二,给IT管理员提供一个“上传会话查询”后台。可以按用户、按文件、按时间段查询所有上传会话,看到每个会话的进度、剩余分片数、是否卡死。这个后台在央企系统里很重要,因为很多用户不会主动反馈“上传一直停在80%”,而是会直接打电话问“系统是不是坏了”。有了后台,管理员一秒定位原因。
第三,把上传结果和业务系统打通。合并成功后会向业务系统推送一条消息,业务系统直接拿到最终文件路径、文件哈希和大小,不需要用户再二次确认。这一步省掉了原系统里“上传到临时区后再点提交”的冗余步骤,用户满意度提升明显。
至于老系统里那些历史文件和旧的ActiveX控件入口,我们采用灰度策略:新入口和旧入口并存一个月,等所有单位都切换完毕再下线旧控件。这个过渡方案在央企是很必要的,因为人员多、终端杂,不可能一个白天之内全切过去。
最后,再分享一个细节。如果你也在用ASP.NET Core做这个功能,一定要关注Kestrel的请求体上限。ASP.NET Core默认限制MaxRequestBodySize为30MB,即使IIS层放开了,Kestrel自己也会拦截,返回413。在Program.cs里配置:
csharp复制builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 16 * 1024 * 1024; // 设为分片大小,不用太大
});
这里设成和分片大小差不多就行,比如8MB分片就设16MB,留点余量给FormData的其他字段。不要设成无限大,否则恶意请求直接打满内存。这个配置坑了我整整半天,当时怎么调都报413,最后用curl分片测试才发现是Kestrel这一层。
8. 移植到其他后端时的通用经验
虽然题目问的是ASP.NET,但整套设计里最核心的东西——上传会话、分片状态、合并校验——在后端换成Java或Node.js后,依然可以复用。我用这套方案指导过别的团队在Spring Boot里做了类似的实现,前端代码几乎不用改,只需要改API地址和适配后端字段名。
如果你也要移植,我的建议是:
- 把“分片上传”抽象成和业务系统解耦的独立服务,开放五个接口:创建会话、查询状态、上传分片、合并文件、查询文件信息。业务系统只关心最终的文件句柄,不用管分片细节。
- Java后端注意MultipartFile接收时要设置
spring.servlet.multipart.max-file-size和max-request-size,这两个配置默认只有1MB和10MB,很多人传大文件报错就是忘了这里。 - Node.js后端如果用的是Express + multer,注意multer的
limits.fileSize默认也是无限大,但如果你没用multer而是自己解析request,要小心内存泄漏。
跨平台这个要求,本质上不是某个语言的问题,而是你有没有把“文件切分”和“状态管理”放在标准协议层。只要HTTP能通,任何现代后端都能实现。相反,如果你的方案里用了任何“只能跑在Windows上的组件”,那不管前端怎么做,跨平台都会打折扣。
这些年经手过不少上传方案,最大的体会是:大文件上传从来不是“加一个接口”的事,它涉及前端资源调度、后端并发控制、网络设备配置、运维清理策略、甚至用户操作习惯。你把它当做一个完整的小系统来设计,才真正经得住生产的考验。后面如果再遇到类似需求,建议你先看看现有的网络链路和设备,再决定分片大小和并发策略,别照搬我的参数,环境不同结论可能完全不同。
