谈谈定时任务实战问题及解决方案、实现原理

1.简介

详解Spring Boot定时任务的几种实现方案一文中,我们详细探讨、总结了平时业务系统开发过程中如何实现定时任务处理逻辑功能,不清楚的可跳转文章先入门了解一下。书接上回,我们今天来讲讲实战中高频使用定时任务出现的一些问题,并给出解决方案,最后介绍下实现原理。

2.定时任务日志链路追踪

我们应该都知道,定时任务一大常见使用场景就是在每天晚上凌晨跑定时任务做数据处理,这样可以在系统流量少、负载低的时候去做一些复杂的逻辑处理,但是一旦定时任务执行失败,如果没做好任务执行过程的日志链路,查找问题起来也是相当困难的,之前就碰到过这么一个问题,客户反馈数据不对,然后测试介入排查发现对应的定时任务执行了(看到了任务的info输出日志),但是没看到有error日志,这就很怪了,造成了一种这个代码执行了,但是数据不对的现状,然后开发又去翻看代码一行一行去解读一下,发现没问题呀。一通操作下来之后百思不得其解,最终还是觉得有报错了,把当天的error日志全部输出出来看看,果然发现报错了。。。我还原一下场景:

less 复制代码
@Component
@Slf4j
public class ScheduledTask2 {
    // 使用 cron 表达式, 每10秒执行一次
    @Async("asyncExecutor")
    @Scheduled(cron = "0/10 * * * * ?")
    public void taskWithCron() {
        log.info("task2====>>开始执行");
        int i = 1/0;
        log.info("task2====>>结束执行");
    }
}

项目启动之后,执行报错如下:

ini 复制代码
[common-demo] [] [2025-01-10 14:21:00.005] [INFO] [asyncExecutor-7@19168] com.shepherd.basedemo.schedule.ScheduledTask2 taskWithCron: task2====>>开始执行
[common-demo] [] [2025-01-10 14:21:00.008] [ERROR] [asyncExecutor-7@19168] org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler handleUncaughtException: Unexpected exception occurred invoking async method: public void com.shepherd.basedemo.schedule.ScheduledTask2.taskWithCron()
java.lang.ArithmeticException: / by zero

很明显,这个日志的上下文关联性太低了,所以这里我们引入的解决方案是对任务执行进行日志链路追踪,简单来说就是加上traceId来解决,对日志链路追踪不熟悉的,请看看之前我们总结的:Spring Boot项目如何实现分布式日志链路追踪。这篇文章主要讲述了一次接口请求怎么设置一个唯一的traceId来链路追踪请求过程,出现异常会在Spring MVC层面的使用全局异常捕获机制(@ControllerAdvice@RestControllerAdvice 捕获到异常进行统一输出,但是Spring 的 @Scheduled 方法不经过控制器,因此 @ControllerAdvice 默认无法捕获 @Scheduled 异常。所以需要通过自定义 AOP切面 或其他方式来设置traceId和实现全局捕获

less 复制代码
@Slf4j
@Aspect
@Component
public class ScheduledTaskAspect {

    @Pointcut("@annotation(org.springframework.scheduling.annotation.Scheduled)")
    public void trace() {}

    @Around(value = "trace()")
    public void doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            String traceId = UUID.randomUUID().toString().replace("-", "");
            MDC.put("traceId", traceId);
            joinPoint.proceed();
        } catch (Exception e) {
            log.error("task error: ", e);
        } finally {
            MDC.remove("traceId");
        }
    }
}

再次执行,日志输出如下:

ini 复制代码
[common-demo] [18d9f9fc64d1423ba4f8d83dd0946bdb] [2025-01-10 14:41:30.006] [INFO] [asyncExecutor-4@25480] com.shepherd.basedemo.schedule.ScheduledTask2 taskWithCron: task2====>>开始执行
[common-demo] [18d9f9fc64d1423ba4f8d83dd0946bdb] [2025-01-10 14:41:30.008] [ERROR] [asyncExecutor-4@25480] com.shepherd.basedemo.interceptor.ScheduledTaskAspect doAround: task error: 
java.lang.ArithmeticException: / by zero

是不是就看到成功插入traceId了。这样就不需要再惧怕查找任务执行的上下文日志了。

当然还有一种最简单方法,直接在 @Scheduled 方法中使用 try-catch

less 复制代码
@Component
@Slf4j
public class ScheduledTask2 {
    // 使用 cron 表达式, 每10秒执行一次
    @Async("asyncExecutor")
    @Scheduled(cron = "0/10 * * * * ?")
    public void taskWithCron() {
        try {
            log.info("task2====>>开始执行");
            int i = 1/0;
            log.info("task2====>>结束执行");
        } catch (Exception e) {
            log.error("task2 fail: ", e);
        }
    }
}

