springboot集成quartz 和redis做分布式锁

前段时间接了一个需求是抽离老系统中的批量任务,单独搭建一个项目实现批量任务的管理运行。
主要支持如下功能:

  1. 手动重启,关闭任务
  2. 支持水平扩展
  3. 老系统的希尔脚本管理执行
  4. 针对特定参数配置运行特定的批量任务,比如查询一段时间的数据库数据之类

选型方面:

  1. quartz是比较成熟的调度框架,经历市场的考验,加上以前用过,比较熟悉相关api,所以调度程序选择quartz
  2. 因为涉及到水平扩展,不能只保证单节点运行,所以需要加上分布式锁,目前用的比较多的是redis分布式锁,数据库行锁,zookeeper分布式锁
  3. 考虑到数据库行锁容易锁表,影响性能,不太适合
  4. zk比较稳定,性能也是我比较倾向的选型,但因为公司原来架构没有搭建zk,如果单纯为了批量任务运行搭建一个zk比较奢侈,暂时放弃这种方案
  5. 最后就是redis了,redis分布式锁原来看过,没用过,基于redis本身的一些特性实现锁,目前可正常完成满足业务需求

中间踩了很多坑,遇见很多问题,且听我一一道来。
既然能看见这篇文章相信很多人对于quartz有了大致的了解,为下文扩展方便,还是简单描述一下吧

  1. quartz本身的job相关信息支持两种方式保存,在内存中和在数据库中
  2. 另外quartz本身自带行锁,所以如果想用行锁实现分布式部署的不需要自己实现
  3. 如果想基于数据库实现quartzjob的保存,需要在数据库创建quartz相关表结构,具体DDL可从quartz官网获取,本文末也会提供以供参考
  4. quartz可以在你项目的resource目录下加个配置文件quartz.peoperties配置相关信息,配置下文会简单贴出,仅供参考,具体需要根据业务需求自行修改
  5. 如果配置文件配置job信息根据数据库保存,quartz会默认从数据库查quat_相关前缀的表进行job信息保存,找不到会抛出异常

下面简单说一下我搭建的项目吧,经历了生产的实践,目前在生产正常运行,但还是有很多改进的空间,项目源码我会放在文末,有疑问可留言一起探讨
在这里插入图片描述
代码结构如上所示,下面针对几个关键类做简单说明
在这里插入图片描述

  1. config 目录下有quartz核心配置类和redis核心配置类,主要是对redis和quartz的相关配置做管理,是整个项目核心类,还有一个类是为了解决项目加载文件顺序问题和获取应用上下文顺序而选择的折中方案,容后再续
  2. controller包下面主要存放的是针对job的增删改接口以及关闭,开启job接口
  3. job包下面主要存的是需要管理的job,所有具体的业务job继承一个抽象类abstractjob,这个抽象类主要是做了分布式锁集成和job执行结果的落库
package cn.newhope.batch.job;

import com.alibaba.fastjson.JSONObject;

import cn.newhope.batch.entity.JobResult;
import cn.newhope.batch.service.JobResultService;
import cn.newhope.batch.util.CommUtil;
import cn.newhope.batch.util.RedisLock;
import cn.newhope.batch.util.RedisUtils;
import cn.newhope.batch.util.ServiceHelper;

import org.quartz.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;

import java.util.Date;
import java.util.UUID;
@DisallowConcurrentExecution
@Component
public abstract class AbstractJob implements Job {

    private Logger logger = LoggerFactory.getLogger(AbstractJob.class);

    private String requestId= UUID.randomUUID().toString();

    @Autowired
    RedisUtils redisUtil;

    @Autowired
    private JobResultService jobResultService;

    /**
     *  获取锁方法
     * @param executorContext
     * @return
     */
    public boolean getLock(JobExecutionContext executorContext){

        JobDataMap map = executorContext.getMergedJobDataMap();
        String jarPath = map.getString("jarPath");
        String parameter = map.getString("parameter");
        String vmParam = map.getString("vmParam");
        logger.info("Running Job name : {} ", map.getString("name"));
        logger.info("Running Job description : " + map.getString("JobDescription"));
        logger.info("Running Job group: {} ", map.getString("group"));
        logger.info("Running Job cron : " + map.getString("cronExpression"));
        logger.info("Running Job jar path : {} ", jarPath);
        logger.info("Running Job parameter : {} ", parameter);
        logger.info("Running Job vmParam : {} ", vmParam);
        String lockKey=map.getString("group")+"_"+map.getString("name");
        boolean flag=false;
        Jedis jedis= ServiceHelper.getJedisPool().getResource();
        try {
            logger.info("lockKey:"+lockKey+"requestId :"+requestId);
            flag= RedisLock.tryGetDistributedLock(jedis,lockKey,requestId,100000);
        }catch (Exception e){
            logger.info("锁获取异常",e);
        }finally {
            jedis.close();
        }
        return flag;
    }

