記錄一次RPC服務有損上線的分析過程

京东云开发者發表於2024-11-25

1. 問題背景

某應用在啟動完提供JSF服務後,短時間內出現了大量的空指標異常。

分析日誌,發現是服務依賴的藏經閣配置資料未載入完成導致。即所謂的有損上線或者是直接釋出應用啟動時,service還沒載入完,就開始對外提供服務,導致失敗呼叫

關鍵程式碼如下

資料的初始化載入是透過實現CommandLineRunner介面完成的

@Component
public class LoadSystemArgsListener implements CommandLineRunner {

    @Resource
    private CacheLoader cjgConfigCacheLoader;

    @Override
    public void run(String... args) {
        // 載入藏經閣配置
        cjgConfigCacheLoader.refresh();

    }
}

cjgConfigCacheLoader.refresh()方法內部會將資料載入到記憶體中

/** 藏經閣配置資料 key:租戶 value:配置資料 */
public static Map<String, CjgRuleConfig> cjgRuleConfigMap = new HashMap<>();

如果此時還未載入完資料,呼叫cjgRuleConfigMap.get("301").getXX(),則會報空指標異常

總結根因:JSF Provider釋出早於服務依賴的初始化資料載入,導致失敗呼叫

2. 問題解決

在解決此問題前,我們需要先回憶並熟悉下Spring Boot的啟動過程、JSF服務的釋出過程

1)Spring Boot的啟動過程(版本2.0.7.RELEASE)

run方法,主要關注refreshContext(context)重新整理上下文

public ConfigurableApplicationContext run(String... args) {
    // 建立 StopWatch 例項:用於計算啟動時間
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    ConfigurableApplicationContext context = null;
    Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
    configureHeadlessProperty();

    // 獲取SpringApplicationRunListeners:這些監聽器會在啟動過程的各個階段傳送對應的事件
    SpringApplicationRunListeners listeners = getRunListeners(args);
    listeners.starting();
    try {
        ApplicationArguments applicationArguments = new DefaultApplicationArguments(
                args);

        // 建立並配置Environment:包括準備好對應的`Environment`,以及將`application.properties`或`application.yml`中的配置項載入到`Environment`中
        ConfigurableEnvironment environment = prepareEnvironment(listeners,
                applicationArguments);
        configureIgnoreBeanInfo(environment);

        // 列印Banner:如果 spring.main.banner-mode 不為 off,則列印 banner
        Banner printedBanner = printBanner(environment);

        // 建立應用上下文:根據使用者的配置和classpath下的配置,建立合適的`ApplicationContext`
        context = createApplicationContext();
        exceptionReporters = getSpringFactoriesInstances(
                SpringBootExceptionReporter.class,
                new Class[] { ConfigurableApplicationContext.class }, context);

        // 準備上下文:主要是將`Environment`、`ApplicationArguments`等關鍵屬性設定到`ApplicationContext`中,以及載入`ApplicationListener`、`ApplicationRunner`、`CommandLineRunner`等。
        prepareContext(context, environment, listeners, applicationArguments,
                printedBanner);

        // 重新整理上下文:這是Spring IoC容器啟動的關鍵,包括Bean的建立、依賴注入、初始化,釋出事件等
        refreshContext(context);
        afterRefresh(context, applicationArguments);
        stopWatch.stop();
        // 列印啟動資訊:如果 spring.main.log-startup-info 為 true,則列印啟動資訊
        if (this.logStartupInfo) {
            new StartupInfoLogger(this.mainApplicationClass)
                    .logStarted(getApplicationLog(), stopWatch);
        }
        // 釋出 ApplicationStartedEvent:通知所有的 SpringApplicationRunListeners 應用已經啟動
        listeners.started(context);
        
        // 呼叫 Runner:呼叫所有的ApplicationRunner和CommandLineRunner
        callRunners(context, applicationArguments);
    }catch(Throwable ex){handleRunFailure(context, ex, exceptionReporters, listeners);thrownewIllegalStateException(ex);}try{// 執行中:通知所有的 SpringApplicationRunListeners 應用正在執行
        listeners.running(context);}catch(Throwable ex){handleRunFailure(context, ex, exceptionReporters,null);thrownewIllegalStateException(ex);}return context;}

refreshContext(context)內部呼叫refresh()方法,此方法主要關注finishBeanFactoryInitialization(beanFactory) 例項化Bean 早於 finishRefresh() 發生

