Spring Cloud 热更新机制原理学习

博主在调研 Spring Cloud Config 跟 Apollo 配置中心的时候,对其中的热更新机制比较有兴趣,并阅读了相关的源码,在这里进行记录,方便以后查看。

调研时使用的 Spring Boot 跟 Spring Cloud 的版本:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>1.5.21.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Edgware.SR6</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

知识背景

Spring Cloud Config 提供了一套配置中心,结合 Spring Cloud Bus 可实现一套配置的自动热更新。其实通过阅读源码,无论是 Spring Cloud Config 的手动刷新,还是通过 Spring Cloud Bus 通知进行自动刷新配置,最终底层都是使用了Spring Cloud 提供的热更新机制,换句话说,其实无需使用配置中心,通过手动修改配置文件,触发refresh 端点也可以触发热更新

Spring Cloud 热更新机制,支持热更新的配置有三类,分别是:@ConfigurationProperties 注入的配置类日志相关@RefreshScope 注解的类。而对于这三类配置,Spring Cloud 提供了两种更新策略,对于前两类,采用类的重新绑定策略进行更新,而最后的 @RefreshScope,采用了延迟加载跟缓存刷新的策略。下面将讲解具体的实现方式。

源码阅读

热更新机制的入口为调用ContextRefresher类的refresh方法,代码如下:

	public synchronized Set<String> refresh() {
    	// 1. 获取当前配置源信息 
		Map<String, Object> before = extract(
				this.context.getEnvironment().getPropertySources());
		// 2. 加载配置
    	addConfigFilesToEnvironment();
		// 3. 获取修改了配置值的kd
    	Set<String> keys = changes(before,
				extract(this.context.getEnvironment().getPropertySources())).keySet();
		// 4. 发布变更事件
    	this.context.publishEvent(new EnvironmentChangeEvent(context, keys));
		// 5. 刷新@refreshScope类
    	this.scope.refreshAll();
		return keys;
	}

下面对上面的代码进行解读:

1. 获取当前属性源信息

这里获取环境的所有属性源的信息,但从中去除标准属性源:

	private Map<String, Object> extract(MutablePropertySources propertySources) {
		Map<String, Object> result = new HashMap<String, Object>();
		List<PropertySource<?>> sources = new ArrayList<PropertySource<?>>();
		for (PropertySource<?> source : propertySources) {
			sources.add(0, source);
		}
        // 除去标准属性源
		for (PropertySource<?> source : sources) {
			if (!this.standardSources.contains(source.getName())) {
				extract(source, result);
			}
		}
		return result;
	}

标准属性源,不可修改的属性源。这里的标准属性源指的是 StandardEnvironment 和 StandardServletEnvironment,前者会注册系统变量(System Properties)和环境变量(System Environment),后者会注册 Servlet 环境下的 Servlet Context 和 Servlet Config 的初始参数(Init Params)和 JNDI 的属性。

	private Set<String> standardSources = new HashSet<>(
			Arrays.asList(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME,
					StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
					StandardServletEnvironment.JNDI_PROPERTY_SOURCE_NAME,
					StandardServletEnvironment.SERVLET_CONFIG_PROPERTY_SOURCE_NAME,
					StandardServletEnvironment.SERVLET_CONTEXT_PROPERTY_SOURCE_NAME));

2. 加载配置

