之前在一个技术微信群看到一个伙伴提了一个问题。那就是 Spring Cloud 项目在标注了 @RefreshScope 与 @Configuration 类中 @Scheduled 的方法。当配置中心修改了配置时,这个定时调度会失效。下面我们来看一下案方现场。

1、@Scheduled 调度失效

我们首先通过一个 demo 项目来重现一下这个情况。

1.1 项目结构

copilot 注释不会生成代码_spring boot

1.2 pom.xm

我本地环境使用的是 JDK 11,当然你修改成 8 或者其它版本也是没有问题的。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.8.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>naoos-spring-cloud</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>nacos-spring-cloud</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.alibaba.cloud/spring-cloud-starter-alibaba-nacos-config -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            <version>2.2.8.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.nacos</groupId>
            <artifactId>nacos-common</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

1.3 启动类

这里以 Spring Boot 来启动项目,并且在启动类上面添加 @EnableScheduling 支持定时调度。

@EnableScheduling
@SpringBootApplication
public class NaoosSpringCloudApplication {

    public static void main(String[] args) {
        SpringApplication.run(NaoosSpringCloudApplication.class, args);
    }

}

1.4 定时调度配置

配置定时调度,因为调度需要的参数需要动态更新,所以添加 @RefreshScope 支持 Spring Cloud 配置中心的动态变更。

@RefreshScope
@Configuration
public class ScheduleConfig {

    @Value(value = "${test}")
    private String test;

    @Scheduled(cron = "*/3 * * * * ?")
    public void test() {
        DateTimeFormatter STANDARD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        System.out.println(STANDARD_FORMATTER.format(LocalDateTime.now()) + " : the test value is " + test);
    }

}

1.5 Nacos 配置

在 Nacos 控制台配置 dataId example,配置格式为 Properties,然后配置 test=111222333

copilot 注释不会生成代码_微服务_02

1.6 bootstrap.properties

bootstrap.properties 用于加载配置中心的连接信息。

spring.application.name=example
spring.cloud.nacos.config.server-addr=127.0.0.1:8848

1.7 application.yml

这个配置用于开启 Spring boot 的监控信息,可以 /actuator查询服务运行时间状态。

management:
  endpoints:
    web:
      exposure:
        # 这里用* 代表暴露所有端点只是为了观察效果,实际中按照需进行端点暴露
        include: "*"
  endpoint:
    health:
      # 详细信息显示给所有用户。
      show-details: always
  health:
    status:
      http-mapping:
        # 自定义健康检查返回状态码对应的http状态码
        FATAL:  503

1.8 运行项目

准备工作都完成了,下面我们运行项目。

copilot 注释不会生成代码_copilot 注释不会生成代码_03


运行项目后,可以看到每三秒进行一次定时调度。打印之前 Nacos 配置中心配置的 test=111222333 的值。

运行 http://localhost:8080/actuator/scheduledtasks 查看项目定时任务的监控信息.

copilot 注释不会生成代码_spring boot_04


在页面可以看到我们项目当中定义在 ScheduleConfig 类当中的 Cron 类型的定时任务。这个定时傻监控实现为 ScheduledTasksEndpoint#scheduledTasks 方法当中。接口我们在配置中心把 test=111222333 的值修复为非 111111111,然后进行发布。

copilot 注释不会生成代码_spring cloud_05


可以看到控制台打印了 Spring 容器重新启动了,并且定时调度停止了。

运行 http://localhost:8080/actuator/scheduledtasks 再次查看项目定时任务的监控信息.

copilot 注释不会生成代码_spring_06


发现没有定时任务调度的信息了。

2、原理分析

如果我们把 @RefreshScope 这个注解去除就不会发生上面的情况,定时调度能够正常运行。所以要知道 @Scheduled 失效的原因,我们就来分析一下 @RefreshScope 的实现原理。在 Spring 当中有不同的 Scope - Spring Bean Scope。当然我们还可以自定义 Scope,Spring Cloud @RefreshScope 中自动刷新原理就是通过自定义 refresh 范围的 Bean 来达到动态刷新的。