public void refresh() throws BeansException, IllegalStateException {
    synchronized (this.startupShutdownMonitor) {
        // 準備重新整理的上下文環境:設定啟動日期,啟用上下文,清除原有的屬性源
        prepareRefresh();

        // 告訴子類啟動 'refreshBeanFactory()' 方法,建立一個新的bean工廠。
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

        // 為 BeanFactory 設定上下文特定的後處理器:主要用於支援@Autowired和@Value註解
        prepareBeanFactory(beanFactory);

        try {
            // 為 BeanFactory 的處理提供在子類中的後處理器。
            postProcessBeanFactory(beanFactory);

            // 呼叫所有註冊的 BeanFactoryPostProcessor Bean 的處理方法。
            invokeBeanFactoryPostProcessors(beanFactory);

            // 註冊 BeanPostProcessor 的處理器,攔截 Bean 建立。
            registerBeanPostProcessors(beanFactory);

            // 為此上下文初始化訊息源。
            initMessageSource();

            // 為此上下文初始化事件多播器。
            initApplicationEventMulticaster();

            // 在特定的上下文子類中重新整理之前的進一步初始化。
            onRefresh();

            // 檢查監聽器 Bean 並註冊它們:註冊所有的ApplicationListenerbeans
            registerListeners();

            // 例項化所有剩餘的(非延遲初始化)單例。
            finishBeanFactoryInitialization(beanFactory);

            // 完成重新整理:釋出ContextRefreshedEvent,啟動所有Lifecyclebeans,初始化所有剩餘的單例(lazy-init 單例和非延遲初始化的工廠 beans)。
            finishRefresh();
        }
        ...
    }


例項化Bean中,需熟悉Bean的生命週期(重要)


2)JSF Provider的釋出過程(版本1.7.5-HOTFIX-T6)

類com.jd.jsf.gd.config.spring.ProviderBean呼叫方法com.jd.jsf.gd.config.ProviderConfig#export進行釋出

JSF原始碼地址:http://xingyun.jd.com/codingRoot/jsf/jsf-sdk

public class ProviderBean<T> extends ProviderConfig<T> implements InitializingBean, DisposableBean, ApplicationContextAware, ApplicationListener, BeanNameAware {
    
    // 此處程式碼省略...

    public void onApplicationEvent(ApplicationEvent event) {
        if (event instanceof ContextRefreshedEvent && this.isDelay() && !this.exported && !CommonUtils.isUnitTestMode()) {
            LOGGER.info("JSF export provider with beanName {} after spring context refreshed.", this.beanName);
            if (this.delay < -1) {
                Thread thread = new Thread(new Runnable() {
                    public void run() {
                        try {
                            Thread.sleep((long)(-ProviderBean.this.delay));
                        } catch (Throwable var2) {
                        }

                        ProviderBean.this.export();
                    }
                });
                thread.setDaemon(true);
                thread.setName("DelayExportThread");
                thread.start();
            } else {
                this.export();
            }
        }

    }

    private boolean isDelay() {
        return this.supportedApplicationListener && this.delay < 0;
    }

    public void afterPropertiesSet() throws Exception {
        // 此處程式碼省略...

        if (!this.isDelay() && !CommonUtils.isUnitTestMode()) {
            LOGGER.info("JSF export provider with beanName {} after properties set.", this.beanName);
            this.export();
        }

    }
}

public synchronized void export() throws InitErrorException {
    if (this.delay > 0) {
        Thread thread = new Thread(new Runnable() {
            public void run() {
                try {
                    Thread.sleep((long)ProviderConfig.this.delay);
                } catch (Throwable var2) {
                }

                ProviderConfig.this.doExport();
            }
        });
        thread.setDaemon(true);
        thread.setName("DelayExportThread");
        thread.start();
    } else {
        this.doExport();
    }

}

可以看出Provider釋出有兩個地方

Ⅰ、Bean的初始化過程(delay>=0)

實現InitializingBean介面,重寫afterPropertiesSet方法。這裡會判斷是否延遲釋出,如果大於等於0,則會此處進行釋出。具體在export方法中,當delay>0,則會延遲釋出,如配置5000,表示延遲5秒釋出;當delay=0,則立即釋出。

Ⅱ、監聽ContextRefreshedEvent事件觸發(delay<0)

實現ApplicationListener介面,重寫onApplicationEvent方法。屬於事件ContextRefreshedEvent,當delay<-1,則會延遲釋出,如配置-5000,表示延遲5秒釋出;反之,則立即釋出。

3)解決方案

場景1:XML方式自動釋出Provider(常用)

由上面的介紹,瞭解到執行順序1.Bean初始化 > 2.ContextRefreshedEvent事件觸發 > 3.呼叫ApplicationRunner或CommandLineRunner;

