一勞永逸的優化!併發RPC呼叫小工具

raledong發表於2021-10-09

前言

系統的效能優化是每一個程式設計師的必經之路,但也可能是走過的最深的套路。它不僅需要對各種工具的深入瞭解,有時還需要結合具體的業務場景得出定製化的優化方案。當然,你也可以在程式碼中悄悄藏上一個Thread.sleep,在需要優化的時候少睡幾毫秒(手動狗頭)。效能優化這個課題實在是太浩瀚了,以至於目前市面上沒有一本優質的書能夠全面的總結這個課題。不僅如此,即使是深入到各個細分領域上,效能優化的手段也非常豐富,令人眼花繚亂。

本文也不會涵蓋所有的優化套路,僅就最近專案開發過程中遇到的併發呼叫這一個場景給出自己的通用方案。大家可以直接打包或是複製貼上到專案中使用。也歡迎大家給出更多的意見還有優化場景。

背景

不知大家在開發過程中是否遇到這樣的一個場景,我們會先去呼叫服務A,然後呼叫服務B,組裝一下資料之後再去呼叫一下服務C(如果你在微服務系統的開發中沒有遇到這樣的場景,我想說,要麼你們系統的拆分粒度太粗,要麼這一個幸運無下游服務依賴的底層系統~)

這條鏈路的耗時就是 duration(A) + duration(B) + duration(C) + 其它操作。從經驗來看,大部分的耗時都來自於下游服務的處理耗時和網路IO,應用內部的CPU操作的耗時相比而言基本可以忽略不計。但是,當我們得知對服務A和B的呼叫之間是無依賴的時候,是否可以通過同時併發呼叫A和B來減少同步呼叫的等待耗時,這樣理想情況下鏈路的耗時就可以優化成 max(duration(A),duration(B)) + duration(C) + 其它操作

再舉一個例子,有時我們可能需要批量呼叫下游服務,比如批量查詢使用者的資訊。下游查詢介面出於服務保護往往會對單次可以查詢的數量進行約束,比如一次只能查一百條使用者的資訊。因此我們需要多請求拆分多次進行查詢,於是耗時變成了 n*duration(A) + 其它操作。同樣,用併發請求的優化方式,理想情況下耗時可以降到 max(duration(A)) + 其它操作

這兩種場景的程式碼實現基本類似,本文將會提供第二種場景的思路和完整實現。

小試牛刀

併發RPC呼叫的整體實現類圖如下:
進階類圖

首先我們需要建立一個執行緒池用於併發執行。因為程式中通常還有別的使用執行緒池的場景,而我們希望RPC呼叫能夠使用一個單獨的執行緒池,因此這裡用工廠方法進行了封裝。

@Configuration
public class ThreadPoolExecutorFactory {

    @Resource
    private Map<String, AsyncTaskExecutor> executorMap;

    /**
    * 預設的執行緒池
    */
    @Bean(name = ThreadPoolName.DEFAULT_EXECUTOR)
    public AsyncTaskExecutor baseExecutorService() {
        //後續支援各個服務定製化這部分引數
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        //設定執行緒池引數資訊
        taskExecutor.setCorePoolSize(10);
        taskExecutor.setMaxPoolSize(50);
        taskExecutor.setQueueCapacity(200);
        taskExecutor.setKeepAliveSeconds(60);
        taskExecutor.setThreadNamePrefix(ThreadPoolName.DEFAULT_EXECUTOR + "--");
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        taskExecutor.setAwaitTerminationSeconds(60);
        taskExecutor.setDaemon(Boolean.TRUE);
        //修改拒絕策略為使用當前執行緒執行
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //初始化執行緒池
        taskExecutor.initialize();

        return taskExecutor;
    }