3.定时任务的一些细节思考

3.1 串行执行阻塞

详解Spring Boot定时任务的几种实现方案一文中我们知道Spring task默认是单线程串行执行的,这就可能造成由于某个任务处理逻辑过于复杂导致执行过慢,甚至出现死循环一直执行不结束,这些现象都是可能出现的,从而导致后续任务得不到执行或者执行时间离触发时间已经很远了,甚至到了白天系统流量高,高负载的时候才执行,最后导致系统性能问题,这就有点得不偿失了,明明我是定时凌晨执行的,结果白天才执行,这不是搞笑吗?要解决这个问题其实很简单,对于核心任务处理,建议开启多线程执行,这样任务之间就不会互相影响了。假如你就是想单线程串行执行,那可以在执行每个任务前先判断下当前时间是不是白天比如说7点了,是的话就不执行了,免得造成性能问题影响到用户正常使用系统,但是这种方式前提是这个任务不执行不会造成数据上的错误,功能上的问题,等着下次再执行也可以的那种任务。

关于多线程执行定时任务的实现方案可以看看前文的总结,但是我这里还是要再次强调下@Async开启多线程的注意点:

  • @Async的默认线程池为SimpleAsyncTaskExecutor,不是真的线程池,这个类不重用线程,默认每次调用都会创建一个新的线程。不自定义一个线程池的话,可能出现资源耗尽问题。
  • @Async实现方式是通过AOP代理实现的,和@Transactional套路差不多(不是同一个后置处理器实现的哈),这就意味@Async失效的场景也挺多的,比如说同一个类方法A调用方法B,方法B使用了@Async是无法开启异步的。

3.2 分布式定时任务

一般来说,实际项目中,为了提高服务的响应能力,我们一般会通过负载均衡的方式,或者反向代理多个节点的方式来进行。通俗点来说,我们一般会将项目部署多实例,或者说部署多份,每个实例不同的启动端口。但是每个实例的代码其实都是一样的。如果我们将定时任务写在我们的项目中,就会面临一个麻烦,就是比如我们部署了3个实例,三个实例一启动,就会把定时任务都启动,那么在同一个时间点,定时任务会一起执行,也就是会执行3次,这样很可能会导致我们的业务出现错误。

一般来说,我们有如下两种办法来处理:

  • 配置文件中增加自定义配置,通过开关来进行控制:比如增加:schedule=enable , schedule=disable,这样在我们的实际代码中,在进行判断,也就是我们可以通过配置,达到,只有一个实例真正执行定时任务,其他的是实例不执行。但是,这种做法实际是还是定时任务都启动,只是在执行中,我们人工来进行判断,执行于不执行真正的处理逻辑。
  • 引入分布式定时任务框架,比如说xxl-job, quartz, power-job等等。

项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用

Github地址github.com/plasticene/...

Gitee地址gitee.com/plasticene3...

微信公众号Shepherd进阶笔记

交流探讨qun:Shepherd_126

4.@Scheduled实现原理

下面来到知其然知其所以然源码环节,谈谈@Scheduled实现原理。先从@EnableScheduling开启任务调度功能:

less 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SchedulingConfiguration.class)
@Documented
public @interface EnableScheduling {

}

该注解就是引入了核心配置类SchedulingConfiguration.class

less 复制代码
@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class SchedulingConfiguration {

 @Bean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
 @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
 public ScheduledAnnotationBeanPostProcessor scheduledAnnotationProcessor() {
  return new ScheduledAnnotationBeanPostProcessor();
 }
}

这个类逻辑也很简单,只干了一件事就是注入ScheduledAnnotationBeanPostProcessor后置处理器, 会扫描所有的 Spring Bean,寻找带有 @Scheduled 注解的方法,核心代码逻辑如下:

typescript 复制代码
 @Override
 public Object postProcessAfterInitialization(Object bean, String beanName) {
  if (bean instanceof AopInfrastructureBean || bean instanceof TaskScheduler ||
    bean instanceof ScheduledExecutorService) {
   // Ignore AOP infrastructure such as scoped proxies.
   return bean;
  }

  Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
  if (!this.nonAnnotatedClasses.contains(targetClass) &&
    AnnotationUtils.isCandidateClass(targetClass, Arrays.asList(Scheduled.class, Schedules.class))) {
   Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
     (MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> {
      Set<Scheduled> scheduledMethods = AnnotatedElementUtils.getMergedRepeatableAnnotations(
        method, Scheduled.class, Schedules.class);
      return (!scheduledMethods.isEmpty() ? scheduledMethods : null);
     });
   if (annotatedMethods.isEmpty()) {
    this.nonAnnotatedClasses.add(targetClass);
    if (logger.isTraceEnabled()) {
     logger.trace("No @Scheduled annotations found on bean class: " + targetClass);
    }
   }
   else {
    // Non-empty set of methods
    annotatedMethods.forEach((method, scheduledMethods) ->
      scheduledMethods.forEach(scheduled -> processScheduled(scheduled, method, bean)));
    if (logger.isTraceEnabled()) {
     logger.trace(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
       "': " + annotatedMethods);
    }
   }
  }
  return bean;
 }