上面已經知道Provider釋出處於1、2過程,需避免使用方式3進行資料的初始化。

前提建議:delay預設配置為-1,可以不配置,或者配置負數。則JSF Provider釋出則處於過程2,即監聽ContextRefreshedEvent事件觸發

方式1:Bean的初始化過程中

解決方法:使用@PostConstruct註解、實現InitializingBean介面、配置init-method方法均可
@Component
public class DataLoader {

    @PostConstruct
    @Scheduled(cron = "${cron.config}")
    public void loadData() {
        // 資料載入
        System.out.println("資料載入工作");
    }

}

注意:該Bean如果依賴了其他Bean,需確保依賴Bean已例項化,否則會報空指標異常。

方式2:ContextRefreshedEvent事件觸發

ContextRefreshedEvent事件是如何釋出的

呼叫過程 AbstractApplicationContext#finishRefresh -> AbstractApplicationContext#publishEvent-> SimpleApplicationEventMulticaster#multicastEvent

public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
   ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
   for (final ApplicationListener<?> listener : getApplicationListeners(event, type)) {
      Executor executor = getTaskExecutor();
      if (executor != null) {
         executor.execute(() -> invokeListener(listener, event));
      }
      else {
         invokeListener(listener, event);
      }
   }
}

SimpleApplicationEventMulticaster的multicastEvent方法中呼叫invokeListener()進行事件釋出getTaskExecutor()預設值是null(除自定義設定Executor物件),所有ApplicationListener實現類序列執行onApplicationEvent方法。

getApplicationListeners(event, type)獲取所有的實現類,繼續向下看內部會呼叫AnnotationAwareOrderComparator.sort(allListeners)對所有ApplicationListener進行排序,allListeners 是待排序的物件列表。該方法將根據物件上的排序註解或介面來確定排序順序,並返回一個按照指定順序排序的物件列表。具體來說,排序的規則如下:

1.首先,根據物件上的 @Order 註解的值進行排序。@Order 註解的值越小,排序優先順序越高
2.如果物件上沒有 @Order 註解,或者多個物件的 @Order 註解值相同,則根據物件是否實現了 Ordered 介面進行排序。實現了 Ordered 介面的物件,可以透過 getOrder() 方法返回一個排序值。
3.如果物件既沒有 @Order 註解,也沒有實現 Ordered 介面,則使用預設的排序值 LOWEST_PRECEDENCE(Integer.MAX_VALUE)。特別的:如果BeanA和BeanB排序值都是預設值,則保持原順序,即Bean的載入順序

總結:預設情況所有ApplicationListener實現類序列執行onApplicationEvent方法,而順序取決於AnnotationAwareOrderComparator.sort(allListeners),@Order 註解的值越小,排序優先順序越高

解決方法:使用@Order註解保證執行順序早於ProviderBean
@Component
@Order(1)
public class DataLoader implements ApplicationListener<ContextRefreshedEvent> {
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        // 資料準備
        System.out.println("初始化工作");
        
    }
}

此外帶有@SpringBootApplication的啟動類中實現也是可以的(在Spring Boot中預設使用基於註解的方式進行配置和管理Bean,所以註解定義的Bean會在XML定義的Bean之前被載入)

@SpringBootApplication
public class DemoApplication implements ApplicationListener<ContextRefreshedEvent> {

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

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        System.out.println("初始化工作");
    }
}

場景2:API方式釋出Provider(較少使用)

應用啟動完成後,先做初始化動作,完成後再手動釋出Provider。這種就可以透過實現介面ApplicationRunner或介面CommandLineRunner去執行初始化。

@Component
public class DataLoader implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 資料準備
        System.out.println("初始化工作");

        // 釋出provider
        // 參考:https://cf.jd.com/pages/viewpage.action?pageId=296129902
    }
}

場景3:XML方式手動釋出(不常用)

provider的dynamic屬性設定為false

標籤 屬性 型別 是否必填 預設值 描述
provider dynamic boolean true 是否動態註冊Provider,預設為true,配置為false代表不主動釋出,需要到管理端進行上線操作

3. 總結

RPC服務(如JSF、Dubbo)進行優雅上線,常用的兩種方式:1、延遲釋出 2、手動發動

如果你的服務需要一些初始化操作後才能對外提供服務,如初始化快取(不限與藏經閣、ducc、mysql、甚至呼叫其他jsf服務)、redis連線池等相關資源就位,可以參考本文中介紹的幾種方式。

此文是筆者透過讀取原始碼+本地驗證得出的結論,如有錯誤遺漏或者更好的方案還煩請各位指出共同進步!

相關文章