ASP.NET Core 后台任务全景:从 BackgroundService 到 Channel 队列,再到分布式调度

用户提交了一个需要生成 5000 行 Excel 的请求,你会让 HTTP 请求同步等待 30 秒吗?当然不会。但"放后台执行"这四个字背后,藏着比你想象多得多的设计决策。

开篇:HTTP 请求不该做的事

HTTP 协议的设计哲学是快进快出。客户端发请求,服务器快速返回。但实际业务中,总有一些事情不适合在请求线程里完成:

  • 生成 5000 行的 Excel 报表
  • 调用 10 个外部 API 汇总数据
  • 定时同步船舶基础数据
  • 发送 100 封通知邮件
  • 视频转码、文件压缩
  • 清理三个月前的日志

这些任务的特点是:耗时长、用户不需要立即看到结果、失败了可以重试。

ASP.NET Core 提供了从简单到复杂的多种后台任务方案。选错了方案,轻则应用偶发卡死,重则任务丢失、数据不一致。

这篇文章把所有方案讲透,帮你根据场景做对选择。


一、六种后台任务方案,一张图说清

复制代码
任务复杂度
    ↑
    │
    │   ┌──────────────┐  ┌──────────────┐
    │   │ Hangfire/    │  │ Quartz.NET   │
    │   │ Quartz       │  │ 集群          │
    │   │ (持久化/重试) │  │ (分布式调度)  │
    │   └──────────────┘  └──────────────┘
    │
    │   ┌──────────────┐  ┌──────────────┐
    │   │ Channel<T>   │  │ IHostedService│
    │   │ + Background  │  │ + Timer       │
    │   │ Service       │  │ (定时任务)    │
    │   │ (生产消费队列)│  │              │
    │   └──────────────┘  └──────────────┘
    │
    │   ┌──────────────┐
    │   │ Task.Run     │
    │   │ (即发即忘)    │
    │   └──────────────┘
    │
    └──────────────────────────────────────→ 可靠性要求
        低                                高

不要一上来就用 Hangfire。90% 的场景,BackgroundService + Channel<T> 就够了。


二、Task.Run:即发即忘的"玩火"方案

最简单的方式:在 Controller 里起一个 Task,不等待它完成:

csharp 复制代码
[HttpPost("export")]
public IActionResult Export([FromBody] ExportRequest request)
{
    // 不 await,让请求立即返回
    _ = Task.Run(async () =>
    {
        await _reportService.GenerateAsync(request);
    });

    return Accepted(new { message = "报表生成中,请稍后下载" });
}

这行代码能工作,但有四个致命问题:

  1. 应用重启/回收时任务直接丢失------IIS 应用池回收、容器重启、部署新版本,正在执行的任务灰飞烟灭
  2. 没有错误处理------后台任务抛异常,没有人知道,日志里也没有
  3. 无法感知进度------用户不知道任务完成没有,只能反复刷新
  4. 不受控的并发------如果同时来 100 个请求,就起 100 个 Task,可能拖垮数据库

唯一适用场景:开发环境快速原型,或者任务执行极短(< 1 秒)且丢了也无所谓。

记住:生产环境永远不要用裸 Task.Run 做后台任务。
💬 互动一下 :老实交代,你有没有在生产代码里写过 _ = Task.Run(...) 然后被它坑过?评论区自首。


三、IHostedService:ASP.NET Core 后台任务的基石

IHostedService 是所有 ASP.NET Core 后台任务的基础接口:

csharp 复制代码
public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}
  • StartAsync 在应用启动时调用
  • StopAsync 在应用关闭时调用(默认有 5 秒超时)

BackgroundService 是它的抽象基类,封装了更常用的模式:

csharp 复制代码
public abstract class BackgroundService : IHostedService, IDisposable
{
    private Task? _executingTask;
    private CancellationTokenSource? _stoppingCts;

    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

    public virtual Task StartAsync(CancellationToken cancellationToken)
    {
        _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        _executingTask = ExecuteAsync(_stoppingCts.Token);
        return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
    }

    public virtual async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_executingTask == null) return;
        try
        {
            _stoppingCts!.Cancel();
        }
        finally
        {
            var tcs = new TaskCompletionSource<object>();
            await using (cancellationToken.Register(s =>
                ((TaskCompletionSource<object>)s!).SetCanceled(), tcs))
            {
                await Task.WhenAny(_executingTask, tcs.Task);
            }
        }
    }
}

