news 2026/10/6 1:46:45

Quartz.NET One-Off Job 实战:一次性任务的四种调度模式与源码级原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Quartz.NET One-Off Job 实战:一次性任务的四种调度模式与源码级原理
  • 任务调度
  • 后端

【免费下载链接】quartznet

Quartz Enterprise Scheduler .NET

项目地址:https://gitcode.com/gh_mirrors/qu/quartznet
点击查看免费下载

本篇技术指南以 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

项目地址:https://gitcode.com/gh_mirrors/qu/quartznet
点击查看免费下载

相关推荐

上一篇:推荐开源项目:AutoPkg - 自动化macOS软件打包神器
下一篇:LottieXamarin:让动画设计与开发无缝衔接

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 1:45:33

晶闸管从原理到实战:PN结、触发与可控整流全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:45:15

VSCode搭建C/C++开发环境:从MinGW配置到多文件工程调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:44:18

汇川SV660N伺服驱动器接线实战:从CN1到CN3完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:44:08

嵌入式DMA原理与实战:从寄存器配置到实时数据流优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:42:52

电压跟随器自激振荡原理与稳定性实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 1:42:50

高云FPGA实战:GW2A的DDR3控制器配置与LVDS接口通信详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华