不談架構,看看如何從程式碼層面優化系統性能!
我們以前看到的很多架構變遷或者演進方面的文章大多都是針對架構方面的介紹,很少有針對程式碼級別的效能優化介紹,這就好比蓋樓一樣,樓房的基礎架子搭的很好,但是蓋房的工人不夠專業,有很多需要注意的地方忽略了,那麼在往裡面填磚加瓦的時候出了問題,後果就是房子經常漏雨,牆上有裂縫等各種問題出現,雖然不至於樓房塌陷,但樓房也已經變成了危樓。那麼今天我們就將針對一些程式碼細節方面的東西進行介紹,歡迎大家吐槽以及提建議。
伺服器環境
伺服器配置:4核CPU,8G記憶體,共4臺
MQ:RabbitMQ
資料庫:DB2
SOA框架:公司內部封裝的Dubbo
快取框架:Redis、Memcached
統一配置管理系統:公司內部開發的系統
問題描述
單臺40TPS,加到4臺伺服器能到60TPS,擴充套件性幾乎沒有。
在實際生產環境中,經常出現數據庫死鎖導致整個服務中斷不可用。
資料庫事務亂用,導致事務佔用時間太長。
在實際生產環境中,伺服器經常出現記憶體溢位和CPU時間被佔滿。
程式開發的過程中,考慮不全面,容錯很差,經常因為一個小bug而導致服務不可用。
程式中沒有列印關鍵日誌,或者列印了日誌,資訊卻是無用資訊沒有任何參考價值。
配置資訊和變動不大的資訊依然會從資料庫中頻繁讀取,導致資料庫IO很大。
專案拆分不徹底,一個Tomcat中會佈署多個專案WAR包。
因為基礎平臺的bug,或者功能缺陷導致程式可用性降低。
程式介面中沒有限流策略,導致很多VIP商戶直接拿我們的生產環境進行壓測,直接影響真正的服務可用性。
沒有故障降級策略,專案出了問題後解決的時間較長,或者直接粗暴的回滾專案,但是不一定能解決問題。
沒有合適的監控系統,不能準實時或者提前發現專案瓶頸。
優化解決方案
1、資料庫死鎖優化解決
我們從第二條開始分析,先看一個基本例子展示資料庫死鎖的發生:
注:在上述事例中,會話B會丟擲死鎖異常,死鎖的原因就是A和B二個會話互相等待。
分析:出現這種問題就是我們在專案中混雜了大量的事務+for update語句,針對資料庫鎖來說有下面三種基本鎖:
Record Lock:單個行記錄上的鎖
Gap Lock:間隙鎖,鎖定一個範圍,但不包含記錄本身
Next-Key Lock:Gap Lock + Record Lock,鎖定一個範圍,並且鎖定記錄本身
當for update語句和gap lock和next-key lock鎖相混合使用,又沒有注意用法的時候,就非常容易出現死鎖的情況。
那我們用大量的鎖的目的是什麼,經過業務分析發現,其實就是為了防重,同一時刻有可能會有多筆支付單發到相應系統中,而防重措施是通過在某條記錄上加鎖的方式來進行。
針對以上問題完全沒有必要使用悲觀鎖的方式來進行防重,不僅對資料庫本身造成極大的壓力,同時也會把對於專案擴充套件性來說也是很大的擴充套件瓶頸,我們採用了三種方法來解決以上問題:
使用Redis來做分散式鎖,Redis採用多個來進行分片,其中一個Redis掛了也沒關係,重新爭搶就可以了。
使用主鍵防重方法,在方法的入口處使用防重表,能夠攔截所有重複的訂單,當重複插入時資料庫會報一個重複錯,程式直接返回。
使用版本號的機制來防重。
以上三種方式都必須要有過期時間,當鎖定某一資源超時的時候,能夠釋放資源讓競爭重新開始。
2、資料庫事務佔用時間過長
虛擬碼示例:
專案中類似這樣的程式有很多,經常把類似httpClient,或者有可能會造成長時間超時的操作混在事務程式碼中,不僅會造成事務執行時間超長,而且也會嚴重降低併發能力。
那麼我們在用事務的時候,遵循的原則是快進快出,事務程式碼要儘量小。針對以上虛擬碼,我們要用httpClient這一行拆分出來,避免同事務性的程式碼混在一起,這不是一個好習慣。
3、CPU時間被佔滿分析
下面以我之前分析的一個案例作為問題的起始點,首先看下面的圖:
專案在壓測的過程中,CPU一直居高不下,那麼通過分析得出如下分析:
1)資料庫連線池影響
我們針對線上的環境進行模擬,儘量真實的在測試環境中再現,採用資料庫連線池為咱們預設的C3P0。
那麼當壓測到二萬批,100個使用者同時訪問的時候,併發量突然降為零!報錯如下:
com.yeepay.g3.utils.common.exception.YeepayRuntimeException: Could not get JDBC Connection; nested exception is java.sql.SQLException: An attempt by a client to checkout a Connection has timed out.
那麼針對以上錯誤跟蹤C3P0原始碼,以及在網上搜索資料發現C3P0在大併發下表現的效能不佳。
2)執行緒池使用不當引起
以上程式碼的場景是每一次併發請求過來,都會建立一個執行緒,將DUMP日誌匯出進行分析發現,專案中啟動了一萬多個執行緒,而且每個執行緒都極為忙碌,徹底將資源耗盡。
那麼問題到底在哪裡呢???就在這一行!
private static final ExecutorService executorService = Executors.newCachedThreadPool();
在併發的情況下,無限制的申請執行緒資源造成效能嚴重下降,在圖表中顯拋物線形狀的元凶就是它!!!那麼採用這種方式最大可以產生多少個執行緒呢??答案是:Integer的最大值!看如下原始碼:
那麼嘗試修改成如下程式碼:
private static final ExecutorService executorService = Executors.newFixedThreadPool(50);
修改完成以後,併發量重新上升到100以上TPS,但是當併發量非常大的時候,專案GC(垃圾回收能力下降),分析原因還是因為Executors.newFixedThreadPool(50)這一行,雖然解決了產生無限執行緒的問題,但是當併發量非常大的時候,採用newFixedThreadPool這種方式,會造成大量物件堆積到佇列中無法及時消費,看原始碼如下:
可以看到採用的是無界佇列,也就是說佇列是可以無限的存放可執行的執行緒,造成大量物件無法釋放和回收。
2)最終執行緒池技術方案
方案一
注:因為伺服器的CPU只有4核,有的伺服器甚至只有2核,所以在應用程式中大量使用執行緒的話,反而會造成效能影響,針對這樣的問題,我們將所有非同步任務全部拆出應用專案,以任務的方式傳送到專門的任務處理器處理,處理完成回撥應用程式器。後端定時任務會定時掃描任務表,定時將超時未處理的非同步任務再次傳送到任務處理器進行處理。
方案二
使用AKKA技術框架,下面是我以前寫的一個簡單的壓測情況:
http://www.jianshu.com/p/6d62256e3327
4、日誌列印問題
先看下面這段日誌列印程式:
像這樣的程式碼是嚴格不符合規範的,雖然每個公司都有自己的列印要求。
首先日誌的列印必須是以logger.error或者logger.warn的方式打印出來。
日誌列印格式:[系統來源] 錯誤描述 [關鍵資訊],日誌資訊要能打印出能看懂的資訊,有前因和後果。甚至有些方法的入參和出參也要考慮打印出來。
在輸入錯誤資訊的時候,Exception不要以e.getMessage的方式打印出來。
合理的日誌格式是:
我們在程式中大量的列印日誌,雖然能夠列印很多有用資訊幫助我們排查問題,但是更多是日誌量太多不僅影響磁碟IO,更多會造成執行緒阻塞對程式的效能造成較大影響。
在使用Log4j1.2.14版本的時候,使用如下格式:
%d %-5p %c:%L [%t] - %m%n
那麼在壓測的時候會出現下面大量的執行緒阻塞,如下圖:
再看壓測圖如下:
原因可以根據log4j原始碼分析如下:
注:Log4j原始碼裡用了synchronized鎖,然後又通過列印堆疊來獲取行號,在高併發下可能就會出現上面的情況。
於是修改Log4j配置檔案為:
%d %-5p %c [%t] - %m%n
上面問題解決,執行緒阻塞的情況很少出現,極大的提高了程式的併發能力,如下圖所示:
在此我向大家推薦一個架構學習交流群。交流學習群號:736220120 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多。