Elastic-Job动态修改定时任务-踩坑篇

「这是我参与11月更文挑战的第9天,活动详情查看:2021最后一次更文挑战

引言

最近小编一直在加班写需求,没有时间更文。项目中有一个需求,觉得挺有意思的,把做的思路分享给大家,需求是每个月的8号和20号我们系统会处理一些单子,这里将这两天称为审单日,如果超过这个时间的单子,需要系统在9号和20号的12:30,自动将单子撤销掉。如果8号或20号遇到周末,则需要提前或者延后到工作日作为审单,第二天的中午12:30做撤销单据的任务。

设计思路

针对这个问题我想了两个实现思路。

动态定时任务(放弃)

创建一个动态定时任务,每次执行任务成功后,修改定时任务的cron表达式,更新其下次执行的时间。处理撤销单据和获取撤销单据日的cron表达式同上面设计思路一致。
优点:

  • 只在需要的时间去执行代码

缺点:

  • 代码实现逻辑相对复杂
  • 担心Elastic-Job出现问题后的处理成本更大

定时任务-12:30分执行(使用)

设置一个定时任务,每天12:30分执行,创建一张表预置撤销单据日期,每次任务执行时,判断当前日期是否是撤销单据日,如果不是直接跳过,如果日期一致的话,则执行撤销单据的操作,并且将这一条撤销单据时间状态修改为已执行。
优点:

  • 实现代码逻辑特别容易

缺点:

  • 不够灵活,每日都需要执行一次定时任务,不能按照需要去执行

撤销时间表

-- auto-generated definition
create table cancel_schedule
(
    id           bigint auto_increment comment '主键' primary key,
    title        varchar(100)                       not null comment '标题',
    execute_time date                               not null comment '执行时间:',
    cron         varchar(50)                        not null comment 'cron表达式',
    status       int      default 0                 not null comment '状态 0-正常',
    remark       varchar(256)                       null comment '备注',
    created_at   datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    modified_at  datetime default CURRENT_TIMESTAMP not null comment '更新时间'
)
    comment '撤销时间表';
复制代码

初始化sql

INSERT INTO saos_csp_fop.fop_cancel_schedule (id, title, execute_time,cron, status, remark, created_at, modified_at) VALUES (1, '2021年11月的对公付款日后的取消', '2021-11-22','0 30 12 9 * ? *', 1, null, '2021-11-22 15:14:22', '2021-11-22 15:14:22');
INSERT INTO saos_csp_fop.fop_cancel_schedule (id, title, execute_time,cron, status, remark, created_at, modified_at) VALUES (2, '2021年12月的对公付款日', '2021-11-22','0 30 12 20 * ? *', 1, null, '2021-11-22 15:14:22', '2021-11-22 15:14:22');
复制代码

动态定时任务

初始化配置(DynamicElasticJobConfig)

import com.hanhang.service.listener.ElasticJobListener;
import com.dangdang.ddframe.job.event.JobEventConfiguration;
import com.dangdang.ddframe.job.event.rdb.JobEventRdbConfiguration;
import com.dangdang.ddframe.job.reg.zookeeper.ZookeeperConfiguration;
import com.dangdang.ddframe.job.reg.zookeeper.ZookeeperRegistryCenter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;
import javax.sql.DataSource;

/**
 * @ClassName DynamicElasticJobConfig
 * @Description 动态定时任务配置
 * @Author hanhang
 * @Date 2021/11/19 4:24 下午
 */
@Configuration
public class DynamicElasticJobConfig {
    /**
     * zookeeper的服务地址
     */
    @Value("${zk.server}")
    private String serverLists;
    /**
    * Elastic-Job的命名空间
    */
    @Value("${chjJob.zookeeper.namespace}")
    private String namespace;
    @Resource
    private DataSource dataSource;

    @Bean
    public ZookeeperConfiguration zookeeperConfiguration() {
        return new ZookeeperConfiguration(serverLists, namespace);
    }

    @Bean(initMethod = "init")
    public ZookeeperRegistryCenter zookeeperRegistryCenter(ZookeeperConfiguration zookeeperConfiguration){
        return new ZookeeperRegistryCenter(zookeeperConfiguration);
    }

    @Bean
    public ElasticJobListener elasticJobListener(){
        return new ElasticJobListener(100, 100);
    }

    /**
     * 将作业运行的痕迹进行持久化到DB
     *
     * @return
     */
    @Bean
    public JobEventConfiguration jobEventConfiguration() {
        return new JobEventRdbConfiguration(dataSource);
    }
}
复制代码

动态定时任务相关操作工具(ElasticJobHandler)

package com.hanhang.service.handler;