你只需要实现 ExecuteAsync,写一个长时间运行的循环即可。


四、BackgroundService 的三种经典模式

4.1 定时轮询模式(Timed Polling)

最常见的后台任务:每隔一段时间执行一次。

csharp 复制代码
public class DataSyncBackgroundService : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<DataSyncBackgroundService> _logger;
    private readonly TimeSpan _interval = TimeSpan.FromMinutes(15);

    public DataSyncBackgroundService(
        IServiceProvider serviceProvider,
        ILogger<DataSyncBackgroundService> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("数据同步服务启动,间隔: {Interval}", _interval);

        // 启动时先等 1 分钟,避免和应用启动竞争资源
        await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await SyncOnceAsync(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // 正常关闭
            }
            catch (Exception ex)
            {
                // 关键:异常不能逃出 ExecuteAsync,否则整个后台服务会挂掉!
                _logger.LogError(ex, "数据同步失败,将在 {Interval} 后重试", _interval);
            }

            try
            {
                await Task.Delay(_interval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break; // 应用关闭
            }
        }

        _logger.LogInformation("数据同步服务已停止");
    }

    private async Task SyncOnceAsync(CancellationToken ct)
    {
        // 必须创建 Scope!BackgroundService 是 Singleton,不能直接注入 Scoped 服务
        using var scope = _serviceProvider.CreateScope();
        var syncService = scope.ServiceProvider
            .GetRequiredService<IDataSyncService>();

        await syncService.SyncAllShipsAsync(ct);
    }
}

关键要点:

  1. 异常不能逃出循环 ------一旦 ExecuteAsync 抛出未捕获异常,整个 BackgroundService 就终止了,不会自动重启。必须用 try-catch 包住每一轮。
  2. 必须手动创建 Scope ------BackgroundService 是 Singleton,不能直接注入 DbContext 等 Scoped 服务。
  3. 使用 stoppingToken------应用关闭时能优雅退出,而不是被强杀。
  4. 启动延迟 ------不要在 StartAsync 中做耗时操作,它会阻塞应用启动。

4.2 队列消费模式(Queue Consumer)

后台任务的第二大场景:Controller 把任务放进队列,后台服务逐个消费。

ASP.NET Core 提供了原生的 Channel<T>,它是一个线程安全的异步队列,比 BlockingCollection<T> 更适合 async/await:

csharp 复制代码
// 任务描述
public record BackgroundJob(
    string JobType,
    string Payload,
    DateTime CreatedAt);

// 后台任务队列(Singleton)
public interface IBackgroundJobQueue
{
    ValueTask EnqueueAsync(BackgroundJob job, CancellationToken ct = default);
    ValueTask<BackgroundJob> DequeueAsync(CancellationToken ct = default);
}

public class BackgroundJobQueue : IBackgroundJobQueue
{
    private readonly Channel<BackgroundJob> _channel;

    public BackgroundJobQueue(int capacity = 1000)
    {
        // 有界通道:防止内存爆炸
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.Wait,  // 队列满时生产者等待
            SingleReader = true,                     // 单个消费者
            SingleWriter = false                     // 多个生产者
        };
        _channel = Channel.CreateBounded<BackgroundJob>(options);
    }

    public ValueTask EnqueueAsync(BackgroundJob job, CancellationToken ct = default) =>
        _channel.Writer.WriteAsync(job, ct);

    public ValueTask<BackgroundJob> DequeueAsync(CancellationToken ct = default) =>
        _channel.Reader.ReadAsync(ct);
}

消费者:

csharp 复制代码
public class JobConsumerBackgroundService : BackgroundService
{
    private readonly IBackgroundJobQueue _queue;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<JobConsumerBackgroundService> _logger;

