关于.netcore优化文件上传代码的一些思考 电脑版发表于:2025/12/5 18:07  >#关于.netcore优化文件上传代码的一些思考 [TOC] tn2>在 ASP.NET Core 里,大家经常用 IFormFile 把文件先读成 byte[] 再保存,看似直观,实则把 16 MB 文件完整塞进托管堆,GC 哭、硬盘慢、用户等。今天用 PowerShell 做了个 5 轮对比测试,结果:单文件耗时从 1939.7 ms 降到 1565.09 ms,内存峰值直接打对折。关键改动只有一句话:别再 ReadAsync 了,直接 CopyToAsync! Stream.CopyToAsync零拷贝 ------------ tn2>首先我们创建一个控制器,分别是旧的写的方式和`Stream.CopyToAsync`代码如下: ```csharp [ApiController] [Route("[controller]")] public class TestController : ControllerBase { static TestController() => Directory.CreateDirectory(Root); private static readonly string Root = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "old"); [HttpPost("OldUpload")] public async Task<IActionResult> Upload1(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("empty"); var path = Path.Combine(Root, Guid.NewGuid().ToString() + file.FileName); // 关键:一次性读进内存 byte[] bytes; using (var ms = new MemoryStream()) { await file.CopyToAsync(ms); bytes = ms.ToArray(); // 这里把文件整个吃光 } await System.IO.File.WriteAllBytesAsync(path, bytes); return Ok(new { len = bytes.Length, path }); } [HttpPost("NewUpload")] public async Task<IActionResult> Upload2(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("empty"); var path = Path.Combine(Root, Guid.NewGuid().ToString()+file.FileName); // 关键:不经过 MemoryStream,直接抄过去 await using var target = System.IO.File.Create(path); await file.CopyToAsync(target); // 零拷贝 + 异步 IO return Ok(new { len = file.Length, path }); } } ``` tn2>通过下面的PowerShell进行测试5次。 ```bash $urlOld = "http://localhost:5194/Test/OldUpload" $urlNew = "http://localhost:5194/Test/NewUpload" $file = "$pwd\1.zip" # 5.1 忽略证书 [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } function Test-Upload { param($Url, $Desc, $Color) Add-Type -AssemblyName System.Net.Http $times = @() 1..5 | ForEach-Object { Write-Host "$Desc 第$_次 ..." -ForegroundColor $Color -NoNewline $sw = [System.Diagnostics.Stopwatch]::StartNew() # 构造 multipart $client = New-Object System.Net.Http.HttpClient $content = New-Object System.Net.Http.MultipartFormDataContent $stream = [System.IO.File]::OpenRead($file) $fileContent = New-Object System.Net.Http.StreamContent($stream) $content.Add($fileContent, "file", [System.IO.Path]::GetFileName($file)) $response = $client.PostAsync($Url, $content).Result $stream.Dispose() $client.Dispose() $sw.Stop() $times += $sw.Elapsed.TotalMilliseconds Write-Host " $($sw.Elapsed.TotalMilliseconds) ms" } $avg = ($times | Measure-Object -Average).Average Write-Host "$Desc 平均耗时: $([math]::Round($avg, 2)) ms" -ForegroundColor $Color } Test-Upload -Url $urlOld -Desc "OLD(byte[])" -Color Yellow Test-Upload -Url $urlNew -Desc "NEW(CopyToAsync)" -Color Green ``` tn2>结果如下,可以看到`CopyToAsync`要快500毫秒:  tn>差异:① 把文件完整加载到托管堆;② 数据从内核缓冲区直接写盘,0 次多余拷贝。 64 KB 缓冲池:RecyclableMemoryStream ------------ tn2>避免 `CopyToAsync` 每次 new 84 KB 数组,造成 LOH 碎片。 首先安装`RecyclableMemoryStream`包。 ```bash dotnet add package Microsoft.IO.RecyclableMemoryStream ``` ```csharp private static readonly RecyclableMemoryStreamManager Pool = new( new RecyclableMemoryStreamManager.Options( blockSize: 64 * 1024, largeBufferMultiple: 1024 * 1024, maximumBufferSize: 16 * 1024 * 1024, maximumSmallPoolFreeBytes: 0, maximumLargePoolFreeBytes: 0 )); [HttpPost("NNewUpload")] public async Task<IActionResult> Upload3(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("empty"); var path = Path.Combine(Root, Guid.NewGuid() + Path.GetExtension(file.FileName)); /* ===== 关键:只用一次 CopyToAsync,缓冲池当“中转” ===== */ await using var target = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, // 64 KB 缓冲,异步 FileOptions.Asynchronous | FileOptions.SequentialScan); // 从池里拿流(不分配托管大数组) var pooledStream = Pool.GetStream(); await using (pooledStream.ConfigureAwait(false)) { await file.CopyToAsync(pooledStream); // ① 先写进池 pooledStream.Position = 0; // rewind await pooledStream.CopyToAsync(target); // ② 再落盘(仅一次系统调用) } return Ok(new { len = file.Length, path }); } ``` tn2>同样 16 MB,GC 从 2 代 1 次 → 0 次,耗时平均再降 100ms 左右。  Parallel.ForEachAsync + 限并发 ------------ tn2>场景:用户一次拖 3 个 16 MB 文件。 首先`Program.cs`做一些修改,它支持的最大上传是30mb,将它改为`500mb`; ```csharp // 1. 先放大 Kestrel 请求体上限(500 MB) builder.WebHost.ConfigureKestrel(opt => { opt.Limits.MaxRequestBodySize = 500 * 1024 * 1024; // 500 MB }); // 2. 再放大 Form 单表单上限(可选,保持一致) builder.Services.Configure<FormOptions>(opt => { opt.MultipartBodyLengthLimit = 500L * 1024 * 1024; }); ``` ```csharp private static readonly RecyclableMemoryStreamManager Pool = new( new RecyclableMemoryStreamManager.Options( blockSize: 64 * 1024, largeBufferMultiple: 1024 * 1024, maximumBufferSize: 16 * 1024 * 1024, maximumSmallPoolFreeBytes: 0, maximumLargePoolFreeBytes: 0 )); [HttpPost("NNewUpload")] public async Task<IActionResult> Upload3(IFormFileCollection files) { if (files.Count == 0) return BadRequest("empty"); var paths = new ConcurrentBag<string>(); foreach (var file in files) { var path = Path.Combine(Root, Guid.NewGuid() + Path.GetExtension(file.FileName)); paths.Add(path); /* ===== 关键:只用一次 CopyToAsync,缓冲池当“中转” ===== */ await using var target = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, // 64 KB 缓冲,异步 FileOptions.Asynchronous | FileOptions.SequentialScan); // 从池里拿流(不分配托管大数组) var pooledStream = Pool.GetStream(); await using (pooledStream.ConfigureAwait(false)) { await file.CopyToAsync(pooledStream); // ① 先写进池 pooledStream.Position = 0; // rewind await pooledStream.CopyToAsync(target); // ② 再落盘(仅一次系统调用) } } return Ok(new { count = files.Count, paths }); } [HttpPost("NNNewUpload")] public async Task<IActionResult> Upload4(IFormFileCollection files, CancellationToken ct) { if (files.Count == 0) return BadRequest("empty"); var opts = new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = 4 // 限并发 }; var paths = new ConcurrentBag<string>(); await Parallel.ForEachAsync(files, opts, async (file, _) => { var path = Path.Combine(Root, Guid.NewGuid() + Path.GetExtension(file.FileName)); paths.Add(path); /* ===== 关键:只用一次 CopyToAsync,缓冲池当“中转” ===== */ await using var target = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, // 64 KB 缓冲,异步 FileOptions.Asynchronous | FileOptions.SequentialScan); // 从池里拿流(不分配托管大数组) var pooledStream = Pool.GetStream(); await using (pooledStream.ConfigureAwait(false)) { await file.CopyToAsync(pooledStream); // ① 先写进池 pooledStream.Position = 0; // rewind await pooledStream.CopyToAsync(target); // ② 再落盘(仅一次系统调用) } }); return Ok(new { count = files.Count, paths }); } ``` tn2>除此之外Powershell脚本也做些更改。 ```bash $urlOld = "http://localhost:5194/Test/NNewUpload" $urlPool = "http://localhost:5194/Test/NNNewUpload" $files = @("$pwd\1.zip", "$pwd\2.zip", "$pwd\3.zip") # 忽略证书 [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } function Test-MultiUpload { param($Url, $Desc, $Color) Add-Type -AssemblyName System.Net.Http $times = @() 1..5 | ForEach-Object { Write-Host "$Desc 第$_次 ..." -NoNewline -ForegroundColor $Color $sw = [System.Diagnostics.Stopwatch]::StartNew() $client = New-Object System.Net.Http.HttpClient # 方便看请求头/响应头 $client.DefaultRequestHeaders.Clear() $content = New-Object System.Net.Http.MultipartFormDataContent foreach ($f in $files) { $fs = [System.IO.File]::OpenRead($f) $sc = New-Object System.Net.Http.StreamContent($fs) $content.Add($sc, "files", [System.IO.Path]::GetFileName($f)) } try { # 发请求 + 等待响应 $response = $client.PostAsync($Url, $content).Result $body = $response.Content.ReadAsStringAsync().Result # 控制台输出状态码 + 简短响应 Write-Host " Status=$($response.StatusCode) Length=$($body.Length) ms=$($sw.Elapsed.TotalMilliseconds)" -NoNewline # 如果服务器返回 400/500,把正文打出来,方便看模型绑定错误 if (-not $response.IsSuccessStatusCode) { Write-Host "`n---- Response ----" -ForegroundColor Red Write-Host $body -ForegroundColor Red Write-Host "------------------" -ForegroundColor Red } } catch [System.Net.Http.HttpRequestException] { # 连不上、404、证书错等 Write-Host " ? $($_.Exception.Message)" -ForegroundColor Red $body = $null } finally { $content.Dispose() $client.Dispose() $sw.Stop() $times += $sw.Elapsed.TotalMilliseconds } } $avg = ($times | Measure-Object -Average).Average Write-Host "$Desc 平均耗时: $([math]::Round($avg, 2)) ms" -ForegroundColor $Color } Test-MultiUpload -Url $urlOld -Desc "OLD-3files" -Color Yellow Test-MultiUpload -Url $urlPool -Desc "POOL-3files" -Color Cyan ```  tn>如果我们从最开始的方式与最后一种方式进行统计的话速度上提高了`31%`。