- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
本文基于 Quartz 4.x 官方文档《Multiple Schedulers with Microsoft DI》整理而成。单个应用进程里往往同时存在"临时任务"与"持久化任务"、"关键业务"与"后台维护"等互不干扰的调度需求,本文讲解如何通过
AddQuartz(string name, ...)注册多个命名调度器(Named Scheduler),每个调度器拥有独立的配置、任务、触发器、监听器与日历,并通过ISchedulerRepository/ISchedulerRegistry在运行时按名称查找与枚举它们。读完你将掌握:命名调度器的注册方式与适用场景、基于 Keyed Service 的依赖注入、appsettings.json 的批量配置方法,以及每个调度器独立的启停控制。
在 Microsoft DI 容器中,AddQuartz(string name, ...)注册的是一个命名调度器。每个命名调度器都有自己的一套配置、Job、Trigger、Listener 和 Calendar,并且全部通过常规的 DI 流式 API 配置。ISchedulerRepository负责按名称跟踪已经构建出来的调度器。
::: tip 不使用 Microsoft DI 时,可以每个调度器各用一个独立的QuartzSchedulerBuilder,通过Create(q => q.ConfigureScheduler(options => options.InstanceName = ...))配置实例名,再对每个 builder 调用BuildScheduler()完成构建。从源码看,BuildScheduler定义在 src/Quartz/QuartzSchedulerBuilder.cs,是一个返回ValueTask<IScheduler>的异步构建入口;默认调度器的完整示例见 src/Quartz/README.md。 :::
什么时候应该使用命名调度器
命名调度器解决的是"一个进程内多种调度诉求并存"的问题,官方文档给出的典型场景包括:
- 不同的 Job Store:临时任务用内存存储(RAMJobStore),需要持久化的任务用数据库存储(如 AdoJobStore)。
- 工作负载隔离:关键任务与后台维护任务分别运行在独立的线程池上,互不抢占执行资源。
- 不同的配置:misfire 阈值、批量大小(batch size)或集群(clustering)设置各调度器之间可以不同。
从源码层面看,命名调度器的"独立性"是有保障的:在 src/Quartz/Configuration/QuartzServiceCollectionExtensions.cs 中,AddQuartz(string name, ...)最终进入AddQuartzScheduler(services, schedulerName: name, ...),调度器的名字同时充当其服务注册键(service key)、实例名(InstanceName)和选项名(options name),因此"它的注册与它的配置永远指向同一个对象"。
基本配置:两个调度器并存
给每次AddQuartz(string name, ...)调用一个唯一的名字即可。下面示例注册了两个调度器:FastScheduler使用内存 Job Store 跑即时通知任务,DurableScheduler使用 SQL Server 持久化存储跑报表任务,最后一次AddQuartzHostedService调用即可启动全部命名调度器:
var builder = Host.CreateApplicationBuilder(args); // First scheduler: fast in-memory jobs builder.Services.AddQuartz("FastScheduler", q => { q.UseInMemoryStore(); q.UseDefaultThreadPool(tp => tp.MaxConcurrency = 5); q.ScheduleJob<NotificationJob>(trigger => trigger .WithIdentity("notify-trigger") .WithSimpleSchedule(TimeSpan.FromSeconds(30))); }); // Second scheduler: persistent database jobs builder.Services.AddQuartz("DurableScheduler", q => { q.UsePersistentStore(s => { s.UseSqlServer(sqlServer => { sqlServer.ConnectionString = "your connection string"; }); s.UseSystemTextJsonSerializer(); }); q.ScheduleJob<ReportJob>(trigger => trigger .WithIdentity("report-trigger") .WithCronSchedule("0 0 2 * * ?")); }); // Single call starts all named schedulers builder.Services.AddQuartzHostedService(options => { options.WaitForJobsToComplete = true; }); builder.Build().Run();这段代码里,UseInMemoryStore与UsePersistentStore(配合UseSqlServer+UseSystemTextJsonSerializer)分别演示了内存与持久化两种 Job Store 的配置路径;MaxConcurrency = 5限定FastScheduler线程池并发度,UseDefaultThreadPool的完整参数说明可参考微软 DI 集成文档 microsoft-di-integration.md。
每个调度器独立的监听器与日历
在某个命名AddQuartz调用内部注册的 Listener 和 Calendar,只作用于该调度器:
builder.Services.AddQuartz("Scheduler1", q => { q.AddSchedulerListener<AuditSchedulerListener>(); q.AddJobListener<LoggingJobListener>(); q.AddTriggerListener<MetricsTriggerListener>(); q.AddCalendar<HolidayCalendar>("holidays", new AddCalendarOptions { Replace = true, UpdateTriggers = true }, cal => cal.AddExcludedDay(new DateOnly(2025, 12, 25))); // These listeners and calendars only apply to Scheduler1 }); builder.Services.AddQuartz("Scheduler2", q => { // Scheduler2 has no listeners or calendars unless explicitly added here });这里AddCalendar<HolidayCalendar>的AddCalendarOptions中,Replace = true表示已存在同名日历时覆盖,UpdateTriggers = true表示让已引用该日历的触发器立即感知日历变更。Scheduler2没有显式注册任何 Listener/Calendar,因此它是"干净"的——这正体现了命名调度器按需装配、互不污染的隔离能力。
注入一个命名调度器
调度器的名字就是它的服务键(service key),因此可以把它作为Keyed Service注入:
public class MyService { private readonly IScheduler scheduler; public MyService([FromKeyedServices("FastScheduler")] IScheduler scheduler) { this.scheduler = scheduler; } public async Task DoWork() { await scheduler.TriggerJob(new JobKey("my-job")); } }也可以直接从容器解析:
var fast = provider.GetRequiredKeyedService<IScheduler>("FastScheduler"); var standard = provider.GetRequiredService<IScheduler>(); // the default scheduler, if one is registered命名调度器的每一个部件都注册在它的键之下,例如GetRequiredKeyedService<ISchedulerFactory>("FastScheduler")。无键(unkeyed)注册则属于默认调度器。这一点在源码中非常明确:AddQuartz(name, ...)的注册流程(见 src/Quartz/Configuration/QuartzServiceCollectionExtensions.cs)将所有部件都以调度器名为键注册,而默认调度器(schedulerName == null)则使用Options.DefaultName作为选项名,组件直接落进无键槽位。
关于注入句柄的注意事项
被注入的IScheduler实际上是一个句柄(handle):由于调度器构建是异步的,而容器构造是同步的,所以该句柄会在第一次使用时才真正构建调度器。官方文档明确提醒:
- 异步成员会等待构建完成,永远安全。
Status、SchedulerInstanceId、Context和ListenerManager这些同步属性,如果读取它们会触发调度器构建,则抛出InvalidOperationException。SchedulerName从不触发任何构建。- 在
AddQuartzHostedService()之下,宿主启动完成后同步成员就是安全的:hosted service 在宿主启动期间就构建了每一个调度器(早于你的业务代码运行),之后才在ApplicationStarted事件触发时启动它们——除非关闭了AwaitApplicationStarted。
这一行为的源码依据在 src/Quartz/Hosting/QuartzHostedService.cs:启动任务分两种路径,StartSchedulers(waitForApplicationStarted: false, ...)在宿主启动阶段就完成构建,StartSchedulers(waitForApplicationStarted: true, ...)则等到ApplicationStarted之后才真正启动。
运行时按名称查找调度器
当调度器名称只在运行时才知道(例如来自仪表盘页面或某个请求参数)时,使用容器的ISchedulerRepository。它保存每一个已被构建的调度器:
public class MyService { private readonly ISchedulerRepository schedulerRepository; public MyService(ISchedulerRepository schedulerRepository) { this.schedulerRepository = schedulerRepository; } public async Task DoWork() { var scheduler = schedulerRepository.Lookup("FastScheduler"); if (scheduler != null) { await scheduler.TriggerJob(new JobKey("my-job")); } // Or every scheduler this container has built var all = schedulerRepository.LookupAll(); } }ISchedulerRepository定义在 src/Quartz/Extensibility/ISchedulerRepository.cs,接口提供了Bind、Remove、Lookup(name)、LookupByName和LookupAll。注意它还有一个按instance id消歧的维度:同名但不同实例 ID 的调度器可以共存(例如指向同一集群不同节点的远程代理),此时可在Lookup时传入instanceId参数区分。
使用ISchedulerRepository时有两点需要注意:
- 仓库中只有已构建的调度器,因此启动过程中它可能并不完整。按键注入(Keyed Injection)没有这个问题——句柄会构建它指向的那个调度器。
- 仓库是**按容器(per container)**的,不是按进程的。由独立
QuartzSchedulerBuilder构建的调度器不在其中;4.0 起不存在进程级全局调度器,详见 migration-guide.md。
如果要知道容器注册了哪些调度器(无论是否已构建),则解析ISchedulerRegistry并调用QuerySchedulers()。它针对每条注册返回一个SchedulerRegistration,外加每一个"已绑定进仓库但没有任何注册对应"的调度器。尚未创建的调度器其Status为null,并且查询不会触发创建。简言之:做"库存清单"用QuerySchedulers(),要"活跃实例"用LookupAll()。
从实现上看,ISchedulerRegistry(src/Quartz/ISchedulerRegistry.cs)与ISchedulerRepository的分工是"注册 vs 运行":前者读注册(QuerySchedulers不会构建任何东西,未解析的注册以null的SchedulerRegistration.Status呈现),后者持实例(只有别人请求过的东西才在里面)。SchedulerRegistration的记录结构(名称、来源SchedulerOrigin、状态Status、实例 ID 等)定义在 src/Quartz/SchedulerRegistration.cs。
混合使用默认调度器与命名调度器
无名的AddQuartz()与命名调度器可以共存于同一容器:
// Default scheduler (traditional single-scheduler usage) builder.Services.AddQuartz(q => { q.ScheduleJob<MainJob>(trigger => trigger .WithIdentity("main-trigger") .WithSimpleSchedule(TimeSpan.FromMinutes(1))); }); // Additional named scheduler builder.Services.AddQuartz("Auxiliary", q => { q.ScheduleJob<CleanupJob>(trigger => trigger .WithIdentity("cleanup-trigger") .WithCronSchedule("0 0 3 * * ?")); }); // Starts both the default and the named scheduler builder.Services.AddQuartzHostedService();::: tip调用顺序无关紧要。hosted service 在宿主启动时才解析调度器,因此无论AddQuartz在它之前还是之后调用,它都会启动每一个已注册的调度器。如果容器中根本没有调度器,启动时会直接报告出来,而不是静默无作为。这正是 src/Quartz/Hosting/QuartzServiceCollectionExtensions.cs 中"AddQuartzHostedService不再必须在AddQuartz之后调用才生效"的设计初衷。 :::
通过 appsettings.json 配置
传入 Quartz 配置的根 section,命名调度器的设置从Schedulers:{name}读取:
builder.AddQuartz("DurableScheduler"); // or, naming the section yourself: builder.Services.AddQuartz("DurableScheduler", builder.Configuration.GetSection("Quartz"));AddQuartzSchedulers则为Schedulers的每个子节点注册一个命名调度器:
builder.AddQuartzSchedulers(); // or: builder.Services.AddQuartzSchedulers(builder.Configuration.GetSection("Quartz"));对应的 JSON 配置:
{ "Quartz": { "Schedulers": { "DurableScheduler": { "Scheduler": { "InstanceId": "AUTO" }, "JobStore": { "Type": "Quartz.Impl.AdoJobStore.LocalTransactionJobStore, Quartz" } } } } }源码实现位于 src/Quartz/Configuration/QuartzServiceCollectionExtensions.cs:AddQuartzSchedulers先校验 section 中存在Schedulers子节点,再遍历其每个子节点,把子节点 key 作为调度器名调用AddQuartz(services, scheduler.Key, scheduler, configure)。同时它做了三处防呆校验:没有Schedulers子节点时报错提示改用AddQuartz(configuration);Schedulers与直接调度器配置并存时报错;Schedulers与顶层Schedule/Scheduling并存时报错(Job/Trigger 必须归属到具体调度器名下)。
对于没有对应类型化选项的扁平键(例如某个插件的自有设置),放进命名选项的Properties字典:
builder.Services.Configure<QuartzOptions>("DurableScheduler", options => options.Properties["quartz.plugin.myPlugin.someSetting"] = "value");注意这里Configure<QuartzOptions>("DurableScheduler", ...)的命名方式:调度器名即选项名,这条规则贯穿整个多调度器机制。
每个调度器独立的启动与关闭
AddQuartzHostedService(configure)配置的是每一个调度器;带名字的重载为某一个调度器覆盖该配置,两种调用顺序均无影响:
// shared by every scheduler builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); // ...except this one, which waits longer before its first fire builder.Services.AddQuartzHostedService("DurableScheduler", options => { options.StartDelay = TimeSpan.FromMinutes(2); });这条规则的实现见 src/Quartz/Hosting/QuartzServiceCollectionExtensions.cs:无名的AddQuartzHostedService使用ConfigureAll让选项作用于所有调度器;带名字的重载则使用PostConfigure(schedulerName, configure),保证该调度器自己的设置在共享设置之后应用,因此无论两个调用谁先谁后,命名的覆盖总是生效。两个重载最终都通过内部AddHostedService注册同一个QuartzHostedService实现,确保容器中恰好只有一个 hosted service 实例(src/Quartz/Hosting/QuartzServiceCollectionExtensions.cs)。
QuartzHostedServiceOptions(src/Quartz/Hosting/QuartzHostedServiceOptions.cs)中与本例相关的可配置项包括:
| 属性 | 默认值 | 说明 |
|---|---|---|
WaitForJobsToComplete | false | 为true时,关闭流程会等待所有正在执行的 Job 完成后再返回 |
StartDelay | null | 非空时调度器在指定延迟后启动;若AwaitApplicationStarted为true,延迟从应用启动完成时开始计时 |
AwaitApplicationStarted | true | 为true(默认)时 Job 直到应用启动完成后才开始执行,避免应用启动期间就运行 Job |
AutoStart | true | 为true(默认)时 hosted service 启动调度器;设为false则只构建、初始化并绑定调度器,让其停留在SchedulerStatus.Created,由应用在合适时机自行Start() |
AutoStart的语义值得一提:它优先于AwaitApplicationStarted与StartDelay(后两者描述的是"何时启动",而AutoStart=false是"根本不由 hosted service 启动")。但关闭不受影响——hosted service 仍会关闭它创建的每一个调度器,无论是否启动过。
一次性配置所有调度器
ConfigureAllQuartzSchedulers(configure)把同一个 builder 回调应用到每一个通过AddQuartz、AddQuartz(name, …)或AddQuartzSchedulers注册的调度器,无论该调用发生在它之前还是之后:
- 回调添加的每个组件,每个调度器都得到自己的实例:一个插件加到三个调度器上,就是三个插件实例。
- 通过
AddQuartzHttpClient注册的远程调度器没有 builder,会被跳过。
实现见 src/Quartz/Configuration/QuartzServiceCollectionExtensions.cs:它借助SchedulerNameRegistry先记录回调(保证后续注册的调度器也被覆盖),再对已注册的默认调度器和所有命名调度器逐个Apply。因为委托按调度器各得一个 builder,其注册落在该调度器自己的键下,等价于写在该调度器的AddQuartz(name, q => …)回调内部——这正是"每个调度器一份实例、而非共享一份"的来源。
更细的语义(比如"给每个调度器相同的东西"在租户隔离场景下的用法)参见 multi-tenancy.md。
限制
- 调度器名称必须唯一,比较时忽略大小写。
Job 类型不是限制。AddJob<T>以无键方式注册类型,因此同一个 Job 类可以服务所有调度器;而AddJobType<TJob, TImplementation>()、AddJobType<TJob>(lifetime)和AddJobType<TJob>(factory)注册在某一个调度器的键之下,Job 工厂会先检查该键,再回退到容器的无键注册。于是两个调度器可以以不同方式构建同一个 Job 类型。详见 multi-tenancy.md 与 microsoft-di-integration.md 中关于 Job 构造方式的章节(作用域注册、TryAdd语义、每触发一次开一个 scope 等)。
延伸阅读
- 单调度器的完整 DI 集成(作业构造、持久化存储、日历、插件、超时与监听器的组装范例):microsoft-di-integration.md
- 多租户场景下"每个调度器配同一套东西"与 Job 类型注册的细节:multi-tenancy.md
- 4.0 移除进程级全局调度器与连接状态的迁移说明:migration-guide.md
- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
相关推荐
Quasar-Preview训练策略揭秘:三阶段训练与去中心化蒸馏完整解析
Quasar Preview训练策略揭秘:三阶段训练与去中心化蒸馏完整解析 Quasar Preview是一个革命性的混合架构大语言模型,采用创新的三阶段训练策
MQTTnet多租户架构:在同一Broker中服务多个独立应用
MQTTnet多租户架构:在同一Broker中服务多个独立应用 🔥 终极指南 :如何在单个MQTT Broker中实现完美的多租户隔离,让多个应用共享同一消息
物联网消息队列Cling多解释器环境:如何在同一进程中运行多个独立C++解释器
Cling多解释器环境:如何在同一进程中运行多个独立C++解释器 想要在同一进程中同时运行多个独立的C++解释器实例吗?Cling作为基于LLVM的C++交互式
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考