資金核對的數據組裝-執行-應急鏈路,有著千萬級TPS併發量,同時由於資金業務特性,對系統可用性和準確性要求非常高;日常開發過程中會遇到各種各樣的高可用問題,也在不斷地嘗試做一些系統設計以及性能優化,在此期間總結了部分性能優化的經驗和方法,跟大家一起分享和交流。 ...
一、背景
資金核對的數據組裝-執行-應急鏈路,有著千萬級TPS併發量,同時由於資金業務特性,對系統可用性和準確性要求非常高;日常開發過程中會遇到各種各樣的高可用問題,也在不斷地嘗試做一些系統設計以及性能優化,在此期間總結了部分性能優化的經驗和方法,跟大家一起分享和交流。
二、什麼是高性能系統
先理解一下什麼是高性能設計,官方定義: 高可用(High Availability,HA)核心目標是保障業務的連續性,從用戶視角來看,業務永遠是正常穩定的對外提供服務,業界一般用幾個9來衡量系統的可用性。通常採用一系列專門的設計(冗餘、去單點等),減少業務的停工時間,從而保持其核心服務的高度可用性。高併發(High Concurrency)通常是指系統能夠同時並行處理很多請求。一般用響應時間、併發吞吐量TPS, 併發用戶數等指標來衡量。高性能是指程式處理速度非常快,所占記憶體少,CPU占用率低。高性能的指標經常和高併發的指標緊密相關,想要提高性能,那麼就要提高系統發併發能力。本文主要對做“高性能、高併發、高可用”服務的設計進行介紹和分享。
三、從哪幾個方面做好性能提升
每次談到高性能設計,經常會面臨幾個名詞:IO多路復用、零拷貝、線程池、冗餘等等,關於這部分的文章非常的多,其實本質上是一個系統性的問題,可以從電腦體繫結構的底層原來去思考,系統優化離不開計算性能(CPU)和存儲性能(IO)兩個維度,總結如下方法:
- 如何設計高性能計算(CPU)
-
減少計算成本: 代碼優化計算的時間複雜度O(N^2)->O(N),合理使用同步/非同步、限流減少請求次數等;
-
讓更多的核參與計算: 多線程代替單線程、集群代替單機等等;
- 如何提升系統IO
-
加快IO速度: 順序讀寫代替隨機讀寫、硬體上SSD提升等;
-
減少IO次數: 索引/分散式計算代替全表掃描、零拷貝減少IO複製次數、DB批量讀寫、分庫分表增加連接數等;
- 減少IO存儲: 數據過期策略、合理使用記憶體、緩存、DB等中間件,做好消息壓縮等;
四、高性能優化策略
1. 計算性能優化策略
1.1 減少程式計算複雜度
簡單來看這段偽代碼(業務代碼facade做了脫敏)
boolean result = true; // 迴圈遍歷請求的requests, 判斷如果是A業務且A業務未達到終態返回false, 否則返回true for(Requet request: requests){ // 1. query DB 獲取TestDO String id = request.getId(); TestDO testDO = queryDOById(id); // 2. 如果是A業務且testDO未到達中態記錄為false if(StringUtils.equals("A", request.getBizType())){ // check是否到達終態 if(!StringUtils.equals("FINISHED", testDO.getStatus)){ result = result && false; } } } return result;
代碼中存在很明顯的幾個問題:
1.每次請求過來在第6行都去查詢DB,但是在第8行對請求做了判斷和篩選,導致第6行的代碼計算資源浪費,而且第6行訪問DAO數據,是一個比較耗時的操作,可以先判斷業務是否屬於A再去查詢DB;
2.當前的需求是只要有一個A業務未到達終態即可返回false, 11行可以在拿到false之後,直接break,減少計算次數;
優化後的代碼:
boolean result = true; // 迴圈遍歷請求的requests, 判斷如果是A業務且A業務未達到終態返回false, 否則返回true for(Requet request: requests){ // 1. 不是A業務的不走查詢DB的邏輯 if(!StringUtils.equals("A", request.getBizType())){ continue; } // 2. query DB 獲取TestDO String id = request.getId(); TestDO testDO = queryDOById(id); // check是否到達終態 if(!StringUtils.equals("FINISHED", testDO.getStatus)){ result = false; break; } } return result;
優化之後的計算耗時從平均270.75ms-->40.5ms
日常優化代碼可以用ARTHAS工具分析下程式的調用耗時,耗時大的任務儘可能做好過濾,減少不必要的系統調用。
1.2 合理使用同步非同步
分析業務鏈路中,哪些需要同步等待結果,哪些不需要,核心依賴的調度可以同步,非核心依賴儘量非同步。
場景:從鏈路上看A系統調用B系統,B系統調用C系統完成計算再把結論返回給A,A系統超時時間400ms,通常A系統調用B系統300ms,B系統調用C系統200ms。
現在C系統需要將調用結論返回給D系統,耗時150ms
此時A系統- B系統- C系統已有的調用鏈路可能會超時失敗,因為引入D系統之後,耗時增加了150ms,整個過程是同步調用的,因此需要C系統將調用D系統更新結論的非強依賴改成非同步調用。
// C系統調用D系統更新結果 featureThreadPool.execute(()->{ try{ dSystemClient.updateResult(resultDTO); }catch (Exception exception){ LogUtil.error(exception, logger, "dSystemClient.updateResult failed! resultDTO = {0}", JSON.toJSONString(resultDTO)); } });
1.3 做好限流保護
故障場景:A系統調用B系統查詢異常數據,日常10TPS左右甚至更少,某一天A系統改了定時任務觸發邏輯,加上代碼bug,調用頻率達到了500TPS,並且由於ID傳錯,繞過了緩存直接查詢了DB和Hbase, 造成了Hbase讀熱點,拖垮集群,存儲和查詢都受到了影響。
後續對A系統做了查詢限流,保證併發量在15TPS以內,核心業務服務需要做好查詢限流保護,同時也要做好緩存設計。
1.4 多線程代替單線程
場景:應急定位場景下,A系統調用B系統獲取診斷結論,TR超時時間是500ms,對於一個異常ID事件,需要執行多個診斷項服務,並記錄診斷流水;每個診斷的耗時大概在100ms以內,隨著業務的增長,超過5個診斷項,計算耗時累加到500ms+,這時候服務會出現高峰期短暫不可用。
將這段代碼改成非同步執行,這樣執行診斷的時間是耗時最大的診斷服務
// 提交future任務併發執行 futures = executor.invokeAll(tasks, timeout, timeUnit); // 遍歷讀取結果 for (Future<Res> future : futures) { try { // 獲取結果 Res singleResult = future.get(); if (singleResult != null) { result.add(singleResult); } } catch (Exception e) { LogUtil.error(e, logger, "併發執行發生異常!,poolName={0}.", threadPoolName); } }
1.5 集群計算代替單機
這裡可以使用三層分發,將計算任務分片後執行,Map-Reduce思想,減少單機的計算壓力。
2. 系統IO性能優化策略
2.1 常見的FullGC解決
系統常見的FullGC問題有很多,先講一下JVM的垃圾回收機制: Heap區在設計上是分代設計的, 劃分為了Eden、Survivor 和 Tenured/Old ,其中Eden區、Survivor(存活)屬於年輕代,Tenured/Old區屬於老年代或者持久代。一般我們將年輕代發生的GC稱為Minor GC,對老年代進行GC稱為Major GC,FullGC是對整個堆來說。
記憶體分配策略:1. 對象優先在Eden區分配 2. 大對象直接進入老年代 3. 長期存活的對象將進入老年代4. 動態對象年齡判定(虛擬機並不會永遠地要求對象的年齡都必須達到MaxTenuringThreshold才能晉升老年代,如果Survivor空間中相同年齡的所有對象的大小總和大於Survivor的一半,年齡大於或等於該年齡的對象就可以直接進入老年代)5. 只要老年代的連續空間大於(新生代所有對象的總大小或者歷次晉升的平均大小)就會進行minor GC,否則會進行full GC。
系統常見觸發FullGC的case:
(1)查詢大對象:業務上歷史巡檢數據需要定期清理,刪除策略是每天刪除上個月之前的數據(業務上打上軟刪除標記),等資料庫定時清理任務徹底回收;
某一天修改了刪除策略,從“刪除上個月之前的數據”改成了“刪除上周之前的數據”,因此刪除的數據從1000條膨脹到了15萬條,數據對象占用了80%以上的記憶體,直接導致系統的FullGC, 其他任務都有影響;
很多系統代碼對於查詢數據沒有數量限制,隨著業務的不斷增長,系統容量在不升級的情況下,經常會查詢出來很多大的對象List,出現大對象頻繁GC的情況。
(2)設置了用不回收的static方法
A系統設置了static的List對象,本身是用來做DRM配置讀取的,但是有個邏輯對配置信息做了查詢之後,還進行了Put操作,導致隨著業務的增長,static對象越來越大且屬於類對象,無法回收,最終使得系統頻繁GC。
本身用Object做Map的Key有一定的不合理性,同時key中的對象是不可回收的,導致出現了GC。
當執行Full GC後空間仍然不足,則拋出如下錯誤【java.lang.OutOfMemoryError: Java heap space】,而為避免以上兩種狀況引起的Full GC,調優時應儘量做到讓對象在Minor GC階段被回收、讓對象在新生代多存活一段時間及不要創建過大的對象及數組。
2.2 順序讀寫代替隨機讀寫
對於普通的機械硬碟而言,隨機寫入的性能會很差,時間久了還會出現碎片,順序的寫入會極大節省磁碟定址及磁碟碟片旋轉的時間,極大提升性能;這層其實本身中間件幫我們實現了,比如Kafka的日誌文件存儲消息,就是通過有序寫入消息和不可變性,消息追加到文件的末尾,來保證高性能讀寫。
2.3 DB索引設計
設計表結構時,我們要考慮後期對錶數據的查詢操作,設計合理的索引結構,一旦表索引建立好了之後,也要註意後續的查詢操作,避免索引失效。
(1)儘量不選擇鍵值較少的列即區分度不明顯,重覆數據很少的做索引;比如我們用is_delete這種列做了索引,查詢10萬條數據,where is_delete=0,有9萬條數據塊,加上訪問索引塊帶來的開銷,不如全表掃描全部的數據塊了;(2)避免使用前導like "%***"以及like "%***%", 因為前面的匹配是模糊的,很難利用索引的順序去訪問數據塊,導致全表掃描;但是使用like "A**%"不影響,因為遇到"B"開頭的數據就可以停止查找列,我們在做根據用戶信息模糊查詢數據時,遇到了索引失效的情況;
(3) 其他可能的場景比如,or查詢,多列索引不使用第一部分查詢,查詢條件中有計算操作,或者全表掃描比索引查詢更快的情況下也會出現索引失效;
目前AntMonitor以及Tars等工具已經幫我們掃描出來耗時和耗CPU很大的SQL,可以根據執行計劃調整查詢邏輯,頻繁的少量數據查詢利用好索引,當然建立過多的索引也有存儲開銷,對於插入和刪除很頻繁的業務,也要考慮減少不必要的索引設計。
2.4 分庫分表設計
隨著業務的增長,如果集群中的節點數量過多,最終會達到資料庫的連接限制,導致集群中的節點數量受限於資料庫連接數,集群節點無法持續增加和擴容,無法應對業務流量的持續增長;這也是螞蟻做LDC架構的其中原因之一,在業務層做水平拆分和擴展,使得每個單元的節點只訪問當前節點對應的資料庫。
2.5 避免大量的表JOIN
阿裡編碼規約中超過三個表禁止JOIN,因為三個表進行笛卡爾積計算會出現操作複雜度呈幾何數增長,多個表JOIN時要確保被關聯的欄位有索引。
如果為了業務上某些數據的級聯,可以適當根據主鍵在記憶體中做嵌套的查詢和計算,操作非常頻繁的流水錶建議對部分欄位做冗餘,以空間複雜度換取時間複雜度。
2.6 減少業務流水錶大量耗時計算
業務記錄有時候會做一些count操作,如果對時效性要求不高的統計和計算,建議定時任務在業務低峰期做好計算,然後將計算結果保存在緩存。
涉及到多個表JOIN的建議採用離線表進行Map-Reduce計算,然後再將計算結果迴流到線上表進行展示。
2.7 數據過期策略
一張表的數據量太大的情況下,如果不按照索引和日期進行部分掃描而出現全表掃描的情況,對DB的查詢性能是非常有影響的,建議合理的設計數據過期策略,歷史數據定期放入history表,或者備份到離線表中,減少線上大量數據的存儲。
2.8 合理使用記憶體
眾所周知,關係型資料庫DB查詢底層是磁碟存儲,計算速度低於記憶體緩存,緩存DB與業務系統連接有一定的調用耗時,速度低於本地記憶體;但是從存儲量來看,記憶體存儲數據容量低於緩存,長期持久化的數據建議放DB存在磁碟中,設計過程中考慮好成本和查詢性能的平衡。
說到記憶體,就會有數據一致性問題,DB數據和記憶體數據如何保證一致性,是強一致性還是弱一致性,數據存儲順序和事務如何控制都需要去考慮,儘量做到用戶無感知。
2.9 做好數據壓縮
很多中間件對數據的存儲和傳輸採用了壓縮和解壓操作,減少數據傳輸中的帶寬成本,這裡對數據壓縮不再做過多的介紹,想提的一點是高併發的運行態業務,要合理的控制日誌的列印,不能夠為了便於排查,列印過多的JSON.toJSONString(Object),磁碟很容易被打滿,按照日誌的容量過期策略也很容易被回收,更不方便排查問題;因此建議合理的使用日誌,錯誤碼僅可能精簡,核心業務邏輯列印好摘要日誌,結構化的數據也便於後續做監控和數據分析。
列印日誌的時候思考幾個問題:這個日誌有沒有可能會有人看,看了這個日誌能做什麼,每個欄位都是必須列印的嗎,出現問題能不能提高排查效率。
2.10 Hbase熱點key問題
HBase是一個高可靠、高性能、面向列、可伸縮的分散式存儲系統,是一種非關係資料庫,Hbase存儲特點如下:1.列的可以動態增加,並且列為空就不存儲數據,節省存儲空間。2.HBase自動切分數據,使得數據存儲自動具有水平scalability。3.HBase可以提供高併發讀寫操作的支持,分散式架構,讀寫鎖等待的概率大大降低。4.不能支持條件查詢,只支持按照Rowkey來查詢。
5.暫時不能支持Master server的故障切換,當Master宕機後,整個存儲系統就會掛掉。
Habse的存儲結構如下:Table在行的方向上分割為多個HRegion,HRegion是HBase中分散式存儲和負載均衡的最小單元,即不同的HRegion可以分別在不同的HRegionServer上,但同一個HRegion是不會拆分到多個HRegionServer上的。HRegion按大小分割,每個表一般只有一個HRegion,隨著數據不斷插入表,HRegion不斷增大,當HRegion的某個列簇達到一個閾值(預設256M)時就會分成兩個新的HRegion。
HBase 中的行是按照 Rowkey 的字典順序排序的,這種設計優化了 scan 操作,可以將相關的行以及會被一起讀取的行存取在臨近位置,便於scan。Rowkey這種固有的設計是熱點故障的源頭。熱點的熱是指發生在大量的 client 直接訪問集群的一個或極少數個節點(訪問可能是讀,寫或者其他操作)。
大量訪問會使熱點 Region 所在的單個機器超出自身承受能力,引起性能下降甚至 Region 不可用,這也會影響同一個 RegionServer 上的其他 Region,由於主機無法服務其他 Region 的請求,這樣就造成數據熱點(數據傾斜)現象。
所以我們在向 HBase 中插入數據的時候,應優化 RowKey 的設計,使數據被寫入集群的多個 region,而不是一個,儘量均衡地把記錄分散到不同的 Region 中去,平衡每個 Region 的壓力。
常見的熱點Key避免的方法: 反轉,加鹽和哈希
- 反轉:比如用戶ID2088這種首碼,以及BBCRL開頭的這種相同首碼,都可以適當的反轉往後移動。
- 加鹽: RowKey 的前面增加一些首碼,比如時間戳Hash,加鹽的首碼種類越多,才會根據隨機生成的首碼分散到各個 region 中,避免了熱點現象,但是也要考慮scan方便
-
哈希:為了在業務上能夠完整地重構 RowKey,首碼不可以是隨機的。 所以一般會拿原 RowKey 或其一部分計算 Hash 值,然後再對 Hash 值做運算作為首碼。
總之Rowkey在設計的過程中,儘量保證長度原則、唯一原則、排序原則、散列原則。
五、實戰-應急鏈路系統設計方案
要保證整體服務的高可用,需要從全鏈路視角去看待高可用系統的設計,這裡簡單的分享一個上游多個系統調用異常處理系統執行應急的業務場景,分析其中的性能優化改造。
以資金應急系統為例分析系統設計過程中的性能優化。如下圖所示,異常處理系統涉及到多個上游App(1-N),這些App發“差異日誌數據”給到消息隊列, 異常處理系統訂閱並消費消息隊列中的“錯誤日誌數據”,然後對這部分數據進行解析、加工聚合等操作,完成異常的發送及應急處理。
- 發送階段高可用設計
-
生產消息階段:本地隊列緩存異常明細數據,守護線程定時拉取並批量發送(優化方案1中單條上報的性能問題)
-
消息壓縮發送:異常規則復用用一份組裝的模型,按照規則則Code聚合壓縮上報(優化業務層數據壓縮復用能力)
-
中間件幫你做好了消息的高效序列化機制以及發送的零拷貝技術
-
存儲階段
- 目前Kafka等中間件,採用IO多路復用+磁碟順序寫數據的機制,保證IO性能
-
同時採用分區分段存儲機制,提升存儲性能
- 消費階段
-
定時拉取一段數據批量處理,處理之後上報消費位點,繼續計算
-
內部好做數據的冪等控制,發佈過程中的抖動或者單機故障保證數據的不重覆計算
-
為了提升DB的count性能,先用Hbase對異常數量做好累加,然後定時線程獲取數據批量update
-
為了提升DB的配置查詢性能,首次查詢配置放入本地記憶體存儲20分鐘,數據更新之後記憶體失效
-
對於統計類的計算採用explorer存儲,對於非結構化的異常明細採用Hbase存儲,對於結構化且可靠性要求高的異常數據採用OB存儲
1.然後對系統的性能做好壓測和容量評估,演練數據是異常數據的3-5倍做好流量隔離,對管道進行拆分,消費鏈路的線程池做好隔離
2.對於單點的計算模塊做好冗餘和故障轉移, 採取限流等措施
限流能力,上報端採用開關控制限流和熔斷
故障轉移能力
3.對於系統內部可以提升的地方,可以參考高可用性能優化策略去逐個突破。
六、高性能設計總結
1. 架構設計
1.1 冗餘能力
做好集群的三副本甚至五副本的主動複製,保證全部數據冗餘成功場景,任務才可以繼續執行,如果對可用性要求很高,可以降低副本數以及任務的提交一執行約束。
冗餘很容易理解,如果一個系統的可用性為90%,兩台機器的可用性為1-0.1*0.1=99%,機器越多,可用性會更高;對於DB這種對連接數有瓶頸的,我們需要在業務上做好分庫分表也是一種冗餘的水平擴展能力。
1.2 故障轉移能力
部分業務場景對於DB的依賴性很高,在DB不可用的情況下,能不能轉移到FO庫或者先中斷現場,保存上下文,對當前的業務場景上下文寫入延遲隊列,等故障恢復後再對數據進行消費和計算。
有些不可抗力和第三方問題,可能會嚴重影響整個業務的可用性,因此要做好異地多話,冗餘災備以及定期演練。
1.3 系統資源隔離性
在異常處理的case中,經常會因為上游數據的大量上報導致隊列阻塞,影響時效性,因此可以做好核心業務和非核心業務資源隔離,對於秒殺類的場景甚至可以單獨部署獨立的集群支撐業務。
如果A系統可用性90%,B系統的可用性40%,A系統某服務強依賴B系統,那麼A系統的可用性為P(A|B), 可用性大大降低。
2. 事前防禦
2.1 做好監控
對系統的CPU,線程CE、IO、服務調用TPS、DB計算耗時等設置合理的監控閾值,發現問題及時應急
2.2 做好限流/熔斷/降級等
上游業務流量突增的場景,需要有一定的自我保護和熔斷機制,前提是避免業務的強依賴,解決單點問題,在異常消費鏈路中,對上游做了DRM管控,下游也有一定的快速泄洪能力,防止因為單業務異常拖垮整個集群導致不可用。
瞬間流量問題很容易引發故障,一定要做好壓測和熔斷能力,秒殺類的業務減少對核心系統的強依賴,提前做好預案管控,對於緩存的雪崩等也要有一定的預熱和保護機制。
同時有些業務開放了不合理的介面,採用爬蟲等大量請求web介面,也要有識別和熔斷的能力
2.3 提升代碼質量
核心業務在大促期間做好封網、資金安全提前部署核對主動驗證代碼的可靠性,編碼符合規範等等,都是避免線上問題的防禦措施;
代碼的FullGC, 記憶體泄漏都會引發系統的不可用,尤其是業務低峰期可能不明顯,業務流量高的情況下性能會惡化,提前做好壓測和代碼Review。
3. 事後防禦和恢復
事前做好可監控和可灰度,事後做好任何場景下的故障可回滾。
其他關於防禦能力的還有:部署過程中如何做好代碼的平滑發佈,問題代碼機器如何快速地摘流量;上下游系統調用的發佈,如何保證依賴順序;發佈過程中,正常的業務已經在發佈過的代碼中執行,逆向操作在未發佈的機器中執行,如何保證業務一致性,都要有充分的考慮。
作者:單光旭(光旭)本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Experience-and-method-of-improving-system-performance.html