去年年中,我给公司内部搭建的视频培训平台加上传功能时,被一个特别实际的问题卡住了——讲师录的授课视频动不动就2GB起步,最大的甚至有6GB。用MVC默认的HttpPostedFileBase方式整包上传,小文件跑得挺稳,一上GB就各种"网络错误"、超时、服务器假死。后来我把分片上传和视频加密当成一套组合拳来做,才算把模块稳定下来,一直用到现在。这篇文章就把这套方案完整拆解开:从分片上传的原理、前端切片的实现,到MVC后端接收合并、再用AES把视频加密落盘的全套代码和踩坑记录。适合正在用.NET MVC做文件上传功能,又被大文件超时和存储安全折腾得不轻的朋友。
1. 大视频上传的痛点:MVC默认机制埋着的三个门槛
1.1 你以为的"大文件"可能根本发不出去
很多人第一次做上传时,会觉得"不就是把文件流写进服务器嘛"。但在MVC里,一个请求要走完IIS、ASP.NET运行时、Controller三层管道,每一层都有默认限制在守门。
- IIS层:
maxAllowedContentLength默认约3000万字节(约28.6MB); - ASP.NET层:
maxRequestLength默认4096KB,也就是4MB; - Controller层:
HttpPostedFileBase会把整个请求体读进内存,4MB的同时占用4MB内存。
这意味着不修改任何配置的情况下,你连一个50MB的短视频都传不上去,会直接收到404.13或者Maximum request length exceeded错误。很多人第一反应是"把这两个配置调到极大",但这只是第一关。
1.2 整包上传的三个致命问题:内存、超时、连接中断
哪怕你把配置调到2GB上限,整包上传依然不稳,原因有三个层面:
内存压力。 整包上传时,服务器要把整个请求体拼在内存或临时文件里,然后SaveAs再写一份。6GB的视频会让工作进程的内存直接冲到好几个GB,托管堆被大对象撑爆,最后酿成OutOfMemoryException,甚至把同进程里的其他站点拖垮。
超时。 ASP.NET默认executionTimeout是110秒。一个2GB文件,就算内网传输速度有30MB/s,加上IIS处理时间,也很容易超过110秒。一旦超时,请求直接中止,用户看到的就是"上传失败",连失败原因都不知道。
连接不稳定。 公网上单个TCP连接持续传输几分钟,中间任何一次网络抖动、路由器NAT会话超时、代理断开,都会把整个传输流程打回原形,而且没有"传了60%"这种概念,只能重头再来。
1.3 把分片和加密放在一起设计的逻辑
把文件切成小块,每块独立上传,单独失败就单独重试,这是分片的核心价值。它解决的是"传输可靠性"和"服务器内存压力"两个问题。而加密解决的是"存储安全"——视频传到服务器之后,文件本身可能包含隐私内容,比如内部培训资料、课程版权内容,如果服务器磁盘被拖走、备份泄露、或者运维误操作把目录权限放开,明文视频就等于直接裸奔。
两者看似独立,但在实际架构里必须一起设计:分片决定了加密的输入粒度,加密决定了分片后合并出来的文件是否能直接被业务系统使用。我见过一个项目,分片做完了再想起来要加密,结果发现合并后的明文文件已经在磁盘上躺了半个月,中间还被人从临时目录拖走过一份。所以,方案设计阶段就必须把"分片传输"和"加密落盘"放在同一条数据流里考虑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案设计:前端切成块,后端按片收,最后统一加密
2.1 技术选型:原生JS分片还是第三方库
前端分片可以自己用File.slice切,也可以直接用plupload、WebUploader这类现成组件。我的建议是:如果项目本身没什么历史包袱,直接用原生XMLHttpRequest加File.slice就够了,核心逻辑不超过100行;用第三方库反而要折腾它们的样式、事件模型和版本兼容问题。
本方案的技术栈:
| 层面 | 选型 | 理由 |
|---|---|---|
| 前端 | 原生JavaScript + File.slice | 无依赖、逻辑透明 |
| 后端 | .NET MVC 5 Controller | 贴合标题要求,无需引入复杂框架 |
| 加密 | AES-256-CBC + RijndaelManaged/Aes | 对称加密性能高,适合服务端批量处理 |
| 传输 | HTTPS(生产环境必须) | 防明文内容在传输途中被截获 |
| 临时存储 | 服务器本地临时目录 | 分片合并完成后立即加密并删除 |
2.2 分片大小和并发数怎么定
分片大小不是拍脑袋定的,它跟网络环境、服务器处理能力、失败重试成本都有关系。
- 分片太小(比如512KB):片数爆炸,请求数过大,IIS队列压力大,前端循环切片的开销也大;
- 分片太大(比如50MB):单片失败重试的成本高,而且失去了分片对网络抖动的容忍度;
- 推荐值:5MB。这是我实测下来性价比最高的档位。2GB视频切成约400片,每片传输时间局域网内不到1秒,公网(按10Mbps上行算)约4秒,失败重试的代价很小。
并发数方面,前端同时发起的上传请求数建议控制在3到5个。并发太高,浏览器到服务器会建立大量TCP连接,后端磁盘随机写入竞争加剧,等于把网络瓶颈换成了磁盘瓶颈。
2.3 为什么选择"先分片、后合并、再加密"的顺序
加密时机有个先后问题:到底是"先加密整个文件再分片",还是"前端分片传输,后端合并后再加密"?
先加密再分片听起来安全,但前端每个分片都要先做加密运算,而且密钥必须下发到前端,密钥一出现在浏览器端,安全性就大打折扣。更实际的是,前端加密后的分片到后端还得解密再合并,等于加了一道无意义的运算。
本方案用一个折中且务实的路径:前端分片明文传输(依赖HTTPS保证传输层安全),后端按片接收暂存,所有分片到齐后按顺序合并成完整文件,然后立刻用AES-256加密写入最终存储目录,随后删除明文临时文件和分片。 这样做的好处是:
- 临时目录里明文存在的时间窗口极短,且路径受权限控制;
- 加密过程在服务端完成,密钥完全不出服务器;
- 一旦加密完成,落盘的就是密文,备份、冷存储、运维拷走都没意义;
- 逻辑清晰,分片、合并、加密三个环节可以分别测试和排查。
提示:如果你的业务要求"传输过程连服务端临时文件都不能出现明文",那应该改用HTTPS配合服务端流式加密分片(即每收到一片就用CryptoStream增量加密写入),代价是断点续传的逻辑会复杂很多。普通内部系统完全没必要背这个复杂度。
3. 前端分片实现:不引库,用原生JS搞定切片与断点续传
3.1 核心切片逻辑:File.slice与分片元数据
HTML5的File.slice可以直接从文件对象里截取指定字节范围,不需要把整个文件读进内存。我封装了一个splitFile函数,每片带序号和文件标识:
javascript复制const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
const fileId = Date.now().toString(36) + '_' + file.name;
function createChunks(file) {
const chunks = [];
let start = 0;
let index = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
chunks.push({
blob,
index: index++,
start,
end,
size: end - start
});
start = end;
}
return chunks;
}
每个分片上传时,FormData里要带这些元数据:
javascript复制function uploadChunk(fileId, chunk) {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', chunk.index);
formData.append('chunkSize', chunk.size);
formData.append('fileName', file.name);
formData.append('totalChunks', totalChunks);
formData.append('chunk', chunk.blob, file.name + '.' + chunk.index);
return fetch('/Upload/UploadChunk', {
method: 'POST',
body: formData
});
}
后端拿到fileId后就知道这堆分片属于谁,按chunkIndex存成临时文件。注意一点:fileId不要用文件名的哈希,直接用时间戳加随机数就行,避免文件名里的中文、特殊字符一路传到数据库和文件系统里去。
3.2 并发控制:给上传队列加个节流阀
如果一次性把400个分片全部fetch出去,浏览器会瞬间建立大量连接,后端IIS线程池被占满,网卡被打满,响应变慢,最后反而容易失败。我写了一个简单的并发队列:
javascript复制class UploadQueue {
constructor(maxConcurrent) {
this.max = maxConcurrent;
this.active = 0;
this.queue = [];
this.onProgress = null;
}
push(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this._next();
});
}
_next() {
while (this.active < this.max && this.queue.length > 0) {
const { task, resolve, reject } = this.queue.shift();
this.active++;
task().then(resolve, reject).finally(() => {
this.active--;
this._next();
});
}
}
}
使用时,把所有分片塞进队列,队列自动控制3个并发。进度计算可以这样汇总:
javascript复制let uploadedCount = 0;
const onChunkDone = () => {
uploadedCount++;
const percent = Math.round(uploadedCount / totalChunks * 100);
// 更新页面进度条
};
这里的进度是基于"片数"而非"字节数",因为每片大小基本一致,误差可以忽略。
3.3 断点续传:上传前先问一下服务器"我传过哪些"
断点续传的关键是:用户刷新页面或者重试时,前端知道哪些分片已经上传成功。我在后端加了一个查询接口/Upload/UploadStatus,根据fileId返回已上传分片的序号列表。前端拿到后,在切片的时候直接跳过这些序号:
javascript复制async function uploadFile(file) {
const fileId = generateFileId(file);
const chunks = createChunks(file);
totalChunks = chunks.length;
const uploadedIndices = await fetch(`/Upload/UploadStatus?fileId=${fileId}`)
.then(res => res.json());
const queue = new UploadQueue(3);
for (const chunk of chunks) {
if (uploadedIndices.includes(chunk.index)) continue;
queue.push(() => uploadChunk(fileId, chunk));
}
await queue.allDone();
await fetch('/Upload/MergeChunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, fileName: file.name })
});
}
这里有个细节:已上传分片的"有效性"怎么判断?最简单的办法是后端记录每个分片文件的字节大小,前端上传时把chunkSize一并传过去,查询接口在返回已上传分片列表前,先比对该分片大小是否和预期的chunkSize一致,一致才算是有效分片。这样能避免"传了一半的分片也被当成已上传"的情况。
4. .NET MVC后端:接收分片、校验参数、合并文件的一个完整链路
4.1 存储结构:临时目录和最终目录严格分离
目录设计必须一开始就想清楚,不然分片和加密文件混在一起,后面清理和排查会让你头大。我用的结构是:
code复制App_Data/
uploads/
{fileId}/
chunk_00000.part
chunk_00001.part
...
encrypted/
20240101_abc12345.bin
uploads是临时目录,分片合并完成后整个文件夹删除;encrypted是加密后的最终文件目录。这个分离还有个额外好处:临时目录可以配置成禁止下载、不参与备份,而最终目录可以单独做权限控制和备份策略。
4.2 分片接收Action:别只看Request.Files
接收分片的Controller代码如下,关键点我标了注释:
csharp复制[HttpPost]
public ActionResult UploadChunk()
{
var file = Request.Files["chunk"];
var fileId = Request.Form["fileId"];
var chunkIndexStr = Request.Form["chunkIndex"];
var chunkSizeStr = Request.Form["chunkSize"];
var fileName = Request.Form["fileName"];
if (file == null || file.ContentLength == 0)
return Json(new { success = false, message = "分片为空" });
// 参数必须校验,防注入和恶意请求
if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(chunkIndexStr))
return Json(new { success = false, message = "参数缺失" });
int chunkIndex;
int chunkSize;
if (!int.TryParse(chunkIndexStr, out chunkIndex)
|| !int.TryParse(chunkSizeStr, out chunkSize)
|| chunkIndex < 0)
{
return Json(new { success = false, message = "分片参数非法" });
}
// fileId做白名单校验:只允许字母数字下划线
if (!Regex.IsMatch(fileId, "^[a-zA-Z0-9_]+$"))
return Json(new { success = false, message = "fileId非法" });
string dir = Server.MapPath($"~/App_Data/uploads/{fileId}");
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
string chunkPath = Path.Combine(dir, $"chunk_{chunkIndex:D5}.part");
file.SaveAs(chunkPath);
// 校验分片大小是否一致,防止传输被截断
var actualSize = new FileInfo(chunkPath).Length;
if (actualSize != chunkSize)
{
System.IO.File.Delete(chunkPath);
return Json(new { success = false, message = "分片大小校验失败" });
}
return Json(new { success = true });
}
几个容易被忽视的细节:
Request.Files["chunk"]拿到的文件,SaveAs之前框架已经在临时目录里存了一份,SaveAs本质是复制,所以大文件分片其实会产生双倍IO。要省IO可以把SaveAs换成移动临时文件的方式,但需要处理HttpPostedFileBase的底层InputStream,普通业务没必要折腾;D5格式化的分片文件名,保证按字典序排序时顺序正确。否则chunk_10.part会排在chunk_2.part前面,合并会乱;- 分片大小校验非常关键。网络传输中偶尔会出现字节数不对的情况,特别是走代理时,
ContentLength和实际写入大小不一致,这里提前发现,后面合并就能避免"文件损坏"。
4.3 合并分片:关掉缓冲合并,用流式写入
合并操作很容易写成"读所有分片到内存再写",但6GB视频这样干直接OOM。正确姿势是用FileStream按顺序逐片写入,缓冲设成1MB:
csharp复制[HttpPost]
public ActionResult MergeChunks(string fileId, string fileName)
{
// 校验参数
if (!Regex.IsMatch(fileId, "^[a-zA-Z0-9_]+$"))
return Json(new { success = false, message = "fileId非法" });
string chunkDir = Server.MapPath($"~/App_Data/uploads/{fileId}");
if (!Directory.Exists(chunkDir))
return Json(new { success = false, message = "分片不存在" });
var chunkFiles = Directory.GetFiles(chunkDir, "chunk_*.part")
.OrderBy(f =>
{
var name = Path.GetFileName(f);
return int.Parse(name.Substring(6, name.Length - 11));
})
.ToArray();
// 如果只有一片,直接改名合并,省去流拼接
if (chunkFiles.Length == 0)
return Json(new { success = false, message = "没有分片可合并" });
string finalPath = Server.MapPath($"~/App_Data/encrypted/{DateTime.Now:yyyyMMdd}_{fileId}.bin");
using (var output = new FileStream(finalPath, FileMode.Create))
{
byte[] buffer = new byte[1024 * 1024];
foreach (var chunkFile in chunkFiles)
{
using (var input = new FileStream(chunkFile, FileMode.Open))
{
int read;
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
}
}
}
}
// 合并完成后,马上删除临时目录
Directory.Delete(chunkDir, true);
return Json(new { success = true, filePath = finalPath });
}
注意这里我把合并后的文件直接命名成了.bin而不是.mp4,为什么?因为下游要立刻对它做AES加密,加密后的内容就不是合法的MP4格式了,扩展名没必要保留。如果业务方需要知道原始文件名,我会把原始文件名、大小、MD5这些元数据存进数据库,文件本身一律用.bin。
4.4 web.config配置:请求大小限制与超时设置的完整清单
分片方案虽然把单次请求压到了5MB,但合并接口、查询接口这些请求头里带的查询字符串、JSON体量都很小,理论上不用放大配置。但为了稳妥,还是建议把整个站点的请求上限放开到1GB,毕竟你不确定后续会不会有人绕过前端直接传大包。
xml复制<system.web>
<httpRuntime targetFramework="4.7.2"
maxRequestLength="1048576"
executionTimeout="3600"
requestValidationMode="2.0" />
</system.web>
<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="1073741824" />
</requestFiltering>
</security>
</system.webServer>
maxRequestLength单位是KB,1048576即1GB;maxAllowedContentLength单位是字节,1073741824也是1GB。两个值需要配套调大,否则你改了httpRuntime却漏了requestFiltering,照样报404.13。executionTimeout单位是秒,3600秒给足大文件操作余量。开发环境可以这么配置,生产环境如果担心恶意请求打满带宽,可以做IP限流或加一层WAF。
注意:如果把
requestValidationMode改成2.0,等于关闭了ValidateRequest对表单和查询串的自动校验,所有参数必须自己在Action里校验。这也是我把fileId做了白名单正则校验的原因——安全没有白嫖的,你关闭一层防护,就要在业务代码里补回来。
5. 视频加密落地:AES-256-CBC从原理到代码
5.1 选型决策:为什么是AES-256-CBC
视频文件加密,核心诉求是"静态数据不可读",不是要对抗专业黑客破解密钥,而是要防止文件被直接拖走后明文泄露。基于这个威胁模型,对称加密完全够用,AES-256是当前公认的安全加密算法之一。
为什么不用MD5或SHA?因为它们是哈希算法,不可逆,加密是双向的,完全不是一类东西。为什么不用DES、3DES?因为密钥长度和算法强度已经过时,AES在性能和安全性上都更优。CBC分组模式的好处是每个分组的密文依赖前一个分组,同样的明文块会产生不同密文,不会让视频里大量重复的帧数据暴露规律。
5.2 流式加密:大视频文件不能ToString
加密6GB视频如果一次性把字节读进内存,会直接OOM。正确的做法是用CryptoStream配合FileStream做流式加密,缓冲1MB:
csharp复制public static class FileEncryptionHelper
{
// 生成随机密钥和IV,用于首次加密
public static (byte[] key, byte[] iv) GenerateKeyAndIv()
{
using (var aes = Aes.Create())
{
aes.KeySize = 256;
aes.GenerateKey();
aes.GenerateIV();
return (aes.Key, aes.IV);
}
}
// 流式加密大文件
public static void EncryptFile(string inputPath, string outputPath, 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 input = new FileStream(inputPath, FileMode.Open))
using (var output = new FileStream(outputPath, FileMode.Create))
using (var cryptoStream = new CryptoStream(output, aes.CreateEncryptor(), CryptoStreamMode.Write))
{
input.CopyTo(cryptoStream, 1024 * 1024);
}
}
}
// 流式解密大文件,主要用来验证加密正确性
public static void DecryptFile(string inputPath, string outputPath, 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 input = new FileStream(inputPath, FileMode.Open))
using (var output = new FileStream(outputPath, FileMode.Create))
using (var cryptoStream = new CryptoStream(input, aes.CreateDecryptor(), CryptoStreamMode.Read))
{
cryptoStream.CopyTo(output, 1024 * 1024);
}
}
}
}
在合并Action里,合并完明文后立即调用加密,然后删除明文:
csharp复制// 合并完成后:立即加密并删除明文
var tempMergedFile = Server.MapPath($"~/App_Data/encrypted/{fileId}_tmp.bin");
// ... 之前合并逻辑写入 tempMergedFile ...
string encryptedFile = Server.MapPath($"~/App_Data/encrypted/{DateTime.Now:yyyyMMdd}_{fileId}.bin");
var (key, iv) = LoadOrCreateFileKey(fileId); // 见5.3
FileEncryptionHelper.EncryptFile(tempMergedFile, encryptedFile, key, iv);
// 删除临时明文
System.IO.File.Delete(tempMergedFile);
// 测试后加入:删除分片目录
Directory.Delete(chunkDir, true);
5.3 密钥管理:密钥放哪里才不算裸奔
密钥管理是整个加密方案里最容易被忽略的部分。很多人把密钥硬编码在web.config里,这相当于把家门钥匙贴在门框上。我的实践方案分三层:
- 开发环境:密钥放在环境变量里,本机测试方便;
- 生产环境:密钥存在Windows DPAPI保护的配置文件中,DPAPI用机器级账户加密,非本机用户无法读取明文;
- 更严格场景:用Azure Key Vault或自建的密钥管理服务,在加密时通过API拿密钥,用后立即释放引用。
保存密钥时,注意不要用纯文本,直接调用ProtectedData.Protect:
csharp复制using System.Security.Cryptography;
public static string ProtectConfigValue(string plainText)
{
var bytes = Encoding.UTF8.GetBytes(plainText);
var protectedBytes = ProtectedData.Protect(bytes, null, DataProtectionScope.LocalMachine);
return Convert.ToBase64String(protectedBytes);
}
读取时反向Unprotect即可。这样即使别人拿到配置文件,没有这台服务器的账户权限也无法还原密钥。
每个文件的密钥和IV,建议按文件独立生成,而不是全部共用一个密钥。这样某个文件的密钥泄露不会波及其他文件。关键信息记数据库,格式大致如下:
code复制FileId | KeyBase64 | IvBase64 | Status
------------|---------------------------------|---------------------------------|--------
PLT001 | T2m8QkD... | vX3pQwL... | Active
5.4 明文清理:加密完成后必须做的事
合并完成后,我上面代码里有一个Directory.Delete(chunkDir, true),很多人会漏掉这步,导致临时分片文件永远残留在磁盘上,既占空间又是安全死角。我的清理流程是:
- 分片全部合并进临时文件后,删除分片目录;
- 临时文件加密成功后,删除临时文件;
- 设置一个定时任务(比如每天凌晨),扫描
uploads目录里超过24小时的残留分片目录,直接删除——因为正常上传流程中,分片目录生命周期应该在几分钟内结束,超过24小时的必然是上传中断留下的垃圾; - 对
encrypted目录里的文件做完整性抽查,定期用解密工具验证文件头是否完整。
加密后的文件用普通的File.Delete删除有恢复软件恢复数据的风险。如果这个视频真的非常敏感,删除明文应该用覆写删除:先往文件里写随机字节再删除。
6. 实测踩坑记录:这些坑比功能本身更值钱
6.1 分片合并顺序错误导致视频花屏
第一次做合并时,我用Directory.GetFiles直接遍历目录,然后按文件名排序。Windows下NTFS的字符串排序规则导致chunk_10.part排在chunk_2.part前面,合并出来的MP4播放时在10秒左右就花屏卡死。排查过程很典型:先怀疑是加密的问题,把加密去掉后再次合并,发现还是花屏,才想起检查分片顺序。
最后把排序改成了按文件名解析出的整数序号排序。这个坑的根本原因是:字典序不总是和自然序一致。任何依赖文件名的排序都必须显式解析数字。
6.2 并发上传导致的分片写冲突
前端并发设成5之后,发现偶尔某个分片保存后大小不对,或者文件损坏。排查后发现,file.SaveAs写入的文件名如果完全按chunk_{index:D5}.part来命名,理论上不会冲突,但问题出在DateTime.Now生成的fileId在并发跨秒时可能出现重复(比如5个并发请求同一毫秒生成相同ID)。后来把fileId生成逻辑改成时间戳 + 随机数,冲突才消失。
另一个隐蔽问题:IIS的应用程序池默认进程数是1,但线程池是并发的,多个请求同时写同一个分片文件时,后写者会覆盖先写者的内容。所以除了保证fileId唯一,还应该对相同fileId的分片写入做加锁处理。简单方案是给分片文件名带上一个前端生成的短随机串,让每个请求写的路径都不一样。
6.3 跨语言解密失败的坑:模式、填充、IV一个都不能错
项目后期需要把加密视频交给另一个Java服务做处理,结果那边解密总是报"BadPaddingException"。排查了半天,发现原因是C#端的PaddingMode.PKCS7和Java端默认的PKCS5Padding在块大小一致时其实可以通用,但问题出在IV的处理上——我最初加密时忘了把IV存下来,Java端猜测IV是空数组,CBC模式下IV不一致会直接导致第一块解密失败。
正确的做法是:加密时用随机IV,然后把IV明文拼在加密文件的最前面(比如IV(16字节) + 密文),解密方先读前16字节作为IV,再解密剩余部分。这个做法省去了存IV的数据库字段,也避免了两端对IV处理不一致的问题。
csharp复制// 加密时:写入IV
using (var output = new FileStream(outputPath, FileMode.Create))
{
output.Write(iv, 0, iv.Length);
using (var cryptoStream = new CryptoStream(output, aes.CreateEncryptor(), CryptoStreamMode.Write))
{
input.CopyTo(cryptoStream, 1024 * 1024);
}
}
csharp复制// 解密时:先读IV
byte[] iv = new byte[16];
using (var input = new FileStream(inputPath, FileMode.Open))
{
input.Read(iv, 0, iv.Length);
using (var output = new FileStream(outputPath, FileMode.Create))
using (var cryptoStream = new CryptoStream(input, aes.CreateDecryptor(), CryptoStreamMode.Read))
{
cryptoStream.CopyTo(output, 1024 * 1024);
}
}
6.4 性能实测:5GB视频的耗时与资源占用
功能开发完成后,我在内网环境和一台4核8G的云服务器上做了压测,数据供你参考:
| 指标 | 数值 |
|---|---|
| 视频大小 | 5GB(5400MB),MP4格式 |
| 分片大小 | 5MB,共1080片 |
| 前端并发数 | 3 |
| 内网传输耗时 | 约1分40秒 |
| 公网上行10Mbps场景耗时 | 约15分钟 |
| 合并耗时 | 约18秒(机械硬盘) |
| AES加密耗时 | 约32秒(CPU占用约40%) |
| 合并+加密阶段峰值内存 | 约180MB(流式处理,未触发大对象堆) |
| 整个流程总耗时(内网) | 约3分钟 |
如果换成SSD,合并耗时能缩短到6秒左右;如果CPU支持AES-NI指令集,加密耗时还能再降一半。原生的Aes.Create()在.NET Framework下如果检测到硬件支持,会走AES-NI,不用额外配置。这也是我推荐用Aes类而不是自己写加密逻辑的原因。
7. 扩展方向:加密后播放、迁移云端、安全加固清单
7.1 加密文件怎么播放:现实的选择
如果你加密了整个视频文件,普通播放器是打不开的,因为文件不再是标准MP4。要在线播放,通常有三个方向:
- 本地解密后播放:下载加密文件,在客户端用密钥解密成临时文件播放。优点是实现简单;缺点是客户端会短暂存在明文文件,且密钥一旦下放客户端,加密的意义就弱了;
- 转成HLS/AES-128加密流:用FFmpeg把视频转成HLS切片(
.m3u8+.ts),再用AES-128对整个流加密,播放器支持标准的加密流规范。这是目前VOD最常见的方案; - 不加密文件,改用访问控制:如果业务的核心诉求是"防止未授权下载",其实用防盗链、签名URL、有效期凭证就够了,文件本身不加密。这是成本和效果最平衡的方案。
我最终在培训平台里选择的是方案二——视频入库时用FFmpeg转成HLS加密流,分片上传只负责把原始视频安全地收上来,收完了转码链路接上。整体架构比"全文件加密"更合理。但如果你只是需要把视频作为机密文件归档存储,那本文的AES全文件加密方案更简单。
7.2 迁移到云存储的改造思路
这个方案跑通后,如果想把文件迁移到阿里云OSS或腾讯云COS,改造点很清晰:分片上传可以直接用云厂商的分片上传API替换,后端保留其中一个服务做MergeChunks的调用,加密部分依然可以在服务端先下载再加密上传,或者用云厂商的KMS服务做加密。核心的"分片传输 + 合并 + 加密落盘"这套概念不需要变,变的只是传输管道和存储介质。
迁移时最省事的做法是:把UploadChunk Action内部的SaveAs换成云SDK的分片上传方法,把MergeChunks里的合并逻辑换成云SDK的合并分片,加密则放在合并完成后用云函数触发。这样前端和数据库几乎不用动。
7.3 一套够用的安全检查清单
做完这个功能后,我让安全同事帮忙过了一遍,整理出一份自检清单,适合同类项目:
- 传输层必须启用HTTPS,不能因为"内网"就裸跑HTTP;
- 所有上传接口都要做身份认证,分片上传不能被匿名调用;
fileId、fileName等参数必须做白名单校验,防止路径穿越攻击(比如../../Windows/);- 上传目录禁止脚本执行权限,IIS里把
App_Data的脚本权限关掉; - 临时目录要定期清理,不能留下大量孤儿分片占磁盘;
- 密钥不能出现在日志、异常信息、前端代码里;
- 加密文件下载接口也要鉴权,因为密文虽然不可读,但可以被复制、删除,加密不等于防止破坏;
- 大文件上传接口要加IP限流或频率限制,防止被恶意程序用分片上传打满带宽。
这套方案做下来,最花时间的其实不是写代码,而是调的三个细节:分片大小和并发数的取舍、合并顺序的可靠性、以及加密参数的跨语言协同。如果你也在做类似的功能,建议先按2GB的小视频跑通全流程,再上大文件压测,能省下不少排查时间。
