- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
本篇技术指南以 Quartz.NET(Quartz Enterprise Scheduler .NET)官方文档 one-off-job.md 为骨架,系统讲解如何用 Quartz.NET 调度"只运行一次"的任务:先注册、后按需触发(TriggerJob),以及运行时动态创建"作业 + 触发器"即刻或定时触发。文中会结合仓库内 IScheduler.cs、QuartzScheduler.cs、SchedulerJobExtensions.cs 等核心实现与单元测试,帮助你理解每种方式的底层行为、适用场景与注意事项,并给出可直接复制运行、可对照源码验证的 C# 代码。
什么是 One-Off Job
Quartz.NET 中的 One-Off Job(一次性作业)指只执行一次、执行后不再重复的任务。它没有 cron 表达式,也没有固定重复次数,通常用于:
- 用户点击"立即运行"按钮后立刻执行的异步操作;
- 延迟到某个时间点执行的提醒、通知、超时清理;
- 在消息处理流程中安排"下一个步骤"(如续作、重试、超时补偿);
- 数据导入、报表生成等一次性批处理。
官方文档给出的一次性作业默认行为是:Misfire Mode(失火策略)为 Smart。对于只触发一次的 SimpleTrigger,SmartPolicy会被解析为FireNow(立即补火),也就是说——如果调度器在任务应触发时处于停机状态,错过的那一次触发会在调度器恢复后立即执行,而不是被丢弃。这一行为在 SimpleTriggerMisfireInstruction.cs 中有明确的注释依据:"Intended for one-shot (non-repeating) triggers"。
方式一:提前注册作业(Ahead of Time),之后按需触发
适用场景:作业集合相对固定,你希望在应用启动时就把它们注册到调度器里,之后在任意时刻(例如某个 API 被调用时)触发它们运行。
注册一个"休眠"的持久作业
public async Task DoSomething(IScheduler scheduler, CancellationToken ct) { var job = JobBuilder.Create<AnExampleJob>() .WithIdentity("name", "group") .Build(); var replace = true; var durable = true; await scheduler.AddJob(job, replace, durable, ct); }这段代码对应的接口签名为IScheduler.AddJob(IJobDetail jobDetail, bool replace, bool durable, CancellationToken ct)(3.x 重载,另见 IScheduler.cs 中 4.x 的AddJob(IJobDetail, AddJobOptions, CancellationToken)):
durable = true(持久):作业即使没有关联任何触发器也会一直保存在 JobStore 中,保持"休眠"状态,直到它被调度或手动触发。非持久作业在没有触发器时会被自动删除。replace = true:允许用同名(name + group)作业覆盖已经存储的旧作业。若replace = false而键已存在,会抛出ObjectAlreadyExistsException。
从源码看,AddJob在 QuartzScheduler.cs 的实现注释明确说明:"The job will be 'dormant' until it is scheduled with a trigger, orTriggerJobis called for it",且作业必须是 durable,除非设置了AddJobOptions.StoreNonDurableWhileAwaitingScheduling,否则会抛出SchedulerException。
用 TriggerJob 立即触发
注册之后,在任意位置通过JobKey找到并触发它:
public async Task DoSomething(IScheduler scheduler, CancellationToken ct) { await scheduler.TriggerJob(new JobKey("name", "group"), ct); }TriggerJob只会让作业立即执行一次,并且"不留任何痕迹"——它内部创建一个只触发一次(repeatCount = 0)的临时 SimpleTrigger,触发完成后即被移除,不会在调度器里留下周期性调度。底层实现见 QuartzScheduler.cs:该方法用NewTriggerId()生成触发器键,ComputeFirstFireTimeUtc计算触发时间后写入 JobStore,再通过NotifySchedulerThread唤醒调度线程去取触发。
给本次触发携带 JobDataMap
TriggerJob的重载允许传入一个JobDataMap,这些数据只对本次触发有效,并会与作业自身的 JobDataMap 合并:
public async Task DoSomething(IScheduler scheduler, CancellationToken ct) { var jobData = new JobDataMap(); await scheduler.TriggerJob(new JobKey("name", "group"), jobData, ct); }// 更常见的用法:把业务参数放进本次触发 var jobData = new JobDataMap { { "CustomerId", "cust-1234" } }; await scheduler.TriggerJob(new JobKey("name", "group"), jobData, ct);从 QuartzScheduler.cs 的实现可以看到,传入的data会被直接挂到临时触发器的JobDataMap上,并且无论是否传数据,都会调用PrepareTriggerData对数据做统一序列化预处理——保证数据以字符串形式进入 JobStore,实现跨存储、跨序列化的可移植性。
小结:方式一的取舍
| 维度 | 说明 |
|---|---|
| 适合场景 | 作业类型固定、需要从多处按需触发、触发时携带动态参数 |
| 作业生命周期 | 持久(durable),即使无触发器也保留 |
| 触发成本 | 每次TriggerJob内部创建一个一次性触发器,触发后即焚 |
| 前提 | 作业必须 durable(或使用StoreNonDurableWhileAwaitingScheduling) |
方式二:动态注册(Dynamic Registration),当场创建作业与触发器
适用场景:作业集合是动态的——你事先不知道会有哪些作业、何时触发,需要在运行时"现场"创建作业和触发器并立刻调度。
public async Task DoSomething(IScheduler scheduler, CancellationToken ct) { var job = JobBuilder.Create<AnExampleJob>() .WithIdentity("name", "group") .Build(); var trigger = TriggerBuilder.Create() .WithIdentity("name", "group") .StartNow() .Build(); await scheduler.ScheduleJob(job, trigger, ct); }这段代码等价于下面这种显式声明简单调度器的写法(文档明确说明 "The above is the same as"):
public async Task DoSomething(IScheduler scheduler, CancellationToken ct) { var job = JobBuilder.Create<AnExampleJob>() .WithIdentity("name", "group") .Build(); var trigger = TriggerBuilder.Create() .WithIdentity("name", "group") .WithSimpleSchedule() .StartNow() .Build(); await scheduler.ScheduleJob(job, trigger, ct); }关键点:
- 不调用
WithSimpleSchedule()时,TriggerBuilder 默认构建的其实就是"无重复、到点触发一次"的 SimpleTrigger;显式加一个空的.WithSimpleSchedule()只是为了后续能配置 repeat 次数、间隔或 misfire 指令,二者行为一致。 .StartNow()表示"从现在起尽快触发"(由调度线程的轮询节奏决定,通常是毫秒级延迟)。若想指定时间点,改用.StartAt(DateTimeOffset)。- 由于作业与触发器一起通过
ScheduleJob(job, trigger)存储,不需要 durable:两者会在触发器完成使命(触发一次且无剩余工作)后一起被自动移除。
从接口签名看,ScheduleJob(IJobDetail, ITrigger)返回ValueTask<DateTimeOffset>(首次触发时间),并支持ScheduleJobOptions(见 IScheduler.cs):默认Replace = false,即键已存在时抛ObjectAlreadyExistsException;置Replace = true则可实现"一次调用、单锁内原子替换",无需自己编排CheckExists→UnscheduleJob→ScheduleJob三步(三步走既慢又存在与其他节点竞争竞态的风险)。相关语义在 ScheduleJobOptions.cs 中有完整注释。
小结:方式二的取舍
| 维度 | 说明 |
|---|---|
| 适合场景 | 作业+触发器都动态生成,例如用户自定义的一次性任务 |
| 作业生命周期 | 非持久,触发器执行完后一起被移除 |
| 触发方式 | StartNow()立即 /StartAt()定时 |
| 副作用 | 若键冲突默认抛异常,可用ScheduleJobOptions.Replace原子替换 |
失火(Misfire)行为:One-Off 任务的"补火"语义
文档开头以:::tip形式强调:One-Off Job 的失火模式是Smart。这里结合 SimpleTriggerMisfireInstruction.cs 把语义讲透:
SmartPolicy(默认值):由调度器根据触发器的 repeat 次数和间隔自行选择策略。对一次性(非重复)触发器,解析结果等价于FireNow——错过即补。FireNow:立即补火。官方注释明确"Intended for one-shot (non-repeating) triggers",用于一次性触发器最合适。IgnoreMisfires:不把错过的触发视为失火,尽快触发并继续,仿佛按时发生。- 其他策略(如
NowWithRemainingCount、NextWithRemainingCount等)主要面向重复触发器,一次性任务用不到。
实际效果:如果调度器在触发时刻处于停机/待机状态,恢复后该一次性任务会被补执行,而不是被静默丢弃。若希望"错过就算了",可以显式配置合适的 misfire 指令,例如:
var trigger = TriggerBuilder.Create() .WithIdentity("name", "group") .StartNow() .WithSimpleSchedule(x => x .WithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow)) .Build();想深入了解 SimpleTrigger 各失火策略的完整定义,可继续阅读仓库文档 SimpleTriggers(若使用 4.x 版本则见 simpletriggers.md)。
一次性任务的进阶形态(4.x 单行 API,源码级解读)
3.x 文档聚焦前两种基础方式;当前仓库的 4.x 版本在 one-off-job.md 中对同一主题做了大幅演进,出现了"一个载荷 + 一个时间,一次调用"的强类型单行 API。虽然本指南以 3.x 文档为主体,这里结合源码做必要的纵向补充,方便迁移读者对照。
ScheduleJob<TJob, TInput>单行调度
public sealed record SendInvoice(string CustomerId, decimal Amount); public sealed class SendInvoiceJob : IJob<SendInvoice> { public ValueTask Execute(IJobExecutionContext context, SendInvoice input, CancellationToken cancellationToken = default) { // input.CustomerId, input.Amount return default; } } public async ValueTask Remind(IScheduler scheduler, ILogger logger, SendInvoice invoice, CancellationToken cancellationToken) { ScheduledOneOffJob firing = await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>( invoice, TimeSpan.FromDays(7), OneOffJobOptions.Replacing($"invoice-{invoice.CustomerId}") with { Group = invoice.CustomerId }, cancellationToken); logger.LogInformation("Reminder {Trigger} scheduled for {At}", firing.TriggerKey, firing.FirstFireTimeUtc); // 取消本次触发 await scheduler.UnscheduleJob(firing.TriggerKey, cancellationToken); }该 API 的实现位于 SchedulerJobExtensions.cs,其设计模型是"每个作业类型一个持久作业,每次调用一个触发器":
- 作业以
SchedulerConstants.ScheduledJobKey<TJob>()(即(typeof(TJob).Name, "QRTZ_SCHEDULED"),见 SchedulerConstants.cs)在第一次调用时以AddJobOptions.Replacing幂等存储一次,并通过ConcurrentDictionary按调度器实例记忆,后续调用省去一次往返——这在集群中多节点同时调用也是安全的。 - 返回的
ScheduledOneOffJob(见 ScheduledOneOffJob.cs)包含TriggerKey(取消/替换的句柄)与FirstFireTimeUtc(存储计算出的首次触发时间,与直接ScheduleJob(ITrigger)的返回值一致)。 OneOffJobOptions(见 OneOffJobOptions.cs)承载触发器设置:Name(默认生成的 GUID)、Group(默认取作业类型名)、Description、Priority、ExecutionGroup、MisfireInstruction、Replace与RequestRecovery。其中Group默认是作业类型名而不是TriggerKey.DefaultGroup——如果既有代码用new TriggerKey(id)取消(它指向默认组),会对不上;迁移时务必显式设置Group = TriggerKey.DefaultGroup。- 支持
DateTimeOffset(定时)、TimeSpan(从现在延迟)、Continuation.After(...)(等待另一次触发完成后再执行,即作业续作)三类时间参数,最后一个配合 Continuation 使用。 - 若作业在单行调度前已存在,而你想给同一个作业挂上自己的重复调度(如 cron),可以指向
SchedulerConstants.ScheduledJobKey<TJob>(),而不是在保留组里再建一个作业。
批量取消:按组撤销整条业务关联
public async Task<int> CustomerWentAway(IScheduler scheduler, string customerId, CancellationToken cancellationToken) { List<TriggerKey> calledOff = await scheduler.UnscheduleJobs( GroupMatcher<TriggerKey>.GroupEquals(customerId), cancellationToken); return calledOff.Count; // 返回实际撤销的数量 }UnscheduleJobs(GroupMatcher<TriggerKey>)在存储层锁内一次性移除匹配组的所有触发器并返回被移除的键,期间其他节点刚加进来的触发也会一并移除。注意:不要用DeleteJobs(GroupMatcher<JobKey>)来"取消整条关联",因为单行 API 的持久作业是所有同类型触发共享的。
源码证据与测试验证
仓库中与本主题直接对应的实现与测试,可以作为你继续深入阅读的入口:
- 接口契约:
IScheduler.AddJob / TriggerJob / ScheduleJob及完整异常语义,见 IScheduler.cs。 - 核心实现:
TriggerJob的一次性触发器构造、AddJob的 dormant 语义、PrepareTriggerData的输入序列化,见 QuartzScheduler.cs。 - 单行 API:
ScheduleJob<TJob, TInput>的 EnsureJob/建触发器/替换逻辑,见 SchedulerJobExtensions.cs。 - 选项类型:
AddJobOptions(AddJobOptions.cs)、ScheduleJobOptions(ScheduleJobOptions.cs)、OneOffJobOptions(OneOffJobOptions.cs)、返回类型 ScheduledOneOffJob.cs。 - 单元测试:
TheOneLinerStoresOneDurableJobAndOneTriggerPerCall、TheOneLinerReplacesAFiringOfTheSameName等,见 SchedulerTest.cs;"一个持久作业挂数千触发器"的规模与持久性语义测试见 RAMJobStoreOneOffScaleTest.cs。 - 可直接运行的示例代码:文档配套样本 OneOffJobSamples.cs;Wolverine 集成示例见 Part2OneOffFromHandler.cs 与 README.md。
总结与选择建议
- 作业固定、多处按需触发→ 方式一:启动时
AddJob(job, replace: true, durable: true),运行时TriggerJob(jobKey, data)。 - 作业动态、即刻/定时触发一次→ 方式二:
JobBuilder.Create<T>() + TriggerBuilder.Create().StartNow()/StartAt(...),再ScheduleJob(job, trigger)。 - 强类型载荷 + 延迟 + 可取消/可替换→ 4.x 单行 API:
ScheduleJob<TJob, TInput>(input, delay, OneOffJobOptions),用返回的TriggerKey取消。 - 错过补火语义:One-Off 默认 Smart → 对一次性触发等价于 FireNow,调度器恢复后补执行;需要"错过即弃"请显式配置 misfire 指令。
无论选择哪种方式,都建议基于 IScheduler.cs 的接口契约与 QuartzScheduler.cs 的实现确认异常与生命周期语义,再结合 OneOffJobSamples.cs 的样例代码快速落地。
- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
相关推荐
Quartz.NET 任务重调度完全指南:从 JobExecutionException 到自调度/自取消的四种实战方案
Quartz.NET 任务重调度完全指南:从 JobExecutionException 到自调度/自取消的四种实战方案 当一次任务执行失败、被限流或需要延后处
任务调度后端KubeVela task 组件类型实战:用 Application 声明式调度一次性 Job 任务
KubeVela task 组件类型实战:用 Application 声明式调度一次性 Job 任务 在 KubeVela 中, task 是内置的组件类型之一
云原生DevOps运维微服务Quartz.NET 任务调度实战:任务重调度策略详解
Quartz.NET 任务调度实战:任务重调度策略详解 前言 在分布式系统和后台任务处理中,任务调度是一个核心组件。Quartz.NET作为.NET平台下功能强
任务调度后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考