    public JobConsumerBackgroundService(
        IBackgroundJobQueue queue,
        IServiceProvider serviceProvider,
        ILogger<JobConsumerBackgroundService> logger)
    {
        _queue = queue;
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("任务消费服务已启动");

        while (!stoppingToken.IsCancellationRequested)
        {
            BackgroundJob? job = null;
            try
            {
                job = await _queue.DequeueAsync(stoppingToken);

                using var scope = _serviceProvider.CreateScope();
                var handlerFactory = scope.ServiceProvider
                    .GetRequiredService<IJobHandlerFactory>();

                var handler = handlerFactory.GetHandler(job.JobType);
                await handler.HandleAsync(job.Payload, stoppingToken);

                _logger.LogInformation(
                    "任务完成: {JobType} (耗时 {Elapsed}ms)",
                    job.JobType, (DateTime.UtcNow - job.CreatedAt).TotalMilliseconds);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "任务处理失败: {JobType}", job?.JobType ?? "unknown");
                // 可以在这里加重试逻辑
            }
        }
    }
}

Controller 中入队:

csharp 复制代码
[ApiController]
[Route("api/[controller]")]
public class ExportController : ControllerBase
{
    private readonly IBackgroundJobQueue _queue;

    public ExportController(IBackgroundJobQueue queue) => _queue = queue;

    [HttpPost]
    public async Task<IActionResult> Export([FromBody] ExportRequest request)
    {
        var jobId = Guid.NewGuid().ToString();
        var payload = JsonSerializer.Serialize(new { request.Filter, jobId });

        await _queue.EnqueueAsync(
            new BackgroundJob("ExportReport", payload, DateTime.UtcNow));

        return Accepted(new
        {
            jobId,
            message = "报表生成中",
            statusUrl = $"/api/export/{jobId}/status"
        });
    }
}

注册:

csharp 复制代码
builder.Services.AddSingleton<IBackgroundJobQueue, BackgroundJobQueue>();
builder.Services.AddHostedService<JobConsumerBackgroundService>();

Channel vs BlockingCollection:为什么选 Channel?

特性 Channel BlockingCollection
异步 API ReadAsync/WriteAsync Take/Add(阻塞)
内存模型 异步友好,无线程阻塞 阻塞线程
背压支持 BoundedChannelFullMode 有界但阻塞
取消 原生 CancellationToken 需额外处理
多消费者 支持 支持

4.3 并行消费模式(Parallel Consumer)

单消费者不够快?用 Parallel.ForEachAsync + Channel 实现多消费者:

csharp 复制代码
public class ParallelJobConsumer : BackgroundService
{
    private readonly IBackgroundJobQueue _queue;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<ParallelJobConsumer> _logger;
    private readonly ParallelOptions _parallelOptions;

    public ParallelJobConsumer(
        IBackgroundJobQueue queue,
        IServiceProvider serviceProvider,
        IConfiguration config,
        ILogger<ParallelJobConsumer> logger)
    {
        _queue = queue;
        _serviceProvider = serviceProvider;
        _logger = logger;
        _parallelOptions = new ParallelOptions
        {
            MaxDegreeOfParallelism = config.GetValue<int>("BackgroundJobs:MaxParallelism", 4)
        };
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("并行任务消费服务启动,并发度: {MaxParallelism}",
            _parallelOptions.MaxDegreeOfParallelism);

        // 从 Channel 中持续读取
        var jobStream = _queue.Reader.ReadAllAsync(stoppingToken);

        await Parallel.ForEachAsync(jobStream, _parallelOptions, async (job, ct) =>
        {
            try
            {
                using var scope = _serviceProvider.CreateScope();
                var handlerFactory = scope.ServiceProvider
                    .GetRequiredService<IJobHandlerFactory>();

                var handler = handlerFactory.GetHandler(job.JobType);
                await handler.HandleAsync(job.Payload, ct);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "并行任务失败: {JobType}", job.JobType);
            }
        });
    }
}

// 需要把 Channel 的 Reader 暴露出来
public interface IBackgroundJobQueue
{
    ValueTask EnqueueAsync(BackgroundJob job, CancellationToken ct = default);
    IAsyncEnumerable<BackgroundJob> ReadAllAsync(CancellationToken ct = default);
}

public class BackgroundJobQueue : IBackgroundJobQueue
{
    // ...
    public IAsyncEnumerable<BackgroundJob> ReadAllAsync(CancellationToken ct = default) =>
        _channel.Reader.ReadAllAsync(ct);
}

💬 互动一下:你的后台任务用的是什么方案?是自己写的 Channel 队列,还是直接上了 Hangfire/Quartz?有没有自己造过任务调度的轮子?


