问题

在我们的项目中,有这样两个类(示意):

@Component
public class FatherScheduler {
    @Scheduled(cron = "0/10 * * * * ?")
    public void execute() {
        System.out.println(new Date() + " 执行任务的类是:" + this.getClass());
    }
}

@Component
public class SonSchedulerImpl extends FatherScheduler {
    @Override
    @Scheduled(cron = "1/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

这是一对父子类。FatherScheduler定义了一个定时任务,并利用Spring-Scheduler注解声明了每10秒调度执行一次。SonSchedulerImpl继承了FatherScheduler,重写了该定时任务的注解。

线上系统中,我们已有一个逻辑比较完备的定时任务父类;子类只需要修改父类的一个注入实例、修改cron表达式即可。所以出现了这样的类结构。

我们希望父类定义的定时任务在启动后的第0/10/20/30……秒启动执行;子类定时任务则在第1/11/21/31……秒启动执行。从代码上看似乎没有问题,但是实际执行结果是这样的:

Tue Jul 30 10:54:40 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.FatherScheduler

Tue Jul 30 10:54:40 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.SonSchedulerImpl

Tue Jul 30 10:54:41 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.SonSchedulerImpl

Tue Jul 30 10:54:50 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.FatherScheduler

Tue Jul 30 10:54:50 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.SonSchedulerImpl

Tue Jul 30 10:54:51 CST 2019 执行任务的类是:class net.loyintean.blog.scheduer.SonSchedulerImpl

也就是说,父类定时任务确实是按照我们的期望在调度执行。但是子类定时任务……在我们的预期之外,它多做了一次调度,而且调度规律与父类相同(红色字体部分)。虽然我们对定时任务都做了幂等处理,即使多跑了几次也只是浪费点服务器性能而已,但是代码应该做且只做我们要做的事,不应该做多余的事——否则说不定哪天“天网”系统就要诞生了哈哈。

原因

从代码运行表现来看,似乎是Spring-Scheduler在为子类注册定时任务时,除了解析子类重写方法上的@Scheduled注解之外,还把父类方法上的注解也解析了一遍。但是到底是不是这样,还是要去找注册定时任务的相关代码。

找到@Scheduled注解,可以看到它在的javadoc中已经指明了这个注解是在哪儿处理的了:

Processing of {@code @Scheduled} annotations is performed by registering a {@link ScheduledAnnotationBeanPostProcessor}. This can be done manually or, more conveniently, through the {@code } element or @{@link EnableScheduling} annotation.

(这也算一个启示:好好写Javadoc。一份好的Javadoc能为使用代码、维护代码的人提供很大的便利。)

ScheduledAnnotationBeanPostProcessor的定义和核心处理代码是这样的:

public class ScheduledAnnotationBeanPostProcessor implements BeanPostProcessor, Ordered,
      EmbeddedValueResolverAware, BeanFactoryAware, ApplicationContextAware,
      SmartInitializingSingleton, ApplicationListener, DisposableBean {
    @Override
    public Object postProcessAfterInitialization(final Object bean, String beanName) {
       Class targetClass = AopUtils.getTargetClass(bean);
       if (!this.nonAnnotatedClasses.contains(targetClass)) {
          final Set annotatedMethods = new LinkedHashSet(1);
          ReflectionUtils.doWithMethods(targetClass, new MethodCallback() {
             @Override
             public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException {
                for (Scheduled scheduled :
                      AnnotationUtils.getRepeatableAnnotation(method, Schedules.class, Scheduled.class)) {
                   processScheduled(scheduled, method, bean);
                   annotatedMethods.add(method);
                }
             }
          });
          if (annotatedMethods.isEmpty()) {
             this.nonAnnotatedClasses.add(targetClass);
             if (logger.isDebugEnabled()) {
                logger.debug("No @Scheduled annotations found on bean class: " + bean.getClass());
             }
          }
          else {
             // Non-empty set of methods
             if (logger.isDebugEnabled()) {
                logger.debug(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
                      "': " + annotatedMethods);
             }
          }
       }
       return bean;
    }               
}

简单看下来,其中的核心逻辑在ReflectionUtils.doWithMethods方法中。这个方法内部是这样的:

public static void doWithMethods(Class clazz, ReflectionUtils.MethodCallback mc, ReflectionUtils.MethodFilter mf) {
    Method[] methods = getDeclaredMethods(clazz);
    Method[] var4 = methods;
    int var5 = methods.length;

    int var6;
    for(var6 = 0; var6 < var5; ++var6) {
        Method method = var4[var6];
        if (mf == null || mf.matches(method)) {
            try {
                mc.doWith(method);
            } catch (IllegalAccessException var9) {
                throw new IllegalStateException("Not allowed to access method '" + method.getName() + "': " + var9);
            }
        }
    }

    if (clazz.getSuperclass() != null) {
        doWithMethods(clazz.getSuperclass(), mc, mf);
    } else if (clazz.isInterface()) {
        Class[] var10 = clazz.getInterfaces();
        var5 = var10.length;

        for(var6 = 0; var6 < var5; ++var6) {
            Class superIfc = var10[var6];
            doWithMethods(superIfc, mc, mf);
        }
    }

}

哎~果不其然地,我们在这个类里面发现了递归调用父类的代码:

if (clazz.getSuperclass() != null) {
        doWithMethods(clazz.getSuperclass(), mc, mf);
    }

结合mc.doWith()方法的定义:

new MethodCallback() {
    @Override
    public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException {
        for (Scheduled scheduled :
          AnnotationUtils.getRepeatableAnnotation(method, Schedules.class, Scheduled.class)) {
               processScheduled(scheduled, method, bean);
               annotatedMethods.add(method);
        }
    }
}

问题原因就很明显了:

ScheduledAnnotationBeanPostProcessor在处理SonSchedulerImpl实例的时候,首先找出了子类重写过的execute()方法及其Scheduled注解,并为其注册了一个定时任务。随后,又按同样的逻辑,找到的它的父类FatherScheduler上定义的execute()方法及其Scheduled注解,又按父类的配置注册了一个定时任务。这样,同一个bean实例上就注册了两个定时任务,从而导致同一个定时任务被调度了两次。

解决方案

解决方案有两个。

首先就是……父类方法上不要注解@Scheduled。为了能尽量复用代码、又能不在父类上注解@Scheduled,我们最后把代码改成了这样:

public class FatherScheduler {
    
    public void execute() {
        System.out.println(new Date() + " 执行任务的类是:" + this.getClass());
    }
}

@Component
public class SonSchedulerImpl extends FatherScheduler {
    @Override
    @Scheduled(cron = "1/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

@Component
public class DaughterSchedulerImpl {
    @Scheduled(cron = "0/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

也就是父类只定义业务逻辑,不做@Scheduled注解。两个子类分别注解。

另一种方式更彻底一些:升级Spring版本。这个问题目前已知是在4.1.6.RELEASE版本中出现的;在最新版的5.1.8RELEASE中已经被修复了。这个版本中调用ReflectionUtils.doWithMethods时,传入的是这样的一个回调方法:

ReflectionUtils.doWithMethods(currentHandlerType, method -> {
      Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
      T result = metadataLookup.inspect(specificMethod);
      if (result != null) {
         Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
         if (bridgedMethod == specificMethod || metadataLookup.inspect(bridgedMethod) == null) {
            methodMap.put(specificMethod, result);
         }
      }
   }, ReflectionUtils.USER_DECLARED_METHODS);
}

注意其中的这一行代码:

Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);

这行代码的作用在于,从targetClass上找出它重写过的method方法。在出现问题的流程中,targetClass始终指向子类SonSchedulerImpl;而method则会随着ReflectionUtils.doWithMethods的递归调用而从SonSchedulerImpl#execute()方法变成了FatherScheduler#execute()方法。但是,经过ClassUtils.getMostSpecificMethod()方法的处理后,我们最终得到的specificMethod仍是子类重写的SonSchedulerImpl#execute()方法,而非父类上原生的FatherScheduler#execute()方法。这样一来,后续处理中也就只会按照子类方法上的@Scheduled注解来注册定时任务了。

这是第二个启示:框架工具应及时升级,以避免踩中别人已经填上的坑。


Spring-Schedule的@Scheduled注解继承问题-_第1张图片