從單機到分散式微服務,大檔案校驗上傳的通用解決方案

James_Shangguan發表於2024-03-18

一、先說結論

本文將結合我的工作實戰經歷,總結和提煉一種從單體架構到分散式微服務都適用的一種檔案上傳和校驗的通用解決方案,形成一個完整的方法論。本文主要解決手段包括多執行緒設計模式分而治之MapReduce等,雖然文中使用的程式語言為Java,但解決問題和最佳化思路是互通的,適合有一定開發經驗的開發者閱讀,希望對大家有幫助。

二、引言

檔案上傳的場景應該都不陌生,不管是C端還是B端,都會有檔案上傳的場景。使用者在平臺頁面點選上傳檔案,使用者請求在最後會到達後端伺服器,後端伺服器會對上傳的檔案進行各種校驗,比如檔名稱校驗、檔案大小校驗、檔案內容校驗等,其中業務邏輯最複雜、技術上有挑戰性的當屬檔案內容校驗了。為什麼這麼說呢?接著看。

三、背景

檔案校驗和上傳,看似是一件很簡單的工作,要做好,可能也並非一件容易得事情。我以一個電商後臺系統為例,上傳csv格式的sku資訊文件將會面臨下面幾方面挑戰:

  1. 上傳sku數量多:上傳檔案中sku數量不定,從個位數到百萬級不等;為了好的使用者體驗,需要在較短的時間內上傳校驗完成並返回結果;

  2. 業務邏輯複雜:檔案上傳校驗需要校驗每條內容,校驗規則多且複雜,校驗規則包括錄入的sku格式是否符合,如不符合需要給出提示語1;校驗上傳的sku是否合法有效,如果需要給出相應的提示語2;校驗該操作人是否有該sku管理許可權,如果沒有給出相應的提示語3……每個校驗邏輯中可能還包含許多分支、迴圈邏輯……

  3. 外部依賴RPC多:上傳校驗過程中涉及多個外部依賴RPC的呼叫,比如sku的管理許可權校驗,需要呼叫使用者中臺RPC介面獲取上傳人的基本資訊;校驗sku是否是本次活動範圍,需要呼叫直播中臺RPC介面……

四、關鍵問題拆解和解決思路

  1. 上傳數量多且要求體驗友好,就要求要注意高效能方面的最佳化:對於業務伺服器來說,如果是單機效能最佳化,需要考慮使用多執行緒技術來充分發揮伺服器效能;如果是分散式的服務,在最佳化單機效能無法業務場景需要的時候,還可以考慮依靠中介軟體來協同不同伺服器,發揮叢集優勢。

  2. 業務邏輯複雜,就要求寫出來的程式碼有較高的可閱讀性、可維護性,不要成為“大泥球”:除了在系統架構方面的最佳化之外,對於開發人員,可以考慮使用設計模式來提高程式碼質量。

  3. 外部RPC依賴多,網路資料IO操作,介面效能可能無法保證,就需要使用非同步呼叫的方式來保證效能;

五、系統架構

假設有這麼一個電商活動管理系統,從架構上來說,可以分為服務層、業務層、資料層和外部依賴,架構圖如下:

  • 服務層:包括對外服務和外部呼叫;
  • 業務層:活動的生命週期,包括建立、檢視、修改、關閉流程;
  • 資料層:資料儲存,主要是資料庫叢集和快取叢集;
  • 外部依賴:外部依賴的RPC服務,包括商品RPC服務等;

在技術實現方面,該系統是前後端分離的系統,前後端透過域名進行互動。
前端服務主要提供操作頁面,使用者可以在頁面端進行各種操作,例如建立活動、檢視活動、修改活動、關閉活動等;

後端採用的是微服務架構,按照功能拆分為提供HTTP介面的soa應用、提供MQ消費功能的MQ應用、提供RPC服務的RPC應用,儲存使用的是MySQL和Redis叢集,大概架構圖如下:

六、Java多執行緒實踐

6.1 使用Java多執行緒最佳化單機效能

分析上面的場景,明顯是IO密集型的場景。IO 密集型指的是大部分時間都在執行 IO 操作,主要包括網路 IO 和磁碟 IO,以及與計算機連線的一些外圍裝置的訪問。在上面場景中,校驗過程中需要呼叫大量RPC介面,大部分時間呼叫都在等待網路IO,所以可以使用非同步和多執行緒的設計方法來提升網路IO效能,從而最佳化整體效能。