五、优雅关闭:别让应用重启杀掉你的任务

当你执行 dotnet stop、部署新版本或者 Kubernetes 滚动更新时,ASP.NET Core 会:

  1. 停止接收新请求(返回 503)
  2. 等待正在处理的请求完成
  3. 调用所有 IHostedService.StopAsync()
  4. 默认只等 5 秒

如果你的后台任务正在处理一个大文件,5 秒根本不够。你需要:

csharp 复制代码
// Program.cs:增加关闭超时
builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromMinutes(2);  // 给后台任务 2 分钟
});

在 BackgroundService 中正确响应取消信号:

csharp 复制代码
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        var job = await _queue.DequeueAsync(stoppingToken);

        try
        {
            // 把 stoppingToken 传给底层方法
            await ProcessJobAsync(job, stoppingToken);
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // 应用正在关闭
            _logger.LogWarning("任务被关闭中断: {JobType},需要持久化", job.JobType);
            await PersistUnfinishedJobAsync(job);  // 保存进度,下次重启继续
            break;
        }
    }
}

关键设计:对于不能中断的任务(如扣款、库存扣减),应该用**检查点(Checkpoint)**模式------每处理一批就保存进度,重启后从检查点恢复,而不是从头开始。


六、Scoped 服务陷阱:Singleton 的 BackgroundService 怎么用 EF Core?

这是新手最常踩的坑。BackgroundService 是 Singleton,但 DbContext 是 Scoped。你不能直接注入:

csharp 复制代码
// ❌ 编译错误或运行时异常
public class MyService : BackgroundService
{
    public MyService(AppDbContext db) { ... }  // captive dependency!
}

正确做法是注入 IServiceScopeFactory,在每次执行时创建 Scope:

csharp 复制代码
public class MyService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public MyService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var repo = scope.ServiceProvider.GetRequiredService<IRepository>();

            // 在这里用 db 和 repo
            var pending = await db.Jobs.Where(j => j.Status == JobStatus.Pending).ToListAsync();

            // scope 在 using 结束时释放,DbContext 被正确 Dispose
        }
    }
}

为什么用 IServiceScopeFactory 而不是 IServiceProvider

两者都能创建 Scope,但 IServiceScopeFactory 语义更清晰,且在某些场景下(如 Testing)更容易替换。


七、定时任务:从 PeriodicTimer 到 Cron 表达式

7.1 .NET 6+ 的 PeriodicTimer

老的 System.Threading.Timer 有回调重入问题(上一次还没执行完,下一次就开始了)。.NET 6 引入的 PeriodicTimer 完美解决:

csharp 复制代码
public class CleanupBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var timer = new PeriodicTimer(TimeSpan.FromHours(24));

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // 等待下一个 tick,如果上一次还没执行完,不会重入
                await timer.WaitForNextTickAsync(stoppingToken);
                await CleanupOldLogsAsync(stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }
    }

    private async Task CleanupOldLogsAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        var cutoff = DateTime.UtcNow.AddDays(-90);
        await db.Logs
            .Where(l => l.CreatedAt < cutoff)
            .ExecuteDeleteAsync(ct);
    }
}

PeriodicTimer 的优势:

  • 自动防重入:上一轮执行完才会开始下一轮
  • 异步友好WaitForNextTickAsync 返回 ValueTask,不阻塞线程
  • 精确控制:可以动态调整间隔

7.2 Cron 表达式:复杂调度需求

如果需要"每周一早上 8 点"或"每月最后一天凌晨 2 点"这种复杂调度,PeriodicTimer 就不够了。

你需要一个 Cron 解析库。推荐 Cronos(轻量、经过验证):

bash 复制代码
dotnet add package Cronos
csharp 复制代码
public class CronJobBackgroundService : BackgroundService
{
    private readonly CronExpression _cron;
    private readonly TimeZoneInfo _timeZone;

    public CronJobBackgroundService(string cronExpression, string timeZoneId = "China Standard Time")
    {
        _cron = CronExpression.Parse(cronExpression, CronFormat.IncludeSeconds);
        _timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var now = DateTime.UtcNow;
            var nextRun = _cron.GetNextOccurrence(now, _timeZone);

            if (!nextRun.HasValue) break;

            var delay = nextRun.Value - now;
            if (delay > TimeSpan.Zero)
            {
                await Task.Delay(delay, stoppingToken);
            }

            try
            {
                await RunJobAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                // 记录但不退出循环
                _logger.LogError(ex, "Cron 任务执行失败");
            }
        }
    }

    protected virtual Task RunJobAsync(CancellationToken ct) => Task.CompletedTask;
}

