@RefreshScope导致@Scheduled失效的原理和解决方案

1,003 阅读2分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战

本文描述的是我在工作当中遇到的一个问题,一开始我是很疑惑的,但是当你了解其原理后,就会豁然开朗。

一、问题背景

我们经常需要在springboot项目中引入定时任务实现定时的功能,而任务当中页经常会有部分参数,甚至是执行任务的时间,都需要动态可配置,而不需要重启,这时候配置中心就是最好的选择,目前国内最好用的,最火的可以说就是nacos了。

我们通常会将可动态配置的内容放在nacos的配置中心当中,然后配合springcloud的@RefreshScope注解去实现这样一个动态修改配置的功能。

而我在使用的过程中,发现了一个问题,当我修改配置文件后,我的定时任务不能够在定时执行了。这是什么问题?下面我们逐步来根据源码分析下。

二、实现代码

下面看下我的代码是如何使用和配置的:

  • 配置文件
    message:
      log:
        timeout: 30 #天
    
  • 启动类:开启定时任务功能
    @EnableLiquibase
    @EnableScheduling // 开启定时任务功能
    @SpringCloudApplication
    @EnableFeignClients
    public class InboxModelApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(InboxModelApplication.class, args);
        }
    
    }
    
  • 调度任务:有一个timeout属性,这是一个注册中心配置,使用@RefreshScope动态刷新。
    /**
     * description: 调度
     *
     * @author: weirx
     * @time: 2021/4/30 11:30
     */
    @Slf4j
    @RefreshScope
    @Component
    public class HistoryLogDeleteTask {
    
        @Value("${message.log.timeout}")
        private String timeout;
    
        @Scheduled(cron = "*/10 * * * * ?")
        public void execute() {
            log.info("thread id:{}", Thread.currentThread().getId());
            this.deleteHistoryLog();
        }
    
        private void deleteHistoryLog() {
            DateTime dateTime = DateUtil.offsetDay(new Date(), Integer.valueOf(timeout));
            log.info("删除时间:{}", dateTime);
        }
    
    }
    

三、RefreshScope原理

4.1 Scope

想要了解RefreshScope,就要先了解Scope。

org.springframework.beans.factory.config.Scope是在spring中就存在的,而RefreshScope是springcloud对Scope的一种特殊实现,用于实现配置、实例的热加载。

Scope,也称作用域,在 Spring IoC 容器是指其创建的 Bean 对象相对于其他 Bean 对象的请求可见范围。

在 Spring IoC 容器中具有以下几种作用域:基本作用域(singleton、prototype)Web 作用域(reqeust、session、globalsession),自定义作用域

4.1.1 spring配置xml

Spring 的作用域在装配 Bean 时就必须在配置文件中指明,配置方式如下(以 xml 配置文件为例):

<!-- 具体的作用域需要在 scope 属性中定义 -->
<bean id="XXX" class="com.XXX.XXXXX" scope="XXXX" />

4.1.2 使用注解@Scope

下面是@Scope的源码,注释当中包含解释信息:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Scope {

	/**
	 * Alias for {@link #scopeName}.
	 * @see #scopeName
	 */
	@AliasFor("scopeName")
	String value() default "";

	/**
         * 为带有@Componet@Bean注解,指定作用域名称
         * 
         * 默认是个空字符串
         *
	 * @since 4.2 从4.2开始可以指定以下作用域
	 * @see ConfigurableBeanFactory#SCOPE_PROTOTYPE 多实例
          -- 每次获取Bean的时候会有一个新的实例
	 * @see ConfigurableBeanFactory#SCOPE_SINGLETON 单例
          -- 全局有且仅有一个实例
	 * @see org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST
         -- 表示该针对每一次HTTP请求都会产生一个新的bean,同时该bean仅在当前HTTP request内有效
	 * @see org.springframework.web.context.WebApplicationContext#SCOPE_SESSION
         -- 表示该针对每一次HTTP请求都会产生一个新的bean,同时该bean仅在当前HTTP session内有效
	 * @see #value
	 */
	@AliasFor("value")
	String scopeName() default "";

	/**
         * 指定是否应将组件配置为作用域代理*,如果是,则指定代理是基于接口还是基于子类
         * 默认是DEFAULT,这表示在没有指定其他作用域配置事,不应该创建任何作用域代理
         * 类似于Spring XML中的{@code <aop:scoped-proxy />}支持
	 */
	ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;
}

4.2 @RefreshScope

4.2.1 简单认识RefreshScope

/**
 * 用这种方式注释的Bean可以在运行时刷新,并且正在使用*的任何组件都将在下一个方法调用上获得一个新实例,  
 * 并对其进行完全初始化并注入所有依赖项
 */
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Scope("refresh")
@Documented
public @interface RefreshScope {

	/**
	 * @see Scope#proxyMode()
	 * @return proxy mode
	 */
	ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;

}

如上所示,该注解使用了@Scope,并默认了ScopedProxyMode.TARGET_CLASS 属性,此属性的功能就是在创建一个代理,在每次调用的时候都用它来调用GenericScope get 方法来获取对象。GenericScope是SpringCloud对Scope的一个实现,Scope的源码如下,我们主要关注get方法:

    public interface Scope {
        /**
         * description: 核心操作,从基础范围返回带有指定名称的对象。
         * @param name 对象名称
         * @param objectFactory 函数式接口对象,用于创建对象
         */
        Object get(String name, ObjectFactory<?> objectFactory);

        /**
         * description: 从基本作用域删除指定对象
         * @param name
         * @return: java.lang.Object
         */
        @Nullable
        Object remove(String name);

        void registerDestructionCallback(String name, Runnable callback);

        @Nullable
        Object resolveContextualObject(String key);

        @Nullable
        String getConversationId();

    }
}

