xxl-job 迁移 PostgreSQL 实战:从建表脚本到 Mapper XML 的完整改造指南
2026/9/8 5:22:28 网站建设 项目流程

简介:xxl-job适配PostgreSQL数据库的定制版源码包,基于2.4.1版本修改官方源码,面向需要在PostgreSQL环境下部署任务调度平台的后端开发者与运维人员。资源在保留MySQL支持的同时,新增PostgreSQL数据库适配,通过修改配置即可切换数据库类型,并附带了两种数据库的建库脚本,可直接完成表结构初始化。压缩包共257个文件,核心为133个Java源文件与30个XML配置,覆盖任务调度、Mapper映射及Spring集成逻辑;另含35个JS、12个CSS及11个FTL等前端资源用于控制台界面,附带SQL脚本、Dockerfile和properties文件,兼顾容器化部署与本地运行需求,整体仅1.8MB,结构紧凑。关键改动集中在数据库方言适配与自动配置部分,与原版代码结构保持一致,便于对照官方代码理解差异并进行二次开发。已有1390人学习下载,适合需要快速获得PostgreSQL版本支持,或希望基于2.4.1做定制扩展的开发团队直接采用。 接手过 xxl-job 的都知道,官方默认支持的关系型数据库就是 MySQL,想要跑在 PostgreSQL 上,不只是改个连接串那么简单。我这次用的是 xxl-job 2.4.1,折腾了大概两天,把调度中心 admin 的源码扒了一遍,改了建表脚本、MyBatis 的 Mapper XML、驱动依赖和少量 Java 代码,总算是让整套调度平台在 PostgreSQL 14 上稳下来了。这篇文章就把我的改造过程、改动的文件、关键 SQL 的差异处理方式,以及启动和自测时踩过的坑全部整理出来,给同样想把 xxl-job 迁移到 PG 的同学一个可以直接照抄的参考。

1. 为什么非改源码不可:官方版本对 PostgreSQL 基本是放弃的状态

1.1 你以为改个配置就行,实际启动就挂

我一开始也抱着侥幸心理,想着 xxl-job 的 DAO 层用的是 MyBatis,SQL 写在 XML 里,理论上只要把application.properties里的 datasource 切换成 PostgreSQL,再把 JDBC 驱动加上,应该就能跑。结果启动直接报错,错误信息集中在表不存在和 SQL 语法错误上。因为 xxl-job 的调度中心在启动时会自动执行初始化 SQL,从 classpath 下面读tables_xxl_job.sql来建表,官方提供的脚本是纯 MySQL 方言,包含ENGINE=InnoDBCOMMENT='xxx'AUTO_INCREMENT、反引号这些语法,PostgreSQL 根本解析不了。

也就是说,不改源码的情况下,你连表都建不出来,后续就算手动建了表,MyBatis XML 里那一堆 MySQL 专属函数也会继续报错。

1.2 需要动刀的范围其实比想象中大

很多人以为只改建表脚本就完事了,其实不够。我自己梳理了一遍,至少要覆盖这几块:

  • 根目录doc/db/tables_xxl_job.sql官方建表脚本,要全部改成 PG 语法;
  • XxlJobAdminConfig 中初始化数据库表的 Java 代码,要能正确解析并执行 PG 版 SQL;
  • mybatis-mapper 目录下所有 XML 文件里涉及 MySQL 语法、函数、分页的 SQL;
  • pom.xml 中添加 PostgreSQL JDBC 驱动;
  • 数据库连接池参数、driverClassName 等配置文件的调整。

考虑到 2.4.1 版本里不少 Mapper 的 SQL 写得比较"奔放",直接改 XML 是最合理的路径,不用大改 Java 代码逻辑。整体评估下来,改造的工作量集中在 SQL 方言转换,而不是业务逻辑,这也是 xxl-job 设计得还算舒服的地方——调度模型和 DAO 解耦得比较干净。

2. 改代码前先把三条数据库访问路径摸清楚

2.1 启动时的自动建表逻辑在哪

xxl-job-admin 的启动入口是XxlJobAdminConfig,它实现了InitializingBean,在afterPropertiesSet()里会调用初始化逻辑,最终找到 classpath 下的tables_xxl_job.sql,按分号切分后逐条执行。源码大致逻辑是读取 SQL 文件内容,通过connection.createStatement()批量执行。

这里有个关键点:官方脚本里的分号切分是"无脑 split",所以 PG 版建表脚本里不能出现函数体、存储过程这类包含分号的结构,最好全部用简单的CREATE TABLE IF NOT EXISTS,注释也尽量不要加,避免解析出错。