import com.dangdang.ddframe.job.api.simple.SimpleJob;
import com.dangdang.ddframe.job.config.JobCoreConfiguration;
import com.dangdang.ddframe.job.config.simple.SimpleJobConfiguration;
import com.dangdang.ddframe.job.event.JobEventConfiguration;
import com.dangdang.ddframe.job.lite.api.listener.ElasticJobListener;
import com.dangdang.ddframe.job.lite.config.LiteJobConfiguration;
import com.dangdang.ddframe.job.lite.internal.schedule.JobRegistry;
import com.dangdang.ddframe.job.lite.spring.api.SpringJobScheduler;
import com.dangdang.ddframe.job.reg.zookeeper.ZookeeperRegistryCenter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * @ClassName ElasticJobHandler
 * @Description 动态定时任务相关操作工具
 * @Author hanhang
 * @Date 2021/11/19 4:29 下午
 */
@Slf4j
@Component
public class ElasticJobHandler {

    @Autowired
    private ZookeeperRegistryCenter zookeeperRegistryCenter;
    @Resource
    private JobEventConfiguration jobEventConfiguration;
    @Resource
    private ElasticJobListener elasticJobListener;

    /***
     * 动态创建定时任务
     * @param jobName:定时任务名称
     * @param cron:表达式
     * @param shardingTotalCount:分片数量
     * @param instance:定时任务实例
     * @param parameters:参数
     * @param description:作业描述
     */
    public void addJob(String jobName, String cron, int shardingTotalCount, SimpleJob instance, String parameters, String description){
        log.info("动态创建定时任务:jobName = {}, cron = {}, shardingTotalCount = {}, parameters = {}", jobName, cron, shardingTotalCount, parameters);

        LiteJobConfiguration.Builder builder = LiteJobConfiguration.newBuilder(new SimpleJobConfiguration(
                JobCoreConfiguration.newBuilder(
                        jobName,
                        cron,
                        shardingTotalCount
                ).failover(true).jobParameter(parameters).description(description).build(),
                instance.getClass().getName()
        )).overwrite(true);
        LiteJobConfiguration liteJobConfiguration = builder.build();

        new SpringJobScheduler(instance,zookeeperRegistryCenter,liteJobConfiguration,jobEventConfiguration,elasticJobListener).init();
    }

    /**
     * 更新定时任务
     * @param jobName
     * @param cron
     */
    public void updateJob(String jobName, String cron) {
        log.info("更新定时任务:jobName = {}, cron = {}", jobName, cron);

        JobRegistry.getInstance().getJobScheduleController(jobName).rescheduleJob(cron);
    }

    /**
     * 删除定时任务
     * @param jobName
     */
    public void removeJob(String jobName){
        log.info("删除定时任务:jobName = {}", jobName);

        JobRegistry.getInstance().getJobScheduleController(jobName).shutdown();
    }
}
复制代码

overwrite(true)此处是开启job可被重写,方便修改任务的cron表达式

配置ElasticJobListener监听器(ElasticJobListener)

package com.hanhang.service.listener;

import com.dangdang.ddframe.job.executor.ShardingContexts;
import com.dangdang.ddframe.job.lite.api.listener.AbstractDistributeOnceElasticJobListener;

/**
 * @ClassName ElasticJobListener
 * @Description 监听器
 * 现分布式任务监听器
 * 如果任务有分片,分布式监听器会在总的任务开始前执行一次,结束时执行一次
 * @Author hanhang
 * @Date 2021/11/19 4:27 下午
 */
public class ElasticJobListener extends AbstractDistributeOnceElasticJobListener {

    public ElasticJobListener(long startedTimeoutMilliseconds, long completedTimeoutMilliseconds) {
        super(startedTimeoutMilliseconds,completedTimeoutMilliseconds);
    }

    @Override
    public void doBeforeJobExecutedAtLastStarted(ShardingContexts shardingContexts) {
    }

    @Override
    public void doAfterJobExecutedAtLastCompleted(ShardingContexts shardingContexts) {
        //任务执行完成后更新状态为已执行,当前未处理
    }
}
复制代码

动态任务执行类(PaymentCancelDynamicJob)

package com.hanhang.service.job;

import com.hanhang.biz.PaymentPlanItemBiz;
import com.hanhang.common.constants.LockConstant;
import com.hanhang.common.enumeration.PaymentPlanItemStatusEnum;
import com.hanhang.common.util.DateUtil;
import com.hanhang.domain.CancelSchedule;
import com.hanhang.domain.PaymentPlanItem;
import com.hanhang.facade.request.PaymentPlanItemRevokeRequest;
import com.hanhang.service.CancelScheduleService;
import com.hanhang.service.PaymentPlanItemService;
import com.chehejia.starter.job.annotation.ElasticJobConf;
import com.dangdang.ddframe.job.api.ShardingContext;
import com.dangdang.ddframe.job.api.simple.SimpleJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * @ClassName DynamicJob
 * @Description 动态定时任务执行
 * @Author hanhang
 * @Date 2021/11/19 4:37 下午
 */
