目錄
- 一、背景
- 二、注冊時機
- 2.1、注冊機制
- 2.2、分析源碼找到注冊時機
- 三、注冊前心跳健康檢測
- 3.1、方案實施
- 3.2、源碼分析
- 3.3、優化代碼
- 四、流量權重配置
- 五、總結
- 5.1、整體完整流程:
- 5.2、流程圖:
- 5.1、優化方案完整代碼:
一、背景
有些面向廣大C端的微服務,類似商品服務,每日需要承擔十萬級的QPS
,每個節點可能會高達數千QPS
的。每一秒的抖動都可能對大量用戶造成影響。因此在高頻產品迭代的前提下,平穩的進行服務發布和新老服務替換是一個必要的能力。
以商品服務為例子product-service,承載了商品的查詢與新增等功能,整體QPS 31k+
,單機qps 1.2k+
,正常平均響應時間10ms
以下
- 優雅方案部署前狀態
可以看到部署優雅功能前,啟動階段會導致100ms+
的響應抖動。這個說明啟動瞬間會有部分用戶體驗受到較大的影響,是值得研究優化的點
- 優雅方案部署后狀態
部署后很直觀可以看到,平均響應能保持正常的10ms
以下
二、注冊時機
2.1、注冊機制
商品微服務架構是基于行業內流行的Spring Cloud
架構體系,因此存在注冊中心(nacos)的概念,專門維護所有可用服務節點的信息。所有服務可以在注冊中心進行注冊,并基于注冊中心提供的其他節點的信息進行相應的調用,這就是所謂的注冊機制。Nacos源碼詳細講解可以看《【Nacos】Nacos源碼保姆級解析》
2.2、分析源碼找到注冊時機
為了解決應用啟動時出現的慢調用問題,首要步驟是檢查注冊時機的合理性。是否存在服務尚未準備就緒,就被過早地部署到生產環境中,從而導致調用端無法接收到正常的響應?
在深入分析源代碼后,確認Nacos的注冊時機是依賴于Spring的生命周期管理機制
。具體來說,它監聽的是WebServerInitializedEvent
事件,即內置Tomcat服務器完全啟動的那一刻。因此,可以觀察到Nacos的心跳(beat)緊隨Tomcat的17000端口啟動之后進行注冊并完成。這表明,自Tomcat啟動并監聽17000端口后,Nacos就已經記錄了該服務器,并可以開始接收對應的請求。
相關日志記錄如下:
public void onApplicationEvent(WebServerInitializedEvent event) {bind(event);
}
## tomcat端口已啟動
[18:01:15.217] INFO [TID: N/A] org.springframework.boot.web.embedded.tomcat.TomcatWebServer 220 start - Tomcat started on port(s): 17000 (http) with context path ''
## 增加本機nacos心跳
[18:01:15.331] INFO [TID: N/A] com.alibaba.nacos.client.naming.beat.BeatReactor 81 addBeatInfo - [BEAT] adding beat: BeatInfo{port=17000, ip='127.0.0.1', weight=1.0, serviceName='DEFAULT_GROUP@@product-v1', cluster='DEFAULT', metadata={preserved.register.source=SPRING_CLOUD}, scheduled=false, period=5000, stopped=false} to beat map.
## 注冊本機服務進nacos
[18:01:15.333] INFO [TID: N/A] com.alibaba.nacos.client.naming.net.NamingProxy 230 registerService - [REGISTER-SERVICE] public registering service DEFAULT_GROUP@@product-v1 with instance: Instance{instanceId='null', ip='127.0.0.1', port=17000, weight=1.0, healthy=true, enabled=true, ephemeral=true, clusterName='DEFAULT', serviceName='null', metadata={preserved.register.source=SPRING_CLOUD}}
## 本機在nacos注冊完成
[18:01:15.338] INFO [TID: N/A] com.alibaba.cloud.nacos.registry.NacosServiceRegistry 75 register - nacos registry, DEFAULT_GROUP product-v1 127.0.0.1:17000 register finished
關鍵源碼如下:
理論上,tomcat是在spring的context準備完后才正式啟動端口的,所以此時應該bean都已經完成了實例化
但凡事都有特例,仔細查看源碼,會發現啟動時實例化的bean是有限制條件的,如下圖。
/*** Return whether this bean is "abstract", i.e. not meant to be instantiated* itself but rather just serving as parent for concrete child bean definitions.*/
@Override
public boolean isAbstract() {return this.abstractFlag;
}/*** Return whether this a <b>Singleton</b>, with a single shared instance* returned from all calls.* @see #SCOPE_SINGLETON*/
@Override
public boolean isSingleton() {return SCOPE_SINGLETON.equals(this.scope) || SCOPE_DEFAULT.equals(this.scope);
}/*** Return whether this bean should be lazily initialized, i.e. not* eagerly instantiated on startup. Only applicable to a singleton bean.* @return whether to apply lazy-init semantics ({@code false} by default)*/
@Override
public boolean isLazyInit() {return (this.lazyInit != null && this.lazyInit.booleanValue());
}
很明顯,如果你的bean標記了@Lazy
,那肯定不會在這里被實例化。還有另外一種重要的使用場景,@RefreshScope
,這個注解廣泛應用于配合@Configuration
動態刷新配置。debug了其觸發時機,如下圖是在spring的refreshContext
的最后,afterRefresh
生命周期之前,觸發的RefreshScope
實例化,而此時context、tomcat都已完成,所以當用戶請求來讀取這些@Lazy
或者@RefreshScope
的bean時就會臨時進行實例化或者等待實例化完成,在大qps的場景下可能會造成卡頓,這也就是啟動時會瞬時卡頓的一個原因。
分析到這一步,其實已經大致能猜到原因,就是服務注冊時機不正確
。而像lazy
或者refreshScope
這種常用注解可能在實際工作中確實可能需要使用,因此單純禁用并不是一個好辦法。
所以解法很簡單,就是修改nacos注冊的時間,放棄nacos自動的注冊時機,改成手動注冊,把注冊時機掌握在自己手上。目前選擇的做法如最下面的代碼塊,在spring runner
(選擇這里是因為callRunners
在afterRefresh
之后)中異步起一個線程進行回調處理,其等待數秒之后,再手動進行注冊。
/*** 進行nacos手動注冊*/
private void doNacosRegister(){log.warn("nacos手動注冊流程開始");try {// 臨時獲取權限拿參數// 通過反射拿registration屬性。即使 registration 是私有字段(private),也可以通過反射獲取。// 公共屬性(public)才能get方法獲取Field declaredField = nacosAutoServiceRegistration.getClass().getDeclaredField("registration");// 設置 registration 字段為可訪問狀態。// 說明:// 如果 registration 字段是私有的(private),默認情況下無法通過反射直接訪問。// setAccessible(true) 可以繞過 Java 的訪問控制檢查,允許訪問私有字段。// 這是一個危險操作,因為它破壞了封裝性,應謹慎使用。declaredField.setAccessible(true);// 通過反射從 nacosAutoServiceRegistration 對象中獲取 registration 字段的值。// 將該值強制轉換為 NacosRegistration 類型。NacosRegistration nacosRegistration = (NacosRegistration) declaredField.get(nacosAutoServiceRegistration);// 將 registration 字段的訪問權限恢復為原始狀態(通常是不可訪問狀態)。// 說明:// 這是一個可選操作,目的是恢復字段的訪問控制,避免對其他代碼產生影響。// 在實際開發中,這一步通常可以省略,因為 setAccessible(true) 的作用范圍僅限于當前反射操作。declaredField.setAccessible(false);// 如果開啟了自動注冊 那么就直接返回if (nacosRegistration.isRegisterEnabled()) {log.warn("nacos已打開自動注冊,跳過手動注冊!");return;}// 手動注冊nacosRegistration.getNacosDiscoveryProperties().setRegisterEnabled(true);nacosAutoServiceRegistration.start();} catch (Exception e) {throw new RuntimeException(e);} finally {log.warn("nacos手動注冊流程結束");}
}
那就只有這一個卡點么?很明顯不是,而且純靠幾秒的延遲也無法保證所有必要的bean都預熱完成了,進一步探索。
三、注冊前心跳健康檢測
3.1、方案實施
包括mysql
和redis
在內的中間件在啟動后采用懶加載機制
,不會主動創建連接
,這樣也有可能會造成卡頓。
繼續探究了一下啟動后可繼續優化的方向。發現更加合理的方向是在注冊流程
中結合spring actuator健康檢查機制
,這樣剛好可以和k8s集群監聽的spring actuator的liveness保持邏輯判斷的一致
。
而spring actuator
會在啟動時
檢測包括mysql、redis等一系列中間件的連接狀態,確認待各項指標全部ok后。此時再進行nacos注冊可以解決該問題。
綜上所述,對nacos注冊進行了進一步的優化,使用健康檢查進行連接預熱,在注冊流程里融合了spring actuator的健康檢查機制,一方面可以確保實例完全可用,一方面可以解決中間件初次連接的問題。
例如心跳檢測失敗:
其實就是ES
注冊失敗
心跳檢測成功:
3.2、源碼分析
以mysql舉例,使用的Hikari
連接池在datasource
創建的時候采用的是懶加載模式
,直到第一次調用getConnection
才會真正和mysql進行連接。
com.zaxxer.hikari.HikariDataSource#getConnection()
而spring actuator
的健康檢查機制可以解決此類問題,針對所有的中間件,不管你是否有主動進行getConnection,它都會在檢查時主動getConnection
。
org.springframework.boot.actuate.jdbc.DataSourceHealthIndicator#doHealthCheck
同樣的問題在redis的lettuce客戶端也有一樣的處理,不會主動創建連接直到首次調用。同樣可以依靠redis的健康檢查進行初次連接
org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.AdaptedReactiveHealthContributors#adapt(org.springframework.boot.actuate.health.ReactiveHealthIndicator)
3.3、優化代碼
// 初次健康檢查,預熱
this.firstHealthCheck();// 異步健康檢查
CompletableFuture.supplyAsync(() -> {log.warn("異步監測健康狀態開始");Boolean isUp = false;// 等待5秒才注冊try {for (int i = 1; i <= CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES; i++) {isUp = this.isUpStatus();log.warn("第{}次異步健康檢測:{}", i, isUp);if (isUp){// 如果已啟動,注冊并中斷循環this.doNacosRegister();break;}Thread.sleep(5000); // 模擬耗時操作}} catch (InterruptedException e) {throw new RuntimeException(e);}return isUp;
}).thenAccept(result -> {if (result) {log.warn("異步監測健康狀態結束");} else {System.exit(99);log.error("異步監測健康狀態一直失敗,請檢查!");}
});
日志:
[14:52:57.978] WARN [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 46 run - ---開始執行應用程序已啟動,執行runner邏輯---
[14:52:57.979] WARN [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 175 firstHealthCheck - 開始進行初次預熱健康檢查
[14:52:59.578] WARN [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 178 firstHealthCheck - 初次預熱健康檢查完成:OUT_OF_SERVICE
[14:52:59.579] WARN [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 76 lambda$handleCommandLineArguments$0 - 異步監測健康狀態開始
[14:52:59.653] WARN [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 83 lambda$handleCommandLineArguments$0 - 第1次異步健康檢測:false
[14:53:04.717] WARN [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 83 lambda$handleCommandLineArguments$0 - 第2次異步健康檢測:true
[14:53:04.717] WARN [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 122 doNacosRegister - nacos手動注冊流程開始
如上述代碼和日志,在注冊前進行一次健康檢查,然后起異步線程進行定時異步檢查健康狀態。可以看到哪怕是到了spring runner
階段,health check
仍然處于不可用狀態,直到第二次異步健康檢測才變更為可用,此時再進行nacos手動注冊最為合適
。
四、流量權重配置
至此已經基本解決優雅上線的問題,但出于精益求精的態度,并且針對C端高請求量的場景特點(低流量業務選做)。重新審視了一遍nacos的設計和架構,發現一個一直忽略的重要功能,權重。因此制定了進一步的優化方向:對用戶流量進行控流,逐步預熱上線。
依托于nacos的權重weight機制,可以對用戶流量進行設置從0.01至1的權重配置,逐步放大用戶流量至全量,這樣做可以更好預熱服務,防止瞬間高請求量導致擴tomcat線程等操作的耗時。
新老版本nacos客戶端代碼編寫及load-balancer的權重適配
在開發過程中發現,雖然nacos服務端有設置weight的地方,但實際上客戶端的lb組件并沒有針對weight做判斷。查閱資料后及翻閱源碼后證實了這一點,在2.x版本之前都是簡單的輪詢機制
。在之后的版本也需要特別打開開關才會采用weight的權重配置
。
org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer#getInstanceResponse
因此針對新版本nacos,可以選擇打開NacosLoadBalancer
的開關,針對老版本,參考了NacosLoadBalancer
的做法,自行實現了LoadBalancer核心算法代碼如下,概括總結一下,各臺機器的weight形成各自的區間,依靠隨機數去命中區間,以此達到權重的效果。
/*** Random get one item with weight.** @return item*/
public T randomWithWeight() {Ref<T> ref = this.ref;double random = ThreadLocalRandom.current().nextDouble(0, 1);int index = Arrays.binarySearch(ref.weights, random);if (index < 0) {index = -index - 1;} else {return ref.items.get(index);}if (index < ref.weights.length) {if (random < ref.weights[index]) {return ref.items.get(index);}}if (ref.weights.length == 0) {throw new IllegalStateException("Cumulative Weight wrong , the array length is equal to 0.");}/* This should never happen, but it ensures we will return a correct* object in case there is some floating point inequality problem* wrt the cumulative probabilities. */return ref.items.get(ref.items.size() - 1);
}
注冊流程加入權重
可以通過以下代碼
// 注冊前實例化節點,并配置weight
Instance instance = new Instance();
instance.setIp(nacosDiscoveryProperties.getIp());
instance.setPort(nacosDiscoveryProperties.getPort());
instance.setWeight(0.5);
五、總結
5.1、整體完整流程:
-
關閉自動注冊進行手動注冊,且應在spring runner階段
-
注冊前進行spring actuator的health check
-
health check返回成功后進行0.01weight的小流量注冊
-
逐步放大weight直至到1
-
服務發布結束
5.2、流程圖:
5.1、優化方案完整代碼:
GitHub地址,有幫助麻煩給個star
import com.alibaba.cloud.nacos.registry.NacosAutoServiceRegistration;
import com.alibaba.cloud.nacos.registry.NacosRegistration;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.concurrent.CompletableFuture;import static org.springframework.boot.actuate.health.Status.UP;/*** @author hanson.huang* @version V1.0* @ClassName NacosDelayRegisterRunner* @Description nacos優雅預熱上線方案* @date 2025/3/14 14:22**/
@Component
@Slf4j
public class NacosDelayRegisterRunner implements ApplicationRunner {/*** 最大健康檢查次數*/private static final int CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES = 10;@Resourceprivate NacosAutoServiceRegistration nacosAutoServiceRegistration;@Resourceprivate HealthEndpoint healthEndpoint;@Overridepublic void run(ApplicationArguments args) throws Exception {// 在這里編寫應用程序啟動后要執行的邏輯log.warn("---開始執行應用程序已啟動,執行runner邏輯---");// 你還可以獲取并處理命令行參數和應用程序參數handleCommandLineArguments(args);}/*** 讀取程序啟動參數并執行* @param args 啟動參數*/private void handleCommandLineArguments(ApplicationArguments args) {// 獲取并處理命令行參數System.out.println("---命令行參數:---");for (String arg : args.getSourceArgs()) {System.out.println(arg);}// 獲取并處理應用程序參數System.out.println("---應用程序參數:---");for (String name : args.getOptionNames()) {System.out.println(name + "=" + args.getOptionValues(name));}// 如果在啟動參數手動設置了不注冊nacos,就跳過手動注冊,為了開發環境和backendif ( !checkDisableNacos(args.getSourceArgs()) ) {// 初次健康檢查,預熱this.firstHealthCheck();// 異步健康檢查CompletableFuture.supplyAsync(() -> {log.warn("異步監測健康狀態開始");Boolean isUp = false;// 等待5秒才注冊try {for (int i = 1; i <= CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES; i++) {isUp = this.isUpStatus();log.warn("第{}次異步健康檢測:{}", i, isUp);if (isUp){// 如果已啟動,注冊并中斷循環this.doNacosRegister();break;}Thread.sleep(5000); // 模擬耗時操作}} catch (InterruptedException e) {throw new RuntimeException(e);}return isUp;}).thenAccept(result -> {if (result) {log.warn("異步監測健康狀態結束");} else {System.exit(99);log.error("異步監測健康狀態一直失敗,請檢查!");}});}}private boolean checkDisableNacos(String[] args){System.out.println(System.getProperty("spring.cloud.nacos.discovery.register-enabled"));for (String arg : args) {if (StringUtils.contains(arg, "spring.cloud.nacos.discovery.register-enabled") && StringUtils.contains(arg,"false")|| StringUtils.equals(System.getProperty("spring.cloud.nacos.discovery.register-enabled"), "false")){return true;}}return false;}/*** 進行nacos手動注冊*/private void doNacosRegister(){log.warn("nacos手動注冊流程開始");try {// 臨時獲取權限拿參數// 通過反射拿registration屬性。即使 registration 是私有字段(private),也可以通過反射獲取。// 公共屬性(public)才能get方法獲取Field declaredField = nacosAutoServiceRegistration.getClass().getDeclaredField("registration");// 設置 registration 字段為可訪問狀態。// 說明:// 如果 registration 字段是私有的(private),默認情況下無法通過反射直接訪問。// setAccessible(true) 可以繞過 Java 的訪問控制檢查,允許訪問私有字段。// 這是一個危險操作,因為它破壞了封裝性,應謹慎使用。declaredField.setAccessible(true);// 通過反射從 nacosAutoServiceRegistration 對象中獲取 registration 字段的值。// 將該值強制轉換為 NacosRegistration 類型。NacosRegistration nacosRegistration = (NacosRegistration) declaredField.get(nacosAutoServiceRegistration);// 將 registration 字段的訪問權限恢復為原始狀態(通常是不可訪問狀態)。// 說明:// 這是一個可選操作,目的是恢復字段的訪問控制,避免對其他代碼產生影響。// 在實際開發中,這一步通常可以省略,因為 setAccessible(true) 的作用范圍僅限于當前反射操作。declaredField.setAccessible(false);// 如果開啟了自動注冊 那么就直接返回if (nacosRegistration.isRegisterEnabled()) {log.warn("nacos已打開自動注冊,跳過手動注冊!");return;}// 手動注冊nacosRegistration.getNacosDiscoveryProperties().setRegisterEnabled(true);nacosAutoServiceRegistration.start();} catch (Exception e) {throw new RuntimeException(e);} finally {log.warn("nacos手動注冊流程結束");}}/*** 進行初次健康檢查*/private void firstHealthCheck(){log.warn("開始進行初次預熱健康檢查");// 進行初次健康檢查HealthComponent endpoint = healthEndpoint.health();log.warn("初次預熱健康檢查完成:" + endpoint.getStatus());}/*** 是否已啟動* @return 是/否*/private Boolean isUpStatus(){return UP.equals( healthEndpoint.health().getStatus() );}
}
其他注意事項:
-
healthcheck必須確保沒有廢棄中間件的引入,以免healthcheck一直不過
-
啟動時CPU資源一定要給足,否則啟動過慢
-
weight參數的使用要謹慎,要確保各種客戶端的兼容性
創作不易,不妨點贊、收藏、關注支持一下,各位的支持就是我創作的最大動力??