
简介xxl-job是流行的分布式任务调度平台官方版本默认使用MySQL存储若想部署到PostgreSQL需要自行改造。这里提供的是基于xxl-job 2.4.1官方源码完成适配修改的完整工程让调度中心同时支持MySQL与PostgreSQL两种数据库用户只需修改配置文件即可自由切换并随包附带两套建库脚本可大大降低部署切换门槛。资源包约1.8MB共257个文件其中133个Java文件承载后端核心改造逻辑35个JS与12个CSS负责前端页面交互30个XML与6个properties用于Spring、MyBatis等框架配置11个FTL为调度报表模板另有2个SQL脚本、Dockerfile等覆盖从数据库初始化到容器化上线的全过程目录结构清晰便于定位。已有1390人学习下载适合正在使用或计划迁移到PostgreSQL的Java开发人员、运维工程师既能对照源码逐行理解适配思路也能直接编译打包用于生产环境具有较高参考价值。1. 适配前的整体评估为什么要动官方源码先说结论XXL-JOB 2.4.1 官方版本默认只支持MySQL作为调度中心的后端存储想跑在PostgreSQL上不改源代码基本没戏。我接手这个需求时第一反应是查一下官方有没有提供多数据库支持的开关结果翻遍2.4.1的源码和配置文档确认了一个事实XXL-JOB的SQL方言绑定得很死所有Mapper XML里的分页、时间函数、主键生成策略都是按MySQL的语法写的而且没有做方言适配层。虽然配置中心可以配任何JDBC URL但脚本一跑就报语法错误数据表初始化也依赖于MySQL特有的自增列申明方式。说白了官方这套调度平台在设计时就只拥抱了MySQL生态其他数据库都是能连上但没法用的状态。那为什么非要用PostgreSQL我当时面对的实际场景有两个。一是项目组统一数据库选型集团规定新系统一律采用PostgreSQL不允许再上MySQL实例二是现有的历史数据量不大但后续要做跨库数据同步如果调度平台能直接写入PostgreSQL就能省掉一层数据搬运。考虑到改造的收益大于成本干脆动手适配。改造目标也明确不改动XXL-JOB的任何对外接口、管理界面展示逻辑、调度任务执行行为只做数据库层的适配保证所有功能与MySQL版本一致包括任务注册、日志回调、调度报表、失败重试等。换句话说要让人感觉不出来的换了数据库。2. 源码改造前的核心问题清单在动手改代码之前我把XXL-JOB 2.4.1的源码结构通读了一遍梳理出需要关注的核心改造点这里分享下我当时整理的清单。2.1 数据库表结构与初始化脚本XXL-JOB的SQL脚本位于源码目录的/doc/db/下官方只提供了tables_xxl_job.sqlMySQL版本。这一步的适配工作量不大因为表结构本身并不复杂核心是把MySQL的ENGINEInnoDB DEFAULT CHARSETutf8mb4等建表语句去掉或替换为PostgreSQL语法同时把自增主键的写法改掉。MySQL里的自增主键是这样写的id bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (id)PostgreSQL里要改成id BIGSERIAL PRIMARY KEY,BIGSERIAL是PostgreSQL特有的自增序列类型会在建表时自动创建一个序列对象。需要注意一点如果用BIGSERIALXXL-JOB往表里插入数据时如果指定了主键值序列不会自动更新后续自增可能撞键。稳妥的做法是建表后用setval手动把序列值推到当前最大ID或者在插入语句里不传主键。这点在后面踩坑环节我会详细讲。另外一个细节是索引长度。MySQL里varchar(255)的字段可以建普通索引但PostgreSQL里如果使用varchar且没有限制长度索引是可以直接建的而text类型则不允许直接建索引除非指定varchar_pattern_ops或使用表达式索引。XXL-JOB的日志表里trigger_msg、handle_msg等字段用的是text或longtext如果索引建在长文本字段上在PostgreSQL里就会报错。我当时的做法是所有longtext统一改成text只对短字段如job_group、job_desc等保留索引长文本字段如果有查询需求先创建varchar截断的表达式索引比如CREATE INDEX idx_trigger_msg ON xxl_job_log ((left(trigger_msg, 200)));这样既能满足模糊查询的性能要求又不会引发索引长度超限。2.2 MyBatis Mapper XML中的方言差异这部分是整个改造里工作量最大、也最容易出错的地方。XXL-JOB 2.4.1持久层用的是MyBatis所有的SQL都写在各个Mapper的XML文件里。我梳理了一遍需要动的文件集中在/xxl-job-admin/src/main/resources/mybatis-mapper/目录下大概有十几个XML文件。差异点主要集中在这几类第一分页语法。MySQL用LIMIT offset, sizePostgreSQL虽然也支持LIMIT但更标准的写法是LIMIT size OFFSET offset。实际上PostgreSQL的JDBC驱动是可以接受LIMIT ?, ?这种写法的但MyBatis参数绑定方式不同最好的做法是统一改成LIMIT #{offset}, #{size}的形式让PostgreSQL解析器去处理。第二时间函数。XXL-JOB里大量使用了NOW()函数这个两个数据库都支持但如果用了DATE_ADD(NOW(), INTERVAL 30 MINUTE)这种MySQL专用语法在控制台任务的调度时间计算和过期清理中很常见PostgreSQL就不认了要改成NOW() INTERVAL 30 minute或NOW() (30 || minutes)::interval。第三主键回填。MyBatis的useGeneratedKeys配合MySQL自增主键可以自动回填ID换成PostgreSQL的BIGSERIAL后会失效必须在插入语句里显式写RETURNING id并把MyBatis的insert标签里配置改成useGeneratedKeysfalse加selectKey子标签或者直接用RETURNING在insert标签里配合keyProperty返回。这块要仔细看每个Mapper漏一个就可能导致后续逻辑拿不到任务ID产生幽灵任务。我建议把每个XML文件打开后分成两步走第一步全局搜索DATE_ADD、NOW()、LIMIT、AUTO_INCREMENT这几个关键字把明显差异先处理掉第二步再逐条阅读SQL逻辑看有没有依赖MySQL隐式类型转换或特定排序规则的地方。2.3 配置项中的驱动类名与方言参数XXL-JOB管理台的application.properties中数据库连接配置原本是spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.passwordroot_pwd spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver适配后要改成spring.datasource.urljdbc:postgresql://127.0.0.1:5432/xxl_job spring.datasource.usernamepostgres spring.datasource.passwordpostgres_pwd spring.datasource.driver-class-nameorg.postgresql.Driver这里有个容易忽略点XXL-JOB本身没有使用MyBatis的分页插件而是自己在XML里写LIMIT所以mybatis.configuration里不需要额外配置dialect。但如果你的项目里引入了PageHelper等插件要注意它和手写LIMIT共存时的拦截冲突尽量保持和官方一致不引入多余组件。还有一个Spring Boot层面的隐藏依赖XXL-JOB是内嵌了HikariCP连接池的在PostgreSQL驱动下连接池的自动探测SQL是SELECT 1这个能正常工作。但如果你在配置里显式设置了spring.datasource.hikari.connection-test-query要确保查询语句兼容PostgreSQL别写成MySQL的语法。3. 核心改造细节直接可用的代码方案这一章我按实际开发顺序把改动过的模块逐一说明。每个点我都会给出修改前后的代码对比以及我当时为什么这么改的判断依据。3.1 初始化脚本改造从MySQL方言到PostgreSQL方言以xxl_job_info表为例MySQL版本的核心建表语句长这样CREATE TABLE xxl_job_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;改造后的PostgreSQL版本CREATE TABLE xxl_job_info ( id SERIAL PRIMARY KEY, job_group INT NOT NULL, job_desc VARCHAR(255) NOT NULL, ... add_time TIMESTAMP DEFAULT NULL, update_time TIMESTAMP DEFAULT NULL );我把每张表的datetime统一改成了TIMESTAMPtinyint(4)改成了SMALLINTdouble保持DOUBLE PRECISION。这里要提个细节MySQL的datetime默认值可以用CURRENT_TIMESTAMPPostgreSQL也支持但PostgreSQL的TIMESTAMP精度默认到微秒MySQL的datetime(0)只到秒。如果你在意这种差异建表时可以显式写成TIMESTAMP(0)。初始化脚本里还有一套初始化数据插入语句本身改动不大但PostgreSQL在插入数据前对自增主键的表有一个坑——如果你在插入语句里显式指定了id值比如官方脚本里写INSERT INTO xxl_job_group VALUES (1, 默认执行器, ...)PostgreSQL的SERIAL序列不会跟着更新。后面应用运行时报主键冲突就懵逼了。我的解决方法是在INSERT语句执行完毕后追加一行SELECT setval(xxl_job_group_id_seq, (SELECT MAX(id) FROM xxl_job_group));把序列推进到当前表的最大ID。注意序列名称的默认格式是表名_主键名_seq如果建表时指定的不是SERIAL而是GENERATED BY DEFAULT AS IDENTITY序列名会变成表名_id_seq确认方法是在psql里执行\ds查看。3.2 Mapper XML中SQL的逐文件排查与重写这一步我按文件逐个处理下面挑几个典型的改造点展开说。XxlJobLogMapper.xml里的分页查询是最先暴露问题的原SQL写法是select idpageList resultMapXxlJobLog SELECT * FROM xxl_job_log where if testjobGroup!0 and jobGroup! AND job_group #{jobGroup} /if ... /where ORDER BY id DESC LIMIT #{offset}, #{pagesize} /selectPostgreSQL的JDBC驱动不支持这种LIMIT ?, ?的语法绑定方式直接运行会报SQLGrammarException。我的改法是LIMIT #{pagesize} OFFSET #{offset}改了之后还要确认offset和pagesize参数在Java代码里传的是int类型如果传的是IntegerPostgreSQL的PreparedStatement参数索引会按smallint处理极端情况下超过32767会报错。我处理时统一在传入前强转int。再说时间函数。在XxlJobLogMapper.xml的清理逻辑里官方用了一段DELETE FROM xxl_job_log WHERE id IN ( SELECT id FROM xxl_job_log WHERE trigger_time DATE_ADD(NOW(), INTERVAL -#{retentionDays} DAY) )这段在PostgreSQL里会直接报语法错误。我改成了DELETE FROM xxl_job_log WHERE id IN ( SELECT id FROM xxl_job_log WHERE trigger_time NOW() - (#{retentionDays} || days)::interval )注意#{retentionDays} || days在MyBatis预编译中如果retentionDays是整数PostgreSQL会把INTERVAL拼接结果正确解析。如果嫌这种写法不直观也可以直接用make_interval(days #{retentionDays})函数。XxlJobRegistryMapper.xml里也有类似的过期清理逻辑不过它用的是NOW() - INTERVAL 90 SECONDMySQL写法和PostgreSQL写法差异一致我统一改成了NOW() - INTERVAL 90 seconds或NOW() - (90 || seconds)::interval。这一步建议全局搜索替换别一个个文件找。还有一个特别容易踩坑的地方是XxlJobGroupMapper.xml它的load查询中使用了WHERE id ?MySQL的id是intPostgreSQL的SERIAL也是int这个没有差异但如果你在MySQL版本里把id定义为int(11)PostgreSQL里SERIAL实际映射为integer在MyBatis的parameterType为Long时MyBatis会自动转成Integer通常没问题但传null进去时PostgreSQL的IN查询就容易出歧义。这点在写Mapper时留意下就行。3.3 主键回填策略的调整XXL-JOB在新增任务、新增执行器时会在Service层拿到插入后的主键ID用于后续的报警配置、权限关联。MySQL模式下只要在insert语句中配置useGeneratedKeystrue keyPropertyid即可自动回填。PostgreSQL下这招失效了因为JDBC驱动对BIGSERIAL的支持依赖于RETURN_GENERATED_KEYS但MyBatis默认生成的行为不总是可靠。我采用的方案是显式使用selectKey。以XxlJobInfoMapper.xml的insert为例insert idsave parameterTypeXxlJobInfo selectKey resultTypejava.lang.Integer orderBEFORE keyPropertyid SELECT nextval(xxl_job_info_id_seq) /selectKey INSERT INTO xxl_job_info (id, job_group, job_desc, ...) VALUES (#{id}, #{jobGroup}, #{jobDesc}, ...) /insert这种orderBEFORE的好处是先在Java端拿到序列值再执行插入语句。注意序列名要和脚本中的SERIAL对应一致如果你在3.1节里的建表SQL写了GENERATED BY DEFAULT AS IDENTITY那这里就不能用nextval(xxl_job_info_id_seq)而是要改成SELECT nextval(xxl_job_info_id_seq)确认序列名或者干脆改回SERIAL。还有一种做法是利用PostgreSQL 10的GENERATED BY DEFAULT AS IDENTITY配合INSERT ... RETURNING idMyBatis的selectKey orderAFTER keyPropertyid resultTypejava.lang.IntegerSELECT LASTVAL()/selectKey也能拿到ID但并发场景下LASTVAL()受连接会话影响如果连接池复用了连接可能拿到别的会话的序列值。踩过一次坑后就改用BEFORE方案了稳妥。3.4 执行器注册与心跳检测中的特殊函数XXL-JOB的执行器注册表xxl_job_registry里有个字段update_time注册时通过NOW()写入当前时间这个没差异。但它的查询逻辑里用到了WHERE update_time NOW() - INTERVAL 90 SECOND来剔除失效的执行器MySQL的写法是DATE_SUB(NOW(), INTERVAL 90 SECOND)PostgreSQL的写法是NOW() - INTERVAL 90 seconds。这里要注意的是INTERVAL的单位名称在两个数据库里都能识别但MySQL允许不写引号PostgreSQL必须写单引号否则报语法错误。我还遇到了一个比较隐蔽的函数差异在XxlJobLogReportMapper.xml中统计报表时用到了DATE_FORMAT(trigger_time, %Y-%m-%d)这个在PostgreSQL里没有对应函数。查询结果集的日期分组逻辑如果走这个函数就直接报错。我的替代方案是改成to_char(trigger_time, YYYY-MM-DD)或者用DATE(trigger_time)。因为报表模块的日期分组是按天汇总to_char完全兼容但我发现有些团队用的是DATE_TRUNC(day, trigger_time)这个更贴近PostgreSQL的写法习惯。建议是全局搜索DATE_FORMAT、DATE_ADD、DATE_SUB、NOW()、IFNULL、LIMIT等关键词把MySQL特有问题全部找出来后续替换也方便。3.5 配置中心与调度线程内的数据源兼容性XXL-JOB的调度中心本身是Spring Boot应用数据源由HikariDataSource管理。PostgreSQL驱动和HikariCP能够直接兼容但要注意一个连接参数targetServerType和ApplicationName可以不用配而stringtypeunspecified这个参数值得关注。为什么提这个我在实际调用时发现对PostgreSQL的varchar类型传参时PreparedStatement的setString没问题但传入空字符串时PostgreSQL有时会把它当成还是NULL处理行为和MySQL不同。如果希望空字符串比较行为与MySQL一致连接字符串可以加一层spring.datasource.urljdbc:postgresql://127.0.0.1:5432/xxl_job?stringtypeunspecified加了stringtypeunspecified后JDBC会把所有字符串参数推断成适合目标列的类型减少类型转换异常。但这个参数在实际使用中也会带来性能损耗我这边任务量不大影响几乎感知不到。另外管理台里的/joblog明细查询有按时间范围过滤的功能用到了BETWEEN ? AND ?这个PostgreSQL没差异。但涉及TIMESTAMP和VARCHAR的比较时如果trigger_time列是TIMESTAMP而传入参数是StringPostgreSQL有时会拒绝隐式转换需要强制用CAST(? AS TIMESTAMP)这在Mapper里改起来工作量不小建议先把应用代码里所有传字符串到时间字段的地方找出来统一切成java.util.Date或LocalDateTime。我实际改造时把XxlJobLog实体类里的所有时间相关字段全换成Date类型再从Controller和Service层一层层影响下去排查了整整一个下午。4. 实操过程中的坑与排查方案改造过程中踩的坑不少挑几个典型的说这部分对准备照着做的人最有参考价值。4.1 任务调度日志报错找不到序列我从PostgreSQL的psql里手工执行建表脚本并导入数据后启动管理台一切正常也能正常注册执行器但手动执行一次任务后日志管理页面报了序列相关的SQL错误。错误信息大概是relation xxl_job_log_id_seq does not exist排查后发现原因我在3.1节用SERIAL建表PostgreSQL自动创建的序列名是xxl_job_log_id_seq但MyBatis的selectKey里我手写的是xxl_job_log_seq名称不对。改成正确的序列名后解决。这里有个规律SERIAL自动序列的命名规则是表名_字段名_seq。如果你建表时指定了主键名比如id BIGSERIAL序列就是表名_id_seq别写错了。4.2 定时任务重复触发这个问题比较隐蔽排查了很久。日常开发中偶尔会发现某个任务重复执行了两次但日志里看不出明显异常。后来发现是调度报表模块里的trigger_time字段类型映射问题。我把xxl_job_log表的trigger_time改成了TIMESTAMP但在Java代码中XxlJobLog实体的triggerTime字段用了LocalDateTime。MyBatis对LocalDateTime的映射没问题但PostgreSQL JDBC驱动对LOCAL DATETIME和TIMESTAMP WITHOUT TIME ZONE之间的转换有一个时区偏移问题。如果JVM默认时区和数据库时区不一致可能出现秒级或微秒级偏差导致调度中心在锁表中判断日志是否被正确处理时出错间接引发重复触发。我的解决办法数据库连接URL加serverTimezoneAsia/Shanghai这种时区参数PostgreSQL的驱动虽然没有直接的serverTimezone参数但可以通过JVM时区统一处理。更稳妥的做法是给spring.jackson.time-zone设置正确的时区同时让数据库字段使用TIMESTAMP WITH TIME ZONE类型并在JDBC URL配置TimeZoneAsia/Shanghai。这部分每个团队的环境不一样建议先把服务器时间和数据库时间统一再跑一轮触发测试确认。4.3 分页查询时的大偏移量性能优化改造完成后我把大量的历史调度日志导入PostgreSQL准备做一次压测。结果发现日志列表翻到后面几页时查询速度从最初的几十毫秒飙升到一秒以上。原因很明显XXL-JOB日志明细查询的SQL是ORDER BY id DESC LIMIT #{size} OFFSET #{offset}当offset达到几万时PostgreSQL需要扫描并丢弃前面的行性能自然差。这个问题MySQL同样存在只是PostgreSQL的OFFSET实现更直白代价更明显。优化方案有两个方向。第一个是在条件查询里加上id ?的游标参数代替深分页但XXL-JOB管理台的前端是官方写死的不太方便加参数。第二个是给xxl_job_log表的trigger_time和job_group等查询字段建复合索引减少排序的IO开销。我给xxl_job_log表的trigger_time DESC, id DESC建了复合索引实践经验是翻页性能提升了大概四五倍日常使用感知不到了。这里提醒一下PostgreSQL的查询规划器对复合索引的选择非常依赖统计信息建完索引后要执行一次ANALYZE xxl_job_log;让统计信息落库否则某些复杂查询依然会走全表扫描。4.4 控制台任务跑批的中文显示乱码这个坑其实是连接层和库层字符集不一致导致的。MySQL迁移到PostgreSQL时如果建库时没有指定UTF8编码PostgreSQL默认可能是SQL_ASCII或LATIN1中文任务描述在管理台显示乱码。解决办法是在创建数据库时强制指定编码CREATE DATABASE xxl_job WITH ENCODING UTF8 LC_COLLATE zh_CN.utf8 LC_CTYPE zh_CN.utf8 TEMPLATE template0;或者在已存在的库里调整但调整字符集比较麻烦。建议直接重建库。还有连接层驱动建议在JDBC URL里加characterEncodingUTF-8PostgreSQL驱动支持useUnicodetruecharacterEncodingUTF-8这些参数和MySQL不太一样参数名略有区别但charSetUTF-8也有效需测试确认。5. 适配完成后的验证清单与上线建议代码改完之后不能只跑一个任务就算通过。我整理了一份自测清单照着跑一遍基本能排查掉90%的隐藏问题。5.1 功能验证清单第一新增、编辑、删除执行器确认执行器的增删改查正常注册表心跳信息能正确写入和清理。第二新增、编辑、删除任务确认任务的全部CRUD操作正常特别是编辑后保存要触发明细更新别让任务列表里出现脏数据。第三手动执行一次任务观察管理台的调度日志确认能完整记录触发时间、执行结果、失败重试信息。第四启动一个定时任务等待至少一个调度周期验证调度逻辑在PostgreSQL存储下能正常运转执行器能正确接收指令并回调日志。第五失败重试故意让执行器返回失败状态观察管理台的失败重试次数和重试记录是否正确。第六首页报表数据确认调度次数、成功次数、失败次数的统计口径与MySQL版本一致尤其是按天汇总的数据能正确展示。第七日志清理配置一个30天自动清理观察调度日志的历史数据能被正确删除清理过程不阻塞正常调度。5.2 上线前的备份与监控建议因为改动了官方源码后续XXL-JOB官方升级时这些改动会面临合并冲突。我建议把定制过的Mapper XML文件和建表脚本单独放到一个/custom-sql目录下管理每次升级版本时先对比官方源码的变动点再决定是否重新打补丁。如果团队里有条件可以给这些改动打上注释标记方便追溯。数据库侧建议启用PostgreSQL的定期备份比如用pg_dump每天早上做一次全量备份调度日志这类递增数据量大的表可以单独做增量归档。XXL-JOB的日志表增长很快尤其是多执行器、高频率任务场景建议提前规划分区表以trigger_time月分区避免单表数据无限膨胀。监控方面除了管理台自带的报表建议在数据库侧对xxl_job_log表的占用空间做一个定时统计超过一定阈值就告警。我习惯用:SELECT pg_size_pretty(pg_total_relation_size(xxl_job_log));来快速确认表大小。如果是日志清理不及时导致膨胀可以在调度线程的低峰期执行VACUUM ANALYZE xxl_job_log;这能回收死元组并更新统计信息。6. 总结性的个人心得这套适配改造我完整走了一遍总共花了两天半时间其中建表和Mapper改造占了大头真正隐蔽的坑是序列名和时区问题这两处排查花费了比预期多得多的时间。改动官方源码这件事本身没有太多技术门槛关键是你要清楚每个改动点的边界在哪里——尽量不动核心调度逻辑只动数据库方言层才是可控的做法。如果只是想把XXL-JOB临时跑在PostgreSQL上做验证不改源码还有一个偷懒办法把PostgreSQL通过外部数据包装成MySQL协议比如用某些中间件做协议转换但这类方案生产环境稳定性没保障我不推荐。另外改完源码后一定要在项目里保留一套完整的补丁说明文档把每个文件的改动原因和原版代码行号记录下来别只靠代码注释不然三个月后你自己都忘了当初为什么这么改。我当时用了一个简单的对照表把每个Mapper文件的修改点记录成原SQL、新SQL、原因、影响范围的格式后来做版本升级时帮了大忙建议你也这样做。最后再分享一个小技巧如果你用的IDE是IntelliJ IDEA可以开启Local History功能每次大改动前做一个快照。这样即使代码被改坏了也能快速回退不用重新compare官方源码来恢复。我的那次时区排查就靠Local History翻回了好几个版本前的代码才发现问题出在实体类字段类型上。本文还有配套的精品资源点击获取