「这是我参与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();
}
}
复制代码
至此动态修改定时任务可以完成,通过配置界面也可以看到任务。
问题
1、通过代码测试,修改任务的cron会成功,定时任务也会按时去执行,但是在管理页面中看不到cron表达式修改。
2、如果Elastic-Job不通,或在定时的时间内重启服务,由于任务有【错过重执行】,那么有可能在撤销了不该撤销的单据。
3、Elastic-Job出现异常,无法做幂等。
综合以上的问题,放弃了这种方式。另一种实现方式就不在文章中体现了,处理逻辑比较简单。
总结
使用Elastic-Job动态修改定时任务,可能踩到的坑有:
1、在execute方法上使用@Transactional注解导致服务启动失败,报nullException。
需要用其他方式进行事务处理,请参照这篇文章@Transactional注解失效
2、在scanJob的时候,需要通过注入的方式,将Job添加到SpringJobScheduler中,否则在Job执行中注入的属性,将不会被Spring代理,出现空指针异常。
3、修改cron之后,在管理页面看不到cron表达式变更,但是通过Elastic-Job通过时间片的轮转,也可以正常执行。