// 使用:每天凌晨 3 点执行
public class DailyReportJob : CronJobBackgroundService
{
    public DailyReportJob() : base("0 0 3 * * ?") { }

    protected override async Task RunJobAsync(CancellationToken ct)
    {
        // 生成日报
    }
}

常用 Cron 表达式:

表达式 含义
0 0 3 * * ? 每天凌晨 3:00
0 0 8 ? * MON 每周一早上 8:00
0 0 2 1 * ? 每月 1 号凌晨 2:00
0 */15 * * * ? 每 15 分钟
0 0 0 L * ? 每月最后一天 0:00

八、进度追踪:让用户知道任务到哪了

后台任务的一个问题是"用户不知道进度"。用一个简单的状态存储就能解决:

csharp 复制代码
public enum JobStatus { Pending, Running, Completed, Failed }

public class JobState
{
    public string JobId { get; set; } = string.Empty;
    public string JobType { get; set; } = string.Empty;
    public JobStatus Status { get; set; }
    public int Progress { get; set; }       // 0-100
    public string? Message { get; set; }
    public string? ResultUrl { get; set; }
    public string? Error { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? CompletedAt { get; set; }
}

public interface IJobStateStore
{
    Task SetAsync(JobState state, TimeSpan? ttl = null);
    Task<JobState?> GetAsync(string jobId);
}

// Redis 实现(多实例共享)
public class RedisJobStateStore : IJobStateStore
{
    private readonly IDistributedCache _cache;
    private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24);

    public RedisJobStateStore(IDistributedCache cache) => _cache = cache;

    public async Task SetAsync(JobState state, TimeSpan? ttl = null)
    {
        var json = JsonSerializer.Serialize(state);
        await _cache.SetStringAsync(
            $"job:{state.JobId}", json,
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = ttl ?? DefaultTtl
            });
    }

    public async Task<JobState?> GetAsync(string jobId)
    {
        var json = await _cache.GetStringAsync($"job:{jobId}");
        return json == null ? null : JsonSerializer.Deserialize<JobState>(json);
    }
}

在任务处理中更新进度:

csharp 复制代码
public async Task HandleAsync(string payload, CancellationToken ct)
{
    var data = JsonSerializer.Deserialize<ExportPayload>(payload)!;
    var state = new JobState
    {
        JobId = data.JobId,
        JobType = "ExportReport",
        Status = JobStatus.Running,
        CreatedAt = DateTime.UtcNow
    };

    await _store.SetAsync(state);

    try
    {
        for (int i = 0; i < 100; i++)
        {
            ct.ThrowIfCancellationRequested();
            await ProcessBatchAsync(i, ct);

            state.Progress = i + 1;
            state.Message = $"正在处理第 {i + 1}/100 批";
            await _store.SetAsync(state);
        }

        state.Status = JobStatus.Completed;
        state.Progress = 100;
        state.ResultUrl = $"/downloads/{data.JobId}.xlsx";
        state.CompletedAt = DateTime.UtcNow;
        await _store.SetAsync(state);
    }
    catch (Exception ex)
    {
        state.Status = JobStatus.Failed;
        state.Error = ex.Message;
        state.CompletedAt = DateTime.UtcNow;
        await _store.SetAsync(state);
        throw;
    }
}

Controller 暴露进度查询:

csharp 复制代码
[HttpGet("{jobId}/status")]
public async Task<IActionResult> GetStatus(string jobId)
{
    var state = await _store.GetAsync(jobId);
    return state == null ? NotFound() : Ok(state);
}

前端轮询 /api/export/{jobId}/status,或者用 SignalR 推送进度更新。


九、当后台任务需要"可靠":Hangfire vs Quartz

到目前为止,所有方案都是进程内的------任务队列在内存中,应用重启就丢了。如果你需要:

  • 任务持久化(重启不丢)
  • 自动重试
  • 任务去重
  • Web 管理界面
  • 分布式执行(多台机器消费同一个队列)