    /**
    * 併發呼叫單獨的執行緒池
    */
    @Bean(name = ThreadPoolName.RPC_EXECUTOR)
    public AsyncTaskExecutor rpcExecutorService() {
        //後續支援各個服務定製化這部分引數
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        //設定執行緒池引數資訊
        taskExecutor.setCorePoolSize(20);
        taskExecutor.setMaxPoolSize(100);
        taskExecutor.setQueueCapacity(200);
        taskExecutor.setKeepAliveSeconds(60);
        taskExecutor.setThreadNamePrefix(ThreadPoolName.RPC_EXECUTOR + "--");
        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        taskExecutor.setAwaitTerminationSeconds(60);
        taskExecutor.setDaemon(Boolean.TRUE);
        //修改拒絕策略為使用當前執行緒執行
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //初始化執行緒池
        taskExecutor.initialize();

        return taskExecutor;
    }
    /**
     * 根據執行緒池名稱獲取執行緒池
     * 若找不到對應執行緒池,則丟擲異常
     * @param name 執行緒池名稱
     * @return 執行緒池
     * @throws RuntimeException 若找不到該名稱的執行緒池
     */
    public AsyncTaskExecutor fetchAsyncTaskExecutor(String name) {
        AsyncTaskExecutor executor = executorMap.get(name);
        if (executor == null) {
            throw new RuntimeException("no executor name " + name);
        }
        return executor;
    }
}

public class ThreadPoolName {

    /**
     * 預設執行緒池
     */
    public static final String DEFAULT_EXECUTOR = "defaultExecutor";

    /**
     * 併發呼叫使用的執行緒池
     */
    public static final String RPC_EXECUTOR = "rpcExecutor";
}

如程式碼所示,我們宣告瞭兩個Spring的執行緒池AsyncTaskExecutor,分別是預設的執行緒池和RPC呼叫的執行緒池,並將它們裝載到map中。呼叫方可以使用fetchAsyncTaskExecutor方法並傳入執行緒池的名稱來指定執行緒池執行。這裡還有一個細節,Rpc執行緒池的執行緒數要顯著大於另一個執行緒池,是因為Rpc呼叫不是CPU密集型邏輯,往往伴隨著大量的等待。因此增加執行緒數量可以有效提高併發效率。

@Component
public class TracedExecutorService {

    @Resource
    private ThreadPoolExecutorFactory threadPoolExecutorFactory;


    /**
     * 指定執行緒池提交非同步任務,並獲得任務上下文
     * @param executorName 執行緒池名稱
     * @param tracedCallable 非同步任務
     * @param <T> 返回型別
     * @return 執行緒上下文
     */
    public <T> Future<T> submit(String executorName, Callable<T> tracedCallable) {
        return threadPoolExecutorFactory.fetchAsyncTaskExecutor(executorName).submit(tracedCallable);
    }
}

submit方法封裝了獲取執行緒池和提交非同步任務的邏輯。這裡採用Callable+Future的組合來獲取非同步執行緒的執行結果。

執行緒池準備就緒,接著我們就需要宣告一個介面用於提交併發呼叫服務:

public interface BatchOperateService {

    /**
     * 併發批量操作
     * @param function 執行的邏輯
     * @param requests 請求
     * @param config 配置
     * @return 全部響應
     */
    <T, R> List<R> batchOperate(Function<T, R> function, List<T> requests, BatchOperateConfig config);
}

@Data
public class BatchOperateConfig {

    /**
     * 超時時間
     */
    private Long timeout;

    /**
     * 超時時間單位
     */
    private TimeUnit timeoutUnit;

    /**
     * 是否需要全部執行成功
     */
    private Boolean needAllSuccess;

}

batchOperate方法中傳入了function物件,這是需要併發執行的程式碼邏輯。requests則是所有的請求,併發呼叫會遞迴這些請求並提交到非同步執行緒。config物件則可以對這次併發呼叫做一些配置,比如併發查詢的超時時間,以及如果部分呼叫異常時整個批量查詢是否繼續執行。

接下來看一看實現類:

@Service
@Slf4j
public class BatchOperateServiceImpl implements BatchOperateService{

    @Resource
    private TracedExecutorService tracedExecutorService;