    public boolean releaseLock(JobExecutionContext executorContext){

        JobDataMap map = executorContext.getMergedJobDataMap();
        String jarPath = map.getString("jarPath");
        String parameter = map.getString("parameter");
        String vmParam = map.getString("vmParam");
        logger.info("Running Job name : {} ", map.getString("name"));
        logger.info("Running Job description : " + map.getString("JobDescription"));
        logger.info("Running Job group: {} ", map.getString("group"));
        logger.info("Running Job cron : " + map.getString("cronExpression"));
        logger.info("Running Job jar path : {} ", jarPath);
        logger.info("Running Job parameter : {} ", parameter);
        logger.info("Running Job vmParam : {} ", vmParam);
        String lockKey=map.getString("group")+"_"+map.getString("name");
        Jedis jedis= ServiceHelper.getJedisPool().getResource();
        boolean flag=false;
        try {
             flag= RedisLock.releaseDistributedLock(jedis,lockKey,requestId);
        }catch (Exception e){
            logger.info("锁获取异常",e);
        }finally {
            jedis.close();
        }
        return flag;
    }

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        JobResult jobResult=new JobResult();
        String uuid=CommUtil.get32UUID();
        String jobKey=jobExecutionContext.getJobDetail().getKey().getGroup()+"_"+jobExecutionContext.getJobDetail().getKey().getName();
        jobResult.setJobKey(jobKey);
        jobResult.setResultId(uuid);
        try {
            if(getLock(jobExecutionContext)){
                logger.info("任务调度拿到锁成功"+jobExecutionContext.getJobDetail().getKey());
                jobResult.setStartTime(new Date());
                jobResult.setStatus("0");
                jobResultService.insert(jobResult);
                //执行job
                JobDataMap map = jobExecutionContext.getMergedJobDataMap();
                executeJob(map.getString("parameter"));
                jobResult.setEndTime(new Date());
                jobResult.setStatus("1");
                jobResultService.updateJobResultById(jobResult);
            }else{
                logger.info("任务调度拿到锁失败"+jobExecutionContext.getJobDetail().getKey());
            }
            releaseLock(jobExecutionContext);
        }catch (Exception e){
            jobResult.setEndTime(new Date());
            jobResult.setStatus("2");
            jobResult.setExceptionInfo(JSONObject.toJSONString(e));
            jobResultService.updateJobResultById(jobResult);
            logger.info("定时器执行异常",e);
        }

    }

    //job的业务方法
    public abstract void executeJob(String parameter);

}

quartz 核心类配置如下:

package cn.newhope.batch.config;

import org.quartz.spi.JobFactory;
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.beans.factory.config.PropertiesFactoryBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;

import java.io.IOException;
import java.util.Properties;
/**
 * Quartz的核心配置类
 */
@Configuration
public class ConfigureQuartz {
    //配置JobFactory
    @Bean
    public JobFactory jobFactory(ApplicationContext applicationContext) {
        AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
        jobFactory.setApplicationContext(applicationContext);
        return jobFactory;
    }
    /**
     * SchedulerFactoryBean这个类的真正作用提供了对org.quartz.Scheduler的创建与配置,并且会管理它的生命周期与Spring同步。
     * org.quartz.Scheduler: 调度器。所有的调度都是由它控制。
     * @param dataSource 为SchedulerFactory配置数据源
     * @param jobFactory 为SchedulerFactory配置JobFactory
     */
    @Bean
    public SchedulerFactoryBean schedulerFactoryBean(JobFactory jobFactory) throws Exception {
        SchedulerFactoryBean factory = new SchedulerFactoryBean();
        //可选,QuartzScheduler启动时更新己存在的Job,这样就不用每次修改targetObject后删除qrtz_job_details表对应记录
        factory.setOverwriteExistingJobs(true);
        factory.setAutoStartup(true); // 设置自行启动
       // factory.setDataSource(dataSource);
        factory.setJobFactory(jobFactory);
        factory.setQuartzProperties(quartzProperties());
        //factory.setGlobalTriggerListeners(new SimpleTriggerListener());
        return factory;
    }
    //从quartz.properties文件中读取Quartz配置属性
    @Bean
    public Properties quartzProperties() throws IOException {
        PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean();
        propertiesFactoryBean.setLocation(new ClassPathResource("/quartz.properties"));
        propertiesFactoryBean.afterPropertiesSet();
        return propertiesFactoryBean.getObject();
    }
    //配置JobFactory,为quartz作业添加自动连接支持
    public final class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements
            ApplicationContextAware {
        private transient AutowireCapableBeanFactory beanFactory;
        @Override
        public void setApplicationContext(final ApplicationContext context) {
            beanFactory = context.getAutowireCapableBeanFactory();
        }
        @Override
        protected Object createJobInstance(final TriggerFiredBundle bundle) throws Exception {
            final Object job = super.createJobInstance(bundle);
            beanFactory.autowireBean(job);
            return job;
        }
    }
}

