- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
本篇指南聚焦 Quartz.NET(Quartz Enterprise Scheduler .NET)的
CronTrigger触发器:它以"日历式"时间概念("每周五中午"、"工作日每天 9:30"、"每周一、三、五的 9:00–10:00 之间每 5 分钟")而非固定时间间隔定义调度。你将学会读懂并编写 Quartz 六/七字段 cron 表达式,掌握用TriggerBuilder+WithCronSchedule在代码中构建触发器的五种方式(含 4.3 新增的内联 lambda 写法与H哈希负载分散),并理解 CronTrigger 四种 misfire 策略的底层行为与适用场景。
CronTrigger 与 SimpleTrigger 的分工
CronTrigger用于按日历概念(calendars notions)而非固定时间间隔重复的调度,例如:
- "每周五中午";
- "工作日每天上午 9:30";
- "每周一、三、五的 9:00 到 10:00 之间,每 5 分钟一次"。
与SimpleTrigger一样,CronTrigger同样拥有开始时间(start time,调度开始生效的时刻)和可选的结束时间(end time,调度停止的时刻)。区别在于:SimpleTrigger 表达的是"从现在起每隔 X 时间单位重复 N 次",而 CronTrigger 表达的是"在哪些具体的钟表时刻触发"。
在 Quartz.NET 中,CronTrigger的实体实现是CronTriggerImpl:它将 cron 表达式解析为不可变的CronExpression,并在每次需要计算下次触发时间时调用GetFireTimeAfter逐次推演。它不具备毫秒精度(HasMillisecondPrecision => false),因为 cron 调度的最小粒度为秒。
Cron 表达式:字段、取值与特殊字符
一条 Quartz cron 表达式是6 或 7 个以空白分隔的字段:秒、分、时、日(day-of-month)、月、周(day-of-week),以及可选的年。每个字段可以是一个具体值、列表、范围、增量,或该字段允许的特殊字符。例如0 0 12 ? * WED表示"每周三 12:00"。
完整的字段表、特殊字符(*、?、-、,、/、L、W、#,以及用于跨触发器分散负载的H哈希标记)和详细示例,见 Cron 表达式参考。其字段结构如下:
| 字段名 | 是否必填 | 允许取值 | 允许的特殊字符 |
|---|---|---|---|
| 秒(Seconds) | 是 | 0–59 | ,-*/H |
| 分(Minutes) | 是 | 0–59 | ,-*/H |
| 时(Hours) | 是 | 0–23 | ,-*/H |
| 日(Day of month) | 是 | 1–31 | ,-*?/LWH |
| 月(Month) | 是 | 1–12 或 JAN–DEC | ,-*/H |
| 周(Day of week) | 是 | 1–7 或 SUN–SAT | ,-*?/L#H |
| 年(Year) | 否 | 空、1970–2099 | ,-*/ |
特殊字符速查
*—— 该字段的全部取值。分钟字段中的*表示每一分钟。?—— 不指定具体值。仅用于"日"和"周"两个字段,与*同义:用于只设置其中一个日期字段。例如日字段写10、周字段写?,表示"无论星期几,都在每月的 10 号触发"。-—— 范围。10-12表示 10、11、12;终点小于起点时范围会回绕:小时字段22-2表示 22、23、0、1、2,FRI-MON表示周五到周一。,—— 列表。MON,WED,FRI表示周一、周三、周五。/—— 增量。秒字段0/15表示 0、15、30、45;5/15表示 5、20、35、50;*/n等价于0/n。L—— "最后"(last)。语义取决于字段:日字段中表示该月最后一天(L-3表示倒数第 3 天);周字段单独使用时表示周六(7),跟在值后面表示该月的最后一个这样的星期几(6L是最后一个周五)。W—— 仅用于日字段、且只能跟在单个日期后:离该日期最近的周内工作日。15W:若 15 号是周六则提前到 14 号(周五),若是周日则顺延到 16 号(周一),若是周二则就在 15 号。它不会跨月:若 1 号是周六,1W触发在 3 号(周一)。#—— 周字段中表示"该月的第 n 个星期几"。6#3是第三个周五,4#5是第五个周三;某月没有第五个该星期几时该月不触发。H—— 哈希(hash)标记,用于跨触发器分散负载(见下文专节)。
字符、月份名和星期名不区分大小写:MON与mon等价。Quartz 还会在构造CronExpression时对表达式统一大写、去除首尾空白,并展开@daily、@hourly等 Vixie cron 宏(见 CronExpression.cs)。
常见示例表达式
| 表达式 | 触发时机 |
|---|---|
0 0/5 * * * ? | 每 5 分钟(整 5 分处) |
10 0/5 * * * ? | 每 5 分钟,且在第 0 分钟后的第 10 秒(10:00:10、10:05:10、…) |
0 30 10-13 ? * WED,FRI | 每周三、周五的 10:30、11:30、12:30、13:30 |
0 0/30 8-9 5,20 * ? | 每月 5 号、20 号的 8:00–10:00 之间每半小时一次:8:00、8:30、9:00、9:30,没有10:00 |
0 0 12 * * ? | 每天 12:00(正午) |
0 15 10 ? * MON-FRI | 工作日每天 10:15 |
0 15 10 L * ? | 每月最后一天的 10:15 |
0 15 10 ? * 6#3 | 每月第三个周五的 10:15 |
0 11 11 11 11 ? | 每年 11 月 11 日 11:11 |
两个日期字段的并集语义:
0 15 10 1,2,3 * MON,FRI表示"每月 1、2、3 号"以及"每个周一、周五"都触发——两个日期字段同时命名日期时是取并集(这是 Unixcrontab(5)的规则)。当两个字段都写成*或?(未命名任何日期)时,每天都匹配。这与部分其他 cron 实现取交集不同,从别处拷贝的表达式在本库中可能会触发得更频繁。
有些调度需要多个触发器才能表达,例如"9:00–10:00 之间每 5 分钟一次,并且 13:00–22:00 之间每 20 分钟一次"——创建两个触发器并同时注册到同一个 Job 即可:
ITrigger morning = TriggerBuilder.Create() .WithIdentity("morning-poll", "group1") .WithCronSchedule("0 0/5 9-10 * * ?") .ForJob("myJob", "group1") .Build(); ITrigger evening = TriggerBuilder.Create() .WithIdentity("evening-poll", "group1") .WithCronSchedule("0 0/20 13-22 * * ?") .ForJob("myJob", "group1") .Build(); await scheduler.ScheduleJob(job, morning, evening);构建 CronTrigger 的五种方式
用TriggerBuilder构建CronTrigger:通用属性(身份、Job 绑定、优先级、日历等)由TriggerBuilder本身设置,CronTrigger 专属属性(表达式、时区、misfire 指令)由WithCronSchedule扩展方法设置。WithCronSchedule的五个重载定义在 TriggerConfiguratorExtensions.cs,覆盖了从"字符串表达式"到"内联 lambda"的全部拼装形态。
单独使用CronScheduleBuilder.Create(cronExpression)也可以构建同样的调度,以便存入变量或在多个触发器间共享。
1. 字符串表达式(最常用)
ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule("0 0/2 8-17 * * ?") // 每天 8:00–17:00 之间每 2 分钟一次 .ForJob("myJob", "group1") .Build();注意Create(string)在调用处立即解析表达式(而非延迟到Build()),因此拼错的表达式会由命名它的那次调用直接抛出FormatException——参见 CronScheduleBuilder.cs。
2. 内联 lambda,无需手写表达式字符串(4.3 新增)
ITrigger weekdays = TriggerBuilder.Create() .WithIdentity("nightly", "group1") .WithCronSchedule(cron => cron.AtTime(new TimeOnly(3, 0)).OnWeekdays()) // "0 0 3 ? * MON-FRI" .ForJob("myJob", "group1") .Build(); ITrigger everyTenMinutes = TriggerBuilder.Create() .WithIdentity("poll", "group1") .WithCronSchedule(cron => cron.Every(TimeSpan.FromMinutes(10))) // "0 0/10 * ? * *" .ForJob("myJob", "group1") .Build();- lambda 收到的是一份全新的
CronExpressionBuilder,每个字段默认未配置(渲染为*); Every按钟表计数:每 10 分钟就是 :00、:10、:20……无论触发器何时启动。它要求间隔能整除上一个单位——即 1、2、3、4、5、6、10、12、15、20、30 秒/分钟,或 1、2、3、4、6、8、12 小时;其他任何间隔在 cron 中没有"均匀触发"的写法,此时应改用WithSimpleSchedule(interval)(从触发器开始时间起计)或直接抛ArgumentOutOfRangeException;Every会写入它所在的字段及所有更小的字段(Every(10min)写秒和分),更大的字段仍负责限定运行时段;AtTime与Every都表述"一天中的何时触发",二者叠加会抛InvalidOperationException。
// 每个字段:单值、列表、范围、增量四种形态 CronExpressionBuilder.Create().AtTime(new TimeOnly(9, 30)); // "0 30 9 ? * *" CronExpressionBuilder.Create() .AtTime(new TimeOnly(9, 30)) .WithDaysOfWeek(DayOfWeek.Monday, DayOfWeek.Thursday); // "0 30 9 ? * MON,THU" // 特殊字符的专属方法 CronExpressionBuilder.Create().OnLastDayOfMonth(); // "* * * L * ?" CronExpressionBuilder.Create().OnNearestWeekdayOfMonth(15); // "* * * 15W * ?" CronExpressionBuilder.Create().OnNthDayOfWeekOfMonth(DayOfWeek.Friday, 3); // "* * * ? * FRI#3" CronExpressionBuilder.Create().OnLastDayOfWeekOfMonth(DayOfWeek.Friday); // "* * * ? * FRIL"构建器的规则:每个字段只能配置一次(重复配置抛InvalidOperationException);它只写一个日期字段,另一个渲染为?,同时配置两个日期字段会被拒绝(这是构建器的约束而非 cron 本身的规则——需要并集语义的表达式请直接写文本并CronExpression.Parse);所有值在设置时即校验取值范围,Build()返回经过最终校验的CronExpression;星期以名称(MON、FRI)输出,避免跨方言的星期编号歧义。
3. 每天 10:42 —— 两个日期字段的?用法
// 写法一:周字段写 '?'(日字段 '?' 也可) ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule("0 42 10 ? * *") .ForJob(myJobKey) .Build(); // 写法二:日字段写 '?' 的等价形式 ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule("0 42 10 * * ?") .ForJob("myJob", "group1") .Build();4. 指定时区
ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule("0 42 10 ? * WED", x => x .InTimeZone(TimeZones.FindById("Central America Standard Time"))) .ForJob(myJobKey) .Build();或先把调度构建出来,供多个触发器共享:
CronScheduleBuilder schedule = CronScheduleBuilder .Create("0 42 10 ? * WED") .InTimeZone(TimeZones.FindById("Central America Standard Time")); ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule(schedule) .ForJob(myJobKey) .Build();时区解析的跨平台细节:TimeZones.FindById是TimeZoneInfo.FindSystemTimeZoneById加上注册的解析器(resolver)。Windows、Linux 与 ICU 环境下可解析的时区 ID 集合不同,因此TimeZones内置了一张 ID 别名表(如UTC↔Coordinated Universal Time、China Standard Time↔Asia/Shanghai),查找失败时依次尝试别名、IANA→Windows 转换和已注册的 resolver。安装 TimeZoneConverter 插件 后,Windows 的时区 ID 也能在 Linux 上解析(该插件的UseTimeZoneConverter正是向TimeZones.AddResolver注册一次进程级解析器)。InTimeZone内部通过cronExpression.WithTimeZone(timeZone)重绑定而非原地修改,避免已交付给已构建触发器的表达式被悄悄改时区(见 CronScheduleBuilder.cs)。
5. 用H哈希分散每日负载
ITrigger trigger = TriggerBuilder.Create() .WithIdentity("nightly-cleanup", "maintenance") .WithCronSchedule("0 H H(0-7) * * ?") // 每天在 00:00–07:59 之间的某个"哈希派生的时刻" .ForJob("cleanupJob", "maintenance") .Build();很多触发器如果都写0 0 0 * * ?(每天午夜),会在同一时刻齐发,造成资源尖峰;H用触发器身份(name + group)的确定性哈希为每个触发器推导出不同的时刻,且只要身份不变结果就不变。语法上:
| 表达式 | 含义 |
|---|---|
H | 字段完整范围内的哈希值 |
H(0-7) | 限定在 0–7 范围内的哈希值 |
H/15 | 哈希派生的起点,然后每 15 重复(例如 7、22、37、52) |
H(0-29)/10 | 0–29 内哈希偏移,然后每 10 重复(例如 3、13、23) |
H可以出现在带固定值的逗号列表中,如H,30,45;H不允许用于年字段,且不能与L、W、#组合;- 经由
TriggerBuilder构建时,必须调用WithIdentity()——哈希种子来自稳定身份,而不是随机 GUID。这在源码层面有强制约束:TriggerBuilder.Build()检测到调度仍持有未解析的H(IHashKeyAwareScheduleBuilder.RequiresHashKey)而触发器没有身份时,会直接抛FormatException,并在Build()中把触发器的TriggerKey编码为哈希种子(默认组前缀:以避免不同键的哈希碰撞)完成解析(见 TriggerBuilder.cs 与 CronScheduleBuilder.cs); - 也可以绕过触发器身份、显式指定哈希键:
CronExpression.ParseWithHash("0 H H(0-7) * * ?", "nightly-cleanup"),非抛错形式是TryParseWithHash,两者都可用CronFormat指定非 Quartz 方言; - 解析后的
CronExpressionString会返回已解析的表达式(如"0 23 3 * * ?"),持久化到 JobStore 的正是这个已解析形态,因此跨重启保持稳定。
Misfire 指令:CronTrigger 错过触发时怎么办
Misfire(失火)的概念详见触发器进阶(More About Triggers):当持久化的触发器因调度器关闭或线程池无空闲线程而错过触发时刻,就产生 misfire;调度器重启后会发现这些错过触发的持久化触发器,并按各自指令更新状态。
CronTrigger 的指令定义在CronTriggerMisfireInstruction枚举上,其取值与ITrigger.MisfireInstructionCode直接对应:
| 指令 | 错过触发后的行为 |
|---|---|
SmartPolicy | 所有触发器的默认策略;对 CronTrigger 而言等价于FireAndProceed |
CronTriggerMisfireInstruction.FireAndProceed | 调度器恢复后立即触发一次,然后从下一个预定时间继续 |
CronTriggerMisfireInstruction.DoNothing | 跳过错过的触发,等待下一个预定时间 |
CronTriggerMisfireInstruction.IgnoreMisfires | 以调度器最快的速度把所有错过的触发全部补发,直到追平调度 |
这些行为实现在CronTriggerImpl.UpdateAfterMisfire(CronTriggerImpl.cs)中:
SmartPolicy首先被解析为FireOnceNow(FireAndProceed);DoNothing:从当前时间向后求下一个触发时刻,并用日历(ICalendar)过滤被排除的时间,得到NextFireTimeUtc;FireOnceNow:直接把NextFireTimeUtc置为当前时刻,即"立即补一次"。
无论错过了多少次触发,除IgnoreMisfires外的已解析指令最多补发一次——触发器向前推进而不会重放。若需要精确补跑某个过去的时间段,应使用 Backfill 功能。
在WithCronSchedule上设置 misfire 指令:
ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger3", "group1") .WithCronSchedule("0 0/2 8-17 * * ?", x => x .WithMisfireInstruction(CronTriggerMisfireInstruction.FireAndProceed)) .ForJob("myJob", "group1") .Build();CronScheduleBuilder.WithMisfireInstruction会把指令存入MisfireInstructionCode,Build()时转交给CronTriggerImpl(见 CronScheduleBuilder.cs)。各家族(Simple、CalendarInterval、DailyTimeInterval、Recurrence)各有自己的 misfire 枚举,应只在对应家族的调度构建器上设置,让取值保持在正确的作用域内。
实战建议与常见陷阱
- 表达式先验证再上线:把同事、在线生成器或旧配置文件给的表达式先用
CronExpression.TryParse走一遍解析器,再用GetNextValidTimeAfter打印未来几次触发时间核对含义——H、回绕范围(22-2、FRI-MON)、MON/2以及两个日期字段的并集规则都与 Java Quartz 或 Unix cron 不同,外部工具无法覆盖全部方言。 - 两个日期字段注意并集语义:
0 15 10 1 * *只在每月 1 号触发(周字段为*未命名日期),0 15 10 * * MON只在周一触发;两者同时命名日期则取并集。 - 务必显式指定时区:表达式不带时区时使用
TimeZoneInfo.Local——开发机上是开发者本地时区,容器里常常是 UTC,生产与开发不一致会导致触发时间错位。带时区时还要留意 DST 行为:固定时刻的表达式在春令时跳变当天只触发一次(落在缺口末端),区间型表达式则会两个来回都触发(详细规则见 Cron 表达式参考的夏令时专节,以及 最佳实践)。 H与WithIdentity():使用哈希负载分散时不要省略身份,否则Build()直接抛FormatException。- 多触发器表达复杂调度:一个 Job 可挂多个
CronTrigger,把"无法用单条表达式表达的复合节奏"拆成若干触发器分别注册。
以上示例全部可以在仓库的可运行样例中验证:src/Quartz.Documentation.Samples/Tutorial/CronTriggersSamples.cs中的每个代码片段都对应本文的构建示例(含每日 10:42、指定时区、共享调度、哈希触发与 misfire 指令),对应的单元测试覆盖见 CronTriggerTest.cs 与 CronExpressionTest.cs。作为教程的一部分,本文属于教程目录的第 8 课(CronTriggers),完整的 cron 语法细节请继续阅读 Cron 表达式参考。
- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
相关推荐
Quartz.NET CronTrigger 完全指南:Cron 表达式语法、构建方式与 Misfire 策略
Quartz.NET CronTrigger 完全指南:Cron 表达式语法、构建方式与 Misfire 策略 CronTrigger 是 Quartz.NET
任务调度后端Quartz.NET CronTrigger 完全指南:Cron 表达式语法、调度构建与 Misfire 策略
Quartz.NET CronTrigger 完全指南:Cron 表达式语法、调度构建与 Misfire 策略 CronTrigger 是 Quartz.NET
任务调度后端Quartz.NET CronTrigger 完全指南:Cron 表达式语法、触发器构建与 Misfire 策略
Quartz.NET CronTrigger 完全指南:Cron 表达式语法、触发器构建与 Misfire 策略 CronTrigger 是 Quartz.NE
任务调度后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考