@Component
@ElasticJobConf(name = "saos-csp-fop-service-PaymentItemCancel", cron = "0 30 12 * * ?",
        overwrite = true, description = "企业平台部-财经部-资金组-FOP-撤销定时任务")
@RequiredArgsConstructor
@Slf4j
public class PaymentCancelDynamicJob implements SimpleJob {
    @Resource
    private PaymentPlanItemService itemService;
    @Resource
    private CuratorFramework curatorClient;
    @Resource
    private PaymentPlanItemBiz itemBiz;
    @Resource
    private CancelScheduleService cancelScheduleService;

    /**
     * 业务执行逻辑
     *
     * @param shardingContext
     */
    @Override
    public void execute(ShardingContext shardingContext) {
        log.info("{}动态定时任务执行逻辑start...", DateUtil.covert2String(LocalDate.now()));
        String jobName = shardingContext.getJobName();
        String jobParameter = shardingContext.getJobParameter();
        log.info("---------PaymentCancelDynamicJob---------撤销动态定时任务正在执行:jobName = {}, jobParameter = {}", jobName, jobParameter);

        List<CancelSchedule> cancelSchedules = cancelScheduleService.getNextSchedule();
        List<PaymentPlanItem> items = itemService.getItemsByStatus(PaymentPlanItemStatusEnum.PLAN_SUBMIT.getCode());
        if (isReturn(cancelSchedules,items)){
            return;
        }
        //根据参数调用不同的业务接口处理,请远程调用业务模块处理,避免本服务与业务依赖过重...
        InterProcessMutex lock = new InterProcessMutex(curatorClient, LockConstant.GLOBAL_LOCK_PATH);
        try {
            if (!lock.acquire(LockConstant.GLOBAL_LOCK_TIME_SECONDS, TimeUnit.SECONDS)) {
                log.info("撤销动态定时任务>>其他任务还未执行完...");
                return;
            }
            try {
                items = itemService.getItemsByStatus(PaymentPlanItemStatusEnum.PLAN_SUBMIT.getCode());
                List<String> vouCherNos = items.stream().map(PaymentPlanItem::getVoucherNo).collect(Collectors.toList());
                PaymentPlanItemRevokeRequest request = PaymentPlanItemRevokeRequest.builder()
                        .voucherNos(vouCherNos).build();
                itemBiz.revokeItem(request);
                CancelSchedule cancelSchedule = cancelSchedules.get(0);
                cancelSchedule.setStatus(1);
                cancelScheduleService.updateByPrimaryKeySelective(cancelSchedule);
            } catch (Exception ex) {
                log.error("撤销动态定时任务>>系统异常", ex);
            } finally {
                lock.release(); // always release the lock in a finally block
            }
        } catch (Exception ex) {
            log.error("撤销动态定时任务>>系统异常", ex);
        }
        log.info("撤销动态定时任务>>定时任务执行结束。");

        log.info("{}动态定时任务执行逻辑end...", DateUtil.covert2String(LocalDate.now()));
    }
    
    private boolean isReturn(List<CancelSchedule> cancelSchedules,List<PaymentPlanItem> items){
        if (CollectionUtils.isEmpty(cancelSchedules)){
            return true;
        }else {
            CancelSchedule cancelSchedule = cancelSchedules.get(0);
            String executeTime = DateUtil.covert2String(cancelSchedule.getExecuteTime(),"yyyy-MM-dd");
            String nowTime = DateUtil.covert2String(LocalDate.now(),"yyyy-MM-dd");
            if (!executeTime.equalsIgnoreCase(nowTime)){
                return true;
            }
        }
        return CollectionUtils.isEmpty(items);
    }
}
复制代码

@Transactional(rollbackFor = Exception.class)在execute方法加事务,会导致启动项目失败

扫描本地持久化的任务、添加任务(ScanDynamicJobHandler)

package com.hanhang.service.handler;

import com.hanhang.framework.beans.exception.BizRuntimeException;
import com.hanhang.common.enumeration.FopErrorCode;
import com.hanhang.domain.CancelSchedule;
import com.hanhang.service.CancelScheduleService;
import com.hanhang.service.job.PaymentCancelDynamicJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.List;

