- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
Quartz.NET 使用 ADO.NET 持久化 Job Store(通常是LocalTransactionJobStore)时,需要一组固定的数据库表来存放调度数据。这篇指南以 Quartz.NET 4.x 官方文档的 Database Schema 为核心,结合仓库中的建表脚本、迁移脚本与源码实现,为你完整拆解:每张表存什么、4.x 强制要求的列与表、QRTZ_TRIGGERS的列语义与状态机、获取索引的设计与性能基准,以及从 3.x 升级到 4.x 的完整操作步骤。读完你将能够独立完成 Quartz.NET 持久化存储的建表、校验、升级与索引调优。
创建 Schema 与迁移的本质区别
一个 ADO.NET Job Store 需要一组表。在 Quartz.NET 4.x 中,创建和迁移是两条完全不同的路径:
- 创建可以自动:
ProvisionSchema()让 store 在启动时为其数据库执行 DDL,创建缺失的对象。它是选择加入的(opt-in),因为建表需要生产数据库通常不授予的权限。 - 迁移永远是手动步骤:Quartz 内部没有任何机制帮你迁移已有 schema。
为什么迁移必须手动?因为 Quartz 的表不携带版本标记。一个有保护的CREATE TABLE只会跳过已存在的表,而不会检查这个表"现在应该有哪些列";要加版本标记,这本身就是一次迁移。因此部署流水线运行的是 database/migrations,其文件夹名就是版本号。作为对比:Hangfire 会记录 schema 版本并在启动时默认迁移,将其最重的 1.8 迁移放在一个 opt-in 开关后面;TickerQ 则把两步都交给应用的 EF Core 迁移。
Schema 创建:自动但仅限创建
ProvisionSchema()(对应AdoJobStoreOptions.SchemaProvisioning)有三种取值:
| 值 | Store 初始化时的行为 |
|---|---|
None | 什么都不做;缺失的表会让第一条命名它的语句失败 |
Validate | 默认值;检查表以及 4.x 新增的列,缺失则拒绝启动 |
CreateIfMissing | 为配置的数据库执行 DDL,然后验证;ProvisionSchema()设置的就是它 |
值得注意的细节:
Validate会对 store 的表执行SELECT 1,并对 3.x 已有表上每个 4.x 新增的列执行SELECT <column> … WHERE 1 = 0,然后点名指出缺什么。CreateIfMissing不会"补列":如果一张表存在但缺少所需列,它不会执行 DDL——这意味着该 schema 不是 4.x 的。- 自动创建的 DDL 是一组按方言内嵌在
Quartz.dll的脚本(src/Quartz/Impl/AdoJobStore/Schema,每方言一个create_<dialect>.sql),表前缀是占位符而非写死的QRTZ_,且没有GO、/或SET TERM,因为每个语句都是单独发送的。构建和集成测试会校验它创建的表格、列和索引与 database/tables 完全一致。 - 它只创建,永不升级:每个语句都有保护,不会 drop 或 alter 任何东西。集群节点同时启动时也安全——只有先到者能创建某个对象,创建失败的节点会转而验证,若通过则说明其他节点已建好。
- 六个方言各有一个
create_脚本,但 SQL Server 的内存优化版(MOT)与 2016 之前版本没有对应脚本,也不存在各自的 driver delegate;对它们只能手动执行 tables_sqlServerMOT.sql / tables_sqlServer_Below2016.sql,并把SchemaProvisioning保持在Validate。
每张表存放什么
| 表 | 存放内容 |
|---|---|
| qrtz_calendars | 非标准日历 |
| qrtz_job_details | IJobDetail数据 |
| qrtz_locks | Quartz 获取的锁 |
| qrtz_scheduler_state | IScheduler数据 |
| qrtz_triggers | ITrigger数据 |
| qrtz_cron_triggers | cron 触发器的 cron 表达式 |
| qrtz_fired_triggers | 当前正在运行的触发器 |
| qrtz_blob_triggers | 以二进制 BLOB 存储的触发器 |
| qrtz_simple_triggers | 简单重复触发器 |
| qrtz_simprop_triggers | 自定义触发器;ICalendarIntervalTrigger、IDailyTimeIntervalTrigger和IRecurrenceTrigger使用它 |
| qrtz_paused_trigger_grps | IScheduler.PauseTriggerGroups数据 |
| qrtz_paused_job_grps | IScheduler.PauseJobGroups数据——每个被暂停的 job 组一行,因此空组也会被报告为已暂停 |
各数据库的建表脚本位于 database/tables,覆盖 SQL Server(含 2016+、内存优化 MOT、2012/2014 三个变体)、PostgreSQL、MySQL/MariaDB、Oracle、SQLite 与 Firebird 共八个脚本。
另一个常见坑是ADO.NET driver 是应用侧的包引用:UsePostgres需要Npgsql,UseSqlServer需要Microsoft.Data.SqlClient。缺失该包的项目能编译通过,但 store 初始化时会以Could not load file or assembly失败。完整的方法-包对应表(共八个方法)在 job-stores 教程:UseSqlServer、UsePostgres、UseMySql、UseMySqlConnector、UseOracle、UseFirebird、UseSqlite、UseSystemDataSqlite,外加用于"其他一切"的UseGenericDatabase(它使用无法分页的StdAdoDelegate,只在触发器很多时才真正吃亏)。
4.x 强制要求的列与表
这四个列在 3.x 上是可选的——3.x 启动时会探测它们,缺失就禁用对应功能并警告。4.x 移除了探测,四个列全部必需:
| 列 | 所在表 | 3.x 中作为可选列加入的版本 |
|---|---|---|
MISFIRE_ORIG_FIRE_TIME | QRTZ_TRIGGERS | 3.17 |
EXECUTION_GROUP | QRTZ_TRIGGERS、QRTZ_FIRED_TRIGGERS | 3.18 |
PREFERRED_NODE | QRTZ_TRIGGERS | 3.19 |
PREFERRED_NODE_AUTO | QRTZ_TRIGGERS | 3.19 |
此外 4.x 还需要 3.x 从未有过的东西:QRTZ_TRIGGERS上的列RETRY_POLICY和RETRY_ATTEMPT,以及表QRTZ_PAUSED_JOB_GRPS——后者让一个空的 job 组可以被暂停,并让 job 组列表能正确报告paused。
应用 database/migrations/4.0 即可补齐其中缺失的任何一项(含整表)。该目录下每个语句都有保护,所以在已经具备部分对象的数据库上运行也是安全的。完整的 3.x → 4.x 变更清单见 Database Schema Changes。
从 3.x 升级到 4.x 是强制性的,因为 4.x 不再探测 3.x 的可选列。
QRTZ_TRIGGERS 表:所有触发器类型的公共数据
QRTZ_TRIGGERS存放所有触发器类型共享的数据。类型特定的数据则根据TRIGGER_TYPE进入QRTZ_CRON_TRIGGERS、QRTZ_SIMPLE_TRIGGERS、QRTZ_SIMPROP_TRIGGERS或QRTZ_BLOB_TRIGGERS。
| 列 | 存放内容 |
|---|---|
SCHED_NAME | 该行所属的Scheduler:InstanceName。每张表都有此列,所以一个数据库可以承载多个 scheduler |
TRIGGER_NAME、TRIGGER_GROUP | TriggerKey。与SCHED_NAME一起构成主键 |
JOB_NAME、JOB_GROUP | 该触发器触发的 job 的JobKey |
DESCRIPTION | ITrigger.Description |
NEXT_FIRE_TIME、PREV_FIRE_TIME | 触发时间,UTC ticks;没有则为 null |
PRIORITY | ITrigger.Priority,在同一时刻到期的触发器之间打破平局 |
TRIGGER_STATE | 存储状态——见下文状态一节 |
TRIGGER_TYPE | CRON、SIMPLE、CAL_INT、DAILY_I、RECUR或BLOB:指明其余数据在哪张兄弟表 |
START_TIME、END_TIME | 调度生效的时间区间,UTC ticks |
CALENDAR_NAME | QRTZ_CALENDARS中从调度排除时间的条目(如有) |
MISFIRE_INSTR | misfire 指令的数值 |
MISFIRE_ORIG_FIRE_TIME | misfire 处理器把触发器移走的那个原始触发时间,让 job 能看出自己错过了什么 |
EXECUTION_GROUP | 触发器的执行组 |
PREFERRED_NODE、PREFERRED_NODE_AUTO | 节点亲和性:哪个节点应获取该触发器,以及是否由该触发器自己认领了这个"钉子" |
RETRY_POLICY、RETRY_ATTEMPT | 重试策略的存储字符串形式,以及当前一次 occurrence 已执行的重试次数 |
JOB_DATA | 触发器自己的JobDataMap,序列化后存储 |
关于重试列的特殊语义:RETRY_POLICY对不重试的触发器为NULL(这是默认值);RETRY_ATTEMPT在 4.x 写入的行上为0,在升级带过来的行上为NULL——store 把两者都读作"到目前为止没有重试"。
触发器状态:存储词汇与对外词汇
TRIGGER_STATE存放的是存储词汇StoredTriggerState(定义在 StoredTriggerState.cs 的Quartz.Extensibility命名空间),而不是应用看到的枚举。
TRIGGER_STATE | 含义 |
|---|---|
WAITING | 就绪,到期即可被取走。普通的静止状态 |
ACQUIRED | 某个节点已取走该触发器,即将触发 |
EXECUTING | 其 job 正在运行 |
COMPLETE | 不会再触发 |
BLOCKED | 其 job 标注了[DisallowConcurrentExecution],且它的另一次触发正在运行 |
PAUSED | 已暂停,直到恢复 |
PAUSED_BLOCKED | 已暂停,且被同一 job 的一次运行中的触发阻塞 |
ERROR | 触发器无法触发,通常是因为其 job 类型无法构建。IScheduler.ResetTriggerFromErrorState可清除它 |
DELETED | 触发器被移除过程中的瞬态标记 |
对外,IScheduler.GetTriggerState返回的是 TriggerState.cs 中的TriggerState:Normal、Paused、Complete、Error、Blocked、Executing,以及触发器不存在时的None。映射规则:
WAITING和ACQUIRED读作Normal;PAUSED_BLOCKED读作Paused;DELETED读作None。- 触发器 job 正在运行时读作
Executing,除非行上写着 deleted、error 或 paused——这些按原样报告。 TriggerStateResolver.Resolve就是这段映射,源码见 TriggerStateResolver.cs。它实现的优先级为None > Error > Paused > Awaiting > Executing > Blocked > Complete > Normal:paused、error 与 awaiting 之所以压过 executing,是因为它们是操作者必须处理的事实,且在前一次触发遗留的执行仍在进行时依然成立;StoredTriggerState.Awaiting则是 4.2 新增的存储状态,用于携带 Continuation 的触发器等待另一触发器触发结束。
索引设计:特别是获取索引(acquisition index)
QRTZ_TRIGGERS上随 schema 附带四个索引,各方言一致:
- 三个键查找:
(SCHED_NAME, JOB_NAME, JOB_GROUP)、(SCHED_NAME, TRIGGER_GROUP, TRIGGER_NAME)、(SCHED_NAME, CALENDAR_NAME); IDX_QRTZ_T_NFT_ST——两条触发器扫描都用它:获取(acquisition),以及带有计数 peek 的 misfire 恢复。它是唯一一个形状随方言变化的索引:
-- SQL Server、PostgreSQL、MySQL、Oracle、SQLite (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME ASC, PRIORITY DESC, MISFIRE_INSTR) -- Firebird (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME)4.0 丢弃了第五个索引
4.0 删除了IDX_QRTZ_T_NFT_ST_MISFIRE(对应 issue #3656):这是 SQL Server、MySQL、Oracle、Firebird 上曾经存在的第五个索引,覆盖(SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE)(PostgreSQL 和 SQLite 从来没有它)。它以前导列MISFIRE_INSTR开头,而两条 misfire 语句都用<> -1比较该列——任何 B-tree 都无法越过不等值谓词去 seek。在四个引擎上逐计划实测(issue #3608、#3656),没有任何优化器会为两条 misfire 语句选它;MySQL 看起来选了,只是因为MySQLDelegate用名字强制指定了它,而现在这个 hint 指向的是获取索引。从 3.x 升级时,删除动作在schema_30_to_40_indexes_<dialect>.sql中,独立成文件是因为 3.x确实从该索引扫描 misfire,所以要等最后一个 3.x 节点关闭后再执行。
获取索引最后两列服务于执行计划而非谓词
获取索引的PRIORITY DESC与MISFIRE_INSTR两列服务于查询计划,而不是谓词(issue #3510)。SelectNextTriggerToAcquire按NEXT_FIRE_TIME ASC, PRIORITY DESC排序,每个方言都把行数限制放进该语句,因此ORDER BY会在平局集合中选出优先级最高的触发器——与RAMJobStore行为一致。当索引方向与排序方向匹配时,引擎直接取第一条记录,而无需读入并排序所有候选:
| 100,000 个触发器,5,000 个到期,单次获取 | SQL Server 2022 | MySQL 8.0 | PostgreSQL 15 | Firebird 4 |
|---|---|---|---|---|
| p50,三列版本 | 21.6 ms | 11.8 ms | 0.89 ms | 14.8 ms |
| p50,发布形态 | 0.59 ms | 0.69 ms | 0.74 ms | 14.8 ms |
| 读取数,三列版本 | 20,395 | 15,517 | 1,526 | 10,025 |
| 读取数,发布形态 | 8 | 96 | 24 | 10,025 |
| 索引体积 | +6.2 % | +13 % | +0.4 % | 不变 |
MISFIRE_INSTR作为第五列服务于 misfire 积压场景。有序 seek 从最旧的等待触发器开始,而语句对NEXT_FIRE_TIME的下界处在与MISFIRE_INSTR的OR中,无法收窄 seek。没有这一列,seek 走过的每一行积压记录都要付出一次表查找;有了它,这些行在索引内就被跳过:面对 5,000 行积压,SQL Server 的逻辑读从 20,401 降到84,MySQL 的缓冲读从 15,071 降到117。
唯一的回退场景:窗口之下有数千个 misfired 触发器、到期者只有寥寥几个时,约 3 ms 对 1.5 ms。而到期者有数千个时则是 3 ms 对 30 ms,且积压会快速排空。
对齐索引定义前的三个注意事项
- Firebird 保留三列索引:它的索引整体只能升序或降序,
CREATE INDEX会拒绝ASC,而带负优先级取反的计算列也无法建索引(报attempt to index COMPUTED BY column)。那里的获取仍然要对候选排序——即上表 10,025 次读取。单按NEXT_FIRE_TIME排序实测快 3.2 倍、便宜 345 倍,但那会改变平局集合中先触发哪个触发器;如果这让你付出代价,请带着数据开 issue,而不是自行修补 schema。 - MySQL 8.0.1 之前与 MariaDB 10.8 之前会解析索引里的
DESC然后忽略它。无害,只是没买到任何东西。 - Oracle会把降序键列变成函数索引,因此
USER_IND_COLUMNS会在该位置显示一个隐藏的SYS_NC000nn$列并带DESC。这是预期行为。
PREFERRED_NODE 不在任何索引中
PREFERRED_NODE不在任何索引里,这一点在 PostgreSQL 15、SQL Server 2022 和 MySQL 8.0 上以 100,000 个触发器实测过(issue #3426);AcquisitionIndexBenchmark 可按需打印执行计划。原因与结论:
- 它对获取毫无帮助:节点亲和性过滤器是一个析取(未钉住、钉在此节点、或钉在已停止 check-in 的节点),而 B-tree 无法服务于
OR,三个引擎上的执行计划都不变。把它加进获取索引,在 SQL Server 和 MySQL 上相比MISFIRE_INSTR一无所获,还会让 PostgreSQL 的积压场景更糟——660 个共享缓冲对 408 个。 - 它只对故障转移的 re-pin 有帮助:即
ClusterRecover对每个死节点发出的那一次UPDATE——从全表扫描(PostgreSQL 8.4 ms、SQL Server 4,319 逻辑读、MySQL 约 88 ms)变成两三页的 seek。这每个节点故障只跑一次,不值得在最繁忙的表上付出永久的写成本。 - 如果仍要自己加,用
(SCHED_NAME, PREFERRED_NODE, PREFERRED_NODE_AUTO),且只针对故障转移足够频繁、多秒级恢复真会痛的集群。先测量:触发器不到几千个时,无论索引如何,每个计划都只读寥寥几页。
从 3.x 升级到 4.x 的实操步骤
升级分两个文件、两个时机,参见 database/migrations/4.0 与 Database Schema Changes:
| 文件 | 状态 | 何时执行 |
|---|---|---|
schema_30_to_40_upgrade_<db>.sql | 强制 | 现在。它并入 3.17、3.18、3.19,每个语句都有保护,全部在 3.x 节点仍在运行时也安全 |
schema_30_to_40_indexes_<db>.sql | 可选,纯性能 | 最后一个 3.x 节点关闭之后,或离线升级时紧随第一个文件之后。它取代了 3.20 |
升级文件的五个章节(SQL Server 版见 schema_30_to_40_upgrade_sqlServer.sql):
MISFIRE_ORIG_FIRE_TIME列(必需)——bigint NULL;EXECUTION_GROUP列(必需)——QRTZ_TRIGGERS与QRTZ_FIRED_TRIGGERS各一个nvarchar(200) NULL;PREFERRED_NODE/PREFERRED_NODE_AUTO(必需)——nvarchar(200) NULL与bit NOT NULL DEFAULT 0,两列必须一起加,Quartz 仅在两者都存在时启用节点亲和性;RETRY_POLICY/RETRY_ATTEMPT(必需且 4.x 新增)——nvarchar(250) NULL与int NULL,可空无默认,现有行直接读作"无重试策略",无需回填数据;QRTZ_PAUSED_JOB_GRPS表(必需且 4.x 新增)——主键(SCHED_NAME, JOB_GROUP),镜像QRTZ_PAUSED_TRIGGER_GRPS。3.x 暂停 job 组时不做任何记录,所以IsJobGroupPaused对所有组都返回false,重启后暂停丢失;4.x 记录组名,使JobGroup.Paused准确、QueryJobGroups(new JobGroupQuery { Paused = true })能列出它们,且没有 job 的组也可以被暂停。
索引文件的内容与理由:
- 重塑
IDX_QRTZ_T_NFT_ST为(SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME ASC, PRIORITY DESC, MISFIRE_INSTR)(Firebird 除外,issue #3510),并删除IDX_QRTZ_T_NFT_ST_MISFIRE(issue #3656)。先建后删、文件内从上到下执行,schema 永远不会处于两个索引都没有的状态;全部语句有保护,重复执行无副作用。 - 4.x 启动校验会检查每张需要的表是否可查询,并对该迁移加到 3.x 已有表上的每个列执行一次
SELECT <column> … WHERE 1 = 0;缺表或缺任何一列都会拒绝启动,报错信息会点名缺失的列与脚本。注意该校验看不到列的类型与宽度,所以要运行完整脚本。 - 在只运行了升级文件、未运行索引文件的数据库上,4.x 节点也能启动——只是扫描代替 seek,在大 schema 上才显著。
另外两项相关的升级提示:
- SQLite 触发器命名:当前 tables_sqlite.sql 把四个引用完整性触发器命名为
QRTZ_DELETE_SIMPLE_TRIGGER等(带表前缀)。已有数据库无需任何操作(Quartz 从不按名字引用它们);只有当你希望两个 Quartz schema 共享一个 SQLite 文件时才需要重命名重建。 - 执行历史表是可选的:database/migrations/4.2 中的
add_execution_history_<db>.sql创建QRTZ_EXECUTION_HISTORY与QRTZ_MISFIRE_HISTORY两张表,只有调用UsePersistentStore(store => store.UseExecutionHistory())的 store 才读写它们。全新安装或ProvisionSchema()会顺带创建它们,因此只有 4.0/4.1 创建的数据库需要该文件。
速查:什么时候该做什么
| 场景 | 操作 |
|---|---|
| 全新数据库,任何方言 | 运行 database/tables 中对应脚本(注意 SQL Server 脚本需先改USE [enter_db_name_here];,且脚本默认会 drop 旧 schema,用DropDb变量关闭);或让 store 在开发/测试环境用ProvisionSchema()自建 |
| 已有 3.x 数据库,升级到 4.x | 先运行schema_30_to_40_upgrade_<db>.sql(强制),最后一个 3.x 节点下线后再运行schema_30_to_40_indexes_<db>.sql(可选) |
| 已有 4.0/4.1 数据库,升级到 4.2 | 运行add_continuations_ .sql(强制,可混合窗口执行,但先迁移再滚节点再开始排 continuation);要用集群执行历史再运行add_execution_history_<db>.sql |
| 触发器量大、获取慢 | 确认IDX_QRTZ_T_NFT_ST已是 4.x 的五列形态;Firebird 例外 |
| 集群频繁故障转移、恢复慢 | 测量后自行添加(SCHED_NAME, PREFERRED_NODE, PREFERRED_NODE_AUTO)索引 |
迁移脚本的完整历史(2.0 → 4.2)见 Database Schema Changes,同一内容的仓库内版本见 database/README.md。务必在测试环境、对生产数据库的副本先行演练——这是 Quartz.NET 官方对每一次 schema 迁移的明确要求。
- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
相关推荐
Quartz.NET 数据库 Schema 完全指南:AdoJobStore 表结构、触发器状态与迁移路径
Quartz.NET 数据库 Schema 完全指南:AdoJobStore 表结构、触发器状态与迁移路径 Quartz.NET 的持久化作业存储(ADO.NE
任务调度后端Quartz.NET 数据库 Schema 迁移完整指南:从 1.x 到 4.2 的升级路径与脚本详解
Quartz.NET 数据库 Schema 迁移完整指南:从 1.x 到 4.2 的升级路径与脚本详解 导读 :Quartz.NET 从不自动迁移你的数据库 S
任务调度后端RTranslator跨版本数据迁移:数据库Schema升级完全指南
RTranslator跨版本数据迁移:数据库Schema升级完全指南 引言:移动应用数据迁移的痛点与解决方案 你是否曾因应用升级导致用户数据丢失而收到差评?是否
人工智能AI 应用本地部署语音NLP移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考