當前位置: 妍妍網 > 碼農

30s到0.8s,記錄一次介面最佳化成功案例!

2024-07-11碼農

來源:juejin.cn/post/7324296963138863138

在高並行的數據處理場景中,介面響應時間的最佳化顯得尤為重要。本文將分享一個真實案例,其中一個數據量達到200萬+的介面的響應時間從30秒降低到了0.8秒內。

這個案例不僅展示了問題診斷的過程,也提供了一系列有效的最佳化措施。

交易系統中,系統需要針對每一筆交易進行攔截(每一筆支付或轉賬就是一筆交易),攔截時需要根據定義好的規則攔截,這次需要最佳化的介面是一個統計規則攔截率的介面。

問題診斷

最初,介面的延遲非常高,大約需要30秒才能完成。為了定位問題,我們首先排除了網路和伺服器裝置因素,並打印了關鍵程式碼的執行時間。經過分析,發現問題出在SQL執行上。

發現Sql執行時間太久,查詢200萬條數據的執行時間竟然達到了30s,下面是是最耗時的部份相關程式碼邏輯:

查詢程式碼(其實就是使用Mybatis查詢,看起來正常的很)

List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);

統計當天的Id號(programhandleidlist欄位)

SELECT programhandleidlist FROM anti_transhandle WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0';

表結構(Postgresql)

我以為是Sql寫的有問題,先拿著sql執行了一邊,發現只執行sql的執行時間是大約800毫秒,和30秒差距巨大。

Sql層面分析

使用 EXPLAIN ANALYZE 函式分析sql。

EXPLAIN ANALYZE
SELECT programhandleidlist FROM anti_transhandle WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0';

分析結果

看來是程式碼的部份有問題。

程式碼層面分析

List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);

Map的Key是 programhandleIdList ,Map的value是每一行的值。

在Java層面,每條數據都建立了一個Map物件,對於200萬+的數據量來說,這顯然是非常耗時的操作,速度是被建立了大量的Map集合給拖垮的。。

為了解決這個問題,我們嘗試了將200萬行數據轉換為單行返回,使用PostgreSQL的 array_agg 和unnest函式來最佳化查詢。

第一次遇到Mybatis查詢返回導致介面速度慢的問題。

最佳化措施

1. SQL最佳化

我的思路是將200萬行轉為一行返回。

要將 PostgreSQL 中查詢出的 programhandleidlist 欄位(假設這是一個陣列型別)的所有元素拼接為一行,您可以使用陣列聚合函式 array_agg 結合 unnest 函式。

這樣做可以先將陣列展開為多行,然後將這些行再次聚合為一個單一的陣列。如果您希望最終結果是一個字串,而不是陣列,您還可以使用 string_agg 函式。

以下是相應的 SQL 語句:

SELECT array_agg(elem) AS concatenated_array
FROM (
SELECT unnest(programhandleidlist) AS elem
FROM anti_transhandle
WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0'
) sub;