那就需要专业的任务调度框架。

9.1 Hangfire:最简单的持久化后台任务

bash 复制代码
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.MySqlStorage  # 或 SqlServer/Redis
csharp 复制代码
builder.Services.AddHangfire(config =>
    config.UseStorage(
        new MySqlStorage(builder.Configuration.GetConnectionString("Default"))));

builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = 4;
    options.Queues = new[] { "critical", "default", "low" };
});

使用极其简单------入队时不需要定义任务类:

csharp 复制代码
public class ExportController : ControllerBase
{
    private readonly IBackgroundJobClient _jobs;

    public ExportController(IBackgroundJobClient jobs) => _jobs = jobs;

    [HttpPost]
    public IActionResult Export([FromBody] ExportRequest request)
    {
        var jobId = _jobs.Enqueue<IExportService>(s =>
            s.ExportAsync(request.Filter, default));

        // 延迟任务
        _jobs.Schedule<INotificationService>(s =>
            s.SendReminderAsync(request.UserId, default),
            TimeSpan.FromHours(24));

        // 定时任务(支持 Cron)
        RecurringJob.AddOrUpdate<IDataSyncService>(
            "daily-sync",
            s => s.SyncAllAsync(default),
            "0 0 3 * * ?");

        return Accepted(new { jobId });
    }
}

Hangfire 内置 Dashboard,访问 /hangfire 就能看到任务状态、重试次数、执行历史:

csharp 复制代码
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireAuthorizationFilter() }
});

9.2 Quartz.NET:更灵活的调度框架

Quartz 更适合复杂的调度场景(日历排除、任务依赖链、错过任务策略):

csharp 复制代码
builder.Services.AddQuartz(q =>
{
    q.UseMicrosoftDependencyInjectionJobFactory();

    // 定义 Job
    var jobKey = new JobKey("dataSyncJob");
    q.AddJob<DataSyncJob>(opts => opts.WithIdentity(jobKey));

    // 每天凌晨 3 点触发
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("dataSync-trigger")
        .WithCronSchedule("0 0 3 * * ?")
        .StartAt(DateBuilder.FutureDate(1, IntervalUnit.Minute)));
});

builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

9.3 怎么选?

特性 Channel + BackgroundService Hangfire Quartz.NET
持久化 ❌ 内存 ✅ 多种存储 ✅ 多种存储
自动重试 ❌ 自己实现 ✅ 内置 ✅ 触发器配置
管理界面 ✅ Dashboard ❌(需第三方)
分布式 ✅(Redis/SQL Server) ✅ 集群
Cron 调度 需 Cronos
延迟任务
学习成本
性能 最高(无存储开销)
适用场景 单实例、短任务 通用、需要可靠性 复杂调度

决策建议:

  • 单实例部署 + 任务丢了也能接受 → Channel + BackgroundService
  • 需要持久化/重试/管理界面 → Hangfire
  • 复杂日历调度/任务编排 → Quartz.NET
  • 多实例 + 高吞吐 → Hangfire + RedisQuartz 集群

💬 互动一下:你用的是 Hangfire 还是 Quartz?有没有在生产环境遇到过任务重复执行或任务丢失的坑?


十、实战架构:一个完整的报表导出系统

把所有概念串起来,设计一个生产可用的报表导出系统:

复制代码
┌─────────────┐     ┌──────────────┐     ┌─────────────────┐
│  Controller  │────→│  API 层       │────→│  Hangfire/Channel│
│  POST /export│     │  生成 JobId   │     │  任务队列         │
└─────────────┘     └──────────────┘     └────────┬────────┘
                                                   │
                                                   ▼
                    ┌──────────────────────────────────────┐
                    │     BackgroundService (消费者)        │
                    │                                      │
                    │  1. 更新状态 → Redis (Running, 0%)   │
                    │  2. 查询数据 (分页 + AsNoTracking)   │
                    │  3. 生成 Excel (EPPlus/ClosedXML)    │
                    │  4. 流式写入文件存储 (本地/OSS/S3)    │
                    │  5. 更新状态 → Redis (Completed)     │
                    │  6. 推送 SignalR 通知前端              │
                    └──────────────────────────────────────┘

核心代码:

