
1. 项目背景与需求拆解1.1 机械制造行业的文件痛点机械制造行业有个很现实的日常图纸、工艺文件、数控程序、质量报告、设备维修记录动辄几十上百MB一个装配体BOM导出的PDF包轻松上GB。我接触过的很多制造企业内部文件服务器老旧有的还停留在共享文件夹加FTP的年代传一个100MB的图纸包断线重传是家常便饭。网页端更是重灾区。很多ERP、MES、PLM系统的附件上传模块一把梭把整个文件塞进multipart/form-data文件一大浏览器内存直接爆掉服务器IIS或Kestrel被一个大请求拖死用户等半天看到的是上传失败请重试。这个体验在车间现场尤其糟糕——技术员、工艺员根本没有耐心跟你玩重传他们只会直接去拷U盘然后你在审计追溯时就找不到受控版本了。事情的本质不是传输速度而是重复传输和传输不可靠。同一个设计图纸可能被几十个人分别审核、浏览、下载后再上传修改版其中大量文件内容是完全重复的。秒传的核心思路就是把传文件变成传文件的指纹——文件在服务器上已经存在就跳过传输直接建立引用。1.2 网页端秒传到底要解决什么很多人一听到秒传第一反应是光速上传其实不是。秒传的本质是文件去重存储用算哈希、查索引替代真正搬数据。前端把文件切成小块逐块算出唯一的哈希值先把哈希列表发给服务器服务器查一下自己存储里的哈希索引发现某个文件块已经在库里了就直接标记为已有前端这一块就不用传了。全部块都已存在整个文件直接完成秒传前后可能只需要几秒钟算哈希的时间。这件事放在机械制造行业还有一层特殊价值同一批产品的图纸、工艺文件版本变化往往只是局部差异主体内容没动。如果做成分块级秒传而不是整文件级秒传那效果更可观——一个200MB的装配图改了5%的内容剩下190MB都是已存在的块照样能秒传。还有一个容易忽略的点秒传和断点续传天然是一家人。既然文件都分块了哪块没传就补哪块断网、断电、浏览器崩溃都不怕重新打开页面继续传就行了。这在车间现场网络不稳的环境里比什么都实用。2. 秒传的底层原理与方案选型2.1 从文件唯一性标识说起要判断两个文件是不是同一个最可靠的办法是比对内容。业界通用做法是对文件内容做哈希运算MD5、SHA-1、SHA-256都有人用。网上很多教程喜欢拿整个文件算一个MD5这种方法对小文件没问题对几百MB的大文件就露怯了——把整个文件读进内存算一遍耗时可能比直接上传还长内存也扛不住。更合理的做法是把文件切成固定大小的分块比如每块4MB逐块计算MD5。前端用File.slice()方法取出分块数据通过FileReader或crypto.subtle接口逐块读取并计算哈希。机械制造行业的图纸文件很多是二进制格式CAD原生格式、字体嵌入的PDF、STEP/IGES交换文件逐块哈希完全不受格式影响只要是字节流就能算。这里有个关键点分块大小怎么定。我实测下来4MB是一个比较均衡的阈值。太小了——比如512KB——哈希计算次数太多前端性能浪费服务器收到的块元数据数量也爆炸太大了——比如100MB——秒传的粒度太粗文件改了局部内容整块哈希全变去重效果归零。在制造业场景下很多CAD文件保存时内部结构会整体重写大粒度反而容易失效4MB左右比较稳妥。2.2 为什么服务端不用数据库做文件目录秒传的核心是服务端先有一张文件指纹索引表。朴素做法是把文件哈希存进数据库每次上传先查一下。但文件多到几十万、上百万份时查一张大表索引命中率再高延迟也在几十毫秒以上如果文件量再大数据库就成了瓶颈。我推荐的做法是用存储目录命名空间做对等索引。服务端存储路径直接用哈希值命名比如/filepool/ab/abcdef123456...第一级目录取哈希的前两个字符做分片避免了单目录文件数过多的问题。判断某个块是否存在直接判断对应路径的文件是否在聊胜于读数据库一次文件系统Stat就搞定。数据库里只记录哪个文件由哪些块组成以及文件名、版本、上传人、时间这类业务元数据。还有一个容易被忽视的细节引用计数。同一个块被多个文件复用比如三个版本的图纸共享了前900MB的数据块删除其中一个版本时不能直接把块删掉得先减引用计数减到0才真正物理删除。这个逻辑不做存储空间早晚被删不掉的僵尸块塞满。// 文件块存储路径规则示例/filepool/AB/ABCDEF...两级散列 // 块是否存在 该路径文件是否存在不需要查数据库 private string BuildBlockPath(string blockHash) { var prefix blockHash.Substring(0, 2).ToUpperInvariant(); return Path.Combine(_storageRoot, prefix, blockHash.ToUpperInvariant() .blk); }2.3 前端与后端的分工边界秒传链路里前后端各管一摊分工必须清晰。前端负责切片、计算每块的哈希、把哈希清单发给后端、接收哪几块要传的响应、上传缺失的块、上传完后通知后端合并。后端负责接收块哈希清单、比对已有块、保存新块、维护文件索引、处理文件合并和版本关联。这里有一个常见的错误设计让后端算哈希。有些人图省事前端把整个文件发到后端让后端算MD5去重。这违背了秒传的初衷——传输已经发生了去重只是省了存储。真正要做的是让哈希计算发生在传输之前让待传输的数据量尽可能小。前端算哈希后端做比对和存储这个方向不能反。前端算哈希还有一个好处支持文件秒传但内容仍需要校验。比如工人上传一个数控程序前端算出哈希后后端比对发现库里有直接秒传但这时可以后台异步触发一次抽查校验把存储里对应块读出来和上传方核对一下长度和校验值防止由于网络损坏导致的伪命中。这一步在数据安全要求高的行业里很有价值。3. C#后端核心实现详解3.1 项目结构与接口契约我用的是ASP.NET Core Web API.NET 8。项目结构上分了三个项目ManufacturingFile.Api接口层、ManufacturingFile.Core领域逻辑、ManufacturingFile.Storage文件存储实现典型的洋葱架构。控制器只做参数校验和结果封装真正的业务判断放在领域服务里存储抽象成接口方便以后从本地磁盘换到对象存储。接口一共三个接口方法功能/api/file/preparePOST前端把文件名、大小、块哈希列表发过来后端返回哪些块需要上传/api/file/upload-blockPOST上传单个文件块只传缺失的块/api/file/completePOST全部块就绪后后端把块合成文件写入业务索引这个接口设计比一个接口传整个文件清晰的点在于prepare接口完成了握手让前端知道该干什么upload-block是一个纯存储动作没有复杂的业务逻辑可以水平扩展——将来文件量大可以把这个接口单独部署到存储节点上complete是最终事务负责合并块、更新版本记录动作最重但只调一次。这些接口的请求和响应模型我一律用record定义省去一堆getter/setter样板代码序列化用System.Text.Json性能好配置少。// Prepare 请求模型 public record FilePrepareRequest( string FileName, long FileSize, string Md5Whole, // 整个文件的MD5用于整文件级秒传快速判断 Liststring BlockMd5List // 每个分块的MD5 ); public record FilePrepareResponse( string UploadToken, bool InstantSuccess, // 是否直接秒传 ListBlockUploadState Blocks ); public record BlockUploadState( int BlockIndex, bool NeedUpload );3.2 内存映射文件与低内存哈希计算C#里算大文件哈希有一个比较优雅的工具MemoryMappedFile。它把磁盘上文件映射到进程地址空间读文件不用一次性Load到托管堆而是按需加载从本质避开了大文件把内存撑爆的问题。我用MemoryMappedFile流式读取文件的每个分块配合IncrementalHash增量式计算哈希。为什么用IncrementalHash它可以分多次喂数据、最后一次性输出哈希结果特别适合分块哈希的场景。对每个4MB块我只需要AppendData喂进去就能逐个算出块的MD5不需要为每个块单独开一个独立哈希对象重新从头算。// 用内存映射文件方式低内存占用地计算整个文件的逐块MD5 public static Liststring ComputeBlockMd5ByMemoryMap(string filePath, int blockSize) { var result new Liststring(); using var mmf MemoryMappedFile.CreateFromFile(filePath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); long fileLength new FileInfo(filePath).Length; long offset 0; int index 0; while (offset fileLength) { long remaining fileLength - offset; int currentBlockSize (int)Math.Min(blockSize, remaining); using var viewAccessor mmf.CreateViewAccessor(offset, currentBlockSize, MemoryMappedFileAccess.Read); using var sha IncrementalHash.CreateHash(HashAlgorithmName.MD5); byte[] buffer new byte[81920]; // 80KB每块分成多段喂入哈希 long bytesRead 0; while (bytesRead currentBlockSize) { int toRead (int)Math.Min(buffer.Length, currentBlockSize - bytesRead); int actualRead viewAccessor.ReadArray(bytesRead, buffer, 0, toRead); if (actualRead 0) break; sha.AppendData(buffer, 0, actualRead); bytesRead actualRead; } string hash Convert.ToHexString(sha.GetHashAndReset()).ToUpperInvariant(); result.Add(hash); offset currentBlockSize; index; } return result; }这里解释两个实际工程里容易踩坑的点。第一CreateFromFile映射一个文件时文件路径的权限必须够IIS或Windows服务里跑经常遇到访问被拒绝得给运行账号加读权限。第二ReadArray每次要指定起始位置这个位置是相对这个View的偏移不是文件全局偏移我写错过一次排查了半天最后发现是视图内的相对偏移导致每块都读了头部数据哈希全错。3.3 Prepare接口秒传判断逻辑Prepare接口的逻辑一句话前端把完整哈希清单发过来后端逐个比对这个块是否已在存储池中返回哪些块需要传。这里要特别注意不能只做整文件级MD5判断——如果一个文件只改变了一个小区域比如改了图纸的标题栏文字整文件MD5全变了但90%以上的块仍然一样分块级判断能命中大部分块。我在代码里用一个HashSet预处理已存在的块哈希把查询复杂度降到O(1)。不要在这个接口里连数据库逐个查文件多的时候SQL查询就是拖油瓶。public async TaskFilePrepareResponse PrepareAsync(FilePrepareRequest request) { // 生成一个上传令牌后续上传块时要带用于防止乱传和错误路径写入 string uploadToken Guid.NewGuid().ToString(N); var existsSet _fileBlockIndex.GetExistingBlockHashes(request.BlockMd5List); bool instantSuccess true; var blockStates new ListBlockUploadState(); for (int i 0; i request.BlockMd5List.Count; i) { bool need !existsSet.Contains(request.BlockMd5List[i]); if (need) instantSuccess false; blockStates.Add(new BlockUploadState(i, need)); } // 如果所有块都已存在直接建立文件记录返回秒传成功 if (instantSuccess) { await _fileMetaRepository.CreateInstantFileAsync(request.FileName, request.FileSize, request.Md5Whole, blockStates); return new FilePrepareResponse(uploadToken, true, blockStates); } // 缓存令牌与待上传清单供后续上传块接口校验 _pendingUploads[uploadToken] new PendingUploadInfo(request, blockStates); return new FilePrepareResponse(uploadToken, false, blockStates); }_pendingUploads是一个内存字典存着令牌-待传块清单。生产环境要考虑分布式一台机器存的内存状态别的机器看不到这时可以用Redis替代精简做法是把令牌设为块清单的签名服务端无状态每次请求重新计算。我在单机内部系统里直接用内存字典部署简单因为机械制造企业的文件服务大多内网单机部署没必要一上来就上Redis。3.4 块上传与合并实现上传块的接口逻辑相对简单校验上传令牌取出前端传的块索引和块哈希校验哈希是否和Prepare阶段声明的一致然后把流写入文件池。关键是别用普通File.WriteAllBytes——大块数据全Load进内存再写出又慢又费内存。用流式拷贝方式一边读请求体一边写盘[HttpPost(upload-block)] public async TaskIActionResult UploadBlock([FromForm] BlockUploadForm form) { if (!_pendingUploads.TryGetValue(form.UploadToken, out var pendingInfo)) return BadRequest(上传令牌无效或已过期); var declaredHash pendingInfo.Blocks[form.BlockIndex].BlockMd5; string targetPath BuildBlockPath(declaredHash); // 防止恶意或异常上传块覆盖已有数据如果块已存在直接返回成功 if (System.IO.File.Exists(targetPath)) return Ok(new { needMerge false }); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); await using var fs new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true); await form.File.CopyToAsync(fs); fs.Flush(true); // 写入后重新计算MD5与声明比对防止传输损坏导致“脏块”入库 string actualHash await ComputeBlockMd5Async(targetPath); if (!string.Equals(actualHash, declaredHash, StringComparison.OrdinalIgnoreCase)) { System.IO.File.Delete(targetPath); return BadRequest(块校验失败); } _fileBlockIndex.AddBlockRef(declaredHash); return Ok(new { needMerge _pendingUploads.IsAllUploaded(form.UploadToken) }); }这里ComputeBlockMd5Async是简单的流式MD5计算IncrementalHash同样适用。校验这步我坚持要因为制造业文件是受控文件一块数据损坏可能导致整张图纸打不开宁可慢几百毫秒也要保证块入库时是干净的。合并部分complete接口把所有块按索引顺序拼接成最终文件同时生成该文件在业务系统里的版本记录。拼接大文件时用固定缓冲区分块读写千万别用string或byte[]一次性拼。100个4MB的块就是400MB一次读进内存Web应用内存直接告急。[HttpPost(complete)] public async TaskIActionResult Complete(CompleteRequest request) { // 按索引读取所有块文件顺序写入业务文件目录 string outputDir Path.Combine(_storageRoot, business, request.BusinessType); Directory.CreateDirectory(outputDir); string outputPath Path.Combine(outputDir, SanitizeFileName(request.FileName)); await using var output new FileStream(outputPath, FileMode.CreateNew, FileAccess.Write); var buffer new byte[81920]; foreach (var blockMd5 in request.BlockMd5List) { string blockPath BuildBlockPath(blockMd5); await using var input new FileStream(blockPath, FileMode.Open, FileAccess.Read); int read; while ((read await input.ReadAsync(buffer, 0, buffer.Length)) 0) { await output.WriteAsync(buffer, 0, read); } } await output.FlushAsync(); // 建立业务元数据记录文件名、大小、版本、上传人、关联订单/产品编码等 await _fileMetaRepository.CreateBusinessFileAsync(request.BusinessType, request.FileName, outputPath); return Ok(new { fileName request.FileName, size new FileInfo(outputPath).Length }); }块合并时有一个并行化技巧如果机器是多核可以并行读多个块到内存再用Channel缓冲、单线程写盘。实测在SSD上提升不明显瓶颈在磁盘写入在机械硬盘上并行读反而增加磁头寻道开销更慢。所以建议保持简单串行合并把并发留给真正的CPU密集场景。4. 网页前端配合实现4.1 文件分块与JS哈希计算前端负责整个交互流程中最考验浏览器能力的部分大文件切片和哈希计算。JavaScript侧的File.slice()能无缝切出文件的任意片段这是分块基础。关键问题在于读块和算哈希老办法是FileReader.readAsArrayBuffer()把整个块读入内存再喂给哈希算法4MB一块无所谓但如果并发5个块同时算内存也能吃到几十MB。我在项目中用crypto.subtle.digest来算哈希它是Web Crypto API的一部分底层走浏览器原生实现速度比纯JS的MD5库快非常多。注意它只能异步调用返回值是Promise算多个块时建议限制并发数比如4个并发别让CPU被打满。async function computeBlockMd5(blob) { const arrayBuffer await blob.arrayBuffer(); const hashBuffer await crypto.subtle.digest(MD5, arrayBuffer); const hashArray Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b b.toString(16).padStart(2, 0)).join().toUpperCase(); } // 分块并发限制为4 async function computeFileBlocks(file, blockSize 4 * 1024 * 1024) { const blockCount Math.ceil(file.size / blockSize); const md5List []; const concurrency 4; let cursor 0; async function worker() { while (cursor blockCount) { const index cursor; const start index * blockSize; const end Math.min(file.size, start blockSize); const blob file.slice(start, end); md5List[index] await computeBlockMd5(blob); } } const workers Array.from({ length: concurrency }, () worker()); await Promise.all(workers); return md5List; }网上很多教程会让你用SparkMD5库它方便但性能一般大文件算5GB哈希要卡一小会儿。实测用crypto.subtle计算4MB块的MD5单块大约几十毫秒200MB的文件几十个块并发总时长在一两秒内用户体感完全可以接受。4.2 秒传与上传的主流程控制前端主流程就是先Prepare、按需上传、后Complete。把状态机搞清楚代码就不会乱。状态流转IDLE HASHING PREPARING UPLOADING COMPLETING DONE。每一步都可能有异常异常发生后回到HASHING重新来也行——反正哈希计算结果可以缓存只要File对象还在。async function uploadFile(file, businessType) { setStatus(HASHING); const blockMd5List await computeFileBlocks(file); const wholeMd5 await computeWholeMd5(file); // 可复用分块结果也可以单独算 setStatus(PREPARING); const resp await fetch(/api/file/prepare, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, md5Whole: wholeMd5, blockMd5List }) }); const data await resp.json(); if (data.instantSuccess) { setStatus(DONE); return data.uploadToken; // 秒传成功直接收工 } setStatus(UPLOADING); const needUploadBlocks data.blocks.filter(b b.needUpload); let uploadedCount 0; for (const block of needUploadBlocks) { const start block.blockIndex * blockSize; const end Math.min(file.size, start blockSize); const form new FormData(); form.append(uploadToken, data.uploadToken); form.append(blockIndex, block.blockIndex); form.append(file, file.slice(start, end)); await fetch(/api/file/upload-block, { method: POST, body: form }); uploadedCount; setProgress(uploadedCount / needUploadBlocks.length); } setStatus(COMPLETING); await fetch(/api/file/complete, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ uploadToken: data.uploadToken, businessType }) }); setStatus(DONE); }这段代码是可运行的最小闭环但生产必须补两个东西。一是进度条要有瞬时速度用已上传字节数除以时间窗口别用平均速度不然用户看到速度忽高忽低以为卡了。二是断点续传把哈希清单和未上传块列表存入localStorage刷新页面后重新从prepare开始后端返回的需要上传的块列表会和本地缓存比对只补缺失块。4.3 与机械制造系统集成的脏活细节前端接到普通文件上传接口还有一系列跟制造业务相关的细节。文件名必须去除非法字符不然ERP系统对接时文件名里带/、:、控制字符Windows存储路径直接抛出异常。业务类型字段要跟MES的文档类型对应比如DWG、STEP、NC_PROGRAM、QUALITY_REPORT后端要按类型分流存储否则审计的时候没有分类会很崩溃。老旧的车间电脑是个大问题。很多工厂车间还是Windows 7/IE11crypto.subtle在现代浏览器里没问题但IE11没有这个API。如果的确需要兼容老旧浏览器只能退回SparkMD5方案或者做降级检测window.crypto.subtle不存在时用异步Web Worker里跑SparkMD5避免阻塞UI线程。现在的Edge和Chrome在新系统上都没问题但如果车间用的是老工控电脑配IE内核这个兼容策略就得提前规划。还有一个制造业特有需求同一份文件上传到多个业务目录。比如一张图纸可能既用在生产订单BOM里也要挂在设备维保记录里。如果每次上传都物理复制一份存储浪费且版本不一致。我在前端加了个fileKey参数同一份文件首次上传后后续关联到其他业务对象时前端直接走prepare接口因为哈希全命中秒传秒关联后端只新增一条业务引用不复制数据。既省空间又保证版本统一。5. 常见问题与排查技巧实录5.1 40个块都传完了为什么合并报错这是最容易踩的坑块上传接口返回needMerge true的条件判断有竞态。我的早期实现里最后一个块上传完成后立即发complete请求但服务端的事务可能还没提交完合并接口拿不到完整的块清单。解决方法是前后端约定前端不能依赖单块上传返回的needMerge标志统一在上传完所有块后轮询或直接调用一个check-ready接口确认服务端所有块已经可见再触发合并。更简单的做法是块上传成功后在complete接口里加一个等待机制扫描块文件是否存在短暂重试3次每次间隔500ms。实践中这个策略能化解99%的时序问题。5.2 计算5GB文件哈希前端卡死了怎么办前面讲的crypto.subtle算哈希速度很快但File.arrayBuffer()会把整个块读进内存如果块大小设置不合理比如用户选了5GB文件我用的4MB块不会出问题。真正卡死的情况是用户一次拖进来20个文件每个几百MB前端并发算所有文件的哈希内存占用立刻飙升。对策是加一个全局任务队列同一时刻只允许处理一个文件的哈希计算其他文件排队。UI上给每个文件一个独立状态卡片排队中计算哈希上传中完成这样用户能看清系统在干什么心里有数。实测在8GB内存的老电脑上同时处理两个超过2GB的文件系统就开始卡了。队列化后稳定很多。5.3 上传到一半浏览器崩溃怎么续传浏览器崩溃后File对象丢失但好消息是localStorage里还存着块哈希清单。刷新页面后用户重新选择同一个文件按文件名大小最后修改时间三重匹配系统先从localStorage恢复哈希列表直接进入prepare服务器返回哪些块还需要传。已经传过的块在存储池里躺着直接跳过。这个方案停电都不怕。有一点要提醒如果用户改过文件内容lastModified变了本地缓存的哈希就不能用了必须重新计算。有些实现直接用lastModified当文件唯一标识这是有隐患的——两个内容不同但碰巧同一毫秒修改的文件会命中错误的缓存。我的做法是缓存里同时存文件大小和最后的块哈希拿现有文件重新算前几块比对一下不匹配就全量重算。5.4 遇到的几个生产环境限定坑第一个是反代超时。Nginx默认proxy_read_timeout是60秒如果某个块上传慢车间网络拥塞超过60秒就被掐断。我的Nginx配置里把这个值调到300秒并且把client_max_body_size设为0——不限制体大小让后端自己处理。第二个是IIS部署时的URL长度限制。虽然上传用的是POST但prepare接口如果走GET传哈希清单URL太长会被IIS拒绝默认是maxQueryString限制。统一用POST就能绕过别为了省事用GET拼接哈希参数。第三个是磁盘空间。分了块的哈希文件每块都是一堆小文件一个200MB的文件切50块删版本时引用计数减到0后要批量清理。清理逻辑建议做成后台任务别在前台请求里同步删不然用户删一个文件要等几十秒才有响应。我是在IHostedService里挂了一个定时清扫器每半小时扫一次引用计数为0且超过24小时的文件块统一清理。第四个是文件池目录权限。IIS应用程序池的运行账号默认权限很低Directory.CreateDirectory往根目录写会直接抛未授权。要么给存储目录单独加Modify权限给IIS进程账号要么用共享目录并配置好模拟身份访问。这个问题通常在上线第一天出现提前配好权限后面省心很多。最后补充一点实用经验我在实际项目里踩过最深的坑就是一开始把秒传当成了上传组件升级只做接口没有考虑制造业的特殊场景。真正落地后发现车间用户更在意的是传不上去了别丢进度和我删掉的图纸千万别还占着服务器空间。秒传只是切入口分块、校验、引用计数、断点清理这些才是维系整个文件系统的基石。如果你要把这套方案推广到企业里建议先在一个产品线试点把它接进原来的图纸管理模块跑两三个月看看引用计数和存储空间的真实变化再决定要不要推广到全部业务线。IPC、TC、PLM这类系统都集成文件管理但很多厂家只做了上传下载没做去重和版本共享这恰恰是可以用秒传分块技术改造的洼地。