在這個查詢中:

  • unnest(programhandleidlist) programhandleidlist 陣列展開成多行。

  • string_agg(elem) 將這些行聚合成一個以逗號分隔的字串。

  • 這將返回一個包含所有元素的單一陣列。

    查詢結果由多行,拼接為了一行。

    再測試,現在是正常速度了,但是查詢時間依舊很高。Sql查詢時間0.8秒,程式碼中平均1秒8左右,還有最佳化的空間。

    將一列數據轉換為了陣列型別,檢視一下記憶體占用,這一段占用了54位元,雖然占用不大,但是不知道為什麽會mybatis處理時間這麽久。

  • 因為mybatis不知道陣列的大小,先給陣列設定一個初始大小,如果超出了陣列長度,因為陣列不能擴容,增加長度只能再復制一份到另一塊記憶體中,復制的次數多了也就增加了計算時間。

  • 數據需要在兩個裝置之間傳輸,磁盤和網路都需要時間。

  • 2. 部份業務邏輯轉到資料庫中計算

    再次最佳化sql,將一部份的邏輯放到Sql中處理,減少數據量。

    業務上我需要統計 programhandleidlist 欄位中id出現的次數,所以我直接在sql中做統計。

    要統計每個陣列中元素出現的次數,您需要首先使用 unnest 函式將陣列展開為單獨的行,然後使用 GROUP BY 和聚合函式(如 count)來計算每個元素的出現次數。這裏是修改後的 SQL 語句:

    SELECT elem, COUNT(*) AS count
    FROM (
    SELECT unnest(programhandleidlist) AS elem
    FROM anti_transhandle
    WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0'
    ) sub
    GROUP BY elem;

    在這個查詢中:

  • unnest(programhandleidlist) 將每個 programhandleidlist 陣列展開成多個行。

  • GROUP BY elem 對每個獨立的元素進行分組。

  • COUNT(*) 計算每個分組(即每個元素)的出現次數。

  • 這個查詢將返回兩列:一列是元素(elem),另一列是該元素在所有陣列中出現的次數(count)。

    這條sql在程式碼中執行時間是0.7秒,還是時間太長,畢竟資料庫的數據量太大,搜了很多方法,已經是我能做到的最快查詢了。

    關系型資料庫 不適合做海量數據計算查詢。

    這個業務場景牽扯到了海量數據的統計,並不適合使用關系型資料庫,如果想要真正的做到毫秒級的查詢,需要從設計上改變數據的儲存結果。比如使用cilckhouse、hive等儲存計算。

    3. 引入緩存機制

    減少查詢資料庫的次數,決定引入本地緩存機制。選擇了Caffeine作為緩存框架,易於與Spring整合。

    分析業務後,當天的統計數據必須查詢資料庫,但是查詢歷史日期的采用緩存的方式。如果業務中對時效性不敏感,也可以緩存當天的數據,每隔一段時間更新一次。我這裏采用緩存歷史日期的數據。

    1.引入Caffeine依賴

    <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
    </dependency>

    2.配置Caffeine緩存

    建立一個專門的Caffeine緩存配置。使用本地緩存選擇淘汰策略很重要,由於我的業務場景使根據實作來查詢,所以Caffeine將按照最近最少使用(LRU)的策略來淘汰舊數據成符合業務。

    import com.github.benmanes.caffeine.cache.Caffeine;
    import org.springframework.cache.CacheManager;
    import org.springframework.cache.annotation.EnableCaching;
    import org.springframework.cache.caffeine.CaffeineCacheManager;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import java.util.concurrent.TimeUnit;
    @Configuration
    @EnableCaching
    public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager();
    cacheManager.setCaffeine(Caffeine.newBuilder()
    .maximumSize(500)
    .expireAfterWrite(60, TimeUnit.MINUTES));
    return cacheManager;
    }
    }

    3.修改ruleHitRate方法來使用Caffeine緩存

    在計算昨天命中率的邏輯前加入緩存檢查和更新的邏輯。

    使用Caffeine緩存:

    @Autowired
    private CacheManager cacheManager; // 註入Spring的CacheManager
    private static final String YESTERDAY_HIT_RATE_CACHE = "hitRateCache"; // 緩存名稱
    @Override
    public RuleHitRateResponse ruleHitRate(LocalDate currentDate) {
    // ... 其他程式碼 ...
    // 使用緩存獲取昨天的命中率
    double hitRate = cacheManager.getCache(YESTERDAY_HIT_RATE_CACHE).get(currentDate.minusDays(1), () -> {
    // 查詢資料庫
    Map<String, String> hitRateList = dataTunnelClient.selectTransHandleFlowByTime(currentDate.minusDays(1));
    // ... 其他程式碼 ...
    // 返回計算後的結果
    return hitRate;
    });
    // ... 其他程式碼 ...
    }


    總結

    最後,測試介面,成功將介面從30秒降低到了0.8秒以內。

    這次最佳化讓我重新真正審視了關系型資料庫的劣勢。選擇哪種型別的資料庫,取決於具體的套用場景和需求。

  • 關系型資料庫(Mysql、Oracle等)適合事務性強、數據一致性和完整性要求高的套用。

  • 列式資料庫(HBase、ClickHouse等)則適合大數據量的分析和統計,特別是在讀取效能方面有顯著優勢。

  • 此次的業務場景顯然更適合使用列式資料庫,所以導致使用關系型資料庫無論如何也不能夠達到足夠高的效能。

    >>

    END

    精品資料,超贊福利,免費領

    微信掃碼/長按辨識 添加【技術交流群

    群內每天分享精品學習資料

    最近開發整理了一個用於速刷面試題的小程式;其中收錄了上千道常見面試題及答案(包含基礎並行JVMMySQLRedisSpringSpringMVCSpringBootSpringCloud訊息佇列等多個型別),歡迎您的使用。

    👇👇

    👇點選"閱讀原文",獲取更多資料(持續更新中