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

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