關於Java多執行緒在這裡不贅述了,直接看關鍵程式碼實現吧:

    ExecutorService executorService = Executors.newFixedThreadPool(10);
    
    @ResponseBody
    @RequestMapping(value = "uploadSku", method = RequestMethod.POST)
    public Result uploadSku(@RequestParam(value = "file", required = false) MultipartFile file) throws IOException {
        Result result = new Result();
        result.setSuccess(true);
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(file.getInputStream()));

        try {
            // 校驗檔名稱
            result = checkFileNameFormat(file);
            if (!result.isSuccess()) {
                return result;
            }

            // 校驗檔案內容格式並填充校驗任務
            List<UploadResInfo> uploadResInfos = new ArrayList<>();
            List<SkuCheckTask> tasks = checkFileContentAndFillSkuCheckTask(result, bufferedReader, uploadResInfos);

            // 執行校驗任務
            result = dealSkuSkuCheckTask(tasks, uploadResInfos);

        } catch (Exception e) {
            result.setSuccess(false);
            result.setErrorMessage("上傳檔案異常!");
        }
        return result;
    }
    
        /**
     * @param tasks
     * @param uploadResInfos
     * @return
     */
    private Result dealSkuSkuCheckTask(List<SkuCheckTask> tasks, List<UploadResInfo> uploadResInfos) throws Exception {
        Result result = new Result();
        result.setSuccess(true);
        List<Long> passedSkus = new ArrayList<>();
        if (!CollectionUtils.isEmpty(tasks)) {
            List<Future<Result>> futureList = executorService.invokeAll(tasks);
            for (Future<Result> tempResult : futureList) {
                if (tempResult.get().isSuccess()) {
                    Result tempRes = tempResult.get();
                    if (null != tempRes.getResult().get("uploadResInfos")) {
                        uploadResInfos.addAll((List<UploadResInfo>) tempRes.getResult().get("uploadResInfos"));
                    }
                    passedSkus.addAll((List<Long>) tempRes.getObject());
                }
            }
        }
        result.addDefaultModel("passedSkus", passedSkus);
        if (passedSkus.size() == 0) {
            result.setErrorMessage("上傳都不透過");
        }
        return result;
    }
public class SkuCheckTask implements Callable<Result> {

    private List<Long> skuList;

    public SkuCheckTask(List<Long> skuList) {
        this.skuList = skuList;
    }

    @Override
    public Result call() throws Exception {
        Result result = new Result();
        result.setSuccess(true);
        List<Long> passedSkuList = new ArrayList<>();
        List<UploadResInfo> uploadResInfos = new ArrayList<>();

        for (int i = 0; i < skuList.size(); i++) {
            if (checkSku(skuList.get(i))) {
                passedSkuList.add(skuList.get(i));
            } else {
                UploadResInfo uploadResInfo = new UploadResInfo(skuList.get(i).toString(), false, "RPC校驗失敗");
                uploadResInfos.add(uploadResInfo);
            }
        }
        result.setObject(passedSkuList);
        result.addDefaultModel("uploadResInfos", uploadResInfos);
        return result;
    }

    /**
     * 校驗sku,複雜校驗邏輯
     *
     * @param sku
     * @return
     */
    private boolean checkSku(Long sku) {
        // 複雜校驗邏輯,例如多個RPC呼叫等耗時操作
        System.out.println("校驗sku:" + sku);
        return true;
    }
}

6.2 執行緒數的設定

我們知道,調整執行緒池中的執行緒數量的主要是為了充分併合理地使用 CPU 和記憶體等資源,從而最大限度地提高程式的效能。

對於CPU密集型任務(比如加解密、壓縮和解壓、計算),最佳的執行緒數為 CPU 核心數的 1~2 倍,如果設定過多的執行緒數,實際上並不會起到很好的效果。因為CPU密集型任務本來就會佔用大量的CPU資源,CPU 的每個核心工作基本都是滿負荷的,而如果設定了過多的執行緒,每個執行緒都要去爭取CPU資源來執行自己的任務,這就會造成不必要的上下文切換,此時執行緒數的增多反而會導致效能下降。