/**
 * @ClassName ScanDynamicJobHandler
 * @Description 扫描本地动态任务
 * @Author hanhang
 * @Date 2021/11/19 4:53 下午
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ScanDynamicJobHandler {
    private final ElasticJobHandler elasticJobHandler;
    private final CancelScheduleService cancelScheduleService;
    private final PaymentCancelDynamicJob paymentCancelDynamicJob;

    /**
     * 扫描动态任务列表,并添加任务
     *
     * 循环执行的动态任务,本服务重启的时候,需要重新加载任务
     *
     * @author songfayuan
     * @date 2021/4/26 9:15 下午
     */
    public void scanAddJob() {
        //这里为从MySQL数据库读取job_dynamic_task表的数据,微服务项目中建议使用feign从业务服务获取,
        // 避免本服务过度依赖业务的问题,然后业务服务新增动态任务也通过feign调取本服务JobOperateController实现,
        // 从而相对独立本服务模块
        List<CancelSchedule> cancelSchedules = cancelScheduleService.getNextSchedule();
        if (!CollectionUtils.isEmpty(cancelSchedules)){
            CancelSchedule cancelSchedule = cancelSchedules.get(0);
            elasticJobHandler.addJob("saos-csp-fop-service-PaymentItemCancel",cancelSchedule.getCron(),1,paymentCancelDynamicJob,"","撤销动态定时任务");
        } else {
            throw new BizRuntimeException(FopErrorCode.COMMON_ERROR.getCode(), "无可启动的任务");
        }
    }
}
复制代码

项目启动程序中新增加载本地持久化任务

package com.hanhang;

import com.hanhang.service.handler.ScanDynamicJobHandler;
import com.chehejia.starter.job.annotation.EnableElasticJob;
import com.chehejia.starter.mq.annotation.EnableMQConfiguration;
import com.ctrip.framework.apollo.spring.annotation.EnableApolloConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.core.env.Environment;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import tk.mybatis.spring.annotation.MapperScan;

import java.net.InetAddress;

@Slf4j
@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrix
@EnableApolloConfig
@EnableElasticJob
@MapperScan(basePackages = "com.chehejia.saos.csp.fop.persistence")
@EnableFeignClients(basePackages = {"com.chehejia.saos.csp.fop"})
@EnableMQConfiguration
@EnableTransactionManagement
@RequiredArgsConstructor
public class RootApplication implements CommandLineRunner {
    private final ScanDynamicJobHandler scanDynamicJobHandler;
    public static void main(String[] args) throws Exception {
        SpringApplication app = new SpringApplication(com.chehejia.saos.csp.fop.RootApplication.class);
        Environment env = app.run(args).getEnvironment();
        app.addListeners();
        log.info("\n----------------------------------------------------------\n\t" +
                        "FOP Application '{}' is running! Access URLs:\n\t" +
                        "Local: \t\thttp://127.0.0.1:{}\n\t" +
                        "External: \thttp://{}:{}\n\t" +
                        "Swagger API:http://{}:{}/swagger-ui.html\n\t" +
                        "Swagger API:http://saos-csp-fop-service.dev.k8s.chj.com/swagger-ui.html\n\t" +
                        "Druid index:http://127.0.0.1:{}/druid/index.html\n" +
                        "----------------------------------------------------------",
                env.getProperty("spring.application.name"),
                env.getProperty("server.port"),
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"),
                InetAddress.getLocalHost().getHostAddress(),
                env.getProperty("server.port"),
                env.getProperty("server.port"));
    }

    @Override
    public void run(String... args) throws Exception {
        log.info(">>>>>>>>>>>>>>>服务启动执行,扫描动态任务列表,并添加任务<<<<<<<<<<<<<");
        scanDynamicJobHandler.scanAddJob();
    }
}
复制代码

至此动态修改定时任务可以完成,通过配置界面也可以看到任务。

image.png

问题

1、通过代码测试,修改任务的cron会成功,定时任务也会按时去执行,但是在管理页面中看不到cron表达式修改。
2、如果Elastic-Job不通,或在定时的时间内重启服务,由于任务有【错过重执行】,那么有可能在撤销了不该撤销的单据。
3、Elastic-Job出现异常,无法做幂等。
综合以上的问题,放弃了这种方式。另一种实现方式就不在文章中体现了,处理逻辑比较简单。

总结

使用Elastic-Job动态修改定时任务,可能踩到的坑有:
1、在execute方法上使用@Transactional注解导致服务启动失败,报nullException。

image.png 需要用其他方式进行事务处理,请参照这篇文章@Transactional注解失效
2、在scanJob的时候,需要通过注入的方式,将Job添加到SpringJobScheduler中,否则在Job执行中注入的属性,将不会被Spring代理,出现空指针异常。
3、修改cron之后,在管理页面看不到cron表达式变更,但是通过Elastic-Job通过时间片的轮转,也可以正常执行。

猜你喜欢

转载自juejin.im/post/7033589451079024654