4.2.2 如何刷新的?

通过跟踪代码,发现是从以下位置开始整个刷新过程,下面这个监听会时时等待刷新的消息推送:

public class RefreshEventListener implements SmartApplicationListener {

	private static Log log = LogFactory.getLog(RefreshEventListener.class);

	private ContextRefresher refresh;

	private AtomicBoolean ready = new AtomicBoolean(false);

	public RefreshEventListener(ContextRefresher refresh) {
		this.refresh = refresh;
	}

	@Override
	public boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
		return ApplicationReadyEvent.class.isAssignableFrom(eventType)
				|| RefreshEvent.class.isAssignableFrom(eventType);
	}

    /**
      * description: 监听刷新消息
      * @param event
      * @return: void
      */
	@Override
	public void onApplicationEvent(ApplicationEvent event) {
		if (event instanceof ApplicationReadyEvent) {
			handle((ApplicationReadyEvent) event);
		}
		else if (event instanceof RefreshEvent) {
			handle((RefreshEvent) event);
		}
	}

	public void handle(ApplicationReadyEvent event) {
		this.ready.compareAndSet(false, true);
	}

   /**
    * description: 处理刷新消息
    * @param event
    * @return: void
    */
	public void handle(RefreshEvent event) {
		if (this.ready.get()) { // don't handle events before app is ready
			log.debug("Event received " + event.getEventDesc());
			Set<String> keys = this.refresh.refresh();
			log.info("Refresh keys changed: " + keys);
		}
	}

}

之后调用ContextRefresher 的refresh方法:

	public synchronized Set<String> refresh() {
         //刷新环境变量
		Set<String> keys = refreshEnvironment();
        //刷新scope
		this.scope.refreshAll();
		return keys;
	}

	public synchronized Set<String> refreshEnvironment() {
        //提取原环境变量属性
		Map<String, Object> before = extract(
				this.context.getEnvironment().getPropertySources());
        //创建新容器加载原配置
		addConfigFilesToEnvironment();
        //提取并比较变更的数据
		Set<String> keys = changes(before,
				extract(this.context.getEnvironment().getPropertySources())).keySet();
        //发布变更事件
		this.context.publishEvent(new EnvironmentChangeEvent(this.context, keys));
		return keys;
	}

下面重点关注RefreshScope.refreshAll()方法,发现其内部有一个调用父级的destroy方法,即GenericScope的destroy方法:

	@ManagedOperation(description = "Dispose of the current instance of all beans "
			+ "in this scope and force a refresh on next method execution.")
	public void refreshAll() {
		super.destroy();
		this.context.publishEvent(new RefreshScopeRefreshedEvent());
	}

destroy()源码,清空缓存,这这时候之前存在的cache信息就都没有了:

	@Override
	public void destroy() {
		List<Throwable> errors = new ArrayList<Throwable>();
		Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
		for (BeanLifecycleWrapper wrapper : wrappers) {
			try {
				Lock lock = this.locks.get(wrapper.getName()).writeLock();
				lock.lock();
				try {
					wrapper.destroy();
				}
				finally {
					lock.unlock();
				}
			}
			catch (RuntimeException e) {
				errors.add(e);
			}
		}
		if (!errors.isEmpty()) {
			throw wrapIfNecessary(errors.get(0));
		}
		this.errors.clear();
	}

到此为止,缓存被清空,终于发现导致我们scheduler失效的原因了,那么我们要再次使其生效要怎么做呢?

只需要触发GenericScope的get方法,就会帮我们创建新的bean,并加入到cache当中。

五、解决方案

经过上面的分析,我们已经对问题产生的原因有了大致的了解,关键就在于如何在修改配置后,再去调用get方法,使其添加到cache中。实际使用过程中,我们只要再次使用这个bean的对象就可以了。

通过前面的分析我们发现,刷新机制是通过Listener进行监听的,所以这里我们也进行监听,监听到之后进行一次定时方法的调用,代码如下:


/**
 * description: 调度
 *  
 * @author: weirx
 * @time: 2021/4/30 11:30
 */
@Slf4j
@RefreshScope
@Component
public class HistoryLogDeleteTask implements ApplicationListener<RefreshScopeRefreshedEvent> {

    @Value("${message.log.timeout}")
    private String timeout;

    @Scheduled(cron = "*/10 * * * * ?")
    public void execute() {
        log.info("thread id:{}", Thread.currentThread().getId());
        this.deleteHistoryLog();
    }

    private void deleteHistoryLog() {
        if (StringUtils.isNotEmpty(timeout)) {
            DateTime dateTime = DateUtil.offsetDay(new Date(), Integer.valueOf(timeout));
            log.info("删除时间:{}", dateTime);
        }
    }

    @Override
    public void onApplicationEvent(RefreshScopeRefreshedEvent event) {
        this.execute();
    }
}
  • 只需要实现ApplicationListener<RefreshScopeRefreshedEvent>onApplicationEvent方法,是不是很简单?

如上所示可以使定时任务bean重新添加到缓存cache当中。过程中调用了GenericScope的get方法,添加缓存。

image.png

以上就是整体原因的简单分析及解决方案。