場景
在高并發(fā)的數(shù)據(jù)處理場景中,接口響應(yīng)時間的優(yōu)化顯得尤為重要慌随。本文將分享一個真實案例芬沉,其中一個數(shù)據(jù)量達(dá)到200萬+的接口的響應(yīng)時間從30秒降低到了0.8秒內(nèi)。
這個案例不僅展示了問題診斷的過程阁猜,也提供了一系列有效的優(yōu)化措施丸逸。
交易系統(tǒng)中,系統(tǒng)需要針對每一筆交易進(jìn)行攔截(每一筆支付或轉(zhuǎn)賬就是一筆交易)剃袍,攔截時需要根據(jù)定義好的規(guī)則攔截黄刚,這次需要優(yōu)化的接口是一個統(tǒng)計規(guī)則攔截率的接口。
問題診斷
最初民效,接口的延遲非常高憔维,大約需要30秒才能完成涛救。為了定位問題,我們首先排除了網(wǎng)絡(luò)和服務(wù)器設(shè)備因素业扒,并打印了關(guān)鍵代碼的執(zhí)行時間检吆。經(jīng)過分析,發(fā)現(xiàn)問題出在SQL執(zhí)行上程储。
發(fā)現(xiàn)Sql執(zhí)行時間太久蹭沛,查詢200萬條數(shù)據(jù)的執(zhí)行時間竟然達(dá)到了30s,下面是是最耗時的部分相關(guān)代碼邏輯:
查詢代碼(其實就是使用Mybatis查詢章鲤,看起來正常的很)
List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);
統(tǒng)計當(dāng)天的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';
表結(jié)構(gòu)(Postgresql)
我以為是Sql寫的有問題摊灭,先拿著sql執(zhí)行了一邊,發(fā)現(xiàn)只執(zhí)行sql的執(zhí)行時間是大約800毫秒咏窿,和30秒差距巨大斟或。
Sql層面分析
使用EXPLAIN ANALYZE函數(shù)分析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';
分析結(jié)果
看來是代碼的部分有問題。
代碼層面分析
List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);
Map的Key是programhandleIdList,Map的value是每一行的值树绩。
在Java層面不脯,每條數(shù)據(jù)都創(chuàng)建了一個Map對象,對于200萬+的數(shù)據(jù)量來說畏陕,這顯然是非常耗時的操作,速度是被創(chuàng)建了大量的Map集合給拖垮的。酥泛。
為了解決這個問題,我們嘗試了將200萬行數(shù)據(jù)轉(zhuǎn)換為單行返回嫌拣,使用PostgreSQL的array_agg和unnest函數(shù)來優(yōu)化查詢柔袁。
第一次遇到Mybatis查詢返回導(dǎo)致接口速度慢的問題。
優(yōu)化措施
1. SQL優(yōu)化
我的思路是將200萬行轉(zhuǎn)為一行返回异逐。
要將 PostgreSQL 中查詢出的programhandleidlist字段(假設(shè)這是一個數(shù)組類型)的所有元素拼接為一行捶索,您可以使用數(shù)組聚合函數(shù)array_agg結(jié)合 unnest 函數(shù)。
這樣做可以先將數(shù)組展開為多行灰瞻,然后將這些行再次聚合為一個單一的數(shù)組腥例。如果您希望最終結(jié)果是一個字符串,而不是數(shù)組酝润,您還可以使用 string_agg 函數(shù)燎竖。
以下是相應(yīng)的 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數(shù)組展開成多行。
string_agg(elem)將這些行聚合成一個以逗號分隔的字符串要销。
這將返回一個包含所有元素的單一數(shù)組构回。
查詢結(jié)果由多行,拼接為了一行。
再測試捐凭,現(xiàn)在是正常速度了拨扶,但是查詢時間依舊很高。Sql查詢時間0.8秒茁肠,代碼中平均1秒8左右患民,還有優(yōu)化的空間。
將一列數(shù)據(jù)轉(zhuǎn)換為了數(shù)組類型垦梆,查看一下內(nèi)存占用匹颤,這一段占用了54比特,雖然占用不大托猩,但是不知道為什么會mybatis處理時間這么久印蓖。
因為mybatis不知道數(shù)組的大小,先給數(shù)組設(shè)定一個初始大小京腥,如果超出了數(shù)組長度赦肃,因為數(shù)組不能擴(kuò)容,增加長度只能再復(fù)制一份到另一塊內(nèi)存中公浪,復(fù)制的次數(shù)多了也就增加了計算時間他宛。
數(shù)據(jù)需要在兩個設(shè)備之間傳輸厅各,磁盤和網(wǎng)絡(luò)都需要時間宜鸯。
2. 部分業(yè)務(wù)邏輯轉(zhuǎn)到數(shù)據(jù)庫中計算
再次優(yōu)化sql,將一部分的邏輯放到Sql中處理灸芳,減少數(shù)據(jù)量冯遂。
業(yè)務(wù)上我需要統(tǒng)計programhandleidlist字段中id出現(xiàn)的次數(shù),所以我直接在sql中做統(tǒng)計。
要統(tǒng)計每個數(shù)組中元素出現(xiàn)的次數(shù),您需要首先使用 unnest 函數(shù)將數(shù)組展開為單獨的行,然后使用 GROUP BY 和聚合函數(shù)(如 count)來計算每個元素的出現(xiàn)次數(shù)推盛。這里是修改后的 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數(shù)組展開成多個行驹闰。
GROUP BY elem對每個獨立的元素進(jìn)行分組控嗜。
COUNT(*)計算每個分組(即每個元素)的出現(xiàn)次數(shù)惫谤。
這個查詢將返回兩列:一列是元素(elem)若专,另一列是該元素在所有數(shù)組中出現(xiàn)的次數(shù)(count)。
這條sql在代碼中執(zhí)行時間是0.7秒沛豌,還是時間太長叫确,畢竟數(shù)據(jù)庫的數(shù)據(jù)量太大,搜了很多方法,已經(jīng)是我能做到的最快查詢了照瘾。
關(guān)系型數(shù)據(jù)庫 不適合做海量數(shù)據(jù)計算查詢逃默。
“
這個業(yè)務(wù)場景牽扯到了海量數(shù)據(jù)的統(tǒng)計,并不適合使用關(guān)系型數(shù)據(jù)庫,如果想要真正的做到毫秒級的查詢,需要從設(shè)計上改變數(shù)據(jù)的存儲結(jié)果。比如使用cilckhouse、hive等存儲計算然低。
3. 引入緩存機(jī)制
減少查詢數(shù)據(jù)庫的次數(shù),決定引入本地緩存機(jī)制刚照。選擇了Caffeine作為緩存框架,易于與Spring集成喧兄。
分析業(yè)務(wù)后无畔,當(dāng)天的統(tǒng)計數(shù)據(jù)必須查詢數(shù)據(jù)庫,但是查詢歷史日期的采用緩存的方式吠冤。如果業(yè)務(wù)中對時效性不敏感浑彰,也可以緩存當(dāng)天的數(shù)據(jù),每隔一段時間更新一次拯辙。我這里采用緩存歷史日期的數(shù)據(jù)郭变。
1.引入Caffeine依賴
<dependency>
? ? <groupId>com.github.ben-manes.caffeine</groupId>
? ? <artifactId>caffeine</artifactId>
? ? <version>3.1.8</version>
</dependency>
2.配置Caffeine緩存
創(chuàng)建一個專門的Caffeine緩存配置。使用本地緩存選擇淘汰策略很重要涯保,由于我的業(yè)務(wù)場景使根據(jù)實現(xiàn)來查詢诉濒,所以Caffeine將按照最近最少使用(LRU)的策略來淘汰舊數(shù)據(jù)成符合業(yè)務(wù)。
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), () -> {
? ? // 查詢數(shù)據(jù)庫
? ? ? ? Map<String, String> hitRateList = dataTunnelClient.selectTransHandleFlowByTime(currentDate.minusDays(1));
? // ... 其他代碼 ...
? // 返回計算后的結(jié)果
? ? ? ? return hitRate;
? ? });
? ? // ... 其他代碼 ...
}
總結(jié)
最后未荒,測試接口,成功將接口從30秒降低到了0.8秒以內(nèi)及志。
這次優(yōu)化讓我重新真正審視了關(guān)系型數(shù)據(jù)庫的劣勢片排。選擇哪種類型的數(shù)據(jù)庫,取決于具體的應(yīng)用場景和需求速侈。
關(guān)系型數(shù)據(jù)庫(Mysql划纽、Oracle等)適合事務(wù)性強(qiáng)、數(shù)據(jù)一致性和完整性要求高的應(yīng)用锌畸。
列式數(shù)據(jù)庫(HBase勇劣、ClickHouse等)則適合大數(shù)據(jù)量的分析和統(tǒng)計,特別是在讀取性能方面有顯著優(yōu)勢潭枣。
此次的業(yè)務(wù)場景顯然更適合使用列式數(shù)據(jù)庫比默,所以導(dǎo)致使用關(guān)系型數(shù)據(jù)庫無論如何也不能夠達(dá)到足夠高的性能。