csharp 复制代码
public class ReportExportJob
{
    private readonly IReportRepository _repo;
    private readonly IFileStorage _storage;
    private readonly IJobStateStore _state;
    private readonly IHubContext<ExportHub> _hub;
    private readonly ILogger<ReportExportJob> _logger;

    // 构造函数注入(Hangfire 支持 DI)
    public ReportExportJob(
        IReportRepository repo,
        IFileStorage storage,
        IJobStateStore state,
        IHubContext<ExportHub> hub,
        ILogger<ReportExportJob> logger)
    {
        _repo = repo;
        _storage = storage;
        _state = state;
        _hub = hub;
        _logger = logger;
    }

    // Hangfire 会自动重试(配置重试策略后)
    [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 120, 300 })]
    public async Task ExecuteAsync(string jobId, ExportFilter filter, CancellationToken ct)
    {
        var state = JobState.Running(jobId, "正在查询数据...", 0);
        await UpdateAndNotify(state);

        try
        {
            // 1. 分页查询,避免一次加载太多数据
            const int pageSize = 5000;
            var totalCount = await _repo.CountAsync(filter, ct);
            var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);

            var filePath = $"exports/{jobId}.xlsx";

            // 2. 流式写入 Excel(不加载全部数据到内存)
            await using var stream = await _storage.OpenWriteAsync(filePath, ct);
            using var package = new ExcelPackage(stream);
            var worksheet = package.Workbook.Worksheets.Add("报表");

            // 写表头
            WriteHeader(worksheet);
            int row = 2;

            for (int page = 0; page < totalPages; page++)
            {
                ct.ThrowIfCancellationRequested();

                var data = await _repo.GetPagedAsync(filter, page, pageSize, ct);

                foreach (var item in data)
                {
                    WriteRow(worksheet, row++, item);
                }

                var progress = (int)((double)(page + 1) / totalPages * 90);
                await UpdateAndNotify(JobState.Running(jobId,
                    $"已处理 {row - 2}/{totalCount} 条", progress));
            }

            await package.SaveAsync(ct);

            // 3. 完成
            var downloadUrl = await _storage.GetDownloadUrlAsync(filePath, ct);
            await UpdateAndNotify(JobState.Completed(jobId, downloadUrl, 100));
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "报表导出失败: {JobId}", jobId);
            await UpdateAndNotify(JobState.Failed(jobId, ex.Message));
            throw;
        }
    }

    private async Task UpdateAndNotify(JobState state)
    {
        await _state.SetAsync(state);
        await _hub.Clients.Group($"job_{state.JobId}")
            .SendAsync("JobProgress", state);
    }
}

十一、避坑指南:我踩过的 7 个后台任务坑

坑 1:ExecuteAsync 里的异常没捕获

csharp 复制代码
// ❌ 异常逃出后,BackgroundService 永久停止
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync(stoppingToken);  // 抛异常就完了
        await Task.Delay(5000, stoppingToken);
    }
}

// ✅ 每轮都 try-catch
while (!stoppingToken.IsCancellationRequested)
{
    try { await DoWorkAsync(stoppingToken); }
    catch (Exception ex) { _logger.LogError(ex, "失败"); }
    await Task.Delay(5000, stoppingToken);
}

坑 2:用 Task.Delay 做定时但没考虑执行时间

csharp 复制代码
// ❌ 如果 DoWork 要 10 秒,实际间隔是 10+5=15 秒
while (...)
{
    await DoWorkAsync();
    await Task.Delay(5000);
}

// ✅ 用 PeriodicTimer(自动防重入,间隔更精确)
var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
    await DoWorkAsync();
}

坑 3:在 BackgroundService 构造函数里做初始化

csharp 复制代码
// ❌ 构造函数中的异步操作无法 await,可能在应用启动时出问题
public MyService(IServiceProvider sp)
{
    _data = sp.GetRequiredService<IApi>().GetData().Result;  // .Result 死锁风险
}

// ✅ 在 ExecuteAsync 开头做初始化
protected override async Task ExecuteAsync(CancellationToken ct)
{
    await InitializeAsync(ct);
    while (...) { ... }
}

坑 4:忘记传递 CancellationToken

csharp 复制代码
// ❌ 应用关闭时不会取消
await _dbContext.Jobs.ToListAsync();

// ✅ 始终传递 token
await _dbContext.Jobs.ToListAsync(stoppingToken);

