
做视频上传功能时我最怕看到的就是用户传个500MB的教学视频传了20分钟最后断网还得从头再来。这是我接手企业培训系统后第一个被吐槽的功能。后来我把上传逻辑彻底重构成了分片上传服务端加密才算是把这块的投诉降下来。这篇文章就完整复盘一下整个方案从协议设计、前端切片、.NET MVC后端接收合并再到流式加密落盘每一步都给出可直接抄作业的代码和参数顺便把我踩过的坑一并交代清楚。不管你是做网盘、视频课程平台还是企业内部资料库这套思路都适用。1. 为什么要做分片上传与加密场景与痛点1.1 大视频上传的三个坑大文件走普通表单上传第一个坑是请求体太大导致超时。IIS默认的请求超时是110秒你一个500MB的视频上行带宽只有20Mbps的话传完基本要3分钟往上中间稍微抖动一下连接断了整个上传就得重来。用户体验就是进度条卡在某个百分比半天不动然后直接报错。第二个坑是服务端内存被撑爆。传统的HttpPostedFileBase方式IIS会把整个请求体缓冲到内存里大文件一来内存直接飙到几百MB甚至上GB。并发一多应用池直接挂掉老板只看到网站打不开不会想到是上传功能跪了。第三个坑是可恢复性为零。网络再稳定也有波动的时候一旦上传中断之前的流量全部浪费。而用户是不可能理解您上传失败这个提示背后的技术原因的他们只会觉得系统烂。分片上传能把一个巨大的任务拆成几十上百个小任务每个小任务都是独立请求失败只需要重传那一个小片进度还能保留这才是做产品该有的态度。1.2 光上传不够还得防裸奔视频文件分片传上来之后如果不做任何处理直接存盘那服务器上就是一堆可以直接拖走播放的原始文件。企业内部培训视频、付费课程、涉密资料这些内容的泄露往往不是外部黑客干的而是内部人员顺手拷走的。给存储在服务端的视频文件做加密等于给资产加了一把锁就算有人拿到磁盘文件没有密钥也解不开。这里多说一句很多朋友以为加密会拖慢上传速度其实不会。加密发生在服务端合并写入磁盘的过程中对用户来说是透明的用户该传多快还是多快。我们做的只是让最终落盘的数据变成密文机密性由服务端把控用户侧无感知。1.3 这套方案适合谁参考如果你正在做以下任何一种系统这篇笔记对你都有直接参考价值在线教育/培训平台的视频课程上传企业内部的文档与视频管理系统个人网盘/私有云存储需要大文件上传能力的业务后台基础要求是熟悉ASP.NET MVC的基础开发流程会写基本的JavaScript。不需要你掌握高深的密码学知识加密部分我用的是.NET自带的System.Security.Cryptography不引入任何第三方库。2. 整体设计协议、加密与架构方案2.1 分片协议怎么定分片上传本质上是一个自定义的应用层协议。我们先约定好前端和后端之间交互的字段再定接口的路径与返回格式。我在项目中用到的协议字段如下字段名类型说明UploadIdstring上传任务的唯一标识前端生成 GUIDFileNamestring原始文件名服务端保存时需要TotalChunksint总分片数ChunkIndexint当前分片的序号从0开始ChunkSizelong每片字节数固定值便于校验FileSizelong文件总字节数FileHashstring整个文件的SHA-256值用于最终校验接口设计上我做三个端点POST /Upload/Start前端先请求创建上传任务服务端记录UploadId返回允许上传的分片大小和是否已存在相同文件秒传逻辑。POST /Upload/Chunk上传单个分片包含分片二进制数据与元数据。POST /Upload/Complete通知服务端所有分片已传完触发合并与加密流程。刚才我提秒传这里解释一下前端先计算整个文件的SHA-256Start接口去数据库查一下这个哈希是否已经存在如果存在就告诉前端不用传了直接返回已存在的文件路径。对企业内部系统来说同一份视频被多个部门重复上传的情况非常多秒传能砍掉大量无效流量。2.2 加密策略从对称到混合加密方案我最终选的是AES-256-CBC HMAC-SHA256的组合用对称加密主要是性能考虑。视频动不动几百MB用RSA这种非对称加密来加密大数据量是不现实的光加解密耗时就能熬死人。正规的做法是非对称加密保护对称密钥对称密钥加密实际数据也就是混合加密。用户上传完成时系统生成一个随机的AES-256密钥用这个密钥加密视频文件再把AES密钥用平台RSA公钥加密后存到数据库。这样即使数据库泄露攻击者拿不到RSA私钥也无法解开视频。要注意的是CBC模式本身不具备完整性校验能力所以我在加密时同时计算HMAC-SHA256存为.hmac文件。解密时先验证HMAC再解密防止密文被篡改。如果你用的是.NET 5以上的环境可以替换为AES-GCM它同时提供机密性和完整性更省事。但考虑到很多企业还在用.NET Framework 4.x我这里以CBCHMAC为准兼容性最好。2.3 总体流程拆解把整个流程画在脑子里其实就五步前端读取文件按固定大小切片计算文件哈希。前端调用Start接口创建任务拿到UploadId查询已上传分片列表。前端并发限制3~5个上传分片每个分片带UploadId和ChunkIndex。全部完成后前端调用Complete接口。服务端按序号合并分片成一个临时文件边合并边用AES加密写入最终存储路径同时计算HMAC入库记录元数据和密钥密文。管线清晰之后每一步实现起来都不复杂。下面的章节就按这个顺序逐步拆开讲。3. 前端分片实现细节3.1 File.slice切片与并发控制前端切片用的是浏览器内置的Blob.slice()方法零依赖。我先把切片大小定为2MB。选2MB主要有三个原因HTTP请求体控制在2MB左右即使网络差单个请求的失败率也低重试成本小。服务器接收2MB的内存压力很小即使10个并发也就20MB。分片数量不会太多。1GB的视频分成512片可管理如果切成100KB上万片光元数据就能把数据库拖死。切片和上传的核心代码我贴在下面注意看注释const CHUNK_SIZE 2 * 1024 * 1024; // 2MB const CONCURRENCY 3; // 并发数别贪多 function chunkFile(file) { const chunks []; let offset 0; while (offset file.size) { const end Math.min(offset CHUNK_SIZE, file.size); chunks.push(file.slice(offset, end)); offset end; } return chunks; } async function uploadInPool(chunks, uploadId, fileHash) { const results new Array(chunks.length).fill(null); let cursor 0; async function worker() { while (cursor chunks.length) { const idx cursor; const blob chunks[idx]; const form new FormData(); form.append(UploadId, uploadId); form.append(ChunkIndex, idx); form.append(TotalChunks, chunks.length); form.append(FileName, blob.name || video.mp4); form.append(FileHash, fileHash); form.append(file, blob); let success false; for (let retry 1; retry 3 !success; retry) { try { const resp await fetch(/Upload/Chunk, { method: POST, body: form }); if (resp.ok) { const json await resp.json(); if (json.success) success true; results[idx] json; } } catch (e) { console.warn(Chunk ${idx} attempt ${retry} failed:, e); } if (!success retry 3) await new Promise(r setTimeout(r, 1000 * retry)); } if (!success) throw new Error(Chunk ${idx} upload failed after 3 retries); } } const workers Array.from({ length: CONCURRENCY }, () worker()); await Promise.all(workers); return results; }并发数这里我强烈建议控制在3~5。为什么浏览器对同一域名的HTTP/1.1并发连接数有限制Chrome是6个但这6个还包括页面本身的其他请求。你并发开太高资源全占在分片上传上用户连页面里的进度条都加载不出来了。另外你服务端的线程池也不是无限的并发过高IIS线程耗尽会全面卡死。实测下来3个并发是最稳的进度也不慢。3.2 断点续传与秒传断点续传的实现思路是服务端记住每个UploadId已接收到哪些分片。前端在Start之后拿到服务端返回的uploadedChunks数组把已上传的分片从待传队列里剔除只传缺失部分。async function startUpload(file) { const fileHash await sha256(file); const resp await fetch(/Upload/Start, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ FileName: file.name, FileSize: file.size, FileHash: fileHash }) }); const data await resp.json(); if (data.exists) { // 秒传直接完成 return { instant: true, fileId: data.fileId }; } const chunks chunkFile(file); // 服务端返回已传分片索引例如断点恢复场景 const uploadedSet new Set(data.uploadedChunks || []); const pendingChunks chunks.map((blob, idx) ({ blob, idx })) .filter(item !uploadedSet.has(item.idx)); await uploadInPool(pendingChunks, data.uploadId, fileHash); await completeUpload(data.uploadId); return { instant: false, fileId: data.fileId }; }断点续传还有一个细节浏览器刷新后怎么找回UploadId我前端用localStorage存一份映射key是文件哈希value是UploadId和分片状态。下次用户拖同一个文件进来先查本地缓存再查服务端两头发力。这里就不贴代码了逻辑比较直白。3.3 前端踩坑点切片上传看起来简单实操中有几个细节不注意就会出问题FormData字段顺序后端如果用Request.Form读取元数据要确保file字段是最后一个append进去的。某些代理服务器会对字段顺序做处理把二进制文件放在最后面最稳妥。File.name可能为空从file.slice()出来的Blob对象name属性不一定继承原始文件名我遇到过一次Chrome下Blob.name为空的情况。所以文件名要在Start接口时就传过去后端存起来分片请求里的FileName只做兜底。fetch的默认行为fetch上传大文件时如果服务器返回4xxfetch并不会自动抛异常要手动检查resp.ok。很多新手在这里栽跟头以为上传成功了实际上服务端早就拒绝了。4. 后端.NET MVC接收与合并4.1 配置IIS与大文件限制后端动手之前先得把IIS的请求限制放宽。默认IIS允许的最大请求内容是约28.6MBmaxAllowedContentLengthASP.NET的maxRequestLength默认4MB。不分片还行分片虽然每个片才2MB但也要留余量我直接设置为10MB防止某些情况下切片不准导致大包被拒。system.webServer security requestFiltering requestLimits maxAllowedContentLength10485760 / /requestFiltering /security /system.webServer system.web httpRuntime targetFramework4.7.2 maxRequestLength10240 executionTimeout120 / /system.web注意maxAllowedContentLength单位是字节maxRequestLength单位是KB别搞混了我当初写反了一次排错排了一整天。4.2 接收分片的Action实现接收分片的Action用MVC标准的写法关键点是用Request.Files拿文件而不是用参数绑定。代码逻辑如下[HttpPost] public ActionResult Chunk() { var uploadId Request.Form[UploadId]; var chunkIndex int.Parse(Request.Form[ChunkIndex]); var totalChunks int.Parse(Request.Form[TotalChunks]); var fileName Request.Form[FileName]; var fileHash Request.Form[FileHash]; if (Request.Files.Count 0) return Json(new { success false, message 未接收到分片文件 }); var chunkFile Request.Files[0]; if (chunkFile.ContentLength 2 * 1024 * 1024 1024) return Json(new { success false, message 分片大小超限 }); // 保存分片到临时目录 var tempDir Server.MapPath($~/App_Data/Chunks/{uploadId}); Directory.CreateDirectory(tempDir); var chunkPath Path.Combine(tempDir, ${chunkIndex}.part); chunkFile.SaveAs(chunkPath); // 更新已上传分片记录推荐写数据库或Redis这里简化为写状态文件 AppendChunkRecord(uploadId, chunkIndex); return Json(new { success true }); }有几个细节我要单独拎出来讲临时目录的命名空间隔离每个UploadId一个独立目录避免多个任务的分片混在一起。UploadId本身就是GUID不存在路径穿越问题但保险起见如果项目允许用户传入UploadId务必校验格式。分片大小校验客户端说每片2MB你不能无条件信。服务端一定要校验ContentLength防止有人恶意传大包把临时目录塞满。这里我留了1KB的余量因为Multipart格式本身会有少量额外字节。SaveAs的路径不要用Path.Combine(tempDir, Request.Files[0].FileName)文件名是用户可控的可能包含路径字符造成任意文件覆盖写入。直接用ChunkIndex命名才是安全的。4.3 分片合并流式写入与顺序保证所有分片传完后前端会调Complete接口服务端开始合并。合并的核心也是唯一要点不要把所有分片读进内存再写出去要用流式读写一个分片读完写盘后立即释放。[HttpPost] public ActionResult Complete(string uploadId) { var chunkDir Server.MapPath($~/App_Data/Chunks/{uploadId}); if (!Directory.Exists(chunkDir)) return Json(new { success false, message 上传任务不存在 }); var totalChunks GetTotalChunks(uploadId); var mergedPath Server.MapPath($~/App_Data/Merged/{uploadId}.mp4); using (var output new FileStream(mergedPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024)) { for (int i 0; i totalChunks; i) { var chunkPath Path.Combine(chunkDir, ${i}.part); if (!File.Exists(chunkPath)) return Json(new { success false, message $缺少分片 {i} }); using (var input new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024)) { input.CopyTo(output); } } } // 合并完成清理分片目录 Directory.Delete(chunkDir, true); return Json(new { success true, message 合并完成 }); }这段代码里用到了1024 * 1024的缓冲大小也就是每次拷贝1MB这个值比较均衡不会太小导致系统调用频繁也不会太大占用内存。这里我还要强调一个排序陷阱分片合并必须按索引顺序循环不能靠文件夹里的文件名字符串排序。如果临时文件命名是1.part、10.part、2.part字符串排序的结果是1、10、2合并出来的文件就废了。我自己在这里栽过跟头后来就一律用整数索引循环。合并完整性的校验同样是用文件的SHA-256。客户端Start时传了整个文件的Hash合并后服务端重新计算Hash不一致就说明有分片损坏或缺失这时不能收工要报错并提示前端重传缺失分片。5. 流式加密与密钥管理5.1 为什么不能合并完再加密很多人会想先把分片合并成完整视频再读一遍文件加密这不就行了功能上没问题但你会发现大文件这么一搞磁盘I/O加倍、耗时也加倍。1GB的视频合并写1GB加密读1GB再写1GB总共3GB的I/O量服务器磁盘再差点用户得盯着进度条转半天。更优的方案是在合并的流式写入过程中直接在输出流上套一层CryptoStream。分片边合并边加密一次I/O就完成写入和加密两件事。这里的核心思路是FileStream只管把密文写盘CryptoStream作为中间层处理加密转换数据从输入流到加密流再到文件流全程不触碰托管堆。5.2 边合并边加密的代码实现用CryptoStream包裹输出流的代码如下注意Aes.Create()和RijndaelManaged的区别using System.Security.Cryptography; private static void MergeAndEncrypt(string chunkDir, int totalChunks, string encryptedPath, byte[] key, byte[] iv) { using (var aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (var encryptedOutput new FileStream(encryptedPath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024)) using (var cryptoStream new CryptoStream(encryptedOutput, aes.CreateEncryptor(), CryptoStreamMode.Write)) { for (int i 0; i totalChunks; i) { var chunkPath Path.Combine(chunkDir, ${i}.part); using (var input new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024)) { input.CopyTo(cryptoStream); } } cryptoStream.FlushFinalBlock(); // 关键最后必须调用否则末尾密文不完整 } } }这里必须强调FlushFinalBlock()。CryptoStream的加密是流式的CopyTo不会自动触发FinalBlock如果你直接using结束最后一块的密文和填充可能没有写入文件导致解密出来的文件末尾缺失。我见过好几个同事写加密代码少了这一行解出来的视频长度不对播放器报错。关于多分片加密的完整性还要注意CBC模式下分片之间是有依赖的前一个分片的密文会作为下一个分片加密的输入。所以上面的循环中绝对不能每个分片单独创建一个CryptoStream和新的IV必须整条流一连到底。5.3 密钥管理别把钥匙放在锁上加密方案做得再严谨密钥泄露等于白干。工作中我见过最离谱的做法是把AES密钥明文写在web.config里。密钥和密文放一起就像把钥匙挂在锁上太危险了。我采用的实践是分级管理AES数据密钥每次上传文件生成一个随机密钥作用域单一只加密这一个文件。RSA平台密钥对公钥加密AES密钥私钥只在授权服务中保存。公钥可以随发布走私钥单独放在配置中心或专用密钥存储中。传输与存储AES密钥被RSA加密后存入数据库解密只在服务内存中进行不落盘。密钥生命周期内如需轮换RSA私钥换掉后可以定期批量解密再加密数据库里的AES密钥。考虑到很多中小项目没有配置中心退而求其次的做法是在Windows环境下用DPAPIProtectedData类保护密钥文件。DPAPI是Windows账户级别的加密只能在本机解密即使密钥文件被拷走在其他机器上也解不开。虽然没有RSA方案灵活但部署成本几乎为零。// 用DPAPI保护AES密钥 var encryptedKey ProtectedData.Protect(aesKey, null, DataProtectionScope.CurrentUser); File.WriteAllBytes(key.bin, encryptedKey); // 解密时 var restoredKey ProtectedData.Unprotect(File.ReadAllBytes(key.bin), null, DataProtectionScope.CurrentUser);使用DPAPI要清楚它的限制绑定Windows账户和机器换机器或换账户后密钥无法还原所以生产环境的部署账户要固定不能随便切换。5.4 解密播放时的注意点加密存储带来一个现实问题用户要播放视频怎么办我项目中的做法是播放时后端动态解密到临时视频目录并设置这个目录禁止直接访问只允许通过授权接口读取。流程是用户点播放前端请求/Play/GetTicket服务端校验用户权限后返回一次性播放令牌。服务端用RSA私钥解开AES密钥用密钥把加密文件解密成临时文件。解密后的临时文件放在App_Data/PlayCache下文件名用GUID这个目录在IIS中通过请求过滤阻止外部直接访问。播放器通过带令牌的URL读取文件文件播完或在缓存一段时间后自动删除。这个方案有一个明显的代价解密后的临时文件在磁盘上又成了明文。高安全场景下更稳妥的做法是写自定义流媒体服务器边解密边推流不产生明文落盘文件。但那需要更深度的开发我的经验是企业内网场景下临时文件访问控制已经能应付绝大多数需求了如果要防外部渗透再考虑流式解密方案。6. 常见问题与排查实录6.1 分片上传的经典故障症状一合并出来的文件无法播放排查步骤先看文件大小对不对如果小于原文件几乎可以断定是分片缺失。我经历过一次前端并发上传时成功率有一个分片返回成功但实际写入失败原因是某分片刚好碰到IIS回收请求中断但状态文件已经记录了。后来我在Complete时增加了服务端二次校验分片是否存在不存在就让前端补传问题就解决了。症状二上传进度条一直99%不动前端逻辑是最后一个分片传完就调Complete但Complete执行时如果服务器在重新合并这时前端收不到响应进度条就一直挂着。我的解决办法是Complete接口处理异步化服务端收到请求立即返回正在处理状态前端轮询/Upload/Status接口获取合并加密进度。这样用户侧体验是99%之后马上变成正在处理不会卡死。症状三同一用户同时传多个文件分片串了这是UploadId没有绑定登录用户导致的。我出现过A任务删除临时目录时把B任务的分片目录误删的情况。解决方案UploadId创建时记录UserId之后所有操作都必须校验归属。6.2 加密相关的坑症状四解密出来的视频花屏或播放器报编码错误这个多半是最后一批数据没有正常解码。我排查过几个案例一个是加密时忘了FlushFinalBlock另一个是解密时Key和IV没有对齐。IV在加密时必须随机生成并和密文一起存储我一般把IV拼在密文文件头部前16字节存IV解密时先读IV再解密。症状五文件长度与加密前不一致CBC模式带PKCS7填充加密后文件会比明文多出1~16字节这是正常的解密后长度会还原。如果你看到解密后文件变大了大概率是解密时用了错误的PaddingMode或忘了裁剪填充。症状六把密钥硬编码在代码里导致安全审计不过这个问题纯粹是意识问题。记住一个原则代码仓库里永远不应该出现真实密钥测试环境的密钥和正式环境的必须完全隔离。CI/CD发布时通过环境变量注入密钥比在代码里写死要安全得多。6.3 实战经验速查表我把这次改造的几个关键参数和心得整理成一张速查表方便直接参考关注点推荐值/做法备注分片大小2MB网络差可降为1MB前端并发数3最多不超过5单分片重试次数3次退避递增第4次失败直接报错IIS请求体限制10MB单位别弄混字节 vs KB分片命名UploadId目录下的索引.part禁止用上传文件名合并顺序整数索引循环勿用字符串排序加密算法AES-256-CBC HMAC-SHA256.NET 5可换AES-GCMIV处理随机生成存密文头部每文件唯一密钥保护RSA公钥加密入库或DPAPI本机保护完整性校验SHA-256 HMAC双保险防损坏防篡改6.4 生产环境还要注意的事上线前我最后检查了几个容易被忽略的点这里也提一下清理机制分片临时目录如果没在Complete时清掉或者用户传一半就关闭浏览器App_Data里会残留大量.part文件。我写了一个后台定时任务每30分钟扫描一次Chunks目录清理超过2小时未更新的临时目录。磁盘空间被垃圾分片占满是迟早的事一定要有兜底。并发写盘监控合并加密时如果碰到多个大文件同时合并磁盘I/O会飙高。我加了数据库锁或者分布式锁每个上传任务分配一个处理队列避免多个合并任务同时抢占磁盘。HTTPS传输分片上传本身是通过公网走HTTP的不加密传输的话视频内容在链路上也是明文。有条件就全站上HTTPS这是基础安全配置不属于可选项。这套方案做完之后我个人的体会是分片上传最大的价值不在于能传大文件这个表面结果而在于它让上传这个操作变得可控、可恢复、可观测。加密则是给整个流程兜底保证数据从落盘那一刻开始就是受保护的状态。两者结合起来才算是一个能交付给用户、能对抗审计的系统。最后再分享一个心得每次改上传逻辑都要拿真实的大文件去测别只用几MB的小视频做演示验证分片场景下小文件走过场是发现不了并发和资源问题的。