quartz.properties配置如下:(不配置的话quartz会读取默认配置,可以点开quartz目录结构看到默认配置的文件)

org.quartz.scheduler.instanceName: DefaultQuartzScheduler
org.quartz.scheduler.rmi.export: false
org.quartz.scheduler.rmi.proxy: false
org.quartz.scheduler.wrapJobExecutionInUserTransaction: false

org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount: 10
org.quartz.threadPool.threadPriority: 5
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread: true

org.quartz.jobStore.misfireThreshold: 60000

org.quartz.jobStore.class: org.quartz.simpl.RAMJobStore

针对这个配置类,有几个坑说一下:

  1. 正常情况针对quartz的job信息保存在哪里可通过上面配置文件里面的org.quartz.jobStore.class属性控制,但是如果你创建quartz调度工厂的时候如果设置了数据源属性,即
    在这里插入图片描述
    即使你设置为基于内存保存,quartz也会用数据库保存job信息,然后会用quartz自带的行锁,然后你会发现你的分布式锁没有意义。。。这个问题我找了好久,最后发现是这里的问题。
    原因是quartz如果设置了数据源的话,他会认为是基于数据库存储的,这个应该算是quartz的一个坑吧?
  2. 同事有点疑惑他继承我写的抽象job后,也没加注解啥的,为什么job可以添加依赖,并交给spring管理
    在这里插入图片描述
    这里会获取应用上下文并获取bean工厂,然后设置自动注入

分布式锁的代码如下,和网上的大同小异

package cn.newhope.batch.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;

import java.util.Collections;

public class RedisLock {


    private static final Long RELEASE_SUCCESS = 1L;
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final String KEY_PREFIX="DE_BATCH_LOCK_";
    private static Logger logger= LoggerFactory.getLogger(RedisLock.class);
    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(KEY_PREFIX+lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(KEY_PREFIX+lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        String value=jedis.get(KEY_PREFIX+lockKey);
        logger.info("拿锁失败,当前锁的value值是"+value);
        return false;

    }
}

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

关于分布式锁,有两点想说:

  1. requestID的问题,为什么要设置一个唯一性id作为redis的value呢?不知道有没有想过这个问题,原因是这样的,每次调度程序会生成一个id作为value尝试从redis拿锁,如果拿到的话执行下面的业务job,等执行完会去redis释放锁,释放锁的依据是什么呢?就是这个请求id。简单点说,这样做主要是为了避免误删除别的客户端加的锁
  2. redis为啥能实现锁唯一,保证同一时间只有一个程序能拿到锁?因为加锁解锁操作是通过lua脚本实现的,而redis有个特性就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

为了能将老系统上面的定时任务迁移过来统一管理,有一个需求是java定时任务执行shell脚本,这个没啥好说的,网上有好多现成的demo,我在后文的项目中有一个job专门用来运行shell脚本,大家可以参考一下,这里贴出我在网上找的一个核心工具类,自测运行结果ok

package cn.newhope.batch.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;

/**
 * Java执行shell脚本工具类
 */
public class ShellExcutor {
    private static Logger log = LoggerFactory.getLogger(ShellExcutor.class);