坑 5:IHostedService 启动顺序不可控

多个 IHostedService 的启动顺序就是注册顺序,但 StartAsync顺序执行 的------前一个没完成,后一个不会启动。如果前一个服务的 StartAsync 做了耗时操作(如数据库迁移),后面的服务全被阻塞。

建议StartAsync 只做初始化,真正的工作在 ExecuteAsync 中异步启动。

坑 6:日志在应用关闭时丢失

应用关闭时日志系统可能先于后台任务被 Dispose。在 StopAsync 中记的日志可能写不出去。建议:关键关闭信息同时写到 EventLog 或文件。

坑 7:Docker/K8s 环境下的优雅关闭

dockerfile 复制代码
# Dockerfile:确保容器正确响应 SIGTERM
ENTRYPOINT ["dotnet", "Pms.Api.dll"]

Kubernetes 在滚动更新时会发 SIGTERM,默认等 30 秒后 SIGKILL。要配合 ShutdownTimeout 和健康检查:

yaml 复制代码
# deployment.yaml
spec:
  terminationGracePeriodSeconds: 120  # 给 2 分钟优雅关闭
  containers:
    - name: api
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "10"]  # 等待 LoadBalancer 摘除流量

十二、Checklist:后台任务上线前检查

  • ExecuteAsync 中的异常已被捕获,不会导致服务终止
  • Scoped 服务通过 IServiceScopeFactory 创建 Scope 获取
  • 所有异步方法传递了 CancellationToken
  • 配置了足够的 ShutdownTimeout(默认 5 秒通常不够)
  • 任务支持优雅关闭(检查检查点,重启可恢复)
  • 队列使用有界 Channel<T>,防止内存溢出
  • 生产环境不使用裸 Task.Run
  • 长任务有进度追踪和状态查询接口
  • 关键任务有持久化和重试机制(Hangfire/Quartz)
  • 日志覆盖任务的入队、开始、完成、失败全生命周期
  • Docker/K8s 的 terminationGracePeriodSeconds 已配置
  • 并发度通过配置控制,不硬编码

结语:后台任务是系统可靠性的试金石

HTTP 请求的错误用户会立刻告诉你------页面报错了、API 返回 500 了。但后台任务的错误可能悄无声息:报表没生成、数据没同步、邮件没发送,直到三天后才有人发现。

所以写后台任务时要比写 API 更谨慎:

  1. 假设它随时会崩溃------所以要有重试和检查点
  2. 假设它会重复执行------所以要幂等
  3. 假设它会被强行终止------所以要优雅关闭和持久化
  4. 假设它会出问题但你不知道------所以要有日志和监控

Channel + BackgroundService 解决"有没有"的问题,Hangfire/Quartz 解决"可靠不可靠"的问题,进度追踪和通知解决"用户知不知道"的问题。三件事都做好了,你的后台任务才算真正能上生产。

💬 最后一个互动:你的项目中后台任务最复杂的场景是什么?数据同步、报表生成、消息推送还是其他?用了什么方案?欢迎评论区分享你的架构。

相关推荐
倾颜1 小时前
NestJS 核心概念梳理:从 Module、Controller 到 Guard、Interceptor
后端·node.js·nestjs
东风破_2 小时前
JWT 1:从一个登录请求开始,理解 React 项目里的 API 层与 Mock
前端·后端
东风破_2 小时前
JWT 3:为什么 Token 要放进 Authorization?Axios 拦截器到底解决了什么?
前端·后端
东风破_2 小时前
JWT 5:路由守卫是什么?把整个 JWT 登录鉴权流程串起来
前端·后端
东风破_2 小时前
JWT 2:HTTP 是无状态的,为什么登录成功后还要给 Token?
前端·后端
东风破_2 小时前
JWT 4:Zustand 到底解决了什么?为什么登录状态要放进 Store?
前端·后端
IT_陈寒2 小时前
Vite动态导入差点让我秃头,原来问题出在这
前端·人工智能·后端
唐青枫4 小时前
别只把大括号当作用域:Zig Block、标签块与控制流实战
后端
TunerT_TQ5 小时前
Microsoft |Playwright CLI 源码静态审阅:从 5 个文件看浏览器自动化工具的工程边界
后端·开源·github