老項目的倔強——性能優化篇
老項目的倔強——性能優化篇
由於各種原因我們總是要與公司各種老項目打交道。天有不測風雲,誰也不知道這坨屎山會從哪個方向把你的嘴塞的滿滿的,還不讓你吐出來。既然如此…那隻能細嚼慢咽的吞下去吧。
說實在話,只要業務不死,那些老大伯項目就還有價值。更何況這個本就沒什麼人關注的項目突然被公司高層盯住了。說好幾個客戶都會用到這個系統,並且必須要做好壓測工作,不能有任何閃失。
然後這項工作任務就毫無徵兆的落在我手上了,改造優化時間不到一周。既然如此,那就只好硬著頭皮上了。
項目整體
整個項目很「老」,用的技術棧是 .net4.5 + 多層架構 + sqlsugar + mssql。為什麼」老「要加引號呢?因為我很難想像這個項目只是3年前的項目(:攤手)。其中orm——sqlsugar我已經找不到開源的項目地址了(用的僅僅是靜態dll),裡面有很多寫法我都找不到文檔了。沒關係,又不能不能用,我只要參照之前的寫法不動就行了。
那麼再來說現在這個項目要進行」手術「的地方:
首當其衝的就是目前這個項目經過測試人員壓測,200並發,持續半小時以及100並發,增量並發到200持續1小時的壓測結果是……
不到20吞吐量,CPU一直100%。根據目前產品給出的用戶量,至少要達到120吞吐量。得到這個消息的我,當時人都麻了……真不誇張。我一度認為我要「死」在這個項目中了。
剖析項目
一邊看程式碼一邊罵人的過程就不說了,相信大家都是這麼過來的。接下來要做的就是熟悉程式碼以及程式碼下的業務場景。涉及優化的業務場景看起來很簡單,就是給定一個碼,系統接收校驗真偽,然後進行激活使用。
在經過非常艱辛的和跟我一樣不熟悉這個業務的產品經理溝通下,確定業務方的需求和目的之後,剩下就是真正實施了。
程式碼層優化
首先我從最簡單的開始著手,就是code review。找出能一眼看出問題的點,結果僅僅只是幾處f12,就讓我找到了」幾坨屎「,雖然不願意,但我還是只能捂著鼻子強迫自己掰開看看究竟。
層與層之間調用關係混亂
因為是多層,所以有BLL,DAL,Model三層。DAL引用ORM組建以及快取組建,BLL引用DAL。DAL引用DBInstance。在實際查看中,我發現雖然BLL引用DAL,但是除了引用DAL之外,又初始化了DBInstance。快取組建也是如此。在實際調用中,多次重複打開資料庫連接以及快取連接,這無疑是一筆不小的開銷,而且還沒有任何意義。
看到這個我要做就是優化層之間的調用結構。本著對老項目最小更改原則,我重新建了ActivationBll和ActivationDal文件,去掉多餘的對象以及無用的IO連接。
程式碼邏輯的一把嗦
往下就是具體程式碼問題了,首先我就在原來的OldActivationBLL文件中看到如下程式碼:
// OldActivationBll.cs
private List<T1> global_fields1; //
private List<T2> global_fields2; //
private T3 field3;
...
private void InitData(string code) {
var dataset = dal.GetInitData(code);
global_fields1 = dataset[0];
global_fields2 = dataset[1];
T3 = dataset[2];
...
}
public void Activate(string code) {
// 略過判斷
InitData(code);
// 引用類全局變數進行各種操作
field3.Property1 = ...;
...
}
有很多細節我都忽略了,大致就是現在一個類中定義一堆變數,然後在InitData
方法中對這些變數一一賦值。這樣在其它地方,我都可以任意調用這些變數了。
這種有什麼問題呢?其實這種webform式的寫法對程式運行結果沒太大的影響。只是我個人不喜歡這種編程模式了,因為這樣非常容易造就義大利麵條式的混亂。讓人看的非常頭痛,維護起來很苦難。特別是換人之後,因為類全局變數哪裡都能被修改,不熟的人很容易導致非預期的結果與錯誤。
當我正閱讀程式碼並嘗試優化這種結果時,發現事情並不是那麼簡單。
這是dal.GetInitData
的程式碼
// OldActivationDal.cs
public DataSet GetInitData(string code)
{
string sql = @"declare @code nvarchar(250)
declare @bid int
declare @aid int
declare @usedId uniqueidentitfier
declare ...
select top 1 * from table1 where code=@code
select @bid = bid, @aid= aid from table1 inner join table2 on ...
select ...
-- 此處省略餘下10幾行select";
var dbset = dbhelper.ExecuteDataSet(sql, new parameter[] { ...});
return dbset;
}
看到這裡是不是很驚訝,我當時是震驚的。我當時的反應是正常人應該不會這麼寫吧。這真是「一把嗦」的寫法,把所有業務場景用到的前置對象一次性查出來賦值給對應的欄位,然後有需要的就引用這些對象。這個方法的引用數是12……。
毫無疑問,這種寫法問題很大,因為將多種業務場景的數據一次性查出來,也不管到底用不用得上,這是種對資源的絕對浪費。況且這對於資料庫來說也是很大的浪費,因為將多個語句合併成了一個大事務執行。
這種優化手段就簡單了,就是將一個大事務的sql語句,拆分成多個小事務的sql語句。不偷懶,多寫幾個方法按需給對象賦值。
這裡面還有一個優化點是用到了快取,在原來十幾個sql查詢中,還有3個查詢語句是基礎數據(如渠道以及資源等一些基礎數據)。
具體程式碼錯誤
前面提到的都還是設計上與流程的問題,還有一些明顯的錯誤就是屬於程式碼的寫法錯誤了。在做了上面的改造措施之後,在我自己的本機做了同樣的壓測,結果令人尷尬。吞吐量只有100左右。這明顯在我的意料之外的,這說明我優化效果不好。然後我繼續詳細找程式碼的問題,同時我寫了個慢查詢語句給db同事查看,讓其導出測試同學壓測的那個時間段的結果。期間還真讓我發現了一些比較明顯的問題,如下面的多任務寫法:
List<Task> taskList = new List<Task>();
object lockObj = new object();
string[] requestIds = bookId.Split(",");
List<Resource> result = new List<Resource>();
foreach (var id in requestIds) {
taskList.Add(Task.Factory.StartNew(delegate() {
var resource = _resourceService.GetBookAsync(id).Result;
if (resource != null) {
lock (lockObj) {
result.Add(r);
}
}
}));
}
Task.WaitAll(taskList.ToArray());
return result;
大家來看下這段程式碼都有哪些問題呢?如何優化呢?這個後面我再給出我實際中的優化方法
資料庫方面的優化
找不到其它明顯的程式碼問題就開始著手是不是資料庫,sql語句的問題了。
與此同時,db也已經把結果導出給到我了,好傢夥,排名第一(最耗時)的就是前面我說的那個十幾個查詢合併為大事務的那個方法sql語句。緊追其後的就是另一個查詢語句,就是查詢該用戶是否已經使用過該資源。該語句join了多個表,並且關聯的表都是百萬級數據量的,並且條件很多(有5個),寫法如下
select a.Id,a.Code,a.Status,b.Type,a.ChannelId,c.ActivateTypeId,a.Bid,a.UserId,b.Name,d.Did,d.Dtype
from a
inner join b on a.Id = b.Id
inner join c on b.uid = c.uid
left join d on d.Bid = b.Id
where a.UserId = @userId and a.Bid = @bid and a.ChannelId = @channelId and a.Status = 1 and d.DeviceCode = @deviceCode;
看到這個語句的第一想法是什麼?
語句有問題?NO,而是檢查資料庫對應的欄位是否有索引,如果沒有命中索引,則會導致全表掃描,特別還join的是大表。結果也讓我有點失望,索引每個字斷都建了。我隨即斷點將那些條件的值拼成sql語句到線上環境執行,結果發現速度非常慢,足足有15-30秒波動。想了大概幾分鐘,立馬得出了一個結論——索引的問題,給目標欄位建立索引針對這種情況效果不大,而是要針對這種熱調用場景有針對性的建索引——即聯合索引。我給a這個大表建立idx_UserId_Bid_ChannelId_Status
的聯合索引,然後去掉了無用的欄位,這樣就減少了要join的表和潛在的回表。建好之後再次執行,只用了300ms左右。
此時壓測的結果已經提升到了200左右(真就無腦建索引就完事了!-_-!)。
其實除此之外,還有幾個查詢也是很慢的。就不細舉例了,解決方案除了聯合索引,還有一種優化手段是包含列的索引。這種手段常見於select子表join是非常有效果的,其目的是為了減少回表的次數,爭取一次查詢就能將數據在多叉樹的節點上直接返回。
總結
自此,完成這些改造手術之後的壓測結果在我本機機器上是達到了200多吞吐。算是完成了領導臨時交給我的任務吧。在部署到線上時,測試同學壓測出來的結果到達了500。不過讓我有點意外的是,技術總監還是毅然決定給伺服器升配加負載。(小聲嘀咕:我還以為可以減配呢)
那麼總結這次的性能優化點可以簡單的概括三點:
- 架構層面(即分層要明確,減少重複的對象構造)
- 程式碼層面(減少明顯的編程常識錯誤,如盡量避免多任務共享變數;還有不要偷懶…)
- 資料庫層面(不要執行大的sql語句,要將大的拆成多個小事務sql語句,建對索引會省很多事)
關於具體實施,特別對手是老項目時,一定要本著「能不改原來的程式碼就不改為第一定律」。把這些老酒用新瓶包裝起來。因為你永遠也不知道你改動了其中一處地方,會給項目造成多大的傷害。
最後
在結束本文之前,我給出之前程式碼的優化版本。在優化之前我們先清楚程式碼有問題。
很明顯的有兩個問題:
- 多任務並行調用非同步方法,在遍歷中共享了result對象,並通過上鎖添加方法返回的結果
- 直接調用了非同步方法
GetBookAsync.Result
這兩點碰到一起了,這讓本不富裕的伺服器資源更是雪上加霜。
下面是我優化的版本
string[] requestIds = bookId.Split(",");
var taskList = new Task[requestIds.Length];
var result = new Resource[requestIds.Length];
for (int i = 0; i < requestIds.Length; i++) {
var idx = i;
taskList[idx] = Task.Run(() => {
}).ContinueWith(t => {
result[idx] = t.Result;
});
}
Task.WaitAll(taskList);
return result.ToList();
這是我想到的優化的版本,這樣既能做到無鎖編程,又可以不用阻塞非同步方法。硬要說其它的問題的話,那就是requestIds的數量是潛在的問題點,因為數量非常多的時候,這個時候就會給系統帶來很大的負擔,最終也會引起API服務或資料庫宕機的情況。這個時候其實我們可以通過PLINQ解決這點,通過分區來取得最佳性能。
好了這篇文章就到這裡了。