部署一套新的 JeecgBoot 环境,启动还没到登录页面,控制台先甩出一屏红色异常,核心就一句:Table 'xxx.QRTZ_LOCKS' doesn't exist。应用直接起不来,连后台都登不进去。这种问题我前后在开发、测试、生产环境里碰到过不少次,原因各不相同,但报错长得几乎一模一样:Quartz 调度器初始化时发现数据库里没有这张锁表,整个调度器直接罢工,而 JeecgBoot 的定时任务模块又强依赖调度器,于是连带 Spring 容器启动失败。
这篇文章就以这个报错为线索,把 QRTZ_LOCKS 表在 JeecgBoot 里到底承担什么角色、缺了它为什么会导致应用起不来、出现报错后怎么样一步步定位是建表失败还是查询失败,以及最终怎么修、怎么从源头上避免再踩一遍,一次性讲清楚。内容按实操场景来组织,不需要你有很深的底层基础,照着链路走基本能复现排查思路。
1. QRTZ_LOCKS 这张表承担什么角色,缺了它为什么连应用都启动不了
先说结论:QRTZ_LOCKS 是 Quartz 调度器在 JDBC 持久化模式下使用的一张数据库行锁表,它和周边其他 QRTZ_ 开头的表一起,共同构成了定时任务在数据库里的“存档系统”。JeecgBoot 默认就把定时任务托管给了 Quartz 的 JDBC JobStore,启动阶段调度器一初始化就要往数据库里读写这些表,任何一张表缺失,都会在初始化阶段直接抛异常,进而阻塞整个 Spring 容器的启动。
1.1 Quartz 持久化的底子:每个 QRTZ_ 表各管一段数据
Quartz 的 JDBC JobStore 设计上依赖一批固定名称的表,以 QRTZ_ 作为表前缀。用表格看它们的分工比较直观:
| 表名 | 存储内容 | 缺失时的一般表现 |
|---|---|---|
| QRTZ_JOB_DETAILS | Job 定义、类名、参数等 | 新增任务或启动定时模块失败 |
| QRTZ_TRIGGERS | Trigger 的概要信息 | 任务触发器读写失败 |
| QRTZ_CRON_TRIGGERS | Cron 表达式的具体规则 | Cron 触发任务失效 |
| QRTZ_SIMPLE_TRIGGERS | 简单重复触发信息 | 简单触发器读写失败 |
| QRTZ_SIMPROP_TRIGGERS | 带扩展属性的触发信息 | 复杂触发器读写失败 |
| QRTZ_BLOB_TRIGGERS | Blob 类型触发器数据 | Blob 触发器相关异常 |
| QRTZ_CALENDARS | 日历排班信息 | 日历功能异常 |
| QRTZ_PAUSED_TRIGGER_GRPS | 暂停的触发器分组 | 分组暂停功能异常 |
| QRTZ_FIRED_TRIGGERS | 正在执行的触发器记录 | 运行状态记录异常 |
| QRTZ_SCHEDULER_STATE | 调度器实例状态 | 集群状态检测异常 |
| QRTZ_LOCKS | 调度器的行锁记录 | 启动时直接报”表不存在” |
不是说只有 QRTZ_LOCKS 重要,而是因为 Quartz 初始化时对锁表的访问很靠前,所以在这个报错里,它往往是最先暴露出来的那一张。把这张表补齐之后,后面可能还会继续暴露出其他表缺失,这点要心里有数。
1.2 锁表的价值:避免两个调度节点同时执行同一个任务
为什么专门要一张锁表?Quartz 在设计时就考虑了集群部署:多个应用实例同时运行,如果不同节点在同一时刻都触发了同一个定时任务,就可能出现重复执行。为了保证同一个 Job 在同一时间只有一个实例真正运行,Quartz 使用了数据库行锁,锁记录就存在 QRTZ_LOCKS 里。
这张表的逻辑结构其实很简约,通常就是调度器和锁名两个字段,主键是调度器名加锁名。运行过程中调度器会通过事务对特定锁名执行SELECT ... FOR UPDATE或者类似的加锁操作,谁先拿到锁,谁就有资格处理任务;其他人等待或跳过。如果这张表不存在,加锁操作无法进行,调度器也就没办法保证任务的唯一执行,初始化阶段它宁可抛异常停止,也不愿意在一个残缺的存储层上继续跑。
1.3 JeecgBoot 的默认集成方式决定了它绕不开这张表
JeecgBoot 集成 Quartz 时,默认走的是 JDBC 持久化路线,配置文件里能看到类似org.quartz.jobStore.class指向JobStoreTX、org.quartz.tablePrefix=QRTZ_这样的配置。只要 JobStore 选的是 JDBC 实现,Quartz 启动时就一定会去数据库里查询这些 QRTZ_ 表。
不少刚接触的朋友会以为是 JeecgBoot 额外写了什么逻辑才去找这张表,其实不是。它只是把平台内置的定时任务模块(包括系统里那些自动统计、缓存清理一类任务)全部交给了 Quartz 管理。Quartz 本身只要选择了 JDBC 存储,锁表就属于硬依赖。缺表时调度器初始化失败,平台的任务模块初始化也会跟着失败,整个应用自然就起不来了。
2. 从启动日志一步步定位:先分清是建表失败还是查询失败
看到报错不要急着去网上复制一段建表 SQL 就执行,结果往往是把表建到了错误的库里,问题还在。先花几分钟看日志、看配置、看数据库当前状态,这条排查链路非常值钱。
2.1 三种常见报错现场,对号入座
我把实际遇见的报错分成三类,方便你对着日志判断自己属于哪一种:
| 报错特征 | 直接原因 | 优先动作 |
|---|---|---|
Table 'xxx.QRTZ_LOCKS' doesn't exist | 目标数据库里确实没有建这张表 | 确认表是否建错库或脚本未执行 |
Could not get lock within specified time | 表存在但获取锁超时,或锁记录异常 | 查锁表数据、检查事务是否有阻塞 |
Can't find delegate / invalid data source | 数据库驱动或数据源指向有问题 | 检查连接配置和驱动 delegate 类 |
第一种最常见,也就是你日志里那一句。第二种偶尔出现在生产上,多为锁表里有脏数据或者事务长时间不提交。第三种容易被误判成缺表,但它和建表没关系,是连接层的问题。
2.2 多数据源环境下,确认锁表到底该建在哪个库
JeecgBoot 的常见部署方式是使用动态数据源,业务库可能有好几个,但 Quartz 调度器只会绑定其中一个作为它自己的存储库。这个库未必是你导入业务脚本的那个库,也可能不是 DBA 一开始想到的那个库。
排查方法很简单:在配置里找到 Quartz 数据源的指向,或者动态数据源的主数据源配置,然后把日志报错中那个库名拿出来,直接用数据库客户端连过去执行:
SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '你的库名' AND table_name = 'QRTZ_LOCKS';返回 0 就说明表没建在这个库里,真正确认了根源。很多情况下,表不是没建,而是建到了别的地方。要避免这个坑,可以在项目配置中单独给 Quartz 指定一个独立数据源,让调度器的存储和业务表存储解耦,这样以后排查时目标非常明确。
2.3 权限不足导致建表脚本“假成功”
有一种隐蔽情况:执行建表脚本的人用的是只读账号,或者账号没有 DDL 权限,工具执行时报告成功但实际什么也没建;还有一种情况是脚本执行了但没有提交事务,尤其在一些图形化客户端里很容易发生。等到应用启动时,Quartz 去查锁表,依然是“表不存在”。
这时候要检查数据库账号的权限,尤其是对目标库的CREATE、ALTER权限。在没有权限的情况下,应该由 DBA 来执行建表脚本,或者为应用账号单独授权。曾经有一次我排查了很久,最后发现是运维在执行脚本后没有提交,这种坑很常见。
如果项目中设置了spring.quartz.jdbc.continue-on-error,建表脚本执行失败时启动不会立刻失败,Quartz 后续查询锁表时才会暴露问题。看到这种“启动时没报错,运行时报缺表”的情况,优先往数据库初始化失败被吞掉了这个方向排查。
3. 直接可用的修复方案:建表脚本与配置调整
定位到根因之后,修复本身并不复杂,难点在于不要修到一半。Quartz 的表是一个整体集合,单补一张最终会引来下一个报错,所以要么一次性执行完整脚本,要么确认其他表都已经存在。
3.1 修复前先确认三件事
第一,确认数据库类型。MySQL、PostgreSQL、SQL Server、达梦等数据库的建表脚本写法有差异,不能直接混用。第二,确认你的 Quartz 版本。不同版本的表结构基本一致,但脚本中字段、索引会有些微区别,最好是使用项目依赖包中对应版本的脚本。第三,确认调度器是否开了集群模式。这决定了一些额外表(比如 QRTZ_SCHEDULER_STATE)是否必须存在。
3.2 单表 SQL 只能应急,完整表集合才是终点
如果是 MySQL,单独创建 QRTZ_LOCKS 的脚本长这样:
CREATE TABLE QRTZ_LOCKS ( SCHED_NAME VARCHAR(120) NOT NULL, LOCK_NAME VARCHAR(40) NOT NULL, PRIMARY KEY (SCHED_NAME, LOCK_NAME) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意,这只能当应急手段。接下来 Quarts 初始化还会访问前面表里列出的其他 QRTZ_ 表,少哪张补哪张是不够的。更干净的做法是执行 Quartz 官方提供的对应数据库类型全套建表脚本,一次性把全部表结构建好。
从哪里找完整脚本?最直接的路径是打开项目依赖的 jar 包,找到org/quartz/impl/jdbcjobstore目录,里面有按数据库类型命名的 SQL 文件,比如quartz_tables_mysql.sql、quartz_tables_postgres.sql。另外 Spring Boot 的spring-context-support依赖包里也有内置的 Quartz 建表脚本,配置自动初始化时会用到。
3.3 通过 Spring Boot 自动初始化建表,以及它的风险边界
Spring Boot 为 Quartz 提供了自动建表开关,对应的配置如下:
spring: quartz: jdbc: initialize-schema: alwaysinitialize-schema可选值有never、embedded、always。默认情况下,Spring Boot 只会为嵌入式数据库(如 H2)自动建表;设置成always后,它会在启动时尝试执行内置的建表脚本。
这个开关在开发环境很好用,一启动表就自动建好了。但要注意两个坑:
- 生产环境如果库里已经存在旧版 QRTZ_ 表,重复执行脚本可能导致执行失败,从而影响启动。
- 项目里如果用了动态数据源,Spring Boot 的自动初始化不一定能命中 Quartz 真正使用的那个数据源。你设置了
always可能还是照样报缺表,原因就是初始化的库不对。
所以我的建议是:开发环境想省事可以用always,生产环境或需要长期维护的环境不要依赖这个开关,应该由脚本管理工具或人工在目标库执行。
3.4 手工执行全量脚本的标准流程
完整步骤我总结为四步:
- 确认目标库:从配置判断 Quartz 所使用的数据库实例与库名,不要让 DBA 去猜。
- 获取对应数据库类型和版本的官方建表脚本:从 jar 包里提取,或从项目依赖中获取。
- 在数据库客户端中执行:执行前检查账号权限,执行后提交事务。
- 验证完整性:
SHOW TABLES LIKE 'QRTZ_%';执行结果中应该能看到完整的表集合,而不是只有一张 QRTZ_LOCKS。这个验证步骤必不可少,许多“修复后仍报错”的例子都死于漏表。
3.5 改表前缀是治标不是治本,反而可能引出新问题
有些朋友觉得表名难听,或者想统一命名规范,于是把org.quartz.tablePrefix从QRTZ_改成XXZ_之类的前缀。这样做本身的逻辑是让 Quarts 去查询新前缀的表,如果你没有同时把表建出来,报错只会从“QRTZ_LOCKS 不存在”变成“XXZ_LOCKS 不存在”。
改前缀属于配置变更,不属于缺表问题的解药。若确实需要改,必须连同建表脚本一起修改,保持表名和配置完全一致,并且在变更前评估对现有任务数据的影响。多数情况下,不建议为了规避一个表缺失问题去动前缀。
4. 不想用数据库锁怎么办:两种绕开 QRTZ_LOCKS 的方式
有时你会想,既然锁表这么麻烦,能不能不让 Quartz 用数据库锁?答案是能绕,但要分清楚绕到什么程度。改掉缓存不代表问题消失,它只是换一种方式满足需求。
4.1 只关集群开关,锁表仍然不会被绕过
isClustered改成false会让 Quarts 不再启用集群特性,但 JobStore 仍然是 JDBC 实现,启动时还是会去检查 QRTZ_LOCKS。这个开关控制的是锁的用途,不是锁表的存在必要性,所以把报错归因到集群配置是常见的误区。
如果你用的是 JDBC JobStore,哪怕单机部署,QRTZ_LOCKS 都是需要存在的。这一点在排查时要特别记住,不然白折腾一圈。
4.2 换成内存调度器 RAMJobStore,和数据库彻底告别
真正想避开数据库表,可以把 JobStore 切换为内存模式。Spring Boot 对应配置如下:
spring: quartz: job-store-type: memory这样配置后,Quartz 不再访问任何 QRTZ_ 表,启动自然也不会报缺表。但代价很现实:任务定义纯内存保存,应用重启后所有任务都会丢失,需要通过初始化逻辑重新注册;集群环境下各节点任务互不可见,无法实现分布式协调;也不利于动态修改后的持久化。
所以这个方案只适合单机部署、任务量不大、任务可以在启动时重新注册的场景。JeecgBoot 的定时任务模块提供动态增删任务的能力,如果改成内存模式,这些任务重启后就需要重新添加,与平台本身的使用方式存在冲突。一般不建议这样操作。
4.3 换调度框架值不值,给你一个决策参考
遇到缺表就想着换掉 Quartz、上 XXL-Job 这类分布式调度平台,其实是把一个问题放大成另一个更大的工程。引入一个独立的调度中心往往需要额外部署服务、配置执行器、改造代码接入,部署复杂度和维护成本都会上升。多数情况下,修复表结构比换框架要快得多,也安全得多。
真正值得考虑替换框架的场景是:你需要更完善的分片调度、动态编排、可视化运维,或者 Quartz 当前的运维成本已经远高于它带来的收益。否则不要为了一张被漏建的表去动架构。
5. 复盘:这个问题为什么反复出现,如何从源头避免
光修好还不够,同一类问题反复出现,说明流程里有漏洞。我在看过好几个团队踩同一个坑后,总结出下面几个源头治理手段,操作成本不高,但能大幅减少手工环节带来的遗漏。
5.1 团队协作里最容易发生的“漏脚本”
新环境数据库初始化时,大多数人会先导入业务表结构,因为表数量多、文件大,Quartz 的 QRTZ_ 建表脚本容易在文件列表里被忽略;或者初始化工具默认过滤掉了非业务表;更快的情况是某些同事在初始化时根本没意识到平台用了 Quartz。
补救的方式不是在群里反复提醒,而是把建表脚本变成部署流程里的必选环节。源码仓库中单独建一个数据库脚本目录,按版本、按数据库类型存放全部 QRTZ_ 建表文件,新环境部署时先执行统一初始化脚本。
5.2 把建表脚本纳入版本管理和自动迁移
将 Quartz 建表脚本放进项目的 SQL 迁移体系,例如使用 Flyway 或 Liquibase。这样新环境只要应用启动,数据库迁移会自动执行,不用人工记忆“先建表再启动”。Quartz 表结构相对稳定,迁移脚本不需要频繁变更,纳入版本管理后每次升级环境都是自动化完成的。
同时在应用启动后可以做一次健康检查,定时查询关键表是否存在,有异常提前预警。这个检查不必太复杂,甚至可以在启动监听器里查一次QRTZ_LOCKS是否存在,不存在就明确打日志提示。
5.3 上线前快速自检清单
按这个清单过一遍,基本不会漏:
| 检查项 | 操作 | 预期结果 |
|---|---|---|
| 目标库是否存在全量 QRTZ_ 表 | 执行SHOW TABLES LIKE 'QRTZ_%' | 返回 11 张左右表 |
| Quartz 数据源指向是否正确 | 对比配置与实际连接库 | 指向一致 |
| 建表脚本是否已纳入部署流程 | 查看脚本目录或迁移脚本 | 包含全量脚本 |
| 数据库账号是否具备 DDL 权限 | 执行一次建表测试或询问 DBA | 权限满足 |
| 集群开关与存储模式是否匹配 | 核对配置文件 | 按实际需求设置 |
表存在不等于配置正确,配置正确也不代表表结构完整,两个维度都要查。
5.4 我在实际项目里的处理习惯
处理这类缺表问题时,我给自己定过一个很简单的原则:不在生产库上单表补丁,只落地完整集合脚本。每次遇到底层组件缺表,我会把完整脚本交给 DBA 一次性执行,并把脚本留档到项目的迁移脚本目录里。这样即使下次再换一个全新环境,也只需执行一次整体初始化,不会再出现补了 A 表又报 B 表的情况。
另外,排查问题时我会把重点放在“Quartz 到底在哪个库上运行”,而不是“缺哪张表”。表可以漏,库不能错。只要数据源指向明确,脚本执行完整,这类启动报错在十分钟内就能收工。