.NET6实现Azure Blob大文件后台上传并即时返回202 Accepted
这个问题我之前也碰到过,你的思路是对的——大文件要后台异步处理,但用Task.Run确实不是正确的姿势,因为ASP.NET的线程池是为请求处理设计的,长时间占用会导致其他请求卡顿,而且应用重启的话任务直接丢了。下面给你几个.NET 6里的靠谱方案:
核心思路是:API只负责接收文件元数据/临时保存文件,然后把上传任务丢进消息队列,立即返回202 Accepted;后台服务专门监听队列,执行Blob上传并更新状态。这样既不占用请求线程池,又能保证任务持久化(重启后不会丢失)。
1. 定义后台服务
创建一个继承BackgroundService的后台任务处理类:
public class BlobUploadBackgroundService : BackgroundService { private readonly IUploadTaskQueue _taskQueue; private readonly IBlobStorage _blobStorage; private readonly IFileRepository _fileRepository; private readonly ILogger<BlobUploadBackgroundService> _logger; public BlobUploadBackgroundService(IUploadTaskQueue taskQueue, IBlobStorage blobStorage, IFileRepository fileRepository, ILogger<BlobUploadBackgroundService> logger) { _taskQueue = taskQueue; _blobStorage = blobStorage; _fileRepository = fileRepository; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Blob上传后台服务已启动"); while (!stoppingToken.IsCancellationRequested) { // 从队列获取待处理任务 var uploadTask = await _taskQueue.DequeueAsync(stoppingToken); if (uploadTask == null) { await Task.Delay(1000, stoppingToken); continue; } try { // 从临时路径读取文件流 using var fileStream = new FileStream(uploadTask.TempFilePath, FileMode.Open, FileAccess.Read); var uploadResult = await _blobStorage.SaveFileAsync(fileStream, uploadTask.FileName, stoppingToken); // 更新文件状态 var status = uploadResult.HasError ? ProcessStatus.Failed : ProcessStatus.Succeeded; await _fileRepository.UpdateFileStatusAsync(uploadTask.FileId, status, stoppingToken); // 清理临时文件 File.Delete(uploadTask.TempFilePath); _logger.LogInformation($"文件 {uploadTask.FileId} 上传完成"); } catch (Exception ex) { _logger.LogError(ex, $"文件 {uploadTask.FileId} 上传失败"); await _fileRepository.UpdateFileStatusAsync(uploadTask.FileId, ProcessStatus.Failed, stoppingToken); // 可选:根据重试策略重新入队 } } } }
2. 实现消息队列(示例用数据库做轻量队列)
如果你没有现成的消息队列(比如Azure Service Bus、RabbitMQ),可以用数据库做一个简单的队列:
// 队列接口 public interface IUploadTaskQueue { Task EnqueueAsync(UploadTaskDto task, CancellationToken cancellationToken); Task<UploadTaskDto?> DequeueAsync(CancellationToken cancellationToken); } // 数据库队列实现 public class DbUploadTaskQueue : IUploadTaskQueue { private readonly AppDbContext _dbContext; public DbUploadTaskQueue(AppDbContext dbContext) { _dbContext = dbContext; } public async Task EnqueueAsync(UploadTaskDto task, CancellationToken cancellationToken) { var queueItem = new UploadTaskQueueEntity { Id = Guid.NewGuid(), FileId = task.FileId, FileName = task.FileName, TempFilePath = task.TempFilePath, CreatedAt = DateTime.UtcNow, IsProcessed = false, IsProcessing = false }; await _dbContext.UploadTaskQueue.AddAsync(queueItem, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); } public async Task<UploadTaskDto?> DequeueAsync(CancellationToken cancellationToken) { // 原子性获取并标记为处理中,避免并发重复处理 var queueItem = await _dbContext.UploadTaskQueue .Where(x => !x.IsProcessed && !x.IsProcessing) .OrderBy(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (queueItem == null) return null; queueItem.IsProcessing = true; await _dbContext.SaveChangesAsync(cancellationToken); return new UploadTaskDto { FileId = queueItem.FileId, FileName = queueItem.FileName, TempFilePath = queueItem.TempFilePath }; } }
3. 修改控制器逻辑
让API立即返回202,同时把任务丢进队列:
[HttpPost] public async Task<IActionResult> UploadFilesAsync(IFormFile uploadedFile, CancellationToken cancellationToken = default) { // 1. 先创建待上传的文件记录,状态设为Pending var fileEntity = new FileEntity { Id = Guid.NewGuid(), FileName = uploadedFile.FileName, Length = uploadedFile.Length, Status = ProcessStatus.Pending }; await _fileRepository.CreateFileAsync(fileEntity, cancellationToken); if (uploadedFile.Length > _appConfig.Value.Threshold) { // 2. 保存文件到临时路径 var tempFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}{Path.GetExtension(uploadedFile.FileName)}"); using var stream = new FileStream(tempFilePath, FileMode.Create); await uploadedFile.CopyToAsync(stream, cancellationToken); // 3. 把任务丢进队列 await _taskQueue.EnqueueAsync(new UploadTaskDto { FileId = fileEntity.Id, FileName = uploadedFile.FileName, TempFilePath = tempFilePath }, cancellationToken); // 4. 立即返回202,附带查询状态的链接 return AcceptedAtRoute(nameof(GetFileStatusAsync), new { fileId = fileEntity.Id }, new { status = "pending", message = "文件正在后台上传" }); } else { // 小文件直接同步上传 var uploadResult = await _blobStorage.SaveFileAsync(uploadedFile.OpenReadStream(), uploadedFile.FileName, cancellationToken); var status = uploadResult.HasError ? ProcessStatus.Failed : ProcessStatus.Succeeded; await _fileRepository.UpdateFileStatusAsync(fileEntity.Id, status, cancellationToken); return CreatedAtRoute(nameof(GetFileStatusAsync), new { fileId = fileEntity.Id }, new { status = status.ToString().ToLower() }); } } // 提供查询上传状态的接口 [HttpGet("{fileId}", Name = nameof(GetFileStatusAsync))] public async Task<IActionResult> GetFileStatusAsync(Guid fileId, CancellationToken cancellationToken) { var fileEntity = await _fileRepository.GetFileByIdAsync(fileId, cancellationToken); if (fileEntity == null) return NotFound(); return Ok(new { fileId = fileEntity.Id, fileName = fileEntity.FileName, status = fileEntity.Status.ToString().ToLower(), length = fileEntity.Length }); }
4. 注册服务
在Program.cs里注册后台服务和队列:
builder.Services.AddHostedService<BlobUploadBackgroundService>(); builder.Services.AddScoped<IUploadTaskQueue, DbUploadTaskQueue>();
如果业务允许,完全可以跳过API中转,让客户端直接上传到Azure Blob:API只需要生成一个SAS令牌返回给客户端,客户端用这个令牌直接上传大文件,API只负责跟踪上传状态。这种方式最省资源,API完全不用处理文件流。
生成SAS令牌的示例代码
public string GenerateBlobSasToken(string containerName, string blobName) { var blobClient = _blobServiceClient.GetBlobContainerClient(containerName).GetBlobClient(blobName); var sasBuilder = new BlobSasBuilder { BlobContainerName = containerName, BlobName = blobName, Resource = "b", // 表示操作对象是Blob ExpiresOn = DateTime.UtcNow.AddHours(1) // SAS有效期 }; sasBuilder.SetPermissions(BlobSasPermissions.Write); var sasUri = blobClient.GenerateSasUri(sasBuilder); return sasUri.Query; // 返回SAS令牌部分 }
控制器返回SAS信息:
[HttpPost("get-upload-sas")] public IActionResult GetUploadSas([FromQuery] string fileName) { var blobName = $"{Guid.NewGuid()}{Path.GetExtension(fileName)}"; var sasToken = _blobStorage.GenerateBlobSasToken("your-container", blobName); var blobUrl = $"https://youraccount.blob.core.windows.net/your-container/{blobName}{sasToken}"; // 同时创建文件记录,状态设为Pending var fileEntity = new FileEntity { Id = Guid.NewGuid(), FileName = fileName, BlobUrl = blobUrl.Split('?')[0], // 保存不带SAS的Blob地址 Status = ProcessStatus.Pending }; _fileRepository.CreateFile(fileEntity); return Ok(new { fileId = fileEntity.Id, uploadUrl = blobUrl, message = "请使用此URL上传文件" }); }
客户端拿到URL后,可以用分块上传的方式传大文件,API后续可以通过Blob的存在状态来更新文件记录。
在ASP.NET中,线程池线程是用来处理HTTP请求的,Task.Run会占用这些线程。如果后台任务长时间运行(比如上传GB级文件),会导致线程池耗尽,其他请求排队等待,出现卡顿。另外,Task.Run的任务没有持久化,一旦应用重启,任务就会丢失,上传到一半的文件直接中断。
内容的提问来源于stack exchange,提问作者RedRose