postProcessAfterInitialization()是在bean初始化之后执行的,这里就是扫描bean是否有@Scheduled, 如果检测到某个方法上有 @Scheduled 注解,则将其封装为 Runnable,并注册到 TaskScheduler,来看看processScheduled(scheduled, method, bean)的执行逻辑

ini 复制代码
 protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
  try {
   Runnable runnable = createRunnable(bean, method);
   boolean processedSchedule = false;
   String errorMessage =
     "Exactly one of the 'cron', 'fixedDelay(String)', or 'fixedRate(String)' attributes is required";

   Set<ScheduledTask> tasks = new LinkedHashSet<>(4);

   // Determine initial delay
   long initialDelay = scheduled.initialDelay();
   String initialDelayString = scheduled.initialDelayString();
   if (StringUtils.hasText(initialDelayString)) {
    Assert.isTrue(initialDelay < 0, "Specify 'initialDelay' or 'initialDelayString', not both");
    if (this.embeddedValueResolver != null) {
     initialDelayString = this.embeddedValueResolver.resolveStringValue(initialDelayString);
    }
    if (StringUtils.hasLength(initialDelayString)) {
     try {
      initialDelay = parseDelayAsLong(initialDelayString);
     }
     catch (RuntimeException ex) {
      throw new IllegalArgumentException(
        "Invalid initialDelayString value "" + initialDelayString + "" - cannot parse into long");
     }
    }
   }

   // Check cron expression
   String cron = scheduled.cron();
   if (StringUtils.hasText(cron)) {
    String zone = scheduled.zone();
    if (this.embeddedValueResolver != null) {
     cron = this.embeddedValueResolver.resolveStringValue(cron);
     zone = this.embeddedValueResolver.resolveStringValue(zone);
    }
    if (StringUtils.hasLength(cron)) {
     Assert.isTrue(initialDelay == -1, "'initialDelay' not supported for cron triggers");
     processedSchedule = true;
     if (!Scheduled.CRON_DISABLED.equals(cron)) {
      TimeZone timeZone;
      if (StringUtils.hasText(zone)) {
       timeZone = StringUtils.parseTimeZoneString(zone);
      }
      else {
       timeZone = TimeZone.getDefault();
      }
      tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))));
     }
    }
   }

   // At this point we don't need to differentiate between initial delay set or not anymore
   if (initialDelay < 0) {
    initialDelay = 0;
   }

   // Check fixed delay
   long fixedDelay = scheduled.fixedDelay();
   if (fixedDelay >= 0) {
    Assert.isTrue(!processedSchedule, errorMessage);
    processedSchedule = true;
    tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay)));
   }
   String fixedDelayString = scheduled.fixedDelayString();
   if (StringUtils.hasText(fixedDelayString)) {
    if (this.embeddedValueResolver != null) {
     fixedDelayString = this.embeddedValueResolver.resolveStringValue(fixedDelayString);
    }
    if (StringUtils.hasLength(fixedDelayString)) {
     Assert.isTrue(!processedSchedule, errorMessage);
     processedSchedule = true;
     try {
      fixedDelay = parseDelayAsLong(fixedDelayString);
     }
     catch (RuntimeException ex) {
      throw new IllegalArgumentException(
        "Invalid fixedDelayString value "" + fixedDelayString + "" - cannot parse into long");
     }
     tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay)));
    }
   }

   // Check fixed rate
   long fixedRate = scheduled.fixedRate();
   if (fixedRate >= 0) {
    Assert.isTrue(!processedSchedule, errorMessage);
    processedSchedule = true;
    tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay)));
   }
   String fixedRateString = scheduled.fixedRateString();
   if (StringUtils.hasText(fixedRateString)) {
    if (this.embeddedValueResolver != null) {
     fixedRateString = this.embeddedValueResolver.resolveStringValue(fixedRateString);
    }
    if (StringUtils.hasLength(fixedRateString)) {
     Assert.isTrue(!processedSchedule, errorMessage);
     processedSchedule = true;
     try {
      fixedRate = parseDelayAsLong(fixedRateString);
     }
     catch (RuntimeException ex) {
      throw new IllegalArgumentException(
        "Invalid fixedRateString value "" + fixedRateString + "" - cannot parse into long");
     }
     tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay)));
    }
   }

   // Check whether we had any attribute set
   Assert.isTrue(processedSchedule, errorMessage);

   // Finally register the scheduled tasks
   synchronized (this.scheduledTasks) {
    Set<ScheduledTask> regTasks = this.scheduledTasks.computeIfAbsent(bean, key -> new LinkedHashSet<>(4));
    regTasks.addAll(tasks);
   }
  }
  catch (IllegalArgumentException ex) {
   throw new IllegalStateException(
     "Encountered invalid @Scheduled method '" + method.getName() + "': " + ex.getMessage());
  }
 }