Spring Cloud @RefreshScope 动态刷新原理为每一个动态刷新的 Bean 都是 RefreshScope 处理的。它的处理过程如下:

  • 通过它的父类 GenericScope 中的 GenericScope#get 获取到标注@RefreshScope 注解的 bean,第一次获取到的时候会把这个 bean 缓存到 BeanLifecycleWrapperCache cache 当中。
  • 配置中心刷新配置会调用 GenericScope#destroy清除掉 BeanLifecycleWrapperCache cache 当中的缓存(等待这个 bean 重新初始化的时候再生成)
  • 下一次调用标注@RefreshScope 注解的 bean 的时候就会重新进行 Spring Bean 的依赖注入,重新把配置中心最新的值注入到这个动态刷新 Bean 当中。

可能有些小伙伴有点疑惑了,在上面控制台打印的时候是有进行 Spring 容器刷新的,但是为什么我们这个定时任务又没有再次执行呢?其实原因是 Spring 只会管理 Singleton (单例)类型的 bean,非单例类型的 bean 不会在 Spring 容器管理范围。所以虽然上面的有对 Spring 容器进行重新启动,但是并不会包括这个标注 @RefreshScope 注解这种 refresh 范围的 Bean。

知道了原因,要解决 @Scheduled 失效这种情况就特别简单了。

3、解决问题

要解决这种问题其实方法有很多种,下面我们就分别举例说明。

3.1 添加配置类

上面的把定时调度配置和动态刷新都放在一个类里面其实有点违背单一职责原则。我们可以提取一个动态配置类。

动态配置类:

@Data
@RefreshScope
@Component
public class ConfigService {

    @Value(value = "${test}")
    private String test;

}

调度任务:

@Configuration
public class ScheduleConfig {

    @Resource(name = "configService")
    private ConfigService configService;

    @Scheduled(cron = "*/3 * * * * ?")
    public void test() {
        DateTimeFormatter STANDARD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        System.out.println(STANDARD_FORMATTER.format(LocalDateTime.now()) + " : the test value is " + configService.getTest());
    }

}

这样修改的配置中心的值,调度任务会马上调用 configService.getTest()ConfigService 进行初始化。

copilot 注释不会生成代码_copilot 注释不会生成代码_07

之前失败的原因就是没有操作会对 ScheduleConfig 这个 Bean 进行实例化。

3.2 RefreshScopeRefreshedEvent 事件

使用 RefreshScopeRefreshedEvent 事件监听事件。

@RefreshScope
@Configuration
public class ScheduleConfig implements ApplicationListener<RefreshScopeRefreshedEvent> {

    @Value(value = "${test}")
    private String test;

    @Scheduled(cron = "*/3 * * * * ?")
    public void test() {
        DateTimeFormatter STANDARD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        System.out.println(STANDARD_FORMATTER.format(LocalDateTime.now()) + " : the test value is " + test);
    }

    @Override
    public void onApplicationEvent(RefreshScopeRefreshedEvent event) {
        // do nothing
    }
}

使用 RefreshScopeRefreshedEvent 从 config配置服务器成功获取并覆盖值。在这个事件监听方法里面,只需要手动再从Spring容器中获取一次当前Bean即可,因为这样便可以迫使当前Bean重新加载,从而重新初始化定时任务。

3.3 Spring Environment

其实我们可以使用 Spring Environment,配置中心参数修改之后会动态刷新 Spring 中的 Environment 对象。这样我们就可以从它之中动态获取我们的参数值了。

@Configuration
public class ScheduleConfig {

    @Autowired
    private Environment env;

    @Scheduled(cron = "*/3 * * * * ?")
    public void test() {
        DateTimeFormatter STANDARD_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        System.out.println(STANDARD_FORMATTER.format(LocalDateTime.now()) + " : the test value is " + env.getProperty("test"));
    }

}

参考文章:

  • 聊聊springboot2的ScheduledTasksEndpoint
  • Spring boot——Actuator 详解
  • [Springboot] Springboot2.0 Actuator配置后无法访问的问题解决
  • 配置中心@RefreshScope 导致@Scheduled失效问题
  • @RefreshScope 导致定时任务注解@Scheduled失效
  • @RefreshScope stops @Scheduled task