來源: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
精品資料,超贊福利,免費領
微信掃碼/長按辨識 添加【技術交流群】
群內每天分享精品學習資料
最近開發整理了一個用於速刷面試題的小程式;其中收錄了上千道常見面試題及答案(包含基礎、並行、JVM、MySQL、Redis、Spring、SpringMVC、SpringBoot、SpringCloud、訊息佇列等多個型別),歡迎您的使用。
👇👇
👇點選"閱讀原文",獲取更多資料(持續更新中)