記一次自定義starter引發的線上事故覆盤

linyb極客之路發表於2022-11-22

前言

本文素材來源於業務部門技術負責人某次線上事故覆盤分享。故事的背景是這樣,該業務部門招了一個技術挺不錯的小夥子小張,由於小張技術能力在該部門比較突出,在入職不久後,他便成為這個部門某個專案組的team leader,同時也擁有review 該專案的權利。(注: 該專案為微服務專案),在某次小張review專案的時候,他發現好幾個專案,發現程式碼有很多重複,於是他就動了把這些重複程式碼封裝成starter的念頭,然後也是因為這次的封裝,帶來一次線上事故。下面就以程式碼示例的形式,模擬這次事故

程式碼示例

注: 本文僅模擬出現事故的程式碼片段,不涉及業務

1、模擬小張的封裝的starter
@Slf4j
public class HelloSevice {

    private ThreadPoolTaskExecutor threadPoolTaskExecutor;

    public HelloSevice(ThreadPoolTaskExecutor threadPoolTaskExecutor){
        this.threadPoolTaskExecutor = threadPoolTaskExecutor;
    }

    public String sayHello(String username){
        threadPoolTaskExecutor.execute(()->{
            log.info("hello: {} ",username);
        });
        return " hello : " + username;
    }
}
@Configuration
public class HelloServiceAutoConfiguration {


    @Bean
    public ThreadPoolTaskExecutor threadPoolTaskExecutor(){
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(2);
        threadPoolTaskExecutor.setMaxPoolSize(4);
        threadPoolTaskExecutor.setQueueCapacity(1);
        threadPoolTaskExecutor.setThreadFactory(new ThreadFactory() {
            private AtomicInteger atomicInteger = new AtomicInteger();
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setName("hello-pool-" + atomicInteger.getAndIncrement());
                return thread;
            }
        });

        return threadPoolTaskExecutor;

    }

    @Bean
    public HelloSevice helloSevice(ThreadPoolTaskExecutor threadPoolTaskExecutor){
        return new HelloSevice(threadPoolTaskExecutor);
    }

}

spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.github.lybgeek.thirdparty.autoconfigure.HelloServiceAutoConfiguration
2、模擬有引用小張封裝的starter的微服務專案

因為這些微服務中有一些耗時的任務,因此使用了spring的非同步。示例如下

@Configuration
public class ThreadPoolConfig {

    @Bean
    public ThreadPoolTaskExecutor threadPoolTaskExecutor(){
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(2);
        threadPoolTaskExecutor.setMaxPoolSize(5);
        threadPoolTaskExecutor.setQueueCapacity(10);
        threadPoolTaskExecutor.setThreadFactory(new ThreadFactory() {
            private AtomicInteger atomicInteger = new AtomicInteger();
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setName("echo-pool-" + atomicInteger.getAndIncrement());
                return thread;
            }
        });

        threadPoolTaskExecutor.setRejectedExecutionHandler((r, executor) -> System.err.println("記錄日誌。。。。"));

        return threadPoolTaskExecutor;

    }
}
@Service
@Slf4j
public class EchoService {

    @Async("threadPoolTaskExecutor")
    public void echo(String content){
        log.info("echo -> {} ",content);
        try {
            //模擬耗時操作
            TimeUnit.MINUTES.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
3、和本文有關係的配置內容
spring:
  main:
    allow-bean-definition-overriding: true
4、模擬呼叫耗時業務程式碼塊示例
@Component
public class BeanCommandRunner implements CommandLineRunner {

    @Autowired
    private EchoService echoService;



    @Override
    public void run(String... args) throws Exception {

        for (int i = 0; i < 6; i++) {
            echoService.echo("content:" + i);
        }

    }
}

相關的程式碼如上述內容

大家可以思考一下上面的示例有沒有什麼問題

我們啟動一下程式,觀察一下控制檯
報了一個執行緒池拒絕異常,而且透過這個異常資訊,我們發現這個執行緒池走是小張封裝執行緒池,而非業務自己定義的執行緒池。這明顯是不正常的,正常的邏輯是業務程式碼優先順序需比公共程式碼優先高才合理

那如何解決呢?

僅需利用springboot的條件註解即可,在小張封裝的starter下做如下改動

@Bean
    @ConditionalOnMissingBean
    public ThreadPoolTaskExecutor threadPoolTaskExecutor(){
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        threadPoolTaskExecutor.setCorePoolSize(2);
        threadPoolTaskExecutor.setMaxPoolSize(4);
        threadPoolTaskExecutor.setQueueCapacity(1);
        threadPoolTaskExecutor.setThreadFactory(new ThreadFactory() {
            private AtomicInteger atomicInteger = new AtomicInteger();
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setName("hello-pool-" + atomicInteger.getAndIncrement());
                return thread;
            }
        });

        return threadPoolTaskExecutor;

    }

修改後,我們在啟動一下程式,觀察控制檯


此時走就是業務自定義的執行緒池了

為什麼加了一個 @ConditionalOnMissingBean就可以了

這就得從springboot的自動裝配說起了,springboot的自動裝配類繼承了org.springframework.context.annotation.DeferredImportSelector,這個介面具有懶載入的功能,當專案啟動後,先載入業務自定義的bean,再來載入starter的bean,當我們專案中沒有配置

spring:
  main:
    allow-bean-definition-overriding: true

時,專案啟動就會直接報類似如下異常

當時他們業務專案因為他們feign沒有指定contextId,導致報了上述的異常,業務開發為了省事就直接把
allow-bean-definition-overriding設定成true,這也為後續小張自定義的starter引發的事故埋下了很好的根基。那我們再切回主線,當spring發現有兩個一樣的bean,且發現allow-bean-definition-overriding為true,後面載入的bean會把前面載入的bean覆蓋掉,這也是為啥小張starter的bean會生效。當我們在starter上的bean上載入 @ConditionalOnMissingBean後,因為業務專案的bean已經存在了,starter的bean就不會載入進spring容器了。

我們從技術維度說明了解決方案,我們再從非技術的角度上覆盤一下這次事故

覆盤

不知道會不會有朋友說,你說那麼多,不就加一個@ConditionalOnMissingBean就能解決這個問題,下次注意就好了啊。但據業務技術人反饋當時他們排查了挺久,因為他們業務專案平時沒啥併發量,所以小張那個問題就被掩蓋住了,而有次他們業務搞了一個營銷活動,因為併發上去了,才把問題暴露出來。這側面也說明專案壓測的重要性,不能因為平時沒啥併發,就掉以輕心

不懂大家的公司是否也有這樣的情況,在我們這邊,底下成員程式碼只能merge request,只有team leader review後,再將程式碼合併到主幹,因為team leader擁有的許可權比較大,他寫的程式碼,只要他願意,直接就能合併到主幹了。這次也是因為小張直接將他寫的程式碼推到主幹釋出,釀成事故。後面我們這邊提出了一個方法,就是team leader的程式碼要由更高階的leader進行走查,但是這個方法我是感覺也不是很好,因為有不少專案組的team leader的老闆基本上已經脫離一線,不敲程式碼了,也不懂能不能行。

其次因為小張入職不久,對業務其實沒有完全吃透,因為看到重複的程式碼,出於技術潔癖,就想去改,出發點是好的,但有句話技術是為業務服務,業務都沒搞懂,就去動,有時候會帶來意想不到的風險

總結

對自己的不熟悉的專案或者開發公共元件,深思熟慮再動手是很重要的

相關文章