ASP.NET Core上传大文件至Azure Blob Storage时如何容忍网络错误?
需求背景
我有一个ASP.NET Core应用,通过API接收MP4文件上传,需要从输入流中分块读取并实时写入Azure Blob Storage。文件可能极大,要求网络出错时至少保留已写入的内容——但使用标准BlobClient.UploadAsync(stream)方法时,哪怕上传到99%失败,最终Blob中也只会保留0字节。我用本地文件做了概念验证,以下代码能实现需求,但想了解是否有更优方案:
var blobUriWithSas = new Uri($"{storageAccountUrl}/?{sasToken}"); var blobServiceClient = new BlobServiceClient(blobUriWithSas); var blobContainerClient = blobServiceClient.GetBlobContainerClient("container"); var blockBlobClient = blobContainerClient.GetBlockBlobClient("file.mp4"); var blockIds = new List<string>(); using var fileStream = File.OpenRead("file.mp4"); var buffer = new byte[2048*10]; int bytesRead; int blockId = 0; while ((bytesRead = await fileStream.ReadAsync(buffer)) > 0) { var blockIdBase64 = Convert.ToBase64String(BitConverter.GetBytes(blockId++)); blockIds.Add(blockIdBase64); using var memoryStream = new MemoryStream(buffer, 0, bytesRead); // Upload the block await blockBlobClient.StageBlockAsync(blockIdBase64, memoryStream); if (blockId % 50 == 0) { // Commit the blocks now and then await blockBlobClient.CommitBlockListAsync(blockIds); } } // Commit the blocks when all is done await blockBlobClient.CommitBlockListAsync(blockIds);
现有方案分析与优化建议
你的核心思路(手动分块上传+定期提交块列表)完全符合Azure Blob分块存储的设计逻辑,能满足"失败时保留已上传内容"的核心需求。以下是几个关键优化方向:
1. 块ID生成规范化
当前用BitConverter.GetBytes(blockId++)生成块ID,当blockId超过单字节范围后,字节长度会变化,虽然Base64编码后不影响唯一性,但会导致块ID长度不一致,可能影响后续Commit时的排序可靠性。建议使用固定长度的字符串格式生成块ID:
// 用6位补零的序号生成块ID,转Base64后长度固定 var blockIdBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(blockId.ToString("D6"))); blockId++;
2. 减少内存拷贝,优化性能
当前代码将读取到的buffer转存到MemoryStream再上传,存在额外的内存分配和拷贝。可以直接使用StageBlockAsync的ReadOnlyMemory<byte>重载,省去中间流:
// 无需创建MemoryStream,直接传递buffer片段 await blockBlobClient.StageBlockAsync(blockIdBase64, new Memory<byte>(buffer, 0, bytesRead));
3. 调整Commit频率,平衡性能与可靠性
Azure Blob允许最多保留50000个未提交的块,每50块Commit一次的频率偏高,会增加网络请求开销。可以根据块大小调整(比如每上传1GB内容Commit一次),或者仅在全部块上传完成后Commit——但如果中途失败,未提交的块会在7天后自动清理。若需要中途保留可访问的Blob,定期Commit是必要的,建议根据业务场景调整间隔。
4. 增加断点续传支持
如果需要客户端断开后重新上传,可以在上传前获取已上传的未提交块,跳过重复上传:
// 获取已上传的所有未提交块 var existingBlocks = await blockBlobClient.GetBlockListAsync(BlockListFilter.Uncommitted); var uploadedBlockIds = existingBlocks.UncommittedBlocks.Select(b => b.Name).ToHashSet(); // 计算已上传的最大块ID,从下一个ID开始上传 if (uploadedBlockIds.Any()) { blockId = uploadedBlockIds .Select(id => int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(id)))) .Max() + 1; }
5. ASP.NET Core场景下的流处理优化
在ASP.NET Core接收上传时,推荐使用PipeReader处理请求体流,避免手动管理buffer的复杂度,提升流处理效率:
var pipeReader = PipeReader.Create(Request.Body); var blockId = 0; var blockIds = new List<string>(); while (true) { var result = await pipeReader.ReadAsync(); var buffer = result.Buffer; foreach (var segment in buffer) { var blockIdBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(blockId.ToString("D6"))); await blockBlobClient.StageBlockAsync(blockIdBase64, segment); blockIds.Add(blockIdBase64); blockId++; // 达到阈值时提交块列表 if (blockId % 500 == 0) { await blockBlobClient.CommitBlockListAsync(blockIds); } } pipeReader.AdvanceTo(buffer.End, buffer.End); if (result.IsCompleted) break; } // 最终提交所有块 await blockBlobClient.CommitBlockListAsync(blockIds);
6. 错误处理与重试
对StageBlockAsync和CommitBlockListAsync添加重试逻辑(比如用Polly库),处理网络波动导致的临时错误,避免单次失败中断整个上传流程:
// 示例:用Polly配置重试策略 var retryPolicy = Policy .Handle<RequestFailedException>(ex => ex.Status == HttpStatusCode.RequestTimeout) .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); await retryPolicy.ExecuteAsync(async () => { await blockBlobClient.StageBlockAsync(blockIdBase64, new Memory<byte>(buffer, 0, bytesRead)); });
替代方案说明
如果你的需求只是客户端断点续传(不需要中途访问部分上传的Blob),可以使用Azure.Storage.Blobs库提供的高级封装,通过StorageTransferOptions配置分块上传,默认支持客户端断点续传:
var transferOptions = new StorageTransferOptions { InitialTransferSize = 20 * 1024 * 1024, // 20MB分块大小 MaximumConcurrency = 5 // 并发上传数 }; await blockBlobClient.UploadAsync(fileStream, transferOptions);
但注意:该方案的断点信息保存在客户端本地,若客户端进程重启则断点丢失;且上传过程中Blob不可访问,只有全部完成后才会Commit,无法满足"失败时保留已写入内容"的核心需求。
内容的提问来源于stack exchange,提问作者Joel

