使用场景

  1. 单机服务端场景(路由器部署,测试支持-Xms32m -Xmx32m配置),一键部署,直接使用
  2. electron 集成使用,使用electron-egg框架,直接启动时集成,无需依赖mysql
  3. 单机测试非常方便,admin + executor集成在一起,一键调度,支持shell、powershell等场景。

具体代码:https://github.com/BoomManPro/xxl-job/tree/boommanpro/feat/sqliteDbAdmin

  1. 额外魔改了前端页面,merge xxl-job-front前端,使用起来比原版好用。
  2. 支持pushplus推送,重构了alarm模块,可支持其他推送。

可能的TODO

  1. XXL-JOB的python、shell、powershell脚本市场。
  2. 调度锁按 database-id 自动选择 MySQL / SQLite 实现(当前分支固定使用 SQLite 实现,见下文“调度锁”)。

魔改链路

  1. 关于锁部分通过重构代码,判断是否是sqlite模式,采用不同sql执行。
  2. 关于其他涉及语法差异部分,使用mybatis的databaseId执行不同类型sql数据库。
  3. spring.datasource.hikari.connection-test-query=PRAGMA journal_mode=WAL; 解决sqlite并发问题
  4. 通过集成XxlJobExecutorConfig实现admin端绑定执行器一体化。

实现细节(对照分支代码)

分支基于 XXL-JOB 2.4.1-SNAPSHOT(Spring Boot 2.7.9),改动集中在 xxl-job-admin 模块,完整代码见 分支 boommanpro/feat/sqliteDbAdmin。

1. 依赖与配置

引入 SQLite JDBC 驱动(pom.xml):

<!-- sqlite -->
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.34.0</version>
</dependency>

数据源与 MyBatis 配置(application.properties,数据库文件路径改成你本机的):

