You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

.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>();
方案二:直接让客户端上传到Blob(最优解)

如果业务允许,完全可以跳过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的存在状态来更新文件记录。

为什么不能用Task.Run?

在ASP.NET中,线程池线程是用来处理HTTP请求的,Task.Run会占用这些线程。如果后台任务长时间运行(比如上传GB级文件),会导致线程池耗尽,其他请求排队等待,出现卡顿。另外,Task.Run的任务没有持久化,一旦应用重启,任务就会丢失,上传到一半的文件直接中断。

内容的提问来源于stack exchange,提问作者RedRose

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.04 16:15:57