入参是@Scheduled,很明显就是根据注解参数(如 fixedRatefixedDelaycron),构造具体的调度规则,将这些任务注册到 TaskScheduler 中,我们来看看其中的cron表达式任务:this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))),来到ScheduledTaskRegistrar的方法#scheduleCronTask():

ini 复制代码
 public ScheduledTask scheduleCronTask(CronTask task) {
  ScheduledTask scheduledTask = this.unresolvedTasks.remove(task);
  boolean newTask = false;
  if (scheduledTask == null) {
   scheduledTask = new ScheduledTask(task);
   newTask = true;
  }
  if (this.taskScheduler != null) {
   scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
  }
  else {
   addCronTask(task);
   this.unresolvedTasks.put(task, scheduledTask);
  }
  return (newTask ? scheduledTask : null);
 }

碍于篇幅问题,就不再深入下去了,最终调度器根据注解参数计算任务的触发时间,基于 TaskSchedulerScheduledExecutorService执行任务,感兴趣的可自行debug调试研究一下。

5.cron表达式

cron一共有7位,但是最后一位是年,可以留空,所以我们可以写6位:

markdown 复制代码
* 第一位,表示秒,取值0-59
* 第二位,表示分,取值0-59
* 第三位,表示小时,取值0-23
* 第四位,日期天/日,取值1-31
* 第五位,日期月份,取值1-12
* 第六位,星期,取值1-7,星期一,星期二...,注:不是第1周,第二周的意思
          另外:1表示星期天,2表示星期一。
* 第7为,年份,可以留空,取值1970-2099

cron中,还有一些特殊的符号,含义如下:

scss 复制代码
(*)星号:可以理解为每的意思,每秒,每分,每天,每月,每年...
(?)问号:问号只能出现在日期和星期这两个位置,表示这个位置的值不确定,每天3点执行,所以第六位星期的位置,我们是不需要关注的,就是不确定的值。同时:日期和星期是两个相互排斥的元素,通过问号来表明不指定值。比如,1月10日,比如是星期1,如果在星期的位置是另指定星期二,就前后冲突矛盾了。
(-)减号:表达一个范围,如在小时字段中使用"10-12",则表示从10到12点,即10,11,12
(,)逗号:表达一个列表值,如在星期字段中使用"1,2,4",则表示星期一,星期二,星期四
(/)斜杠:如:x/y,x是开始值,y是步长,比如在第一位(秒) 0/15就是,从0秒开始,每15秒,最后就是0,15,30,45,60    另:*/y,等同于0/y

下面列举几个例子供大家来验证:

bash 复制代码
0 0 3 * * ?     每天3点执行
0 5 3 * * ?     每天3点5分执行
0 5 3 ? * *     每天3点5分执行,与上面作用相同
0 5/10 3 * * ?  每天3点的 5分,15分,25分,35分,45分,55分这几个时间点执行
0 10 3 ? * 1    每周星期天,3点10分 执行,注:1表示星期天    
0 10 3 ? * 1#3  每个月的第三个星期,星期天 执行,#号只能出现在星期的位置

在线cron表达式生成:qqe2.com/cron/index

如果本文对你有帮助的话,麻烦给个一键三连(点赞、在看、转发分享)支持一下,感谢Thanks♪(・ω・)ノ

相关推荐
Barcke3 分钟前
深入浅出 Spring WebFlux:从核心原理到深度实战
后端
JuiceFS4 分钟前
从 MLPerf Storage v2.0 看 AI 训练中的存储性能与扩展能力
运维·后端
大鸡腿同学6 分钟前
Think with a farmer's mindset
后端
Moonbit27 分钟前
用MoonBit开发一个C编译器
后端·编程语言·编译器
Reboot1 小时前
达梦数据库GROUP BY报错解决方法
后端
稻草人22221 小时前
java Excel 导出 ,如何实现八倍效率优化,以及代码分层,方法封装
后端·架构
渣哥1 小时前
原来 Java 里线程安全集合有这么多种
java
间彧1 小时前
Spring Boot集成Spring Security完整指南
java
掘金者阿豪1 小时前
打通KingbaseES与MyBatis:一篇详尽的Java数据持久化实践指南
前端·后端
间彧2 小时前
Spring Secutiy基本原理及工作流程
java