这里的做法是:通过重新创建一个非 WEB 的 Spring Boot 环境,用于重新加载一遍属性源。即这一步执行后,环境中的配置源,均为更新后的配置信息。

	/* for testing */ ConfigurableApplicationContext addConfigFilesToEnvironment() {
		ConfigurableApplicationContext capture = null;
		try {
			StandardEnvironment environment = copyEnvironment(
					this.context.getEnvironment());
			SpringApplicationBuilder builder = new SpringApplicationBuilder(Empty.class)
					.bannerMode(Mode.OFF).web(false).environment(environment);
			// Just the listeners that affect the environment (e.g. excluding logging
			// listener because it has side effects)
			builder.application()
					.setListeners(Arrays.asList(new BootstrapApplicationListener(),
							new ConfigFileApplicationListener()));
			capture = builder.run();
			if (environment.getPropertySources().contains(REFRESH_ARGS_PROPERTY_SOURCE)) {
				environment.getPropertySources().remove(REFRESH_ARGS_PROPERTY_SOURCE);
			}
			MutablePropertySources target = this.context.getEnvironment()
					.getPropertySources();
			String targetName = null;
			for (PropertySource<?> source : environment.getPropertySources()) {
				String name = source.getName();
				if (target.contains(name)) {
					targetName = name;
				}
				if (!this.standardSources.contains(name)) {
					if (target.contains(name)) {
						target.replace(name, source);
					}
					else {
						if (targetName != null) {
							target.addAfter(targetName, source);
						}
						else {
							// targetName was null so we are at the start of the list
							target.addFirst(source);
							targetName = name;
						}
					}
				}
			}
		}
		finally {
			ConfigurableApplicationContext closeable = capture;
			while (closeable != null) {
				try {
					closeable.close();
				}
				catch (Exception e) {
					// Ignore;
				}
				if (closeable.getParent() instanceof ConfigurableApplicationContext) {
					closeable = (ConfigurableApplicationContext) closeable.getParent();
				}
				else {
					break;
				}
			}
		}
		return capture;
	}

3. 比较更新前后的属性源配置

进行比较,用一个 Map 记录比较更新前后不同的属性源。

	private Map<String, Object> changes(Map<String, Object> before,
			Map<String, Object> after) {
		Map<String, Object> result = new HashMap<String, Object>();
		for (String key : before.keySet()) {
            // 放入更新前有,但更新后没有的 KEY
			if (!after.containsKey(key)) {
				result.put(key, null);
			}
            // 放入更新前后值不同的 KEY,跟更新后的值
			else if (!equal(before.get(key), after.get(key))) {
				result.put(key, after.get(key));
			}
		}
        // 放入更新后有,但是更新前没有的 KEY,跟更新后的值
		for (String key : after.keySet()) {
			if (!before.containsKey(key)) {
				result.put(key, after.get(key));
			}
		}
		return result;
	}

这里得到了配置更新前后属性源有变化的 KEY,如果当前是用 BUS 进行下发的通知,这些 KEY 最终会反馈到服务端。
到这步结束时,我们的应用中已经是新的配置源,但是对于 Spring Bean 中,他们仍然是旧的配置源,因此接下来,需要使 Spring Bean 中的属性为新的配置源。

4. 发布变更事件

这里使用的消息通知机制,发布了 EnvironmentChangeEvent 环境变更事件:

	this.context.publishEvent(new EnvironmentChangeEvent(context, keys));

监听该事件的监听器多个,但需要关注的只有两个:LoggingBebinderConfigurationPropertiesRebinder

4.1 ConfigurationPropertiesRebinder

该类监听 EnvironmentChangeEvent 事件,实现对 @ConfigurationProperties注解的类进行重绑。

	@Override
	public void onApplicationEvent(EnvironmentChangeEvent event) {
         // ConfigurationPropertiesRebinder 类中监听了 EnvironmentChangeEvent
		if (this.applicationContext.equals(event.getSource())
				// Backwards compatible
				|| event.getKeys().equals(event.getSource())) {
			// 触发解绑操作
            rebind();
		}
	}
	
	// 收集使用 @ConfigurationProperties 修饰的类以及父类
	private ConfigurationPropertiesBeans beans;

	@ManagedOperation
	public void rebind() {
		this.errors.clear();
		for (String name : this.beans.getBeanNames()) {
            // 这里的 this.beans,为 ConfigurationPropertiesBeans
            // 对所有 @ConfigurationProperties 进行解绑,利用新的环境源绑定新的类
			rebind(name);
		}
	}

	@ManagedOperation
	public boolean rebind(String name) {
		if (!this.beans.getBeanNames().contains(name)) {
			return false;
		}
		if (this.applicationContext != null) {
			try {
				Object bean = this.applicationContext.getBean(name);
				if (AopUtils.isAopProxy(bean)) {
					bean = ProxyUtils.getTargetObject(bean);
				}
				if (bean != null) {
                    // 销毁原有的 Bean
					this.applicationContext.getAutowireCapableBeanFactory().destroyBean(bean);
                    // 利用新的环境源初始化新的 Bean
                    this.applicationContext.getAutowireCapableBeanFactory()
                            .initializeBean(bean, name);
					return true;
				}
			}
			catch (RuntimeException e) {
				this.errors.put(name, e);
				throw e;
			}
			catch (Exception e) {
				this.errors.put(name, e);
				throw new IllegalStateException("Cannot rebind to " + name, e);
			}
		}
		return false;
	}