2.2 MyBatis 方言与 XML 映射文件的差异

xxl-job 的 DAO 层全部用 MyBatis XML 写 SQL,涉及调度的核心表有XXL_JOB_QRTZ_TRIGGER_INFOXXL_JOB_QRTZ_TRIGGER_LOGXXL_JOB_QRTZ_TRIGGER_GROUPXXL_JOB_QRTZ_REGISTRYXXL_JOB_QRTZ_LOG_REPORT等。这些 XML 里大量使用了 MySQL 的分页写法LIMIT #{offset}, #{pagesize}、时间函数DATE_ADD()/DATE_SUB()、条件判断IFNULL()、字符串拼接CONCAT(),这些在 PostgreSQL 里都不完全兼容,必须要一条一条处理。

2.3 实际要改的 Mapper 文件清单

以下是我在 2.4.1 里实际改过的文件,按重要程度排序:

  • XxlJobLogMapper.xml:改动最大的一个,分页、时间过滤、清理日志都有 MySQL 语法;
  • XxlJobInfoMapper.xml:分页查询、调度状态更新、child_job_id字符串处理;
  • XxlJobRegistryMapper.xml:注册表清理、过期判断;
  • XxlJobGroupMapper.xml:分页查询;
  • XxlJobLogReportMapper.xml:报表查询里的日期聚合逻辑;
  • XxlJobLogGlueMapper.xml:少量分页和查询语句。

Java DAO 接口本身不用动,MyBatis 会自动绑定 XML,改完 XML 重启即可生效。

3. 逐文件适配 PostgreSQL 的完整改动记录

3.1 官方建表脚本的转换处理

官方tables_xxl_job.sql里每张表的结构基本都是同一个套路,MySQL 风格非常明显。以任务信息表为例,原始 DDL 长这样:

