适用版本 :Hangfire 1.8.x / .NET 8 +
项目场景 :船舶PMS系统报表生成、数据同步、定时提醒、后台批处理
阅读时间:约 35 分钟
目录
- 为什么需要后台任务调度
- [Hangfire 架构与核心概念](#Hangfire 架构与核心概念)
- 快速集成
- 五种任务类型
- 持久化与存储
- [Dashboard 与监控](#Dashboard 与监控)
- 依赖注入与作用域
- 错误处理与自动重试
- [批处理与 Continuations](#批处理与 Continuations)
- [PMS 场景实战:报表导出系统](#PMS 场景实战:报表导出系统)
- [PMS 场景实战:船岸同步调度器](#PMS 场景实战:船岸同步调度器)
- [PMS 场景实战:定时维保提醒](#PMS 场景实战:定时维保提醒)
- 性能调优与生产部署
- 测试策略
- 常见陷阱与最佳实践
- Checklist
1. 为什么需要后台任务调度
1.1 请求-响应之外的世界
Web 应用中不是所有操作都适合在 HTTP 请求内完成:
| 场景 | 为什么不能在请求中做 | PMS 示例 |
|---|---|---|
| 耗时计算 | 用户等不了30秒以上 | 年度维修报表导出(30万行数据) |
| 定时执行 | 没有HTTP请求触发 | 设备维保到期提醒、库存低水位预警 |
| 异步解耦 | 主流程不应等待副作用 | 审批通过后发邮件通知 |
| 失败重试 | 网络抖动需要自动重试 | 船岸数据同步失败后自动重传 |
| 批量处理 | 需要限流、分批执行 | 月末所有船舶物料成本核算 |
| 延迟执行 | 将来某个时间执行 | 提交申请24小时未审批自动催办 |
1.2 Hangfire vs 其他方案
| 方案 | 持久化 | 可视化 | 分布式 | Cron | 复杂度 |
|---|---|---|---|---|---|
BackgroundService |
❌ 需自己实现 | ❌ | ❌ | 需自己实现 | 低 |
IHostedService + Timer |
❌ | ❌ | ❌ | 需自己实现 | 低 |
| Quartz.NET | ✅ | ❌ | ✅ | ✅ | 中 |
| Hangfire | ✅ | ✅ Dashboard | ✅ | ✅ | 低 |
| Azure WebJobs | ✅ | ✅ | ✅ | ✅ | 中(绑定Azure) |
| Hangfire + Redis/PostgreSQL | ✅ | ✅ | ✅ | ✅ | 中 |
Hangfire 的独特优势:开箱即用的 Dashboard 、** fire-and-forget 即发即弃**、自动重试 、持久化透明 、.NET 生态集成最好。
💬 互动一下:你在项目中用过哪些后台任务方案?有没有遇到过"进程重启后后台任务丢失"的事故?
2. Hangfire 架构与核心概念
2.1 架构组件
┌──────────────────────────────────────────────────┐
│ Client(客户端) │
│ BackgroundJob.Enqueue / Schedule / AddOrUpdate │
└─────────────────────┬────────────────────────────┘
│ 创建 Job 记录
▼
┌──────────────────────────────────────────────────┐
│ Job Storage(持久化) │
│ ┌─────────┬──────────┬──────────┬────────────┐ │
│ │ Job表 │ State表 │ Set表 │ Counter表 │ │
│ └─────────┴──────────┴──────────┴────────────┘ │
│ (SQL Server / PostgreSQL / Redis / SQLite) │
└──────────────────────────────────────────────────┘
▲
│ 轮询获取 Job
│
┌─────────────────────┴────────────────────────────┐
│ Server(服务端) │
│ ┌──────────────────────────────────────────────┐│
│ │ Worker × N(从 Storage 拉取 Job 执行) ││
│ │ RecurringJobScheduler(Cron 调度) ││
│ │ SchedulePoller(延迟任务触发) ││
│ │ Heartbeat(心跳保活) ││
│ └──────────────────────────────────────────────┘│
└──────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Dashboard(可视化面板) │
│ 实时查看 / 手动触发 / 重跑 / 删除 / 监控 │
└──────────────────────────────────────────────────┘
2.2 任务状态机
┌──────────┐
创建 ────▶│ Enqueued │
└─────┬────┘
│ Worker 取走
▼
┌──────────┐
│ Processing│◀───── 重试
└─────┬────┘
┌────┴────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Succeeded│ │ Failed │
└──────────┘ └─────┬────┘
│ 手动重试
▼
回到 Enqueued
延迟任务:Scheduled →(时间到)→ Enqueued
周期任务:通过 RecurringJobScheduler 定期创建 Enqueued 实例
2.3 关键概念
| 概念 | 说明 |
|---|---|
| Job | 一个可执行的工作单元(方法调用) |
| Background Job | 即发即弃的异步任务 |
| Scheduled Job | 延迟执行的任务 |
| Recurring Job | 按 Cron 表达式重复执行 |
| Continuations | 前驱任务完成后执行后续任务 |
| Batch | 一组任务作为整体管理(Hangfire.Pro) |
| Worker | 执行 Job 的后台线程 |
| Queue | 任务队列,可按优先级分队列 |
| State | Job 当前状态(Enqueued/Processing/Failed等) |
| Filter | Job 执行的 AOP 过滤器 |
| Server | 运行 Worker 的进程 |
3. 快速集成
3.1 安装
bash
dotnet add package Hangfire
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer # 或 PostgreSQL/Redis
dotnet add package Hangfire.Console # Dashboard 增强(可选)
3.2 最小配置
csharp
var builder = WebApplication.CreateBuilder(args);
// 注册 Hangfire 服务
builder.Services.AddHangfire(config =>
{
config.UseSqlServerStorage(
builder.Configuration.GetConnectionString("Hangfire"),
new SqlServerStorageOptions
{
PrepareSchemaIfNecessary = true,
QueuePollInterval = TimeSpan.FromSeconds(15),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
});
// 启用 Console 日志
config.UseConsole();
});
// 添加 Hangfire Server(Worker 进程)
builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = Environment.ProcessorCount * 2;
options.Queues = new[] { "critical", "default", "batch" };
options.ServerName = $"pms-server-{Environment.MachineName}";
options.SchedulePollingInterval = TimeSpan.FromSeconds(15);
options.HeartbeatInterval = TimeSpan.FromSeconds(30);
});
var app = builder.Build();
// 启用 Dashboard
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
DashboardTitle = "PMS 任务调度中心",
DisplayStorageConnectionString = false,
AppPath = "/",
IsReadOnlyFunc = ctx => !ctx.User.IsInRole("Administrator"),
Authorization = new[]
{
new HangfireDashboardAuthFilter()
}
});
app.Run();
3.3 Dashboard 安全
csharp
public class HangfireDashboardAuthFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// 仅允许管理员访问
if (!httpContext.User.Identity?.IsAuthenticated ?? true)
return false;
return httpContext.User.IsInRole("Administrator");
}
}
3.4 数据库初始化
Hangfire 首次启动会自动创建表结构(约 20 张表,都以 Hangfire. 前缀):
HangFire.AggregatedCounter HangFire.Hash
HangFire.Counter HangFire.Job
HangFire.JobParameter HangFire.JobQueue
HangFire.List HangFire.Server
HangFire.Set HangFire.State
HangFire.Schema HangFire._MigrationLog
生产环境建议:
- 为 Hangfire 创建独立数据库或独立 Schema
- 数据库连接字符串使用专用账号(最小权限)
- 不要和业务数据库混用(避免锁竞争)
4. 五种任务类型
4.1 Fire-and-Forget(即发即弃)
csharp
// 最基础:立即执行
BackgroundJob.Enqueue<IEmailService>(service =>
service.SendAsync("user@example.com", "欢迎", "欢迎使用PMS系统"));
// 指定队列(不同优先级)
BackgroundJob.Create<ISyncService>(service =>
service.SyncShipDataAsync(shipId, CancellationToken.None))
.WithParameter("Queue", "critical")
.Enqueue();
// 从 IBackgroundJobClient 注入(推荐,可测试)
public class ApplyAppService
{
private readonly IBackgroundJobClient _backgroundJobs;
public ApplyAppService(IBackgroundJobClient backgroundJobs)
{
_backgroundJobs = backgroundJobs;
}
public void OnApproved(Guid applyId)
{
// 审批通过后,后台异步发通知
_backgroundJobs.Enqueue<INotificationService>(svc =>
svc.NotifyApprovalAsync(applyId));
// 后台异步生成PDF
_backgroundJobs.Enqueue<IPdfGenerator>(svc =>
svc.GenerateApplyPdfAsync(applyId));
}
}
4.2 Delayed(延迟执行)
csharp
// 24小时后执行(自动催办)
BackgroundJob.Schedule<INotificationService>(service =>
service.SendReminderAsync(applyId, "pending_approval"),
TimeSpan.FromHours(24));
// 指定具体时间
var remindAt = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero);
BackgroundJob.Schedule<IMaintenanceService>(service =>
service.CheckUpcomingMaintenanceAsync(),
remindAt);
4.3 Recurring(周期任务)
csharp
// 每天凌晨2点执行库存预警
RecurringJob.AddOrUpdate<IInventoryAlertService>(
"inventory-daily-alert",
service => service.CheckLowStockAsync(),
"0 2 * * *", // Cron 表达式:分 时 日 月 周
TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"),
queue: "batch");
// 每周一早上8点生成周报
RecurringJob.AddOrUpdate<IReportService>(
"weekly-maintenance-report",
service => service.GenerateWeeklyReportAsync(),
"0 8 * * 1",
TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));
// 每月1号凌晨3点月结
RecurringJob.AddOrUpdate<IBillingService>(
"monthly-billing",
service => service.MonthlySettlementAsync(),
"0 3 1 * *",
TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"),
queue: "batch");
// 每15分钟同步一次船舶状态
RecurringJob.AddOrUpdate<ISyncService>(
"ship-status-sync",
service => service.SyncAllShipsStatusAsync(),
"*/15 * * * *");
// 每小时清理过期临时文件
RecurringJob.AddOrUpdate<ICleanupService>(
"temp-file-cleanup",
service => service.CleanupTempFilesAsync(),
"0 * * * *");
Cron 表达式速查:
| 表达式 | 含义 |
|---|---|
* * * * * |
每分钟 |
*/5 * * * * |
每5分钟 |
0 * * * * |
每小时整点 |
0 2 * * * |
每天凌晨2点 |
0 9 * * 1-5 |
工作日早上9点 |
0 0 1 * * |
每月1号午夜 |
0 0 ? * MON |
每周一午夜 |
4.4 Continuations(后续任务)
csharp
// 第一步:导出数据
var exportJobId = BackgroundJob.Enqueue<IReportService>(service =>
service.ExportRawDataAsync(DateTime.Now.AddMonths(-1), DateTime.Now));
// 第二步:导出完成后生成PDF
BackgroundJob.ContinueJobWith<IPdfGenerator>(
exportJobId,
generator => generator.GenerateReportPdfAsync(exportJobId));
// 第三步:PDF生成后发送邮件
BackgroundJob.ContinueJobWith<IEmailService>(
parentJobId: exportJobId, // 注意:挂在第一步之后
continuation: emailService => emailService.SendReportEmailAsync(
"manager@example.com", exportJobId));
// 只有前驱任务成功才执行(默认)
BackgroundJob.ContinueJobWith<INotificationService>(
jobId,
svc => svc.NotifySuccessAsync(),
JobContinuationOptions.OnlyOnSucceededState);
// 只有前驱任务失败才执行
BackgroundJob.ContinueJobWith<INotificationService>(
jobId,
svc => svc.NotifyFailureAsync(),
JobContinuationOptions.OnlyOnFailedState);
4.5 Batch(批处理)
Batch 是 Hangfire Pro 的功能,开源版可以用 Continuations 组合实现类似效果:
csharp
// 开源版模拟 Batch:多个任务完成后触发汇总
var jobIds = new List<string>();
foreach (var shipId in shipIds)
{
var jobId = BackgroundJob.Enqueue<ISyncService>(svc =>
svc.SyncShipDataAsync(shipId, CancellationToken.None));
jobIds.Add(jobId);
}
// 用一个"协调任务"等待所有任务完成
BackgroundJob.Enqueue(() =>
WaitForAllAndAggregate(jobIds));
5. 持久化与存储
5.1 存储方案选择
| 存储 | 优势 | 劣势 | 适用 |
|---|---|---|---|
| SQL Server | 稳定、生态好 | 需要License | Windows技术栈 |
| PostgreSQL | 免费、性能好 | - | Linux/容器化 |
| Redis | 极高性能 | 内存成本、持久化需配置 | 高吞吐、任务量大 |
| SQLite | 零配置、嵌入式 | 并发性能有限 | 开发/船端本地 |
5.2 PostgreSQL 存储
bash
dotnet add package Hangfire.PostgreSql
csharp
builder.Services.AddHangfire(config =>
{
config.UsePostgreSqlStorage(
builder.Configuration.GetConnectionString("HangfirePg"),
new PostgreSqlStorageOptions
{
QueuePollInterval = TimeSpan.FromSeconds(15),
InvisibilityTimeout = TimeSpan.FromMinutes(5),
DistributedLockTimeout = TimeSpan.FromMinutes(2),
TransactionSynchronisationTimeout =
TimeSpan.FromMinutes(2),
SchemaName = "hangfire"
});
});
5.3 Redis 存储
bash
dotnet add package Hangfire.Redis.StackExchange
csharp
config.UseRedisStorage(
builder.Configuration.GetConnectionString("Redis"),
new RedisStorageOptions
{
Prefix = "hangfire:pms:",
Db = 2,
InvisibilityTimeout = TimeSpan.FromMinutes(5),
FetchTimeout = TimeSpan.FromMinutes(3)
});
5.4 多实例部署
Hangfire 天然支持多 Server 实例:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
│ Worker×4 │ │ Worker×4 │ │ Worker×4 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────────┼────────────────┘
▼
┌─────────────────┐
│ Job Storage │
│ (SQL/Redis) │
└─────────────────┘
多个 Server 共享同一个 Storage,通过分布式锁保证 Job 不被重复执行。Worker 数量和队列可以不同:
csharp
// 船岸同步服务:只处理 critical 队列
builder.Services.AddHangfireServer(options =>
{
options.Queues = new[] { "critical" };
options.WorkerCount = 8;
options.ServerName = "sync-worker";
});
// 报表服务:处理 batch 队列
builder.Services.AddHangfireServer(options =>
{
options.Queues = new[] { "batch" };
options.WorkerCount = 2; // 报表耗资源,少几个Worker
options.ServerName = "report-worker";
});
6. Dashboard 与监控
6.1 Dashboard 功能
- Jobs:查看所有任务,按状态筛选
- Recurring Jobs:管理周期任务(手动触发/禁用)
- Servers:查看活跃的 Worker 节点
- Retries:重试中的任务
- Succeeded/Failed:成功/失败记录
- 实时图表:任务吞吐量、失败率
6.2 自定义监控页面
csharp
// 在 Dashboard 中添加自定义统计
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
StatsPollingInterval = 5000,
DashboardTitle = "PMS 任务调度中心",
Authorization = new[] { new HangfireDashboardAuthFilter() },
FaviconPath = "/favicon.ico"
});
// 通过 Hangfire API 获取统计
public class JobStatsService
{
public JobStats GetStats()
{
var monitor = JobStorage.Current.GetMonitoringApi();
var stats = monitor.GetStatistics();
var queues = monitor.Queues();
return new JobStats
{
Succeeded = stats.Succeeded,
Failed = stats.Failed,
Enqueued = stats.Enqueued,
Scheduled = stats.Scheduled,
Processing = stats.Processing,
Servers = stats.Servers,
Queues = queues.Select(q => new QueueStats
{
Name = q.Name,
Length = q.Length,
Fetched = q.Fetched
}).ToList()
};
}
}
6.3 告警接入
csharp
// Job 失败时发送告警
public class FailureAlertFilter : JobFilterAttribute, IApplyStateFilter
{
private readonly IAlertService _alerts;
public void OnStateApplied(
ApplyStateContext context, IWriteOnlyTransaction transaction)
{
if (context.NewState is FailedState failed)
{
_alerts.SendCriticalAsync(
"Hangfire任务失败",
$"任务: {context.BackgroundJob.Job.Method.Name}\n" +
$"异常: {failed.Exception.Message}\n" +
$"时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
"hangfire");
}
}
}
// 全局注册
GlobalJobFilters.Filters.Add(new FailureAlertFilter());
7. 依赖注入与作用域
7.1 Job 中的 DI
Hangfire 使用 JobActivator 从 DI 容器解析 Job 类型。ASP.NET Core 集成默认使用 AspNetCoreJobActivator:
csharp
// ✅ 正确:通过接口注入,每个 Job 执行时创建独立 Scope
public class ReportGenerationJob
{
private readonly IServiceProvider _serviceProvider;
public ReportGenerationJob(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task ExecuteAsync(Guid reportId)
{
// 为每个 Job 创建独立 Scope(和 HTTP 请求隔离)
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<PmsDbContext>();
var emailService = scope.ServiceProvider
.GetRequiredService<IEmailService>();
var report = await dbContext.Reports
.FindAsync(reportId);
// ... 生成报表
await emailService.SendReportReadyAsync(report!);
}
}
// 也可以直接依赖 Scoped 服务,Hangfire 会自动创建 Scope
public class SyncJob
{
private readonly IRepository<Ship> _shipRepo;
private readonly ISyncService _syncService;
// Hangfire 自动解析,每个 Job 执行创建独立 Scope
public SyncJob(
IRepository<Ship> shipRepo,
ISyncService syncService)
{
_shipRepo = shipRepo;
_syncService = syncService;
}
public async Task ExecuteAsync(Guid shipId)
{
var ship = await _shipRepo.GetByIdAsync(shipId);
await _syncService.SyncShipAsync(ship!);
}
}
7.2 注意:不能在 Job 构造函数中注入 Scoped 服务到 Singleton
Hangfire Server 是单例的,但 Job 实例在每次执行时创建。构造函数注入的 Scoped 服务会跟随 Job 实例的 Scope,这是安全的。
7.3 获取 Job 信息
csharp
public class MyJob
{
public void Execute(PerformContext context)
{
var jobId = context.BackgroundJob.Id;
var cancellationToken = context.CancellationToken;
var connection = context.Connection;
// 写日志到 Dashboard Console
context.WriteLine("开始执行...");
// 设置进度
context.SetJobParameter("Progress", "50%");
}
}
8. 错误处理与自动重试
8.1 默认重试策略
Hangfire 默认自动重试10次,指数退避延迟:
重试1: 等 ~16秒
重试2: 等 ~32秒
重试3: 等 ~1分钟
重试4: 等 ~2分钟
...
重试10: 等 ~2.3小时
8.2 自定义重试
csharp
// 全局配置
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute
{
Attempts = 5,
DelayInSeconds = 30,
OnAttemptsExceeded = AttemptsExceededAction.Fail
});
// Job 级别配置
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
public class SyncJob
{
public async Task ExecuteAsync(Guid shipId) { ... }
}
// 不重试
[AutomaticRetry(Attempts = 0)]
public class NonRetryableJob
{
public void Execute() { ... }
}
// 针对特定异常不重试
public class SmartRetryAttribute : AutomaticRetryAttribute
{
public override void OnStateElection(ElectStateContext context)
{
if (context.CandidateState is FailedState failed)
{
if (failed.Exception is BusinessException)
{
// 业务异常不重试
Attempts = 0;
}
}
base.OnStateElection(context);
}
}
8.3 自定义错误处理过滤器
csharp
public class ErrorHandlerFilter : JobFilterAttribute,
IServerFilter, IElectStateFilter
{
private readonly ILogger<ErrorHandlerFilter> _logger;
public void OnStateElection(ElectStateContext context)
{
if (context.CandidateState is FailedState failed)
{
_logger.LogError(failed.Exception,
"Job {JobId} 执行失败: {JobName}",
context.BackgroundJob.Id,
context.BackgroundJob.Job.Method.Name);
// 可根据异常类型决定是否告警
if (failed.Exception is HttpRequestException)
{
// 网络异常,重试即可,不告警
return;
}
// 其他异常告警
_logger.LogCritical("需要立即关注的任务失败!");
}
}
public void OnPerforming(PerformingContext context)
{
_logger.LogInformation("开始执行 Job: {JobName}",
context.BackgroundJob.Job.Method.Name);
}
public void OnPerformed(PerformedContext context)
{
if (context.Exception != null)
{
_logger.LogError(context.Exception, "Job 执行异常");
}
else
{
_logger.LogInformation("Job 执行成功");
}
}
}
9. 批处理与 Continuations
9.1 工作流编排
用 Continuations 编排多步骤任务:
csharp
public class ReportWorkflow
{
public string StartMonthlyReport(int year, int month)
{
// 步骤1:收集数据
var collectId = BackgroundJob.Enqueue<DataCollector>(c =>
c.CollectMonthlyData(year, month));
// 步骤2a:数据收集后生成报表
var generateId = BackgroundJob.ContinueJobWith<ReportGenerator>(
collectId,
gen => gen.GenerateReport(year, month));
// 步骤2b:同时生成图表
var chartId = BackgroundJob.ContinueJobWith<ChartGenerator>(
collectId,
ch => ch.GenerateCharts(year, month));
// 步骤3:两个都完成后合并PDF
var mergeId = BackgroundJob.ContinueJobWith<PdfMerger>(
generateId,
merger => merger.MergeReport(year, month));
// 步骤4:发送邮件
BackgroundJob.ContinueJobWith<EmailSender>(
mergeId,
email => email.SendReportToManagement(year, month));
return collectId;
}
}
9.2 并行任务
csharp
// 多船并行同步,全部完成后汇总
public async Task SyncAllShipsAsync()
{
var shipIds = await _shipRepo.GetAllShipIdsAsync();
var syncJobIds = new List<string>();
foreach (var shipId in shipIds)
{
var jobId = BackgroundJob.Enqueue<ShipSyncJob>(job =>
job.SyncShipAsync(shipId, CancellationToken.None));
syncJobIds.Add(jobId);
}
// 注册一个"门控"任务检查所有任务完成
BackgroundJob.Schedule(
() => CheckSyncCompletion(syncJobIds, 0),
TimeSpan.FromMinutes(2));
}
public void CheckSyncCompletion(List<string> jobIds, int checkCount)
{
var monitor = JobStorage.Current.GetMonitoringApi();
var allDone = true;
var hasFailed = false;
foreach (var jobId in jobIds)
{
var details = monitor.JobDetails(jobId);
if (details == null) continue;
var stateName = details.History.FirstOrDefault()?.StateName;
if (stateName != "Succeeded")
{
allDone = false;
if (stateName == "Failed") hasFailed = true;
}
}
if (allDone)
{
if (hasFailed)
{
BackgroundJob.Enqueue<INotificationService>(s =>
s.NotifySyncPartialFailureAsync());
}
else
{
BackgroundJob.Enqueue<ISummaryService>(s =>
s.GenerateSyncSummaryAsync());
}
}
else if (checkCount < 30) // 最多检查1小时
{
BackgroundJob.Schedule(
() => CheckSyncCompletion(jobIds, checkCount + 1),
TimeSpan.FromMinutes(2));
}
}
10. PMS 场景实战:报表导出系统
10.1 场景分析
PMS 系统需要导出多种报表:
- 备件库存明细(可能5万行)
- 维修历史(可能10万行)
- 月度成本分析(多船汇总)
- 设备运行记录(可能30万行)
用户在浏览器点击导出后,不能同步等待。需要:
- 创建后台任务
- 实时显示进度
- 完成后提供下载链接
- 失败可重试
10.2 实现
csharp
// 报表导出任务
public class ReportExportJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IFileStorageService _fileStorage;
private readonly IHubContext<ReportHub> _hub;
private readonly ILogger<ReportExportJob> _logger;
public async Task ExportAsync(
Guid exportId,
PerformContext context)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<PmsDbContext>();
var exportRecord = await dbContext.ReportExports
.FindAsync(exportId);
if (exportRecord == null) return;
exportRecord.Status = ExportStatus.Processing;
exportRecord.StartedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync();
// 通知前端
await _hub.Clients.User(exportRecord.UserId)
.SendAsync("ExportProgress", new
{
exportId,
progress = 0,
status = "processing"
});
try
{
// 分批查询,避免大结果集内存溢出
var batchSize = 5000;
var totalCount = await GetTotalCountAsync(
dbContext, exportRecord);
var batches = (int)Math.Ceiling(
(double)totalCount / batchSize);
var filePath = Path.Combine(
Path.GetTempPath(),
$"report_{exportId}_{Guid.NewGuid():N}.xlsx");
using var package = new ExcelPackage(new FileInfo(filePath));
var worksheet = package.Workbook.Worksheets
.Add(exportRecord.ReportName);
// 写表头
WriteHeader(worksheet, exportRecord.ReportType);
var row = 2;
for (int batch = 0; batch < batches; batch++)
{
context.CancellationToken.ThrowIfCancellationRequested();
var data = await GetBatchAsync(
dbContext, exportRecord,
batch * batchSize, batchSize);
foreach (var item in data)
{
WriteRow(worksheet, row++, item);
}
var progress = (int)((double)(batch + 1) / batches * 100);
context.WriteLine($"已导出 {row - 2}/{totalCount} 行");
await _hub.Clients.User(exportRecord.UserId)
.SendAsync("ExportProgress", new
{
exportId,
progress,
status = "processing",
processed = row - 2,
total = totalCount
});
}
await package.SaveAsync();
// 上传到文件存储
var downloadUrl = await _fileStorage.UploadAsync(
filePath, $"reports/{exportRecord.Id}.xlsx");
exportRecord.Status = ExportStatus.Completed;
exportRecord.CompletedAt = DateTime.UtcNow;
exportRecord.DownloadUrl = downloadUrl;
exportRecord.FileSize = new FileInfo(filePath).Length;
await dbContext.SaveChangesAsync();
await _hub.Clients.User(exportRecord.UserId)
.SendAsync("ExportCompleted", new
{
exportId,
progress = 100,
downloadUrl,
status = "completed"
});
// 清理临时文件
File.Delete(filePath);
}
catch (OperationCanceledException)
{
exportRecord.Status = ExportStatus.Cancelled;
await dbContext.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "报表导出失败: {ExportId}", exportId);
exportRecord.Status = ExportStatus.Failed;
exportRecord.ErrorMessage = ex.Message;
await dbContext.SaveChangesAsync();
await _hub.Clients.User(exportRecord.UserId)
.SendAsync("ExportFailed", new
{
exportId,
status = "failed",
error = ex.Message
});
throw; // 让 Hangfire 知道失败,可以重试
}
}
}
10.3 API 接口
csharp
[HttpPost("export")]
public async Task<IActionResult> Export(
[FromBody] ExportRequest request,
[FromServices] IBackgroundJobClient backgroundJobs,
CancellationToken ct)
{
var exportRecord = new ReportExport
{
Id = Guid.NewGuid(),
UserId = User.GetUserId(),
ReportType = request.ReportType,
Parameters = JsonSerializer.Serialize(request.Parameters),
Status = ExportStatus.Pending,
CreatedAt = DateTime.UtcNow
};
await _dbContext.ReportExports.AddAsync(exportRecord, ct);
await _dbContext.SaveChangesAsync(ct);
var jobId = backgroundJobs.Enqueue<ReportExportJob>(job =>
job.ExportAsync(exportRecord.Id, null!));
exportRecord.JobId = jobId;
await _dbContext.SaveChangesAsync(ct);
return Accepted(new
{
exportId = exportRecord.Id,
jobId,
status = "pending",
message = "报表正在后台生成,请稍候..."
});
}
[HttpGet("export/{id:guid}/status")]
public async Task<IActionResult> GetStatus(Guid id, CancellationToken ct)
{
var record = await _dbContext.ReportExports
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == id, ct);
if (record == null) return NotFound();
return Ok(new
{
record.Id,
record.Status,
record.Progress,
record.DownloadUrl,
record.ErrorMessage,
record.CreatedAt,
record.CompletedAt
});
}
11. PMS 场景实战:船岸同步调度器
11.1 场景分析
船端PMS需要定期将本地数据同步到岸端:
- 设备状态变更实时上报(高优先级)
- 库存数据每15分钟同步(中优先级)
- 日志数据每天同步一次(低优先级)
- 网络不可用时自动等待,恢复后自动重试
- 同步失败需要指数退避重试
11.2 实现
csharp
// 船端同步调度器
public class SyncScheduler : BackgroundService
{
private readonly IBackgroundJobClient _backgroundJobs;
private readonly IRecurringJobManager _recurringJobs;
private readonly INetworkMonitor _networkMonitor;
private readonly ILogger<SyncScheduler> _logger;
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
// 实时变更:事件触发,立即入队
_recurringJobs.AddOrUpdate<SyncJob>(
"sync-realtime-changes",
job => job.SyncRealtimeChangesAsync(
CancellationToken.None),
"*/2 * * * *", // 每2分钟检查一次待同步变更
TimeZoneInfo.Local);
// 库存同步:每15分钟
_recurringJobs.AddOrUpdate<SyncJob>(
"sync-inventory",
job => job.SyncInventoryAsync(CancellationToken.None),
"*/15 * * * *",
TimeZoneInfo.Local);
// 日志同步:每天凌晨3点(低峰期)
_recurringJobs.AddOrUpdate<SyncJob>(
"sync-logs",
job => job.SyncLogsAsync(CancellationToken.None),
"0 3 * * *",
TimeZoneInfo.Local,
queue: "low-priority");
// 网络状态监控
_ = MonitorNetworkAsync(stoppingToken);
return Task.CompletedTask;
}
private async Task MonitorNetworkAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (await _networkMonitor.IsShoreReachableAsync())
{
// 网络恢复:触发一次全量同步
_backgroundJobs.Enqueue<SyncJob>(job =>
job.SyncPendingChangesAsync(CancellationToken.None));
}
await Task.Delay(TimeSpan.FromSeconds(30), ct);
}
}
}
// 同步任务
public class SyncJob
{
private readonly ILocalDataQueue _localQueue;
private readonly IShoreSyncClient _shoreClient;
private readonly ILogger<SyncJob> _logger;
[AutomaticRetry(Attempts = 5,
DelaysInSeconds = new[] { 30, 60, 180, 600, 1800 })]
public async Task SyncRealtimeChangesAsync(CancellationToken ct)
{
var pending = await _localQueue
.GetPendingChangesAsync(batchSize: 100, ct);
if (!pending.Any()) return;
var synced = 0;
foreach (var change in pending)
{
ct.ThrowIfCancellationRequested();
try
{
await _shoreClient.UploadChangeAsync(change, ct);
await _localQueue.MarkSyncedAsync(change.Id);
synced++;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex,
"同步失败,将由Hangfire重试: {ChangeId}", change.Id);
throw; // 触发重试
}
}
_logger.LogInformation("实时同步完成: {Count} 条", synced);
}
public async Task SyncInventoryAsync(CancellationToken ct)
{
var snapshot = await _localQueue.GetInventorySnapshotAsync(ct);
await _shoreClient.UploadInventoryAsync(snapshot, ct);
}
public async Task SyncLogsAsync(CancellationToken ct)
{
// 压缩后上传,节省卫星带宽
var logs = await _localQueue.GetLogBatchAsync(
DateTime.Today.AddDays(-1), ct);
if (!logs.Any()) return;
var compressed = CompressLogs(logs);
await _shoreClient.UploadLogsCompressedAsync(compressed, ct);
}
}
12. PMS 场景实战:定时维保提醒
csharp
// 维保提醒服务:每天早上8点检查即将到期的维保任务
public class MaintenanceReminderJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHubContext<NotificationHub> _hub;
private readonly IEmailService _email;
private readonly ILogger<MaintenanceReminderJob> _logger;
public async Task CheckUpcomingMaintenanceAsync(PerformContext context)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider
.GetRequiredService<PmsDbContext>();
var today = DateTime.UtcNow.Date;
var warningDate = today.AddDays(7);
var overdueDate = today;
// 查询7天内到期和已逾期的维保计划
var upcomingPlans = await dbContext.MaintenancePlans
.AsNoTracking()
.Include(p => p.Equipment)
.Where(p => p.IsActive
&& p.NextDueDate <= warningDate
&& p.Status != MaintenanceStatus.Completed)
.ToListAsync();
context.WriteLine($"找到 {upcomingPlans.Count} 条待提醒维保计划");
var groupedByShip = upcomingPlans
.GroupBy(p => p.Equipment.ShipId);
foreach (var group in groupedByShip)
{
var shipId = group.Key;
var shipName = group.First().Equipment.ShipName;
var overdue = group
.Where(p => p.NextDueDate < overdueDate)
.ToList();
var upcoming = group
.Where(p => p.NextDueDate >= overdueDate)
.ToList();
// 实时通知
await _hub.Clients
.Group($"ship-{shipId}")
.SendAsync("MaintenanceReminder", new
{
shipId,
shipName,
overdueCount = overdue.Count,
upcomingCount = upcoming.Count,
overdueItems = overdue.Select(p => new
{
p.Id,
p.Equipment.EquipmentName,
p.MaintenanceType,
p.NextDueDate,
daysOverdue = (today - p.NextDueDate).Days
}),
upcomingItems = upcoming.Select(p => new
{
p.Id,
p.Equipment.EquipmentName,
p.MaintenanceType,
p.NextDueDate,
daysRemaining = (p.NextDueDate - today).Days
})
});
// 逾期项发送邮件给轮机长
if (overdue.Any())
{
var chiefEngineer = await dbContext.Users
.FirstOrDefaultAsync(u =>
u.ShipId == shipId &&
u.Role == "ChiefEngineer");
if (chiefEngineer != null)
{
await _email.SendMaintenanceOverdueAsync(
chiefEngineer.Email,
shipName,
overdue);
}
}
context.WriteLine(
$"船舶 {shipName}: 逾期 {overdue.Count} 项, " +
$"即将到期 {upcoming.Count} 项");
}
}
}
// 注册为每天早上8点执行(船端本地时间)
RecurringJob.AddOrUpdate<MaintenanceReminderJob>(
"maintenance-reminder-daily",
job => job.CheckUpcomingMaintenanceAsync(null!),
"0 8 * * *",
TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));
13. 性能调优与生产部署
13.1 Worker 数量
csharp
builder.Services.AddHangfireServer(options =>
{
// CPU 密集型:ProcessorCount
// IO 密集型(API调用/数据库):ProcessorCount * 2~4
options.WorkerCount = Environment.ProcessorCount * 3;
// 队列优先级顺序:先 critical,再 default,最后 batch
options.Queues = new[] { "critical", "default", "batch" };
// 停止时等待 Job 完成的时间
options.StopTimeout = TimeSpan.FromMinutes(5);
// 关闭时不再接收新 Job
options.ShutdownTimeout = TimeSpan.FromMinutes(1);
});
13.2 数据库优化
csharp
config.UseSqlServerStorage(connectionString,
new SqlServerStorageOptions
{
// 推荐隔离级别,减少锁
UseRecommendedIsolationLevel = true,
// 禁用全局锁(需要数据库支持)
DisableGlobalLocks = true,
// 命令批处理超时
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
// Worker 获取 Job 后不可见时间
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
// 队列轮询间隔
QueuePollInterval = TimeSpan.FromSeconds(15),
// Job 过期时间(成功的 Job 保留多久)
JobExpirationCheckInterval = TimeSpan.FromHours(1),
// 自动清理旧 Job
CountersAggregateInterval = TimeSpan.FromMinutes(5)
});
13.3 定期清理
csharp
// 每天凌晨4点清理30天前的成功Job
RecurringJob.AddOrUpdate(
"hangfire-cleanup",
() => CleanupOldJobs(),
"0 4 * * *");
public void CleanupOldJobs()
{
var cutoff = DateTime.UtcNow.AddDays(-30);
var storage = JobStorage.Current;
using var connection = storage.GetConnection();
// Hangfire 会自动按 JobExpirationCheckInterval 清理
// 但可以手动删除特别老的记录
}
13.4 容器化部署
dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# ... build ...
FROM base AS final
WORKDIR /app
COPY --from=build /app/out .
# Hangfire Server 模式(不暴露HTTP端口)
ENV ASPNETCORE_URLS=""
ENTRYPOINT ["dotnet", "Pms.Worker.dll"]
K8s 部署建议:
- API 进程:启用 Dashboard + Server
- 独立 Worker 进程:只运行 Hangfire Server,不暴露端口
- 多副本:至少2个 Worker 副本保证高可用
- HPA:根据队列长度自动扩缩容
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pms-hangfire-worker
spec:
replicas: 2
template:
spec:
containers:
- name: worker
image: pms-worker:latest
env:
- name: Hangfire__WorkerCount
value: "8"
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 2Gi
livenessProbe:
exec:
command: ["dotnet", "healthcheck.dll"]
initialDelaySeconds: 30
periodSeconds: 60
13.5 队列长度告警
csharp
// 监控队列长度,超过阈值告警
RecurringJob.AddOrUpdate(
"queue-length-monitor",
() => MonitorQueueLength(),
"*/5 * * * *");
public void MonitorQueueLength()
{
var api = JobStorage.Current.GetMonitoringApi();
var queues = api.Queues();
foreach (var queue in queues)
{
if (queue.Length > 100)
{
// 队列积压,告警
Log.Warning("Hangfire队列 {Queue} 积压: {Count} 个任务",
queue.Name, queue.Length);
}
}
}
14. 测试策略
14.1 Job 逻辑单元测试
Job 本身只是普通类,可以直接单元测试:
csharp
public class ReportExportJobTests
{
private readonly Mock<IServiceScopeFactory> _scopeFactory;
private readonly Mock<IFileStorageService> _fileStorage;
private readonly Mock<IHubContext<ReportHub>> _hub;
private readonly ReportExportJob _job;
[Fact]
public async Task Export_WithValidData_CompletesSuccessfully()
{
// Arrange
var scope = new Mock<IServiceScope>();
var dbContext = CreateTestDbContext();
scope.Setup(s => s.ServiceProvider
.GetService(typeof(PmsDbContext)))
.Returns(dbContext);
_scopeFactory.Setup(f => f.CreateScope())
.Returns(scope.Object);
// 插入测试数据
await dbContext.ReportExports.AddAsync(new ReportExport
{
Id = Guid.NewGuid(),
ReportType = "SparePartInventory",
UserId = "test-user",
Status = ExportStatus.Pending
});
await dbContext.SaveChangesAsync();
// Act
await _job.ExportAsync(exportId, MockPerformContext());
// Assert
var record = await dbContext.ReportExports
.FindAsync(exportId);
record.Status.Should().Be(ExportStatus.Completed);
record.DownloadUrl.Should().NotBeNullOrEmpty();
}
}
14.2 集成测试:验证 Job 入队
csharp
public class ApplyApprovalIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Approve_EnqueuesNotificationJob()
{
var client = _factory.CreateClient();
// 审批操作
var response = await client.PostAsJsonAsync(
$"/api/applies/{applyId}/approve",
new { level = 1, opinion = "同意" });
response.EnsureSuccessStatusCode();
// 验证 Job 已创建(通过 JobStorage 检查)
var monitor = JobStorage.Current.GetMonitoringApi();
var enqueued = monitor.EnqueuedJobs("default", 0, 10);
enqueued.Should().Contain(j =>
j.Value.Job.Method.Name
.Contains("NotifyApprovalAsync"));
}
}
15. 常见陷阱与最佳实践
陷阱1:Job 方法捕获局部变量
csharp
// ❌ 错误:闭包捕获 DbContext
foreach (var item in items)
{
BackgroundJob.Enqueue(() => ProcessItem(item.Id, dbContext));
// dbContext 在 Job 执行时可能已被释放!
}
// ✅ 正确:只传 ID,Job 内部创建 Scope
foreach (var item in items)
{
var itemId = item.Id; // 必须创建副本
BackgroundJob.Enqueue<ItemProcessor>(p =>
p.ProcessAsync(itemId, CancellationToken.None));
}
陷阱2:foreach 中的闭包陷阱
csharp
// ❌ 错误:所有 Job 拿到的都是最后一个值
foreach (var shipId in shipIds)
{
BackgroundJob.Enqueue(() => Sync(shipId));
}
// ✅ 正确:创建局部副本
foreach (var id in shipIds)
{
var shipId = id;
BackgroundJob.Enqueue(() => Sync(shipId));
}
陷阱3:Job 太大或执行时间过长
单个 Job 执行时间建议控制在 30分钟以内。超过的应该拆分为多个小 Job。
陷阱4:在 Job 中使用 DateTime.Now
使用 DateTime.UtcNow 或注入 IClock,避免时区问题。周期任务的 Cron 表达式指定时区。
陷阱5:Job 参数过大
Hangfire 将参数序列化到数据库。参数应该是简单类型(ID、字符串),不要传大对象:
csharp
// ❌ 传大对象
BackgroundJob.Enqueue<ReportJob>(j =>
j.GenerateAsync(largeDataTable, CancellationToken.None));
// ✅ 传 ID,Job 内部加载
BackgroundJob.Enqueue<ReportJob>(j =>
j.GenerateAsync(reportId, CancellationToken.None));
陷阱6:忽略 CancellationToken
长时间运行的 Job 必须检查 PerformContext.CancellationToken,以便在 Server 关闭时优雅退出。
陷阱7:Job 不是事务的
Job 执行和状态更新不在同一事务中。Job 内的数据库操作需要自己管理事务。如果 Job 部分成功后崩溃,重试可能导致重复操作。必须保证幂等。
陷阱8:RecurringJob 不会自动在多个实例间只执行一次
Hangfire 使用分布式锁保证 RecurringJob 在多实例环境中只触发一次,但要确保所有实例使用相同的 Storage 和相同的 JobId。
最佳实践
- Job 方法参数只传简单类型/ID,不传大对象
- Job 内部创建 DI Scope,不依赖外部 Scope
- 长时间 Job 检查 CancellationToken
- Job 逻辑必须幂等,重试不会产生副作用
- 使用队列区分优先级:critical/default/batch
- 合理设置重试次数,网络异常重试,业务异常不重试
- 配置独立数据库,不和业务库混用
- 生产环境启用 Dashboard 认证
- 监控队列长度和失败率,设置告警
- 定期清理历史 Job,避免数据库膨胀
- Worker 数量根据负载调整,IO密集型可多一些
- 多实例部署保证高可用,至少2个 Worker
- 周期任务指定时区,避免 UTC 偏差
- 用 ContinueJobWith 编排工作流,不要在一个 Job 中做所有事
- Job 中用 try-catch 区分可重试和不可重试异常
- foreach 中创建局部变量副本,避免闭包陷阱
- 船端使用 SQLite 存储 + 本地 Worker,岸端使用 PostgreSQL/Redis
- Job 失败不影响主流程,但要可观测可告警
16. Checklist
- 安装 Hangfire.AspNetCore 和对应存储包
- 在 Program.cs 中 AddHangfire + AddHangfireServer
- 配置独立数据库连接
- Dashboard 启用认证授权
- 配置合理的 WorkerCount 和 Queues
- 全局/Job 级别配置重试策略
- Job 方法参数只传 ID 和简单类型
- Job 内部使用 CreateScope 获取 Scoped 服务
- 长时间 Job 检查 CancellationToken
- Job 逻辑保证幂等
- 网络异常自动重试,业务异常不重试
- 周期任务指定时区
- 关键任务失败有告警通知
- 监控队列长度,设置积压告警
- 配置定期清理历史 Job
- 生产环境多实例部署
- foreach 闭包变量使用局部副本
- 报表等长任务有进度通知(SignalR)
- 船端使用 SQLite 存储
- 容器化部署配置资源限制和健康检查