4.2 LoggingBebinder

该类监听 EnvironmentChangeEvent 事件,实现了对日志级别的修改。

	@Override
	public void onApplicationEvent(EnvironmentChangeEvent event) {
		if (this.environment == null) {
			return;
		}
        // 调用了 LoggingSystem 的方法重新设置了日志级别
		LoggingSystem system = LoggingSystem.get(LoggingSystem.class.getClassLoader());
		setLogLevels(system, this.environment);
	}

	protected void setLogLevels(LoggingSystem system, Environment environment) {
		Map<String, String> levels = Binder.get(environment)
				.bind("logging.level", STRING_STRING_MAP).orElseGet(Collections::emptyMap);
		for (Entry<String, String> entry : levels.entrySet()) {
			setLogLevel(system, environment, entry.getKey(), entry.getValue().toString());
		}
	}

5. 刷新 @RefreshScope

对于@RefreshScope注解的类,当每次被调用的时候,都会进行初始化,同时采用懒代理的方法,将作用域充当初始值的缓存,当缓存存在时,不会再进行初始化。因此,对于刷新@RefreshScope注解的类,只需要将其缓存进行清空,则在下一次访问的时候,依赖新的配置源,将生成新的缓存。

	public void refreshAll() {
        // 清除缓存信息
		super.destroy();
        // 发布更新事件
		this.context.publishEvent(new RefreshScopeRefreshedEvent());
	}

	public void destroy() {
		List<Throwable> errors = new ArrayList<Throwable>();
        // 此步清除了缓存信息,通过继续往下翻源码可以发现
        // 缓存信息最终存放在 ConcurrentMap 中,通过调用期 clear 方法进行清除
		Collection<BeanLifecycleWrapper> wrappers = this.cache.clear();
        // 获取相关的销毁时的回调函数并执行
		for (BeanLifecycleWrapper wrapper : wrappers) {
			try {
				Lock lock = 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();
	}

热更新无法保证配置的一致性

热更新需要注意一个问题,配置的热更新无法保证配置的一致性。

  • 对于 @ConfigurationProperties 注入的 Bean,刷新配置时,需要每个 Bean 进行重绑
  • 对于 @RefreshScope 注解的Bean,底层通过 ConcurrentHashMap 的 clear 方法清空缓存,但该方法为弱一致性。

两类更新策略都非原子性操作,无法保证配置的一致性。

例如:
在这里插入图片描述

会出现配置类未完全更新,即对外提供服务的情况,关于这个问题,我咨询了代码的原作者,相关 issue:

https://github.com/spring-cloud/spring-cloud-config/issues/1562

博主个人理解作者的意思是:可以通过自行监听 EnvironmentChangeEvent 事件,来限制请求的进入,已实现配置更新的原子性。例如 Eureka 接收到 EnvironmentChangeEvent 事件会先将服务标记为下线,再重新上线。但这样子就会在 Eureka 上短时间的上下线,具体的方案我也没有想到,如果这个问题没有比较完美的方案,则热更新不适合在生产环境进行使用。

关于不一致这点,如果有不同的看法或好的解决方案,欢迎交流 o( ̄▽ ̄)ブ

总结

  • 通过使用 ContextRefresher 可以进行手动的热更新,而并非一定需要配置中心。
  • 程序通过创建一个新的非 web 的 SpringBoot 来加载新的配置源。
  • 热更新提供了两种机制,类的重绑跟刷新缓存,分别适用于 @ConfigurationProperties 的对象,跟 @RefreshScope 对象。
  • 热更新无法保证配置的一致性。
  • 通过自行监听 EnvironmentChangeEvent 事件可以实现自己的热更新逻辑。

之前在网上看到的另外一篇讲热更新的文章,觉得讲的不错,推荐:https://www.cnblogs.com/niechen/p/8979578.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章