CREATE TABLE `XXL_JOB_QRTZ_TRIGGER_INFO` ( `id` int(11) NOT NULL AUTO_INCREMENT, `job_group` int(11) NOT NULL COMMENT '执行器主键ID', `job_desc` varchar(255) NOT NULL, `add_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我改成 PostgreSQL 版本的时候,主要处理了四类差异:

  • AUTO_INCREMENT 换成GENERATED BY DEFAULT AS IDENTITYSERIAL,我选的是GENERATED BY DEFAULT AS IDENTITY这种标准写法;
  • 反引号全部去掉;
  • COMMENT子句去掉,改成在表创建后用COMMENT ON语句单独加,或者干脆不加;
  • MySQL 的datetime换成timestamp,默认值写成CURRENT_TIMESTAMP

改造后的示例:

CREATE TABLE IF NOT EXISTS xxl_job_qrtz_trigger_info ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, job_group bigint NOT NULL, job_desc varchar(255) NOT NULL, add_time timestamp DEFAULT CURRENT_TIMESTAMP, update_time timestamp DEFAULT CURRENT_TIMESTAMP );

注意:PG 表名、字段名如果没加双引号,会全部转成小写,MyBatis 的 SQL 对大小写不敏感,所以我在 XML 里也统一用了大写。这里更稳妥的做法是建表时就建小写表名,XML 里也不加引号,让 PG 的"小写折叠"生效,省得之后写 SQL 时分不清大小写。

3.2 分页 SQL 从LIMIT ?, ?到 OFFSET 的转换

xxl-job 里凡是列表页,SQL 几乎都长这样:

<select id="pageList" resultType="com.xxl.job.admin.core.model.XxlJobInfo"> SELECT * FROM XXL_JOB_QRTZ_TRIGGER_INFO WHERE 1=1 <if test="jobGroup != null and jobGroup != ''"> AND job_group = #{jobGroup} </if> ORDER BY id DESC LIMIT #{offset}, #{pagesize} </select>

PostgreSQL 不支持LIMIT offset, size,必须拆成LIMIT size OFFSET offset。我把所有LIMIT #{offset}, #{pagesize}统一替换成:

LIMIT #{pagesize} OFFSET #{offset}

之所以用#{offset}而不是写成OFFSET #{offset}后仍然报错,是因为部分 XML 里offset被当作保留字处理了,需要加上 PG 的语义理解——在 PG 里 OFFSET 后面跟参数完全没问题,不用担心。

这种替换要非常仔细,我把所有 Mapper XML 都搜了一遍,确认没有遗漏。统计下来至少改了八个查询方法,主要集中在 XxlJobInfoMapper 和 XxlJobLogMapper 里。

3.3 日期函数与字符串函数的兼容性处理清单

这部分是坑最多的。MySQL 和 PG 在时间计算、字符串拼接上的语法差异,让不少 SQL 改起来极其费眼神。

xxl-job 里执行日志清理的 SQL 大概是这样的:

<delete id="clearLog"> DELETE FROM XXL_JOB_QRTZ_TRIGGER_LOG WHERE trigger_time &lt;= DATE_ADD(NOW(), INTERVAL -#{clearBeforeNum} DAY) </delete>

日期运算在 PG 里要写成CURRENT_TIMESTAMP - INTERVAL '1 day' * #{clearBeforeNum},或者用NOW() - make_interval(days := #{clearBeforeNum})。我采用的是第二种方式,因为参数直接来自外部,用make_interval更直观。

改造后的写法:

DELETE FROM XXL_JOB_QRTZ_TRIGGER_LOG WHERE trigger_time &lt;= CURRENT_TIMESTAMP - make_interval(days := #{clearBeforeNum})

还有一处是统计报表的查询,MySQL 里用DATE_FORMAT(trigger_time, '%Y-%m-%d'),PG 里对应的是TO_CHAR(trigger_time, 'YYYY-MM-DD')。这个也好处理,直接替换函数名和格式符即可。

IFNULL在 PG 里是COALESCE,xxl-job 里好几个查询用到了,比如获取下一个触发时间、处理调度日志的 handle_code,我都改成了COALESCE,语义一致。

字符串拼接方面,MySQL 的CONCAT(?, ?)在 PG 里同样支持CONCAT函数,但要注意 PG 的||运算符在遇到 NULL 时会返回 NULL,导致结果不对。xxl-job Mapper 里有个别地方直接用了#{jobGroup} || '%',这种如果参数为空会出问题,我统一改成CONCAT(#{jobGroup}, '%'),稳妥很多。

3.4 注册表清理和日志表清理的额外处理

XxlJobRegistryMapper 里有一条清理过期注册信息的 SQL,原始逻辑类似:

DELETE FROM XXL_JOB_QRTZ_REGISTRY WHERE update_time < DATE_SUB(NOW(), INTERVAL 90 SECOND)

改成 PG:

DELETE FROM XXL_JOB_QRTZ_REGISTRY WHERE update_time < CURRENT_TIMESTAMP - INTERVAL '90 seconds'

注意这里 PG 的 interval 字符串必须带单引号,且数字和单位之间要有空格,写'90s'是不行的,必须'90 seconds'或者'90 second'

XxlJobLogMapper 里还有一个根据执行器地址和任务Id查询日志的 SQL,用到了ORDER BY trigger_time DESC LIMIT 1,PG 也支持这种写法,唯一要注意的是 LIMIT 后面不能像 MySQL 那样写0, 1这种。另外,PG 对ORDER BY字段的隐式类型转换要求更高,trigger_time如果建表时是timestamp,没有问题;如果是字符串存的,就要先CAST(trigger_time AS timestamp),否则排序结果可能不符合预期。

3.5 配置文件和 pom.xml 的调整

这一步相对简单,但是漏了就不行。

pom.xml 中 xxl-job-admin 模块需要新增 PostgreSQL 驱动依赖,我加的是官方驱动:

<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.1</version> </dependency>

版本号自己选一个稳定版本即可,建议 42.x 以上,兼容 PG 14、15 和 16。

application.properties里的改动更直白:

spring.datasource.url=jdbc:postgresql://localhost:5432/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai spring.datasource.username=postgres spring.datasource.password=yourpassword spring.datasource.driver-class-name=org.postgresql.Driver

需要注意 PostgreSQL 的连接参数currentSchema如果有需要,也可以加上,但默认用 public schema 就够了。连接池那块用的是 HikariCP,不用额外改东西。

4. 首次启动的初始化流程与异常排查

4.1 建表脚本在什么时候执行

xxl-job-admin 启动时会自动执行初始化脚本,整个过程就是拿 classpath 下tables_xxl_job.sql的内容,按;分割,逐条执行。所以我在替换成 PG 版建表脚本后,把脚本里所有的注释和空行都清理了一遍,保证每条 SQL 是独立的、完整的,避免出现解析异常。

如果希望每次启动都不重复建表,PG 的CREATE TABLE IF NOT EXISTS也能兜底,执行第二次不会报错。

4.2 我遇到的启动报错和解决对照

我把这次适配过程中遇到的几个报错和对应的解决办法列成了一张表,方便排查时直接对号入座。

报错现象原因解决办法
relation "xxl_job_qrtz_trigger_info" does not exist建表脚本没执行成功或表名大小写不一致确认初始化脚本已加载 PG 版本,手动执行建表 SQL 排查错误
syntax error at or near "ENGINE"建表脚本仍是 MySQL 方言重新替换官方建表脚本为 PG 语法
LIMIT # {offset}, # {pagesize}附近的语法错误MySQL 分页写法不兼容改成LIMIT #{pagesize} OFFSET #{offset}
function concat(timestamp with time zone, unknown) does not exist使用了 PG 不支持的隐式类型拼接显式转换,比如CONCAT(CAST(trigger_time AS varchar), '')
operator does not exist: timestamp <= character varying表字段类型和查询参数类型不匹配统一参数类型,必要时候在 XML 中做CAST
invalid input syntax for type bigint: "1,2"child_job_id 这类字段是用逗号拼接的把字符串按逗号拆开后转数组或子查询处理

4.3 老项目升级时的数据迁移心得

如果本来就有 MySQL 环境下跑着的 xxl-job 调度数据,在切换到 PG 之前,建议先停调度、导出、再导入。任务信息表、日志表、执行器表这几张核心表直接导出 CSV 再导入 PG 即可,但要注意自增 ID 的序列问题。

PG 字段如果用了GENERATED BY DEFAULT AS IDENTITYSERIAL,导入之前要把序列值调整到当前最大 ID 之后,否则后续新增任务会主键冲突。用setval直接调:

SELECT setval(pg_get_serial_sequence('xxl_job_qrtz_trigger_info', 'id'), (SELECT MAX(id) FROM xxl_job_qrtz_trigger_info));

这个操作一定不能省,我一开始迁移完忘了调,新增第一个任务就报主键重复的错,排查了半天才反应过来。

5. 适配完成后的调度链路自测清单

5.1 必须覆盖的调度场景

代码改完、服务能起来,只算完成了一半。调度系统这种基础设施,核心链路不验证一遍就上线,等于埋雷。我自己整理了一个自测清单,照着跑一遍,基本能保证没有大问题:

  • 新建执行器、编辑执行器、删除执行器;
  • 新增 GLUE IDE 任务,保存并在线编辑;
  • 新增 Bean 模式任务,手动执行一次;
  • 配置 cron 表达式,等待自动触发,观察调度日志;
  • 任务执行失败后,查看失败回调、重试次数是否正常;
  • 执行器断线后,调度中心是否把注册实例标记为过期;
  • 任务日志清理(按天删除)是否能正常执行;
  • 报表页的每日调度统计曲线是否有数据。

5.2 我实测下来最容易出问题的两个点

第一个是失败日志的handle_codehandle_msg在 PG 里的类型处理。调度中心回调时写入的是小整数和长文本,MySQL 下 lenient 一点不会报错,PG 对类型要求比较严格,如果表字段类型和写入类型不匹配,会直接抛异常,导致调度日志一直显示执行中。定位后我把handle_code字段改成integerhandle_msg改成text,再也没有出过类型转换的问题。

第二个是调度日志报表的日期分组查询。MySQL 的DATE_FORMAT可以根据%Y-%m-%d直接做字符串分组,PG 的TO_CHAR也能做,但如果字段本身是带时区的timestamptz,分组的时区基准会受数据库时区影响。我的解决办法是在连接串里强制指定serverTimezone=Asia/Shanghai,同时把日志表里触发时间字段建为timestamp without time zone,避免时区错乱导致报表统计偏差。

5.3 关于连接池和性能,再说几句

xxl-job 默认用的是 HikariCP,连接池大小可以在application.properties里配置。调度中心的并发压力通常不会高到需要很大的连接池,maximum-pool-size设成 20 足够。但 PG 数据库服务器的max_connections要提前确认,如果 PG 实例还有其他业务共用,连接数爆了反而是最尴尬的。

另外,PG 的自动清理机制和 MySQL 不太一样,xxl-job 的调度日志表会越来越大,建议定期清理。官方自带的控制台点"清理日志"就能触发DELETE,但大表直接 DELETE 会比较慢,有条件的话可以在 PG 里做分区表,按月份分区,或者定期用 TRUNCATE 归档后删除,能省掉不少 I/O。

最后分享一个小经验

这套改造方案我已经在测试环境稳定跑了两周,任务调度、日志回调、失败重试都正常。如果你也在做同样的事,我的建议是:不要试图用 ORM 的自动建表功能绕过 SQL 方言问题,MyBatis 是不会帮你翻译 SQL 的,老老实实把 XML 逐条过一遍最省心。另外就是建表脚本和 Mapper XML 改完之后,先用一个最小任务跑通全链路,再进行数据迁移,这样即使有问题,定位范围也会小很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询