从Quartz.NET迁移到Hangfire后,如何获取周期性作业的计划执行时间?
我刚好处理过从Quartz.NET迁移到Hangfire的类似场景,针对你提出的两个问题,给你整理了可行的解决方案:
获取周期性作业的计划触发时间
Hangfire确实没有像Quartz的IJobExecutionContext.ScheduledFireTimeUtc那样直接暴露计划触发时间的属性,但我们可以通过两种方式来实现这个需求:
方案1:用JobFilter提前注入计划时间
自定义一个状态过滤器,在作业被调度进入队列时,计算出它的计划触发时间并存入JobData,这样在作业执行时就能直接读取。
先写过滤器代码:
public class ScheduledTimeFilter : IApplyStateFilter { public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction) { // 只处理TestJob类型的周期性作业进入队列的情况 if (context.BackgroundJob.Job.Type == typeof(TestJob) && context.NewState is EnqueuedState && !string.IsNullOrEmpty(context.RecurringJobId)) { var recurringJob = context.Connection.GetRecurringJob(context.RecurringJobId); if (recurringJob == null) return; // 解析Cron表达式,计算本次应该触发的计划时间 var cronExpression = CronExpression.Parse(recurringJob.Cron); // 以上次执行时间为起点,获取下一个调度时间(就是本次的计划时间) var scheduledTime = cronExpression.GetNextOccurrence( recurringJob.LastExecution ?? DateTime.UtcNow.AddDays(-1) ); if (scheduledTime != null) { // 把计划时间存入Job参数,用ISO格式避免序列化问题 transaction.SetJobParameter( context.BackgroundJob.Id, "ScheduledFireTimeUtc", scheduledTime.Value.ToString("o") ); } } } public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction) { // 这里不需要处理任何逻辑 } }
然后在ABP模块的ConfigureServices里注册这个全局过滤器:
services.AddHangfire(config => { // 你的其他Hangfire配置(比如存储、仪表盘等) config.GlobalJobFilters.Add(new ScheduledTimeFilter()); });
接下来修改你的TestJob,读取这个计划时间:
public override void Execute(PerformContext context) { var scheduledTimeStr = context.Connection.GetJobParameter( context.BackgroundJob.Id, "ScheduledFireTimeUtc" ); if (DateTimeOffset.TryParse(scheduledTimeStr, out var scheduledTime)) { // 转换为你需要的时区时间,比如本地时间 var localScheduledTime = scheduledTime.LocalDateTime; // 在这里使用localScheduledTime处理业务逻辑 } }
方案2:在Job内部直接计算计划时间
如果不想用过滤器,也可以在Job执行时,通过RecurringJob的ID获取调度规则,反向计算计划时间。不过这种方法要注意,当作业是重试或者延迟执行时,计算结果可能有偏差,但正常的周期性作业是没问题的:
public override void Execute(PerformContext context) { var recurringJobId = context.BackgroundJob.RecurringJobId; if (string.IsNullOrEmpty(recurringJobId)) return; var recurringJob = context.Connection.GetRecurringJob(recurringJobId); if (recurringJob == null) return; var cronExpression = CronExpression.Parse(recurringJob.Cron); // 计算上次执行后的下一个调度时间,也就是本次的计划触发时间 var scheduledTime = cronExpression.GetNextOccurrence( recurringJob.LastExecution ?? DateTime.UtcNow.AddDays(-1) ); if (scheduledTime != null) { // 使用这个计划时间处理业务 } }
处理宕机后错过的任务补跑
Hangfire默认的RecurringJob不会自动补跑错过的任务——它只会计算下一次执行时间,不会回溯之前的周期。要实现类似Quartz的补跑功能,需要自定义一套逻辑:
核心思路
- 记录每个周期性作业的上次执行时间:用数据库表存储,确保服务重启后能读取到历史执行记录;
- 服务启动时检查补跑:遍历所有RecurringJob,计算从上次执行时间到当前时间之间的所有计划触发时间,手动触发这些任务,并把对应的计划时间作为参数传给Job;
- 修改Job接收计划时间参数:让Job直接使用传入的计划时间,避免内部计算偏差。
示例代码实现
首先,修改TestJob,让它接收计划时间作为参数:
public class TestJob : BackgroundJob<(PerformContext Context, DateTime ScheduledTime)>, ITransientDependency { private readonly IRepository<User, long> _usersRepository; public TestJob(IRepository<User, long> usersRepository) { _usersRepository = usersRepository; } public override void Execute((PerformContext Context, DateTime ScheduledTime) args) { var scheduledTime = args.ScheduledTime; // 直接使用这个scheduledTime处理业务逻辑 } }
然后,创建一个补跑服务:
public interface IRecurringJobRecoveryService { Task RecoverMissedJobsAsync(); } public class RecurringJobRecoveryService : IRecurringJobRecoveryService, ITransientDependency { private readonly IBackgroundJobClient _backgroundJobClient; private readonly IHangfireConnection _hangfireConnection; private readonly IRepository<RecurringJobExecutionRecord, string> _executionRecordRepo; public RecurringJobRecoveryService( IBackgroundJobClient backgroundJobClient, IHangfireConnection hangfireConnection, IRepository<RecurringJobExecutionRecord, string> executionRecordRepo) { _backgroundJobClient = backgroundJobClient; _hangfireConnection = hangfireConnection; _executionRecordRepo = executionRecordRepo; } public async Task RecoverMissedJobsAsync() { // 获取所有已配置的RecurringJob var recurringJobs = await _hangfireConnection.GetRecurringJobsAsync(); foreach (var job in recurringJobs) { // 获取上次执行记录,没有的话用Job的LastExecution或一个初始时间 var record = await _executionRecordRepo.FindAsync(job.Id); var lastExecutedUtc = record?.LastExecutedUtc ?? job.LastExecution ?? DateTime.UtcNow.AddMonths(-1); var currentUtc = DateTime.UtcNow; var cronExpression = CronExpression.Parse(job.Cron); var nextOccurrence = cronExpression.GetNextOccurrence(lastExecutedUtc); // 遍历所有错过的计划时间,逐个触发任务 while (nextOccurrence != null && nextOccurrence < currentUtc) { // 手动触发Job,传入计划时间(转成本地时间或者保持UTC,根据你的业务需求) _backgroundJobClient.Enqueue<TestJob>( j => j.Execute((null, nextOccurrence.Value.LocalDateTime)) ); // 更新上次执行时间,继续找下一个错过的时间 lastExecutedUtc = nextOccurrence.Value; nextOccurrence = cronExpression.GetNextOccurrence(lastExecutedUtc); } // 更新执行记录 if (record == null) { await _executionRecordRepo.InsertAsync(new RecurringJobExecutionRecord { Id = job.Id, LastExecutedUtc = lastExecutedUtc }); } else { record.LastExecutedUtc = lastExecutedUtc; await _executionRecordRepo.UpdateAsync(record); } } } } // 存储执行记录的实体类 public class RecurringJobExecutionRecord : Entity<string> { public DateTime LastExecutedUtc { get; set; } }
最后,在ABP模块的PostInitialize方法里触发补跑逻辑(用后台任务执行,避免阻塞服务启动):
public override void PostInitialize() { var recoveryService = IocManager.Resolve<IRecurringJobRecoveryService>(); // 用Hangfire自己的后台任务执行补跑,不影响服务启动 BackgroundJob.Enqueue(() => recoveryService.RecoverMissedJobsAsync()); }
注意事项
- 时区统一:所有时间计算尽量用UTC,避免时区转换带来的错误;
- 防止重复补跑:如果是多实例部署,要加分布式锁,避免多个实例同时执行补跑逻辑;
- 性能优化:如果错过的时间跨度很大或者任务很多,建议分批处理,避免一次性触发大量任务导致系统过载。
内容的提问来源于stack exchange,提问作者Giacomo De Liberali
相关产品推荐
相关产品推荐