### mybatis
mybatis.configuration.database-id=sqlite
mybatis.mapper-locations=classpath:/mybatis-mapper/*Mapper.xml
#mybatis.type-aliases-package=com.xxl.job.admin.core.model
### xxl-job, datasource
#spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
#spring.datasource.username=root
#spring.datasource.password=root_pwd
## xxl-job sqlite
spring.datasource.url=jdbc:sqlite:/path/to/xxl-job/doc/sqlite/test.db
spring.datasource.driver-class-name=org.sqlite.JDBC
### datasource-pool
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.auto-commit=true
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.pool-name=HikariCP
spring.datasource.hikari.max-lifetime=900000
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.connection-test-query=PRAGMA journal_mode=WAL;
spring.datasource.hikari.validation-timeout=1000
  • mybatis.configuration.database-id=sqlite:既告诉 MyBatis 走 sqlite 方言的 SQL,也是下面数据源配置生效的开关。

  • PRAGMA journal_mode=WAL:WAL 模式一旦设置就写进数据库文件,对之后所有连接持续生效(WAL 是持久化的日志模式),这里借 Hikari 的连接校验语句保证它被启用。WAL 下读和写可以并发,但同一时刻仍然只允许一个写事务。

  • 仓库自带一份已建好 8 张表的数据库文件 doc/sqlite/test.db,复制一份改路径即可使用。

2. 数据源

只在 database-id 为 sqlite 时生效。额外把文本日期的解析格式设为 yyyy-MM-dd HH:mm:ss:初始化数据里的时间是“2018-11-03 22:21:31”这样的文本,而 sqlite-jdbc 默认按带毫秒的 yyyy-MM-dd HH:mm:ss.SSS 解析,不指定会读取失败;程序运行后新写入的时间按驱动默认存成毫秒时间戳,库里两种格式并存(DataSourceTypeConfiguration.java):

@Bean
@ConditionalOnProperty(value = "mybatis.configuration.database-id", havingValue = "sqlite")
@ConfigurationProperties(prefix = "spring.datasource.hikari")
public HikariDataSource dataSource(DataSourceProperties properties) {
    HikariDataSource dataSource = createDataSource(properties, HikariDataSource.class);
    if (StringUtils.hasText(properties.getName())) {
        dataSource.setPoolName(properties.getName());
    }
    Properties dataSourceProperties = new SQLiteConfig().toProperties();
    dataSourceProperties.setProperty(SQLiteConfig.Pragma.DATE_STRING_FORMAT.pragmaName, "yyyy-MM-dd HH:mm:ss");
    dataSource.setDataSourceProperties(dataSourceProperties);
    log.info("load datasource for sqlite type");
    return dataSource;
}

3. SQL 方言差异:MyBatis databaseId

注册数据库厂商与 databaseId 的映射(DatabaseConfig.java):

@Bean
public DatabaseIdProvider getDatabaseIdProvider() {
    Properties properties = new Properties();
    properties.setProperty("Oracle", "oracle");
    properties.setProperty("MySQL", "mysql");
    properties.setProperty("DB2", "db2");
    properties.setProperty("Derby", "derby");
    properties.setProperty("H2", "h2");
    properties.setProperty("HSQL", "hsql");
    properties.setProperty("Informix", "informix");
    properties.setProperty("MS-SQL", "ms-sql");
    properties.setProperty("PostgreSQL", "postgresql");
    properties.setProperty("Sybase", "sybase");
    properties.setProperty("Hana", "hana");
    properties.setProperty("SQLite", "sqlite");

    DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
    databaseIdProvider.setProperties(properties);
    return databaseIdProvider;
}

Mapper 里给有方言差异的 SQL 写一份 databaseId="sqlite" 的版本,MyBatis 会优先使用和当前数据库匹配的语句。以注册表清理为例,MySQL 版用 DATE_ADD,SQLite 没有这个函数(XxlJobRegistryMapper.xml):

<select id="findDead" parameterType="java.util.HashMap" resultType="java.lang.Integer" >
		SELECT t.id
		FROM xxl_job_registry AS t
		WHERE t.update_time <![CDATA[ < ]]> DATE_ADD(#{nowTime},INTERVAL -#{timeout} SECOND)
	</select>
	<select id="findDead" parameterType="java.util.HashMap" resultType="java.lang.Integer" databaseId="sqlite">
		SELECT t.id
		FROM xxl_job_registry AS t
		WHERE t.update_time <![CDATA[ < ]]> #{deadDate}
	</select>

SQLite 版本的截止时间改成在 Java 里算好再传进来(JobRegistryHelper.java):

Date deadDate = LocalDateTimeUtils.toDate(LocalDateTime.now().minusSeconds(RegistryConfig.DEAD_TIMEOUT));
List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().findDead(RegistryConfig.DEAD_TIMEOUT, new Date(),deadDate);

4. 调度锁

XXL-JOB 调度线程每轮都先执行一条加锁 SQL,保证多个调度中心不会重复调度。把这条 SQL 抽成接口,分别实现(XxlJobSqlConfigService.java、MySQLDbSqlConfigService.java、SqliteDbSqlConfigService.java):

package com.xxl.job.admin.core.util;

public interface XxlJobSqlConfigService {

    String getLockSql();
}

package com.xxl.job.admin.core.util;

import org.springframework.stereotype.Service;

@Service
public class MySQLDbSqlConfigService implements XxlJobSqlConfigService{
    @Override
    public String getLockSql() {
        return "select * from xxl_job_lock where lock_name = 'schedule_lock' for update";
    }
}

package com.xxl.job.admin.core.util;

import org.springframework.stereotype.Service;

@Service
public class SqliteDbSqlConfigService implements XxlJobSqlConfigService{
    @Override
    public String getLockSql() {
        return "select * from xxl_job_lock where lock_name = 'schedule_lock'";
    }
}

调度线程里使用(JobScheduleHelper.java):

connAutoCommit = conn.getAutoCommit();
conn.setAutoCommit(false);
preparedStatement = conn.prepareStatement(xxlJobSqlConfigService.getLockSql());
preparedStatement.execute();
  • SQLite 不支持 SELECT ... FOR UPDATE,所以 SQLite 版本去掉了 for update。单机部署(只有一个调度中心)时没有问题,但这条语句在 SQLite 下不再起分布式锁的作用,不能多实例部署 admin。

  • 当前分支在 XxlJobAdminConfig.java 里直接返回 SQLite 实现:

public XxlJobSqlConfigService xxlJobSqlConfigService(){
    return sqliteDbSqlConfigService;
}

如果要在同一份代码里切回 MySQL,需要改成按 database-id 选择对应实现。

5. admin 与执行器合一

通过 xxl.job.executor.enable=true 在 admin 进程里直接启动一个执行器,并注册到本机 admin,一个进程就能完成调度和执行(XxlJobConfig.java):

@Slf4j
@Configuration
@ConditionalOnProperty(value = "xxl.job.executor.enable", havingValue = "true")
public class XxlJobConfig {


    @Bean
    public SampleXxlJob sampleXxlJob() {
        return new SampleXxlJob();
    }

    @Bean
    @ConfigurationProperties(prefix = "xxl.job")
    public XxlJobExecutorConfig xxlJobExecutorConfig() {
        return new XxlJobExecutorConfig();
    }


    @Bean
    public XxlJobSpringExecutor xxlJobExecutor(XxlJobExecutorConfig xxlJobExecutorConfig) {
        log.info(">>>>>>>>>>> xxl-job config init.");
        return new XxlJobSpringExecutor(xxlJobExecutorConfig);
    }



}
xxl.job.accessToken=default_token
xxl.job.executor.enable=true
xxl.job.admin-addresses=http://127.0.0.1:8080/
xxl.job.executor.appname=xxl-job-executor-sample
xxl.job.executor.address=
xxl.job.executor.ip=
xxl.job.executor.port=9999
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
xxl.job.executor.logretentiondays=30

6. PushPlus 告警

告警类型新增 PushPlus(AlarmType.java),按任务配置推送的 token 和群组(PushPlusAlarmConfig.java),发送逻辑见 PushPlushJobAlarm.java:

@Getter
@AllArgsConstructor
public enum AlarmType {

    EMAIL("email", "通过邮件发送告警信息"),

    PUSH_PLUSH("push_plush", "通过PushPlus发送告警信息"),

    ;

    private final String value;

    private final String desc;
}
@Data
public class PushPlusAlarmConfig {
    private String desc;

    private List<Single> configs;

    @Data
    public static class Single {
        private String token;

        private String topic;
    }
}