    /**
     * 脚本文件具体执行及脚本执行过程探测
     * @param script 脚本文件绝对路径
     * @throws Exception
     */
    public void callScript(String script) throws Exception{
        try {
            String cmd = "sh " + script;

            //启动独立线程等待process执行完成
            CommandWaitForThread commandThread = new CommandWaitForThread(cmd);
            commandThread.start();

            while (!commandThread.isFinish()) {
                log.info("shell " + script + " 还未执行完毕,10s后重新探测");
                Thread.sleep(10000);
            }

            //检查脚本执行结果状态码
            if(commandThread.getExitValue() != 0){
                throw new Exception("shell " + script + "执行失败,exitValue = " + commandThread.getExitValue());
            }
            log.info("shell " + script + "执行成功,exitValue = " + commandThread.getExitValue());
        }
        catch (Exception e){
            throw new Exception("执行脚本发生异常,脚本路径" + script, e);
        }
    }

    /**
     * 脚本函数执行线程
     */
    public class CommandWaitForThread extends Thread {

        private String cmd;
        private boolean finish = false;
        private int exitValue = -1;

        public CommandWaitForThread(String cmd) {
            this.cmd = cmd;
        }

        public void run(){
            try {
                //执行脚本并等待脚本执行完成
                Process process = Runtime.getRuntime().exec(cmd);

                //写出脚本执行中的过程信息
                BufferedReader infoInput = new BufferedReader(new InputStreamReader(process.getInputStream()));
                BufferedReader errorInput = new BufferedReader(new InputStreamReader(process.getErrorStream()));
                String line = "";
                while ((line = infoInput.readLine()) != null) {
                    log.info(line);
                }
                while ((line = errorInput.readLine()) != null) {
                    log.error(line);
                }
                infoInput.close();
                errorInput.close();

                //阻塞执行线程直至脚本执行完成后返回
                this.exitValue = process.waitFor();
            } catch (Throwable e) {
                log.error("CommandWaitForThread accure exception,shell " + cmd, e);
                exitValue = 110;
            } finally {
                finish = true;
            }
        }

        public boolean isFinish() {
            return finish;
        }

        public void setFinish(boolean finish) {
            this.finish = finish;
        }

        public int getExitValue() {
            return exitValue;
        }
    }
}

总结:

有几个需要完善的地方

  1. 针对任务执行出现异常的情况,可以加一个监听器监听job运行情况,有异常情况可以实现邮件通知关联人
  2. 随着业务扩展,可能需要redis集群来部署,这时候,分布式锁要做一些修改
  3. 现在项目代码能满足要求,但是结构划分不合理,可扩展性不强等原因,后续可以针对性做一些优化

另附
quartz建表依赖表结构:

自己设计的项目依赖job实体表和job运行结果表

#job实体表
CREATE TABLE `t_job_entity` (
  `entity_id` varchar(32) NOT NULL,
  `entity_name` varchar(64) DEFAULT NULL ,
  `entity_group` varchar(64) DEFAULT NULL,
  `cron` varchar(32) DEFAULT NULL,
  `parameter` varchar(255) DEFAULT NULL,
  `description` varchar(255) DEFAULT NULL,
  `job_class` varchar(255) DEFAULT NULL,
  `jar_path` varchar(255) DEFAULT NULL,
  `status` varchar(16) DEFAULT '',
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT 'system' COMMENT '创建者',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT 'system' COMMENT '修改者',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`entity_id`),
  UNIQUE KEY `entity_name` (`entity_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

#job运行结果表
CREATE TABLE `t_job_result` (
  `result_id` varchar(32) NOT NULL,
  `job_key` varchar(64) DEFAULT NULL,
  `start_time` timestamp NULL DEFAULT NULL,
  `end_time` timestamp NULL DEFAULT NULL,
  `status` varchar(16) DEFAULT NULL,
  `exception_info` longtext,
  `description` varchar(255) DEFAULT NULL,
  `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT 'system' COMMENT '创建者',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT 'system' COMMENT '修改者',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`result_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


quartz官网文档
最新的quartz表结构定义可通过如下方式获取:
quartz 官网这个目录有这样一句话:
quartz表结构指引目录
在这里插入图片描述
然后你会发现这个目录没有你想要的表结构。。。最后在下面的源码目录下找到想要的表sql

quartz-2.3.0-distribution.tar\quartz-2.3.0-distribution\quartz-2.3.0-SNAPSHOT\src\org\quartz\impl\jdbcjobstore

quartz相关配置官网描述
注:因为是外网,网速可能较慢需要耐心等等
最后附上我的项目地址:(注:涉及到公司机密东西已删除整理,本代码只能用于个人学习请勿用于商业用途
项目git地址

  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值