用户提交了一个需要生成 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 = "报表生成中,请稍后下载" });
}
这行代码能工作,但有四个致命问题:
- 应用重启/回收时任务直接丢失------IIS 应用池回收、容器重启、部署新版本,正在执行的任务灰飞烟灭
- 没有错误处理------后台任务抛异常,没有人知道,日志里也没有
- 无法感知进度------用户不知道任务完成没有,只能反复刷新
- 不受控的并发------如果同时来 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);
}
}
关键要点:
- 异常不能逃出循环 ------一旦
ExecuteAsync抛出未捕获异常,整个BackgroundService就终止了,不会自动重启。必须用 try-catch 包住每一轮。 - 必须手动创建 Scope ------
BackgroundService是 Singleton,不能直接注入 DbContext 等 Scoped 服务。 - 使用
stoppingToken------应用关闭时能优雅退出,而不是被强杀。 - 启动延迟 ------不要在
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 会:
- 停止接收新请求(返回 503)
- 等待正在处理的请求完成
- 调用所有
IHostedService.StopAsync() - 默认只等 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 + Redis 或 Quartz 集群
💬 互动一下:你用的是 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 更谨慎:
- 假设它随时会崩溃------所以要有重试和检查点
- 假设它会重复执行------所以要幂等
- 假设它会被强行终止------所以要优雅关闭和持久化
- 假设它会出问题但你不知道------所以要有日志和监控
Channel + BackgroundService 解决"有没有"的问题,Hangfire/Quartz 解决"可靠不可靠"的问题,进度追踪和通知解决"用户知不知道"的问题。三件事都做好了,你的后台任务才算真正能上生产。
💬 最后一个互动:你的项目中后台任务最复杂的场景是什么?数据同步、报表生成、消息推送还是其他?用了什么方案?欢迎评论区分享你的架构。