前几天在整理项目交付文档时,又翻到这套分片上传的代码,想想当初为了在.NET WebForm老系统里做能源化工行业的大文件上传,确实没少掉头发。厂区里那些动辄几GB的设计图纸、地震采集数据、DCS导出文件,靠浏览器直传根本不现实。最后落地的方案很朴素:前端把文件切成小块,逐块上传,后端用一般处理程序接着并记录进度,断点续传天然就有了。这篇文章把整个方案的思路、代码、配置和踩过的坑一次讲透,给还在维护WebForm存量系统的朋友一份能直接抄作业的参考。
1. 能源化工行业的文件上传痛点,为什么不能一把梭直接传
1.1 业务场景里到底传的是什么文件
能源化工行业的“大文件”和互联网行业常说的“大文件”不是一个量级。普通办公系统里传个几十MB的PPT就觉得很大了,但在能源化工项目里,几十MB只是起步。
我这次接手的系统里,最典型的是这几类:
- 设计院图纸与三维模型:CAD图纸还好说,几百MB到头了;PDMS、SP3D这种三维工厂模型的导出包,经常超过1GB,图纸间还有引用关系,必须整包上传不能拆散。
- 地震勘探数据:做油气勘探的部门传SEG-Y格式地震数据,单个文件几GB是家常便饭,而且这些文件通常是一次性从野外采集站拷贝回研究院,时效性要求极高。
- DCS/SCADA系统导出文件:化工装置里的分布式控制系统会定期导出历史趋势、报警记录、操作日志,这些文件是明文CSV或二进制格式,积累几个小时就能到几百MB。
- 长输管道检测报告:管道内检测器跑一圈回来,导出的数据包和对应的波形图文件,单次任务几个GB。
- 视频监控与安全巡检记录:厂区防爆区域的监控视频、智能巡检终端录制的检查视频,一段就有几百MB。
这些文件的共性是:不仅大,而且上传用户所在网络环境差异悬殊。研究院、总部的人走企业内网或者专线,带宽高、丢包少;但到了钻井现场、加油站、野外站,走的是卫星链路或低质量4G/5G,链路抖动频繁。在这种环境下做文件上传,必须把“网络可能随时断开”当成默认前提来设计。
1.2 直接上传大文件会撞上哪些墙
很多人习惯性地改一下web.config里的上传大小限制,然后把文件一口气POST上去。这在文件稍微大点的时候确实能对付,但文件到了几百MB以上时,问题会一个一个冒出来。
第一个墙是平台默认限制。ASP.NET WebForm时代,httpRuntime的maxRequestLength默认只有4MB;IIS 7及以上还有一层maxAllowedContentLength,默认30MB。两层限制叠加,任何一个超了都会直接拒绝请求,客户端看到的可能只是404、400或者“Connection Reset”这类莫名其妙的结果。
第二个墙是内存占用。传统方式上传时,服务端要把整个请求体读入内存处理。IIS把请求体缓冲到内存,ASP.NET又把HttpRequest整个拿在手里,一个1GB的文件,w3wp.exe进程内存直接飙到1GB以上,应用池很可能当场被回收或崩溃。生产环境里遇到过一次,用户传了一半,整个站点挂了,影响的不只是上传功能,所有业务全断。
第三个墙是网络不可靠。一条链路几十秒乃至几分钟的抖动,在传输几GB文件时几乎是必然发生的。如果整个过程只能“一次成功”,那用户就得不断重试,每次重试都是从0开始。现场工作人员本来就不会有耐心,传几次失败后基本就开始打电话骂人了。
第四个墙是WebForm页面自身的开销。WebForm的上传页面有完整的页面生命周期,ViewState在每次回传时要序列化、校验,如果库里已经有其他控件,页面越来越大,上传请求也会被带着走一遍这些流程,白白浪费时间和带宽。而且页面回传时还会触发事件验证、请求验证,文件内容里稍微有点特殊字符就可能引发“Invalid postback or callback argument”的错误。
所以结论很直接:在WebForm这种老架构里做企业级大文件上传,不能走常规POST路线,也不该被页面生命周期绑架。应该把上传功能单独拆出来,用最轻量的一般处理程序接收,再在前端把文件切片,配合断点记录和校验,形成一套独立于页面框架的上传通道。这套设计思路,就是这篇文章后面所有内容的基础。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分片断点续传的核心设计:切、记、验、并
2.1 分片大小怎么定,我为什么最终选了2MB
分片上传的第一步是决定切多大。这个参数直接影响上传的稳定性、速度和后端压力,不能拍脑袋。
切得太小,比如256KB,单个分片传输时间很短,任何一次网络往返的开销占比都很大,而且总请求数会非常多。1GB文件切256KB,要发出4096个请求,就算并发10个,每个请求的HTTP头、服务端处理请求的固定开销都会被放大很多倍,总体速度反而慢。切得太大,比如50MB,遇到链路抖动时重传成本太高,一个分片传一半断掉,这50MB全部作废,和前文说的直接直传没有本质区别。
我当时用一台内网服务器加千兆以太网做过一组简单测试,分别用1MB、2MB、5MB、10MB切一个1GB文件,观察两个指标:总耗时和服务端CPU峰值。结论是2MB在稳定性与吞吐量之间取得了比较好的平衡:请求数适中,单个分片传输时间在百毫秒到两秒之间,一旦失败重传代价可控,服务端合并时的顺序排序也不复杂。
如果读者的网络环境是稳定的局域网,可以适当提到5MB甚至10MB;如果是卫星链路或跨公网传输,建议降到1MB-2MB。分片大小并不是固定值,最好做成前端的一个配置项,上线后根据现场反馈再调。
启动代码时判断totalChunks时要注意边界条件:文件大小正好是分片大小的整数倍时,最后一片不是多余的,不能多加也不能漏掉最后一块。Math.ceil(file.size / chunkSize)算出来是多少就是多少。
2.2 “断点”怎么记:文件指纹、分片清单、跳过已传部分
断点续传的核心问题只有一个:怎么知道“已经传到了哪里”。实现方式有很多,但我在WebForm场景里选择了最简单可靠的一种——用文件MD5做任务标识,用服务端文件系统里的分片清单做进度记录。
具体流程是:
- 前端拿到文件后,先计算整个文件的MD5值(用浏览器端JS库,常见的是spark-md5)。
- 切片之后,把文件名、文件大小、MD5、分片总数、每片索引这些信息组织好,先请求后端一个“查询已上传分片”的接口。
- 后端根据文件MD5去临时目录里查已经存在的分片文件,返回一个已传分片索引数组。
- 前端拿到数组后,把未传的分片依次上传,已传的直接跳过。
- 当前任务全部上传完成后,调用“合并文件”接口,后端把临时分片拼装为完整文件。
这里有个值得注意的点:整个文件的MD5计算,在浏览器端其实是有点耗时和占内存的。1GB文件用spark-md5全量计算,普通PC可能要几十秒,用户会看到一个进度条卡在“计算MD5”上。我在项目里做了一步取舍:如果文件超过500MB,不再计算整个文件的MD5,而是退化为用“文件名+文件大小+修改时间”拼接任务标识。虽然不能保证全局唯一,但配合分片序号校验,已经足够支撑断点续传的核心需求。文件合并完成后,可以再在服务端计算MD5做最终一致性校验,有没有整个文件MD5就不那么关键了。
分片级校验也是类似道理。每个分片可以单独算一个MD5随请求传上来,服务端保存时校验一遍,确保该分片在传输过程中没有损坏。如果前端的全文件MD5因为性能原因被舍弃,分片MD5就成为唯一的数据完整性依据,不能省。
2.3 合并策略:攒齐再并,还是边收边写
分片到齐之后的合并策略,有两种选择,我直接把结论放在前面:不要边收边写,攒齐再并。
边收边写的意思是,每个分片到达服务端时,就把它写入最终文件的对应偏移位置。听起来效率高,多个分片同时写入还能提升IO利用率。但问题也很明显:分片如果是并发到达,谁先谁后不确定,两个分片同时在一个FileStream里写,必须加锁协调;网络传了一半断了,最终文件里可能留下来半个分片的残缺数据,后续这个文件即使继续拼接也很难判断哪里是完整的。这种“脏状态”在断点续传场景里非常难处理。
攒齐再并就简单得多:所有分片先独立存到一个以文件MD5命名的临时目录里,每个分片单独存成一个小文件;当分片数量达到总数时,进入合并流程,按索引从小到大依次读各个分片文件,用流的方式写入最终目标文件。这个流程不需要复杂锁,失败时把已经合并出来的文件删除、保留分片重试即可,逻辑清晰、排错容易。
有人可能会问,全部落盘会不会造成临时空间占用很大?会,而且这是必须接受的成本。能源化工行业的大文件上传,服务端磁盘就是拿来换稳定性的。只要规划好临时目录所在磁盘的剩余空间,及时做清理策略,这不是问题。
3. 在WebForm里动手落地:前端切分、后端接收的完整实现
3.1 前端用File API把大文件切成小块
现代浏览器原生支持File对象的slice方法,可以按字节偏移切出一段Blob。这就是分片的底层能力,完全不需要第三方插件,也不需要flash、ActiveX那种老古董。
我写的上传模块是一个原生JavaScript类,核心配置包括chunkSize、并发数量、重试次数。上传前先调用查询接口,得到已传分片列表,然后从尚未上传的分片开始。为避免浏览器对同一域名的并发连接数限制,我用一个简单的并发队列,最多同时传3个分片。这个数字是经验值,太大容易让服务端磁盘写成瓶颈,太小则浪费带宽。
关键代码结构如下:
javascript复制function ChunkUploader(file, uploadUrl, checkUrl, mergeUrl, options) {
this.file = file;
this.uploadUrl = uploadUrl;
this.checkUrl = checkUrl;
this.mergeUrl = mergeUrl;
this.chunkSize = (options && options.chunkSize) || 2 * 1024 * 1024;
this.concurrent = (options && options.concurrent) || 3;
this.totalChunks = Math.ceil(file.size / this.chunkSize);
this.fileMd5 = options.fileMd5 || '';
this.uploaded = [];
}
ChunkUploader.prototype.check = function (callback) {
var self = this;
var formData = new FormData();
formData.append('action', 'check');
formData.append('fileMd5', this.fileMd5);
formData.append('fileName', this.file.name);
formData.append('fileSize', this.file.size);
fetch(this.checkUrl, {
method: 'POST',
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
self.uploaded = data.uploaded || [];
callback(null, self.uploaded);
})
.catch(function (err) {
callback(err, null);
});
};
ChunkUploader.prototype.uploadChunk = function (index) {
var self = this;
return new Promise(function (resolve, reject) {
var start = index * self.chunkSize;
var end = Math.min(start + self.chunkSize, self.file.size);
var blob = self.file.slice(start, end);
var formData = new FormData();
formData.append('action', 'upload');
formData.append('fileMd5', self.fileMd5);
formData.append('fileName', self.file.name);
formData.append('chunkIndex', index);
formData.append('totalChunks', self.totalChunks);
formData.append('file', blob, self.file.name + '_' + index);
fetch(self.uploadUrl, {
method: 'POST',
body: formData
})
.then(function (res) {
if (res.ok) {
self.uploaded.push(index);
resolve(index);
} else {
reject(new Error('HTTP ' + res.status));
}
})
.catch(reject);
});
};
ChunkUploader.prototype.start = function () {
var self = this;
this.check(function (err, uploaded) {
if (err) {
console.error('查询已上传分片失败', err);
return;
}
var pending = [];
for (var i = 0; i < self.totalChunks; i++) {
if (uploaded.indexOf(i) === -1) {
pending.push(i);
}
}
// 并发队列控制,最多同时执行 self.concurrent 个上传任务
var index = 0;
var active = 0;
var done = 0;
var failed = false;
function next() {
if (failed) return;
if (index >= pending.length && active === 0) {
// 所有分片传完,调用合并接口
self.merge();
return;
}
while (active < self.concurrent && index < pending.length) {
var chunkIndex = pending[index++];
active++;
self.uploadChunk(chunkIndex)
.then(function () {
active--;
done++;
updateProgress(done, pending.length);
next();
})
.catch(function (e) {
failed = true;
console.error('分片上传失败', chunkIndex, e);
});
}
}
next();
});
};
ChunkUploader.prototype.merge = function () {
var formData = new FormData();
formData.append('action', 'merge');
formData.append('fileMd5', this.fileMd5);
formData.append('fileName', this.file.name);
formData.append('totalChunks', this.totalChunks);
formData.append('fileSize', this.file.size);
// 调用合并接口,成功后拿到下载/预览地址
fetch(this.mergeUrl, {
method: 'POST',
body: formData
})
.then(function (res) { return res.json(); })
.then(function (data) {
// data.downloadUrl 指向最终文件,前端更新UI
});
};
上面这段代码里的fetch基于Promise,注意我特意把并发队列和失败处理拆了出来。实际项目中建议给每个分片加“失败重试”逻辑,重试次数建议3次,重试间隔使用指数退避,第一次等1秒,第二次等2秒,第三次等4秒。这样面对短暂链路抖动时,不打断整个上传流程,用户体验会好很多。
一个容易被忽略的问题:FormData存放分片二进制时,我给Blob一个带fileName + '_' + index的文件名。原因很简单,服务端拿Request.Files["file"]时,文件名会被默认当成原始文件名,如果所有分片都叫同一个名字,后端保存临时分片时区分不便。加上索引号一次解决。
3.2 后端用一般处理程序接收分片并支持查询
后端我刻意绕开了WebForm的.aspx页面,直接用.ashx一般处理程序。这么做的原因前面提过:页面的生命周期、ViewState、事件验证对上传大文件没有任何帮助,只会增加无谓开销和失败概率。一般处理程序只有ProcessRequest一个入口,干净利落。
先看完整的事务轮廓:三个action对应流程中的三次请求。
csharp复制public class FileUploadHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "application/json";
string action = context.Request["action"];
switch (action)
{
case "check":
HandleCheck(context);
break;
case "upload":
HandleUpload(context);
break;
case "merge":
HandleMerge(context);
break;
default:
WriteJson(context, new { success = false, message = "unknown action" });
break;
}
}
private void HandleCheck(HttpContext context)
{
string fileMd5 = context.Request["fileMd5"];
string fileName = context.Request["fileName"];
long fileSize = long.Parse(context.Request["fileSize"]);
string tmpDir = GetTmpDir(fileMd5);
List<int> uploaded = new List<int>();
if (Directory.Exists(tmpDir))
{
string[] files = Directory.GetFiles(tmpDir, "*.part");
foreach (string file in files)
{
string name = Path.GetFileName(file);
// chunk文件名形如 "00001.part"
int index = int.Parse(name.Replace(".part", ""));
uploaded.Add(index);
}
}
WriteJson(context, new { success = true, uploaded = uploaded });
}
private void HandleUpload(HttpContext context)
{
string fileMd5 = context.Request["fileMd5"];
string fileName = context.Request["fileName"];
int chunkIndex = int.Parse(context.Request["chunkIndex"]);
int totalChunks = int.Parse(context.Request["totalChunks"]);
string chunkMd5 = context.Request["chunkMd5"];
HttpPostedFile file = context.Request.Files["file"];
if (file == null || file.InputStream == null || file.ContentLength <= 0)
{
WriteJson(context, new { success = false, message = "no file content" });
return;
}
string tmpDir = GetTmpDir(fileMd5);
if (!Directory.Exists(tmpDir))
{
Directory.CreateDirectory(tmpDir);
}
string partPath = Path.Combine(tmpDir, chunkIndex.ToString("D5") + ".part");
using (FileStream fs = new FileStream(partPath, FileMode.Create, FileAccess.Write))
{
file.InputStream.CopyTo(fs);
}
// 分片MD5校验:如果传了chunkMd5,服务端再算一遍比对
if (!string.IsNullOrEmpty(chunkMd5))
{
string actualMd5 = GetFileMd5(partPath);
if (!actualMd5.Equals(chunkMd5, StringComparison.OrdinalIgnoreCase))
{
File.Delete(partPath);
WriteJson(context, new { success = false, message = "chunk md5 mismatch" });
return;
}
}
WriteJson(context, new { success = true });
}
private void HandleMerge(HttpContext context)
{
string fileMd5 = context.Request["fileMd5"];
string fileName = context.Request["fileName"];
int totalChunks = int.Parse(context.Request["totalChunks"]);
long fileSize = long.Parse(context.Request["fileSize"]);
string tmpDir = GetTmpDir(fileMd5);
if (!Directory.Exists(tmpDir))
{
WriteJson(context, new { success = false, message = "tmp dir not exists" });
return;
}
string[] partFiles = Directory.GetFiles(tmpDir, "*.part");
if (partFiles.Length != totalChunks)
{
WriteJson(context, new { success = false, message = "chunk count not matched" });
return;
}
// 排序时不能按字符串排序,必须按整数索引排序
Array.Sort(partFiles, (a, b) =>
{
int indexA = int.Parse(Path.GetFileName(a).Replace(".part", ""));
int indexB = int.Parse(Path.GetFileName(b).Replace(".part", ""));
return indexA.CompareTo(indexB);
});
string finalDir = context.Server.MapPath("~/Uploads/");
if (!Directory.Exists(finalDir))
{
Directory.CreateDirectory(finalDir);
}
string finalPath = Path.Combine(finalDir, BuildSafeFileName(fileName, fileMd5));
using (FileStream output = new FileStream(finalPath, FileMode.Create, FileAccess.Write))
{
foreach (string partFile in partFiles)
{
using (FileStream input = new FileStream(partFile, FileMode.Open, FileAccess.Read))
{
input.CopyTo(output);
}
}
}
long actualSize = new FileInfo(finalPath).Length;
if (actualSize != fileSize)
{
File.Delete(finalPath);
WriteJson(context, new { success = false, message = "merge size mismatch" });
return;
}
// 合并成功后,临时目录可以删除
Directory.Delete(tmpDir, true);
WriteJson(context, new { success = true, downloadUrl = "/Uploads/" + Path.GetFileName(finalPath) });
}
public bool IsReusable
{
get { return false; }
}
}
GetTmpDir是辅助方法,传入文件MD5返回App_Data/UploadTmp/{fileMd5}路径。之所以放在App_Data下,是因为这个目录默认有权限保护,IIS不会把它暴露成可下载的静态路径,避免上传一半时别人可以通过URL直接猜文件路径。分片文件名叫00001.part这种固定宽度的数字,D5格式化成五位数字,避免合并时1.part排在10.part后面导致排序错乱。
HandleCheck里的查询逻辑没有查数据库,而是直接查文件系统。有几个分片文件存在,就返回哪些序号。这个设计比维护一张分片表要简单得多,也更容易保持状态一致——服务端不会因为忘记更新数据库而判断出错。文件系统本身就是最好的状态存储。
这里有个注意事项:用FileMode.Create而不是FileMode.Append写分片。因为断点续传时,前端可能会重复提交同一个分片,用Create覆盖旧文件比用Append把内容叠加两遍要安全得多。只要保存前用FileMode.Create重新创建文件,重复上传就变成了幂等操作,不用担心分片内容错乱。
3.3 全部分片收集完之后如何安全合并
合并代码刚才已经在HandleMerge里给出了,核心逻辑就三步:检查分片数量、按整数索引排序、用流复制把所有分片顺序拼接。
这里多说几个细节,每一个都是坑。
Array.Sort的排序比较器必须把文件名解析成整数再比较,如果直接按照字符串文件名排序,10.part会排在2.part前面,合并出来的文件就全乱套了。这种错误在分片少于10个的时候不太明显,分片一多必然暴露。
合并时用FileStream而不是File.ReadAllBytes。一个GB的文件,ReadAllBytes会把整个文件读进内存,合并进程瞬间内存暴涨。用CopyTo配合流式读写,缓冲区默认大概是80KB左右,内存占用恒定,几十个GB也扛得住。
合并完成后,要比较最终文件大小和前端传过来的fileSize是否一致。大小对不上,说明中间缺少内容或者顺序有误,这时候宁可把合并结果删除、让前端重传,也不要交付一个损坏文件。如果对数据完整性要求更高,可以进一步在服务端计算MD5和前端全文件MD5比对,但要注意大文件算MD5耗时较长,需要放到异步队列里做,不能堵在请求线程里。
合并成功后再删除临时目录。如果合并失败,保留临时分片,前端可以重新触发合并,或者只补传丢失的分片再触发合并。有了这个机制,整个流程基本不需要人工介入。
3.4 web.config里必须改的几个配置
WebForm项目的web.config需要针对大文件上传单独调几项。少改一个,你上面写的所有代码都可能白干。
xml复制<configuration>
<system.web>
<!-- maxRequestLength 单位是 KB,这里设置成 2GB -->
<httpRuntime maxRequestLength="2097152"
executionTimeout="7200"
requestValidationMode="2.0"
enableVersionHeader="false" />
</system.web>
<system.webServer>
<security>
<requestFiltering>
<!-- maxAllowedContentLength 单位是字节,这里设置成 2GB -->
<requestLimits maxAllowedContentLength="2147483648" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
maxRequestLength是ASP.NET层面的请求体大小上限,单位KB。因为分片之后单次请求只有2MB多一点,理论上来讲不需要设这么大;但同一个站点里很可能还有其他模块直接上传几十MB到几百MB的文件,所以我直接把它放开到2GB。maxAllowedContentLength是IIS层面的限制,单位是字节,两者取较严格的那个生效。只改system.web不改system.webServer,IIS层照样会拦你。
executionTimeout默认是110秒。虽然服务端接收单个分片通常很快,但无法完全排除某个分片因为网络慢而长时间占用请求线程。设成7200秒是为了避免极端情况下请求被强制终止。
requestValidationMode="2.0"配合validateRequest="false",避免文件内容里的特殊字符触发ASP.NET请求验证。分片上传时如果不关闭,二进制内容里含有类似HTML标签的字节组合可能会被误判为危险字符,直接返回500或“A potentially dangerous Request.Form value was detected”的错误。注意,requestValidationMode要放在httpRuntime节点,validateRequest如果想彻底关掉,既可以在页面级设,也可以在system.web下统一设。
4. 实操中踩过的坑与排查实录
4.1 合并后文件打不开?多半是分片排序和重复写入的锅
这个项目在联调阶段,最重要的一次事故是:上传一个1.2GB的SEG-Y数据文件,前端提示100%上传成功,合并接口也返回成功,但用户下载后文件损坏,专业软件直接打不开。
排查过程是这样的:
先检查最终文件大小,和原文件完全一致,都是1,289,912,320字节。大小对得上,说明没有内容丢失。接着怀疑排序,把最终文件用十六进制工具打开看头部,能看到SEG-Y格式的卷头文本,前几百字节正常,但偏移到某个位置后数据模式开始错乱。问题几乎可以肯定是分片拼接顺序错了。
当时的代码用Array.Sort(partFiles)直接按字符串排序,分片文件命名规则是{index}.part,所以10.part排在2.part前面。后来改成固定宽度命名D5,按整数索引排序,问题当场消失。
另一个隐蔽的重复写入问题:断点续传时,前端“查询已上传分片”接口返回了某个索引已经上传,但紧接着网络又短暂超时,前端重试时把这个分片再发了一遍。后端用FileMode.Create,重复写入会覆盖旧内容,是幂等的,所以没出问题。但如果当初用了FileMode.Append,同一个分片的内容就会叠加在一起,文件大小对不上不说,数据完全错位。
这个坑的教训是:分片上传的保存逻辑一定要设计成幂等,同一分片传几次都不该改变最终结果。
4.2 IIS和.NET Framework里那些隐蔽限制
除了上传限制,还有几个在排错时特别让人抓狂的配置点。
一是maxRequestLength虽然改成了2GB,但IIS上站点的maxAllowedContentLength忘了改。调试时从web.config里看到的httpRuntime已经很大,但请求超过30MB依然被拒。后来抓IIS日志才看到500 - Internal Server Error,连详细错误码都没有,只有一条写在C:\Windows\System32\LogFiles\W3SVC1\下的日志记录了HEADER信息。解决办法是把两个限制同时放开,并确认requestFiltering节点在applicationHost.config没有被更高层级覆盖。
二是.NET Framework 3.5/4.x环境下,如果站点开启的是集成模式,System.Web.HttpException里报“Maximum request length exceeded”很容易被误认为是代码问题。其实这是ASP.NET管道的标准行为。排查时直接看事件查看器,如果看到w3wp.exe抛出的WebException,优先检查request限制配置。
三是事件验证。如果上传功能不小心放在了.aspx页面里而没有关闭事件验证,上传大文件时经常会报“Invalid postback or callback argument”。特别是文件内容较大时,页面需要处理的回传数据量激增,触发事件验证失败的概率随之上升。换成.ashx后这个问题从根上消失了。
4.3 并发冲突、文件锁和临时目录垃圾清理
生产环境里踩到的第一个并发问题,是同一个用户同时在两个浏览器标签页上传同一个文件。两个标签页都去查询已传分片,得到一样的“未上传”列表,然后同时传第0片、第1片,后端就出现了两个线程同时写同一个临时分片文件。FileMode.Create本身是排他打开的,第二个线程会报“文件正在被另一个进程使用”,虽然不会损坏数据,但会向上抛IOException。
解决办法分两层。第一层是在临时目录里按文件MD5 + 下划线 + GIT_短任务ID再分一级子目录,这样同一个文件的两次上传任务互不干扰。第二层是在数据库里给上传任务建一个唯一键,如果检测到同一个MD5的任务已经存在,就复用旧任务,而不是再开一个新任务。
文件锁问题主要集中在合并阶段。如果合并线程正在读某个分片文件,而另一个线程收到同一个分片的重复上传请求,就会发生文件锁冲突。解决方案是:合并开始时,先把临时目录里所有分片文件统一重命名成.merging后缀,合并完成后删除;整个过程中如果发现.merging文件已存在,说明已经有合并任务在跑了,后面的合并请求直接返回“正在合并中”。这样就把“正在被合并”和“可安全写入”两个状态彻底隔离开。
临时目录的垃圾文件也要处理。用户上传到一半关闭了浏览器,临时分片文件会一直留在磁盘上。我写了一个定时任务,每天凌晨扫描App_Data/UploadTmp,删除所有最后修改时间超过24小时的目录。分片上传过程中由于分片会持续写入,最后修改时间不断刷新,正常任务不会被误删;而中断的任务,最多24小时就会被清理掉。
5. 最后留几句话给后来人
这套方案在WebForm老项目里稳定跑了一年多,最忙的时候一天处理了几百个任务,其中超过1GB的文件占了三分之一,没有再出现上传过程中把应用池拖垮的情况。
我个人最想强调的几点经验是:
第一,凡是做上传功能,一定要先写一个模拟弱网的测试脚本。没有办法模拟弱网,也可以直接在浏览器开发者工具里把网络节流开到Slow 3G,甚至手动断网再恢复。确认断点续传行为符合预期再交付,比上线后被用户骂要体面得多。
第二,分片上传不是银弹,它在服务端代码之外,还要有人关注磁盘空间、临时目录清理、IIS配置和异常日志。哪怕代码逻辑再正确,磁盘满了不是照样失败。
第三,如果将来有机会把系统迁移到ASP.NET Core或前后端分离架构,这套“前端切片、后端按索引保存、最后合并校验”的思路依然可以直接平移,最多把.ashx换成Controller的IActionResult,前端JS基本不用大改。做技术方案的时候多考虑一步演进路径,省下来的可不只是重构的时间。
这个项目交付以后,我又用同样的方案接了一个地质数据平台的附件功能,只改了目录和文件名规则就复用了。技术选型不是越新越好,能解决现场实际问题、能在老系统里活得久,才是真正有价值的东西。
