一、背景
資金核對的資料組裝-執行-應急鏈路,有著千萬級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
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/540951.html
標籤:其他
上一篇:常用設計模式之簡單工廠模式
下一篇:常用設計模式之簡單工廠模式