    @Override
    public <T, R> List<R> batchOperate(Function<T, R> function, List<T> requests, BatchOperateConfig config) {
        log.info("batchOperate start function:{} request:{} config:{}", function, JSON.toJSONString(requests), JSON.toJSONString(config));

        // 當前時間
        long startTime = System.currentTimeMillis();

        // 初始化
        int numberOfRequests = CollectionUtils.size(requests);

        // 所有非同步執行緒執行結果
        List<Future<R>> futures = Lists.newArrayListWithExpectedSize(numberOfRequests);
        // 使用countDownLatch進行併發呼叫管理
        CountDownLatch countDownLatch = new CountDownLatch(numberOfRequests);
        List<BatchOperateCallable<T, R>> callables = Lists.newArrayListWithExpectedSize(numberOfRequests);

        // 分別提交非同步執行緒執行
        for (T request : requests) {
            BatchOperateCallable<T, R> batchOperateCallable = new BatchOperateCallable<>(countDownLatch, function, request);
            callables.add(batchOperateCallable);

            // 提交非同步執行緒執行
            Future<R> future = tracedExecutorService.submit(ThreadPoolName.RPC_EXECUTOR, batchOperateCallable);
            futures.add(future);
        }

        try {
            // 等待全部執行完成,如果超時且要求全部呼叫成功,則丟擲異常
            boolean allFinish = countDownLatch.await(config.getTimeout(), config.getTimeoutUnit());
            if (!allFinish && config.getNeedAllSuccess()) {
                throw new RuntimeException("batchOperate timeout and need all success");
            }
            // 遍歷執行結果,如果有的執行失敗且要求全部呼叫成功,則丟擲異常
            boolean allSuccess = callables.stream().map(BatchOperateCallable::isSuccess).allMatch(BooleanUtils::isTrue);
            if (!allSuccess && config.getNeedAllSuccess()) {
                throw new RuntimeException("some batchOperate have failed and need all success");
            }

            // 獲取所有非同步呼叫結果並返回
            List<R> result = Lists.newArrayList();
            for (Future<R> future : futures) {
                R r = future.get();
                if (Objects.nonNull(r)) {
                    result.add(r);
                }
            }
            return result;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        } finally {
            double duration = (System.currentTimeMillis() - startTime) / 1000.0;
            log.info("batchOperate finish duration:{}s function:{} request:{} config:{}", duration, function, JSON.toJSONString(requests), JSON.toJSONString(config));

        }
    }
}

通常我們提交給執行緒池後直接遍歷Future並等待獲取結果就好了。但是這裡我們用CountDownLatch來做更加統一的超時管理。可以看一下BatchOperateCallable的實現:

public class BatchOperateCallable<T, R> implements Callable<R> {

    private final CountDownLatch countDownLatch;

    private final Function<T, R> function;

    private final T request;

    /**
     * 該執行緒處理是否成功
     */
    private boolean success;

    public BatchOperateCallable(CountDownLatch countDownLatch, Function<T, R> function, T request) {
        this.countDownLatch = countDownLatch;
        this.function = function;
        this.request = request;
    }

    @Override
    public R call() {
        try {
            success = false;
            R result = function.apply(request);
            success = true;
            return result;
        } finally {
            countDownLatch.countDown();
        }
    }

    public boolean isSuccess() {
        return success;
    }
}

無論呼叫時成功還是異常,我們都會在結束後將計數器減一。當計數器被減到0時,則代表所有併發呼叫執行完成。否則如果在規定時間內計數器沒有歸零,則代表併發呼叫超時,此時會丟擲異常。

潛在問題

併發呼叫的一個問題在於我們放大了訪問下游介面的流量,極端情況下甚至放大了成百上千倍。如果下游服務並沒有做限流等防禦性措施,我們極有可能將下游服務打掛(這種原因導致的故障屢見不鮮)。因此需要對整個併發呼叫做流量控制。流量控制的方法有兩種,一種是如果微服務採用mesh的模式,則可以在sidecar中配置RPC呼叫的QPS,從而做到全域性的管控對下游服務的訪問(這裡選擇單機限流還是叢集限流取決於sidecar是否支援的模式以及服務的流量大小。通常來說平均流量較小則建議選擇單機限流,因為叢集限流的波動性往往比單機限流要高,流量過小會造成誤判)。如果沒有開啟mesh,則需要在程式碼中自己實現限流器,這裡推薦Guava的RateLimiter類,但是它只支援單機限流,如果要想實現叢集限流,則方案的複雜度還會進一步提升

小結

將專案開發中遇到的場景進行抽象並儘可能的給出通用的解決方案是我們每一個開發者自我的重要方式,也是提高程式碼複用性和穩定性的利器。併發Rpc呼叫是一個常見解決思路,希望本文的實現可以對你有幫助。

相關文章