
有一年我们给一家炼化设计院升级图纸归档系统底层是ASP.NET WebForm上线第一个月收到最多的工单不是检索慢而是传了一个多小时的文件最后提示上传失败。这类问题以前做Web项目时也遇到过但能源化工行业的文件尺寸直接刷新了我的认知——PDMS三维模型动辄十几GBSCADA导出的历史数据几十GB还有地震解释成果、录井曲线、成套设计图纸哪个都不是普通Web系统能伺候的体量。当时摆在面前的就是一个很现实的命题在存量WebForm项目里怎么把大文件分片、断点续传这摊事做扎实让用户真的敢把几十GB的文件交给浏览器去传。这篇文章我按实际落地顺序来写先讲清楚传统WebForm为什么扛不住大文件再给出整体数据流设计然后分别拆解前端、后端的关键实现最后是能源化工行业工程化时绕不开的避坑经验。适合正在维护老系统、又必须解决大文件上传问题的工程师参考。1. 先说痛点传统WebForm上传为什么在能源化工场景扛不住大文件1.1 FileUpload控件的三个天然瓶颈老ASP.NET WebForm开发都熟悉这个场景拖一个asp:FileUpload后台SaveAs完事。这个组合应付几MB的附件没问题可一旦文件到了GB级别麻烦是连环的。第一个瓶颈是HTTP请求大小限制。ASP.NET在httpRuntime里有个maxRequestLength默认4096KBASP.NET 4.x里实测还会受IIS的maxAllowedContentLength约束后者默认约28MB。也就是说光靠拖控件就算把maxRequestLength改到2GBIIS那层也会拦一道。两个地方不一起调上传大文件会直接收到404.13或413错误。公司内部不懂后端的人看到这个错误一定会以为系统坏了。第二个瓶颈是内存。WebForm的FileUpload控件在服务器端会把整个请求内容缓冲到内存或者暴露成一个HttpPostedFile大对象。当年我测试传一个200MB的文件w3wp.exe进程内存瞬间涨上去一大截。到了GB级IIS进程内存会吃得很凶多几个用户一起传服务器基本就告别了。所以大文件方案的第一原则就是永远不要让服务器一次性持有整个文件内容。第三个瓶颈是超时。浏览器到IIS之间只要长时间没有数据传输任何一个环节都可能掐断连接代理、防火墙、负载均衡设备都有空闲超时。动辄一小时的大文件在传输过程中网络稍微抖动一下整个请求就废了。最要命的是传统WebForm上传没有重试机制失败了只能从头开始。这种体验在工程领域是完全不能接受的。1.2 能源化工行业要传的到底是什么文件做这套东西不能只当大文件上传做得先搞清楚用户到底在传什么方案才有针对性。从我接触过的项目来看能源化工行业的大文件通常集中在几类设计院交付的三维模型和图纸包比如PDMS、SP3D、AutoCAD成套图纸一个装置项目几GB很常见生产运维侧SCADA导出的历史数据、DCS报警记录、PLC趋势文件单个导出动辄几十GB还有勘探开发侧的地震数据、测井曲线、录井资料大多是文本或专用格式体积大且数量多另外各类监控系统的录像文件也越来越多。这些文件有两个共同特点一是体量大二是重要程度高要求数据完整、可追溯。它们往往牵扯到安全审计和生产事故分析传到一半说失败不是重新传一次那么简单可能直接影响项目验收甚至影响应急指挥时对历史数据的调阅。所以传输方案的可靠性优先级高于速度。1.3 分片断点续传到底解决了什么分片断点续传的核心思想不复杂但恰恰能化解上面三个问题。把大文件切成若干小分片每个分片单独成为一个HTTP请求。单个请求体积变小内存自然不会暴涨IIS请求大小限制也只需要配到分片大小级别。因为每次只传一个小请求中途失败不会让整个传输作废只需要重传失败的那一片。已经传好的分片在服务端有记录下次重新发起上传时先问一下服务端哪些分片已经成功跳过它们只传缺的这就是断点续传的本体。配合前端并发控制可以同时发多个分片请求利用浏览器同域名下多连接数的特性把吞吐做上去。但并发数太高会在专网链路上打爆带宽甚至更慢实际工程中我一般控制在2到4个。原理先明白后面的具体实现慢慢展开。2. 整体方案WebForm加一般处理程序的分片上传数据流设计2.1 技术选型为什么是HTML5 File API加ashxASP.NET WebForm虽然老旧但我不建议一上来就引入重量级第三方上传组件。至少在我接手的项目里前端要求是页面少改后端要用现有服务器跑起来。所以我的选型是前端用HTML5 File API做切片Blob.slice()把文件切块FormData封装分片数据XMLHttpRequest发送请求。这套东西IE10以上都支持能源化工企业内网用户的主流浏览器基本没问题。如果项目已经在用vue-simple-uploader这类组件也没关系它底层就是同样的逻辑包了一层我在后面会专门讲它和后端对接时的校验思路。后端用一般处理程序.ashx也就是IHttpHandler。选择它是因为上传请求不涉及WebForm页面的完整生命周期不需要经过页面控件树和ViewState那一套直接用轻量handler接收流处理最快、资源占用最少。WebForm里的Page页面太重每个请求都要走十几步管道事件对高频分片请求来说没必要。2.2 核心参数与元数据表设计不管前端多复杂后端必须先定义好一套协议。我的习惯是固定这几个参数每次分片请求都必须携带fileId文件唯一标识前端选完文件后生成一个GUID整个上传过程不变。这是断点续传的锚点。注意不要用文件名做标识同名文件在多个装置里很常见。index分片序号从0开始。totalChunks总分片数。fileName原始文件名。fileMd5整个文件的MD5可选用于最终审计。chunkMd5当前分片的MD5推荐用于单分片校验。服务端要有一张表记录上传文件元数据一张表记录分片状态。我常用的字段设计大致是这样上传文件表UploadFileInfoId自增、fileId唯一键、fileName、fileSize、fileMd5、chunkSize、totalChunks、status0未完成/1已合并/2校验失败、uploadStartTime、uploadEndTime。分片状态表UploadChunkInfoId、fileId、chunkIndex、partSize、chunkMd5、status0未上传/1已上传、uploadTime。为什么非要用数据库表而不用文件夹扫描因为断点续传时服务端要快速回答哪些分片已经存在。靠遍历目录里几十上百个文件来做慢且容易出错。数据库索引一查就是毫秒级。2.3 一次完整的上传会话是怎么走的先用一个全局视角把流程走一遍方便后面理解代码用户选完文件后JS先计算总分片数生成fileId把文件meta信息提交给服务端。对每个分片前端用Blob.slice(index * chunkSize, (index1) * chunkSize)切出二进制数据放到FormData里POST到upload.ashx。服务端收下分片写入临时目录/Uploads/Temp/{fileId}/{index}.part写完计算该分片MD5和请求携带的chunkMd5对比。一致则把该分片状态置为1不一致返回上传失败前端自动重试这个分片。前端并发控制维护一个任务队列同一时刻最多跑3个分片请求每个分片最多重试3次。所有分片状态变为1后前端再发一个merge请求到合并handler。服务端把所有.part文件按index顺序读取用FileStream边读边写生成最终文件。合并完成后计算整体MD5和fileMd5比对更新文件表状态删除临时分片。整个链路每一步都需要日志后面排查问题全靠它。我在实际项目里是每片写数据库和本地日志双份。3. 前端实现在WebForm页面里写切片与并发上传3.1 先处理WebForm页面的坑WebForm页面有个特有麻烦ViewState。如果你把上传控件放在一个ViewState很大的Page里页面加载会慢而且如果你误用UpdatePanel做上传那更是雪上加霜。我的做法是上传页面用普通ASPX页面设置EnableViewStatefalse不用UpdatePanel上传控件直接写HTML的input typefile。反正分片方案里服务端FileUpload控件也用不到了。前端页面只保留选择文件按钮、进度条、速度信息、日志区没有回发没有异步面板。另一个需要注意的是IIS动态压缩。如果开启了动态压缩分片请求可能会被网关或代理层做压缩处理。分片本来就是二进制压缩反而浪费CPU和网络建议对上传路径关闭动态压缩或者确认前置网关不会对上传接口做压缩。3.2 切片、排队与并发控制的核心JavaScript核心JS大概是这样的简化版保持可运行结构var file, fileId, chunkSize 5 * 1024 * 1024; // 5MB var chunkQueue []; var activeCount 0; var maxConcurrent 3; var maxRetry 3; function handleFile(e) { file e.target.files[0]; fileId generateUUID(); totalChunks Math.ceil(file.size / chunkSize); for (var i 0; i totalChunks; i) { chunkQueue.push({ index: i, retry: 0 }); } // 先查询服务端过滤已上传分片 queryUploadStatus(fileId, totalChunks).then(function (uploadedIndexes) { chunkQueue chunkQueue.filter(function (item) { return uploadedIndexes.indexOf(item.index) 0; }); startQueue(); }); } function startQueue() { while (activeCount maxConcurrent chunkQueue.length 0) { var item chunkQueue.shift(); activeCount; uploadChunk(item.index) .then(function () { activeCount--; onChunkDone(); }) .catch(function () { activeCount--; handleChunkError(item); }); } } function uploadChunk(index) { return new Promise(function (resolve, reject) { var formData new FormData(); var start index * chunkSize; var end Math.min(file.size, start chunkSize); formData.append(file, file.slice(start, end)); formData.append(fileId, fileId); formData.append(index, index); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); // 如果做了前端分片MD5这里带上 chunkMd5 var xhr new XMLHttpRequest(); xhr.open(POST, /upload.ashx, true); xhr.timeout 120000; xhr.onload function () { if (xhr.status 200) resolve(); else reject(new Error(HTTP xhr.status)); }; xhr.onerror reject; xhr.ontimeout reject; xhr.send(formData); }); }这里有两个容易忽视的点。第一并发度不是越大越好。我实测普通千兆内网里4并发比1并发快很多但8并发提升有限到了专网环境8并发甚至会出现大量重传。建议做成配置项默认3。第二xhr.timeout要合理设置。分片在专网链路上可能比预想慢得多5MB的分片在差的链路下跑1分钟以上很正常。我的建议是至少给到120秒并且重试逻辑要能容忍偶发慢分片不是一慢就重试否则只会把链路堵得更死。3.3 断点续传的前端落地细节断点续传对用户来说就是断网了、关浏览器了、第二天重新打开选同一个文件能从上次进度继续。前端需要做三件事。第一持久化fileId。选完文件后在localStorage里记录一条JSON{fileName, fileSize, lastModified, fileId}。下次用户再选文件时先看有没有匹配的记录有就复用fileId没有才生成新的。第二先查询再上传。每次开始上传前都调一次服务端接口让服务端返回已上传成功的分片index数组本地把队列里这些分片剔除。这样即使上次上传到一半崩溃续传时也不会重复消费服务端已经收好的分片。第三处理文件被改动的场景。能源化工行业的文件很多是设备联动实时导出的万一用户在断点续传期间重新生成了文件大小和lastModified都会变。我建议在匹配localStorage记录时比对文件大小和最后修改时间不一致就认为这是一个新文件重新生成fileId。如果不做这一步很可能出现分片错位最终文件损坏。提示查询接口的响应体里要额外带一个服务端记录的totalChunks。如果上次上传的totalChunks和现在前端计算的totalChunks不一致说明文件被改过同样需要重新生成fileId不要只依赖前端判断。4. 后端核心ashx接收、校验与合并分片的完整实现4.1 web.config里必须改的配置先列一下我每次都要改的配置少了任何一个都会在测试阶段撞墙configuration system.web httpRuntime targetFramework4.7.2 maxRequestLength104857600 executionTimeout3600 / /system.web system.webServer security requestFiltering requestLimits maxAllowedContentLength104857600 / /requestFiltering /security /system.webServer /configurationmaxRequestLength单位是KB104857600KB等于100MB。为什么只配100MB而不是直接配10GB因为单个分片是5MB100MB上限已经非常富余。故意不配太大是为了防止万一有老页面走了普通上传、把整个大文件POST过来时内存直接被打爆。executionTimeout默认110秒对长时间运行的一般处理程序会导致执行超时。调到3600秒但要注意执行超时只在调试器关闭时生效而且实际请求超时还受前端xhr.timeout限制。注意这些配置修改会影响整个应用池。如果应用池里还有其他业务系统建议给上传服务单独建一个站点或应用池否则maxRequestLength调大后万一有老接口被人传大文件内存风险会波及整个池子。4.2 一般处理程序接收分片读取流与落盘.ashx的实现要点如下public class UploadHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { string fileId context.Request.Form[fileId]; int index Convert.ToInt32(context.Request.Form[index]); string fileName Path.GetFileName(context.Request.Form[fileName]); string chunkMd5 context.Request.Form[chunkMd5]; HttpPostedFile file context.Request.Files[file]; if (file null) { context.Response.Write(error:no file); return; } string tempDir Path.Combine( context.Server.MapPath(~/Uploads/Temp), fileId); Directory.CreateDirectory(tempDir); string partPath Path.Combine(tempDir, index .part); // 关键点流式写入不要用byte[]一次性读完整包 using (var input file.InputStream) using (var output new FileStream(partPath, FileMode.Create, FileAccess.Write)) { byte[] buffer new byte[1024 * 1024]; // 1MB缓冲 int read; while ((read input.Read(buffer, 0, buffer.Length)) 0) { output.Write(buffer, 0, read); } } // 校验分片MD5防止传输损坏 string actualMd5 CalculateFileMd5(partPath); if (!string.IsNullOrEmpty(chunkMd5) actualMd5 ! chunkMd5.ToLower()) { File.Delete(partPath); context.Response.Write(error:md5 mismatch); return; } // 标记该分片已上传 MarkChunkUploaded(fileId, index, partPath.Length, actualMd5); context.Response.Write(ok); } public bool IsReusable { get { return false; } } }这段代码里读取请求流时用1MB的buffer循环读写这是刻意为之千万不要把file.InputStream转到byte[]或MemoryStream那等于把分片完整载入内存。分片虽然只有5MB但并发3个加请求线程栈内存也会悄悄涨上去分片调大或并发调高时尤其明显。我踩过的坑context.Request.Files[file]取不到文件。原因多半是前端FormData里字段名没对。如果实在取不到可以用context.Request.InputStream直接读原始流但那样要自己解析multipart边界比较麻烦。所以我在前端严格固定字段名尽量用原生XMLHttpRequest避免第三方组件改字段名的幺蛾子。IsReusable返回false意思是一个请求一个handler实例避免并发写同一个文件时出现共享状态问题。吞吐上有点损耗但安全第一没必要为微优化引入共享状态。4.3 合并分片边读边写不要让内存爆炸合并逻辑我习惯单独写一个MergeHandler因为合并是重活和上传请求分开方便设置不同的超时和日志。public class MergeHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { string fileId context.Request.Form[fileId]; string fileName Path.GetFileName(context.Request.Form[fileName]); int totalChunks Convert.ToInt32(context.Request.Form[totalChunks]); string tempDir Path.Combine( context.Server.MapPath(~/Uploads/Temp), fileId); string finalDir context.Server.MapPath(~/Uploads/Final); Directory.CreateDirectory(finalDir); string finalPath Path.Combine(finalDir, fileId _ fileName); using (var output new FileStream(finalPath, FileMode.Create, FileAccess.Write)) { for (int i 0; i totalChunks; i) { string partPath Path.Combine(tempDir, i .part); if (!File.Exists(partPath)) { context.Response.Write(error:missing chunk i); return; } using (var input new FileStream(partPath, FileMode.Open, FileAccess.Read)) { input.CopyTo(output, 1024 * 1024); } } } string finalMd5 CalculateFileMd5(finalPath); UpdateFileStatus(fileId, merged, finalMd5); Directory.Delete(tempDir, true); context.Response.Write(ok); } }这里有个能源化工场景的隐藏坑图纸文件经常是总平面布置图.dwg这种同名文件不同装置反复上传会覆盖。所以最终文件名建议带上fileId前缀如{fileId}_{fileName}展示给用户时再还原原始文件名。合并时间如果很长前端不能傻等一个merge请求几十秒。实际项目里最好在上传完所有分片后前端发merge请求同时开启轮询另一个查询接口轮询到status1再提示用户成功。否则用户看到已传完但迟迟没有下一步会产生困惑。还有一个细节文件超过20GB时合并逐片CopyTo依然不占内存但要盯紧磁盘空余空间。把临时目录和最终目录放在不同磁盘卷能降低同盘IO竞争。我后来上线就把Temp放在SSDFinal放在大容量机械盘阵列效果明显。5. 断点续传的校验机制状态表、问询接口与MD5的工程平衡5.1 状态表为什么是续传的命根子服务端必须能回答我手里到底有哪些分片是完整、校验通过的。文件系统上有.part文件只能说明写过不能说明写完整了而且检查文件系统比查库慢。所以UploadChunkInfo表里的status字段才是唯一权威。续传问询接口很简单public class UploadStatusHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { string fileId context.Request[fileId]; var list GetUploadedChunkIndexes(fileId); string json new JavaScriptSerializer().Serialize(new { uploaded list.ToArray(), totalChunks GetTotalChunks(fileId) }); context.Response.ContentType application/json; context.Response.Write(json); } }前端拿到uploaded数组后把本地队列里这些分片直接跳过。这样断点续传的断点本质上是服务端已确认的分片集合而不是前端传到了哪个分片。这个区别很重要前端并发上传时分片1传完、分片2可能还在队列里分片3可能正在传没有任何单调的进度条能准确描述服务端状态只有查库最可靠。幂等处理也是必修课。同一个分片因为网络原因重试了3次服务端可能已经成功接收了2次。第二次请求再来时应该怎么办我的处理是分片上传成功后就写状态当同一个index的请求再进来时如果文件已存在且MD5一致直接返回ok不再重复写文件。这样前端重试、并发重复都不会造成错误。5.2 MD5校验怎么做才不吃力这是讨论最多、最容易踩坑的地方我把我的选择讲清楚。全文件MD5校验计算一个20GB文件的MD5服务器本地算也要一两分钟浏览器里算更是灾难。所以我只在合并完成后由服务端算一次最终文件的MD5存库备查不参与前台交互。这个值的主要用途是给上层系统做完整性审计而不是传输过程控制。分片MD5校验服务端收到每个分片后计算分片MD5前端如果传了chunkMd5就比对。分片只有5MB算MD5几乎无感。这才是传输过程的实时防线。如果前端能用Web Worker算分片MD5就能提供chunkMd5做端到端校验如果不算服务端单侧MD5也能兜底这个分片落盘是否完整。关于vue-simple-uploader这类组件它内部会在上传前用spark-md5计算整个文件的MD5然后通过check接口把MD5传给后端后端判断文件是否已经传过、返回需要续传的分片列表。这套思路在WebForm里完全能对接只要你的UploadStatusHandler能接收一个文件标识并返回chunkList即可。本质和我上面讲的状态表问询一样只是把fileId换成了文件MD5。但在WebForm老项目里我不太建议用整个文件MD5作为fileId的替代因为计算大文件MD5在用户浏览器里非常卡我会让续传标识始终是GUIDMD5只做审计。5.3 中断后到底怎么续传一口气讲清楚把完整时序用文字走一遍方便对照自己的代码用户第一次选文件a.dwg大小8GB。JS生成fileIdf1按5MB切片得到1639个分片。上传到第500片时网络中断前端重试3次失败用户关闭了浏览器。服务端现在有UploadChunkInfo表里500条status1的记录临时目录里躺着500个.part文件UploadFileInfo里有一条fileIdf1、status0的记录。第二天用户重新打开页面选择同一个a.dwg。JS比对localStorage里的文件名、大小、最后修改时间全部一致于是复用fileIdf1。然后调UploadStatusHandler返回uploaded[0..499]JS把前500片从队列剔除从第500片继续传。传完剩余1139片后调MergeHandler合并服务端按0到1638顺序合并计算最终MD5更新status1删除临时目录收工。如果用户在断网期间重新导出了一个新的a.dwg大小变成8.1GBJS比对发现文件最后修改时间变了于是生成新的fileIdf2整套流程重新走绝不复用f1。旧分片留在临时目录里等清理任务回收。6. 能源化工行业工程化落地的避坑清单6.1 专网断网频繁超时参数怎么配能源化工企业的网络情况五花八门有跨省专线有工厂内部Wi-Fi还有大量现场在车间里通过临时AP上传。我实测发现这类环境的共性是带宽不见得小但抖动大、丢包恢复慢。前端xhr.timeout如果设成30秒在丢包稍多时大量分片会重试重试又加剧拥塞。我把默认设为120秒重试次数3次重试间隔采用指数退避第1次等1秒、第2次等3秒、第3次等8秒。实测成功率上升明显。服务端方面executionTimeout要调大但也不要无限大。IIS应用池默认会定期回收回收瞬间如果正在执行一个合并任务它会被强行断掉。我遇到过一次合并12GB文件到一半应用池回收了最终文件只有一半。解决方案有两个要么给上传站点单独建池把回收时间安排在深夜触发条件设为特定时间而不是内存阈值要么在临时文件设计上做文章让合并任务可以断点重来。我强烈建议两个一起做。6.2 数据安全与临时文件生命周期能源化工行业的数据敏感度高有几件事别偷懒。所有上传接口进入处理逻辑前先做身份认证。有登录态Cookie就检查Cookie内网系统没有统一认证至少校验来源IP或token。裸奔的ashx任何人都能往里塞文件会把磁盘塞满。文件名和路径处理fileName必须用Path.GetFileName过滤防止路径穿越。我在上线前专门用脚本往上传字段里塞各种路径穿越字符串发现早期版本还真有漏洞后来统一加了过滤才安心。临时目录生命周期断电、用户放弃上传都会在Temp目录留下大量.part文件。必须写一个定时任务比如每天凌晨3点删除所有超过48小时没更新的.part文件以及对应的UploadChunkInfo记录。一个传输任务不可能连续48小时没有任何活动分片过来所以超过48小时没更新的一律清理不会误伤真正在传的任务。证书问题也别忘了。很多化工厂内网Web系统用自签名HTTPS证书浏览器会拦截。普通页面用户手点继续访问可能没问题但XMLHttpRequest上传时浏览器对自签名证书的错误处理非常严格经常出现请求直接失败但页面看起来正常的诡异情况。解决方式是在客户端机器上把证书导入受信任的根证书存储区做成安装脚本分发给现场别指望用户手动操作。6.3 别踩WebForm老底子的坑Session与ViewState这里单独拎出一个隐蔽坑不要在上传handler里使用Session。ASP.NET的Session默认是进程内且带锁的同一个会话并发请求会排队。分片上传是并发请求如果handler碰了Session状态两个分片请求可能互相等待上传吞吐会塌方。默认的IHttpHandler本来就不需要Session千万不要为了图方便去实现IRequiresSessionState保持默认即可。ViewState的坑前面说过我再补充一点如果上传页面和别的功能共用了一个MasterPageMasterPage里的ViewState字段可能会让隐藏域变得巨大。上传逻辑全走ashx后页面身上的ViewState只影响初始加载不影响上传请求本身。但如果你把上传控件放到UpdatePanel里还想要进度条那基本全白搭UpdatePanel的异步回发机制和二进制分片上传八字不合。6.4 实测验收清单怎么确认这套东西真的能扛住我总结了一份上线前会跑的验收清单可以直接抄用脚本生成一个5GB的随机二进制文件通过页面上传观察内存、CPU、磁盘IO确认服务器进程内存没有异常上涨。上传到中段手动断开网络等30秒恢复重新选同一个文件确认只上传缺失分片合并后的文件与原始文件MD5一致。模拟用户关浏览器再重开localStorage里的fileId正确复用续传任务接着跑。用同一个文件同时发起多个上传任务不同fileId确认互不干扰。验证临时目录清理任务能正确删除过期分片。需要说明的是如果从临时目录手动删除.part文件数据库状态表不会自动更新问询接口返回的uploaded还是会包含被删的分片但Merge时发现缺失就会失败。真正的容灾顺序是先写临时文件再更新状态表一旦出现不一致靠定时清理任务兜底而不是靠前端修复。把这条清单跑完基本可以放心交付。最后说点个人体会。我在那套化工图纸归档系统上落地这套方案后运维工单里上传失败的投诉基本消失。最长的单文件传输记录是34GB的SCADA历史导出用户从早上传到中午中途断过两次都续传成功。这让我确信WebForm这种老技术栈完全可以把大文件传输做到工业级关键不是换框架而是把分片、校验、状态管理这三件事做扎实。如果你也在维护类似的存量系统希望这篇文章能帮你少走几步弯路。