對於IO密集型任務(比如資料庫讀寫、檔案讀寫、網路通訊等),這種任務並不會太消耗CPU資源,反而是在等待IO操作。執行緒數設定可以參考以下公式:

執行緒數 = CPU核心數 * (1 + 平均等待時間/平均工作時間)

在本程式中,使用了執行緒池:FixedThreadPool,並將執行緒數設定為10。這裡的考慮是容器為16C32G的配置,除了上傳任務,服務端還會處理其他的任務,還有其他的執行緒池,為了綜合考慮,這裡只是分配了10個執行緒數。當然,最佳實踐是使用遠端配置中心動態調整執行緒池執行緒數,實現動態執行緒池,在實踐中進行調整和壓測,最終找到合適的執行緒數配置。

七、責任鏈模式實踐

對於上述這個校驗邏輯,最常見的處理方式是使用 if…else…條件判斷語句來處理,這樣處理可能存在這樣的問題:

  1. 程式碼複雜度高:該場景中的判定條件通常不是簡單的判斷,需要呼叫外部RPC介面查詢資料,從結果中解析到需要的欄位,才能進行邏輯判斷。這樣程式碼的巢狀層數就會很多,程式碼複雜度就會很高,不用太久,這段程式碼將發展成為“大泥球”。
  2. 程式碼耦合度高:如果業務需求新增校驗邏輯,那麼就要繼續新增 if…else…判定條件;另外,這個條件判定的順序也是寫死的,如果想改變順序,那麼也只能修改這個條件語句。

那麼面對上面這種場景,如何實現更優雅呢?。其實這裡也很簡單,就是把判定條件的部分放到處理類中,這就是責任鏈模式。如果滿足條件 1,則由 Handler1 來處理,不滿足則向下傳遞;如果滿足條件 2,則由 Handler2 來處理,不滿足則繼續向下傳遞,以此類推,直到條件結束。部分程式碼如下:

Handler介面:

public interface SkuCheckHandler {
    BaseResult doHandler(UploadInfo uploadInfo);
}

SkuCheckHandler介面實現Handler1:

public class Handler1 implements SkuCheckHandler {
    @Override
    public BaseResult doHandler(UploadInfo uploadInfo) {
        // 呼叫使用者中臺校驗許可權
        return new BaseResult();
    }
}

遍歷Handler進行校驗,如果Handler校驗不透過直接返回校驗結果,校驗透過則繼續進入下一個Handler進行校驗:

public class SkuCheckHandlerChain {

    private List<SkuCheckHandler> handlers = new ArrayList<>();

    public void addHandler(SkuCheckHandler skuCheckHandler) {
        this.handlers.add(skuCheckHandler);
    }

    public BaseResult handle(UploadInfo uploadInfo){
        BaseResult baseResult = new BaseResult();
        baseResult.setSuccess(true);
        for (SkuCheckHandler handler : handlers) {
            baseResult = handler.doHandler(uploadInfo);
            if (!baseResult.isSuccess()) {
                return baseResult;
            }
        }
        return baseResult;
    }

}

責任鏈設定和呼叫:

    private boolean checkSku(Long sku) {
        // 複雜校驗邏輯,例如多個RPC呼叫等耗時操作
        System.out.println("校驗sku:" + sku);
        // 後續校驗都依賴商品資訊,所以需要調商品RPC獲取Sku資訊-uploadInfo
        UploadInfo uploadInfo = new UploadInfo();
        SkuCheckHandlerChain handlerChain = new SkuCheckHandlerChain();
        handlerChain.addHandler(new Handler1());
        handlerChain.addHandler(new Handler2());
        BaseResult baseResult = handlerChain.handle(uploadInfo);
        return baseResult.isSuccess();
    }

如果想了解更多責任鏈模式,可以參考:《設計模式:如何優雅地使用責任鏈模式》

八、分散式檔案上傳最佳實踐

8.1 MapReduce簡介

當使用了多執行緒技術,並最佳化了執行緒數,似乎單機效能已經達到了極限。但是如果此時仍然不能滿足業務場景需要,那又該怎麼最佳化呢?

有人可能會想到垂直擴容,升級更高配的機器來提升效能。這個辦法當然是可行的,也是最簡單粗暴的方式,唯一的缺點就是“費錢”,土豪請隨意。一般來說,Google的方式可能更加值得借鑑,Google使用“3M膠帶粘在一起的伺服器”打敗了成本更高的高配計算機。

在面對海量資料背景下,Google科學家傑夫·迪恩提出了MapReduce技術。MapReduce其實並不複雜,使用的正是分而治之(Divide and Conquer)的思想。打個不太恰當的比方就是,老闆分作業,小兵完成作業,老闆進行彙總

MapReduce其實也是自頂向下的遞迴。MapReduce先在最頂層將一個複雜的大任務分解成為成百上千個小任務;然後將每個小任務分配到一個伺服器上去求解;最後再將每個伺服器上面的結果綜合起來,得到原來大任務的最終結果。第一個自頂向下分解的過程稱為Map,第二個自底向上合併的過程稱為Reduce

其核心原理其實可以看這張圖,圖片出自論文《MapReduce: Simplified Data Processing on Large Clusters》。

8.2 MapReduce在檔案上傳場景的應用

單機伺服器效能無法滿足,應該考慮合理利用多臺機器,不同微服務之間相互協作,共同完成上傳的任務。借鑑MapReduce核心思想,可以使用現有系統架構,實現大檔案的分散式上傳和校驗。

一圖勝前言,方案說明都在圖片中了,詳細請看:

九、踩坑和程式碼除錯

9.1 踩坑1:MQ消費中使用LoginContext獲取使用者資訊異常

其中有個踩坑點需要注意,在soa應用中常用的LoginContext獲取使用者資訊;在MQ應用中,使用LoginContext將無法獲取到使用者資訊,如果使用將會出現空指標異常;出現異常之後,MQ消費將會進行重試,重試也一直會發生異常,從而死迴圈,無法得到正確的結果。

9.2 程式碼除錯-Idea遠端Debug

在開發工作中,程式碼寫完並不是萬事大吉了。部署到伺服器測試過程中,可能還會發現各種各樣意料之外的錯誤。當伺服器日誌列印過多或者過少都影響問題排查的效率,以檔案上傳場景為例,如果不列印完整的出入參,出現問題沒有日誌可以用來排查問題;如果每個方法都列印完整的出入參日誌,當上傳檔案中sku數量較多,可以想象下如果有100w條的sku資訊,從這麼多的日誌中去排查問題無異於“大海撈針”。

那這個問題無解了嗎?當然不是,遠端Debug可以提升排查效率,同事妹子看見了都直呼YYDS。其實這個工具就是我們幾乎人人都在用的Idea,Idea自帶了遠端除錯工具。下面是我的使用經驗,適用於部署在Tomcat容器工程程式碼:

9.2.1 環境配置

  1. 遠端Tomcat配置

遠端Tomcat新增啟動引數並重啟生效:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

  1. Idea配置

話不多說,圖上都有:

  1. 啟動除錯

9.2.2 常見問題

  1. 為什麼除錯斷點沒生效?

本地和遠端程式碼要相同,不一樣則會出現無法進入斷點的情況;
如果程式碼一致還是無法進入,嘗試重啟,一般可以解決;

  1. 進入斷點除錯之後,伺服器還可以處理其他請求嗎?

伺服器在斷點處停住了,無法處理其他請求;

  1. 改了原生代碼可以直接debug嗎?

不可以,需要部署在遠端伺服器之後再次啟動debug;

通用解決方案總結

透過上述過程之後,總結出一套通用的大檔案上傳和校驗的解決方案。總結一下就是,如果現在技術架構還處在單機架構的階段,可以考慮使用多執行緒技術最佳化單機效能;為了使程式碼優雅一點,可以考慮使用責任鏈模式;如果現在技術架構已經發展到分散式和微服務了,可以借鑑分而治之的思想,讓多伺服器協作工作,發揮多伺服器的優勢。

如果用三個詞總結,那就是:多執行緒、責任鏈模式、分而治之和MapReduce

一起學習

歡迎各位在評論區或者私信我一起交流討論,或者加我主頁weixin,備註技術渠道(如部落格園),進入技術交流群,我們一起討論和交流,共同進步!

也歡迎大家關注我的部落格園、公眾號(碼上暴富),點贊、留言、轉發。你的支援,是我更文的最大動力!

相關文章