面向.Net程序員的后端性能優化實戰


最近2個月沒做什么新項目 完全是對於舊的系統進行性能優化 避免超時 死鎖 數據處理能力不夠等常見的性能問題

這里不從架構方面出發 畢竟動大手腳成本比較高 那么我們以實例為前提 從細節開始 


優化角度

一.業務邏輯優化

二.DB優化

三.數據處理優化

四.鎖與性能

五.cpu飆高小結

六.crash現象分析


 業務邏輯優化

這一條不具有普遍性 不同的業務不同的場景 如果歸納起來 就是在不影響業務的前提下進行流程精簡

1. 廢棄冗余邏輯 

常見於各種基於數據庫的檢查 很多同學在維護別人代碼的時候 沒有深入理解別人的邏輯 也許別人在取數據的時候已經在查詢條件中已經過濾了相關邏輯 而后來維護的同學又來了一次check

當然如果處於數據安全的角度 double check無可厚非,但是如果連鎖都沒有的double check 其實不做也罷。

畢竟 省一次dbcall 可能效果勝於你做的N多優化

2. 合並業務請求

出發點和上述一致 節省dbcall 但是存在一個矛盾的點 如果業務包在事務里 這條需要慎重考慮 事務的設計原則里 當然能小則小


DB優化

這個其實是比較核心的點

1. 索引優化

這個點比較泛泛 但是做好的人不多 一個專攻於索引優化的人也可以在運維方面獨當一面了

我們拿實例來看一個索引優化例子

首先利其器 選中你需要的調試信息

然后打開自動統計信息更新 特別對於測試階段 數據以及數據量頻繁變更的時候 統計信息一定要記得刷新

  創建索引時,查詢優化器自動存儲有關索引列的統計信息。另外,當 AUTO_CREATE_STATISTICS 數據庫選項設置為 ON(默認值)時, 數據庫引擎自動為沒有用於謂詞的索引的列創建統計信息。隨着列中數據發生變化,索引和列的統計信息可能會過時,從而導致查詢優化器選擇的查詢處理方法不是最佳的。 當 AUTO_UPDATE_STATISTICS 數據庫選項設置為 ON(默認值)時,查詢優化器會在表中的數據發生變化時自動定期更新這些統計信息。 每當查詢執行計划中使用的統計信息沒有通過針對當前統計信息的測試時就會啟動統計信息更新。 采樣是在各個數據頁上隨機進行的,取自表或統計信息所需列的最小非聚集索引。從磁盤讀取一個數據頁后,該數據頁上的所有行都被用來更新統計信息。 常規情況是:在大約有 20% 的數據行發生變化時更新統計信息。但是,查詢優化器始終確保采樣的行數盡量少。 對於小於 8 MB 的表,則始終進行完整掃描來收集統計信息。

最后執行 SET STATISTICS PROFILE ON,可以看到更詳細的計划

隨便拿個典型sql來作示例 相關值為虛假值 僅供參考

SELECT DISTINCT TOP 1000 a.CustomerID FROM TravelTicket(nolock) a
WHERE  a.TicketChargeDate < GETDATE()
AND a.AvailableAmount > 999
AND a.[Status] <>999
AND a.IsInCome <>999
AND a.IsInCome <>998 
AND NOT EXISTS 
(SELECT TOP 1 1 FROM TicketCharge(NOLOCK) b 
	WHERE   b.CustomerID = a.CustomerID
                AND b.chargetype = 999
		AND b.IsSuccessful = 999
		AND b.IsDeleted != 999
		AND b.FeeMonth = '999'

)

在完全沒有任何索引的前提下我們查詢一遍看下各種io信息以及執行計划

如圖中所示 存在2個聚集索引掃描 先介紹下基礎知識

【Table Scan】:遍歷整個表,查找所有匹配的記錄行。這個操作將會一行一行的檢查,當然,效率也是最差的。
【Index Scan】:根據索引,從表中過濾出來一部分記錄,再查找所有匹配的記錄行,顯然比第一種方式的查找范圍要小,因此比【Table Scan】要快。
【Index Seek】:根據索引,定位(獲取)記錄的存放位置,然后取得記錄,因此,比起前二種方式會更快。
  在有聚集索引的表格上,數據是直接存放在索引的最底層的,所以要掃描整個表格里的數據,就要把整個聚集索引掃描一遍。在這里,聚集索引掃描【Clustered Index Scan】就相當於一個表掃描【Table Scan】。所要用的時間和資源與表掃描沒有什么差別。並不是說這里有了“Index”這個字樣,就說明執行計划比表掃描的有多大進步。當然反過來講,如果看到“Table Scan”的字樣,就說明這個表格上沒有聚集索引。換句話說 上面那段sql存在2個表掃描。

【Clustered Index Seek】:直接根據聚集索引獲取記錄,最快!

所以我們優化的目標是將掃描(scan)變為查找(seek)

  先來嘗試TicketCharge表 下面我們所新添並且討論的索引都是非聚集索引

  江湖上流傳着這么一篇秘訣,建符合索引根據where查詢的順序來,好吧我們姑且先嘗試一下

/****** Object:  Index [rgyu_test1]    Script Date: 2015-2-2 17:52:50 ******/
CREATE NONCLUSTERED INDEX [chongzi_test] ON [dbo].[TicketCharge]
(
	[CustomerID] ASC,
	[ChargeType] ASC,
	[IsSuccessful] ASC,
	[IsDeleted] ASC,
	[FeeMonth] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO
ALTER INDEX [chongzi_test] ON [dbo].[TicketCharge] DISABLE
GO

  再看看新的執行計划

  沒變!江湖秘訣果然還是得慎重點用,我們來分析下索引沒命中的原因。

第一塊是索引基本信息

列名 描述說明

Name

統計信息對象名稱

Update

上一次更新統計信息的日期和時間

Rows

在目標索引、統計信息或列中的總行數。如果篩選索引或統計信息,此行數可能小於表的行數。
Rows Sampled 用於統計信息計算的抽樣總行數。
Steps 統計信息對象第一個鍵列的直方圖中的值范圍數。每個步驟包括在直方圖結果中定義的 RANGE_ROWS 和 EQ_ROWS。

Density

查詢優化器不使用此值。顯示此值的唯一目的是為了向后兼容。密度的計算公式為 1 / distinct rows,其中 distinct rows 是直方圖輸出中所有步驟的 DISTINCT_RANGE_ROWS 之和。如果對行進行抽樣,distinct rows 則基於抽樣行的直方圖值。
Average Key Length 統計信息對象的鍵列中,所有抽樣值中的每個值的平均字節數
String Index 如果為“是”,則統計信息中包含字符串摘要索引,以支持為 LIKE 條件估算結果集大小。僅當第一個鍵列的數據類型為charvarcharncharnvarcharvarchar(max)nvarchar(max)textntext 時,才會對此鍵列創建字符串索引。
Filter Expression 包含在統計信息對象中的表行子集的表達式。NULL = 未篩選的統計信息。有關詳細信息,請參閱篩選統計信息。
Unfiltered Rows 應用篩選器表達式前表中的總行數。如果 Filter Expression 為 NULL,Unfiltered Rows 等於行標題值。

 第二塊對指定 DENSITY_VECTOR 時結果集中所返回的列進行了說明。

列名 說明
All Density 針對統計信息對象中的列的每個前綴計算密度(1/ distinct_rows)。
Average Length 每個列前綴的列值向量的平均長度(按字節計)。例如,如果列前綴為列 A 和 B,則長度為列 A 和列 B 的字節之和。
Columns 為其顯示 All densityAverage length 的前綴中的列的名稱。

第三塊對指定 HISTOGRAM 選項時結果集中所返回的列進行了說明。

列名 說明
RANGE_HI_KEY 直方圖步驟的上限值。

RANGE_ROWS

表中位於直方圖步驟內(不包括上限)的行的估算數目。
EQ_ROWS 表中值與直方圖步驟的上限值相等的行的估算數目。
DISTINCT_RANGE_ROWS 直方圖步驟內(不包括上限)非重復值的估算數目。
AVG_RANGE_ROWS 直方圖步驟內(不包括上限)重復值的頻率或平均數目(如果 DISTINCT_RANGE_ROWS > 0,則為 RANGE_ROWS / DISTINCT_RANGE_ROWS)。

越小的SQL Server索引密度意味着具有更高的索引選擇性。當密度趨近於1,索引就變得有更少的選擇性,基本上沒有用處了。當索引的選擇性低的時候,優化器可能會選擇一個表掃描(table scan),或者葉子級的索引掃描(Index scan),而不會進行索引查找(index seek),因為這樣會付出更多的代價。當心你的數據庫中低選擇性的索引。這樣的索引通常是對系統的性能是一個損害。它們通常不僅不會用來進行數據的檢索,而且也會使得數據修改語句變得緩慢,因為需要額外的索引維護。識別這些索引,考慮刪除掉它們。

而上圖我們的密度已經達到0.33,因為這不是一個好的方案。我們調整索引順序,將feemonth提到第一列。

再看執行io和執行計划

 

搞定,我們再看下統計分析

到此為止或許你以為已經搞定了這個索引問題 ticketcharge的讀取從1w3降低到了13 但是友情提請一下 密度會隨着數據分布的變化而變化 本次的demo數據具有特殊性 具體的問題還需要具體來分析

我們看一下更詳細的計划

另外針對本文這種sql寫法 還有另外一個點需要關注 那就是索引第一列不可以是於第一張表關聯的列

調整我們的索引順序同樣可以讓sql命中索引 我們來看下效果

 

 執行一下

 

雖然命中了索引 但是由於關聯鍵的問題 導致ticketcharge進行了循環。

所以說 江湖上流傳的按照where查詢條件設計索引順序是完全錯誤的 第一列的選擇要根據密度選擇性來判斷

另外非聚集索引列分為鍵值列和包含列(include) 

復合索引的鍵值列不是越多越好,首先索引本身有長度的限制。但是使用非鍵值列就不算在索引長度內。鍵列存儲在索引的所有級別中,而非鍵列僅存儲在葉級別中。簡單來說本來索引類似於字典的部首查找,你根據部首查找以后還要根據該漢字對應的頁數去查看詳細,但是如果你只是要一個簡單的該漢字的拼音,那么包含列相當於把拼音直接附加在部首查找后面,你就不需要再去詳細頁查看其它你不需要的信息了。

換個角度來說,當查詢中的所有列都作為鍵列或非鍵列包含在索引中時,帶有包含性非鍵列的索引可以顯著提高查詢性能。這樣可以實現性能提升,因為查詢優化器可以在索引中找到所有列值;不訪問表或聚集索引數據,從而減少磁盤 I/O 操作。

至於travelticket的索引設計也類似,不過需要注意的點是不等於運算 以及like運算 都是不能使用索引的。需要結合業務來調整。


除了上述人為的添加索引 還有一種取巧的辦法

  打開sql server profiler

  

  按照自己的需求新建一個跟蹤腳本

  

  寫個腳本循環跑這個語句然后保存跟蹤腳本。打開推薦sql優化器

  

  導入剛才的跟蹤腳本,並且選擇索引優化

  

  執行分析

  

  


數據處理優化

  對於db優化在程序員能力已經到瓶頸的前提下,可以着手從應用程序上的細節出發,例如並行。

  所謂並行也就是多線程針對同任務分區分塊協同處理。多線程的技術大家都很了解,這里突出以下線程同步的問題。例如我有1000個人我分10組任務執行,如何正確的保證當前10組任務正確的完成互相不沖突並且等到所有任務完成后才開啟下一輪1000.

  這里介紹一個比較通用的方法,首先申明一個信號量隊列List<ManualResetEvent>();

  取出1000條后對於1000條進行添加順序標識表示並且模余分組。標識從1開始遞增就可以,模余分組方法如下

  testInfos.GroupBy(i => i.index % workTaskCount).Select(g => g.ToList()).ToList();    

  其中index為剛才添加的順序標識, workTaskCount為分組的任務數。

//循環處理批次任務
foreach (var testGroup in testGroups)
{
    var mre = new ManualResetEvent(false);
    manualEvents.Add(mre);
    var testMethodParam = new TestMethodParam
    {
        mrEvent = mre,
        testGroup = TestGroup,
        testParam = testParam
    };
    //線程池處理計划任務
    ThreadPool.QueueUserWorkItem(DoTestMethod, testMethodParam);
}
if (manualEvents.Count != 0)
{
    //等待所有信號量完成 切記這里最大值為64
    WaitHandle.WaitAll(manualEvents.ToArray(), 30 * 60 * 1000);
}

  DoTestMethod就是舊的任務處理邏輯,testMethodParam負責涵蓋你舊邏輯中所需要的參數並且包含一個完成信號量。

  這里需要牢記的是WaitHandle等待的信號量最大值為64.

  如果你需要的任務分組數超過64那么這里推薦在DoTestMethod方法中不適用信號量,而是使用原子操作的標識,例如Interlocked.Increment(taskCount)。當taskCount累加到1000(你設計的當前批次值)就結束一輪。不過比起WaitHandle性能上要慢一些。

  另外如果你使用線程池來管理線程,最好加上最大線程限制。過多的線程是導致cpu資源消耗的原因之一,最好的線程數是服務器cpu個數的2倍-1或者-2,


死鎖問題

  鎖超時的問題大多是因為表鎖產生。解決表鎖的問題說難也不難,不過需要犧牲性能。mssql針對主鍵的更新不會產生表鎖而是產生行鎖。針對這個問題那么死鎖的問題初步解決起來就簡單了。

  這是比較通用的處理方法

DECLARE @step INT 
DECLARE @id CHAR(12)
create table #tmpTest  --創建臨時表
(
	rec_index INT ,
	id CHAR(12)
);
INSERT INTO #tmpTest  (rec_index,ID) 
SELECT ROW_NUMBER() OVER(ORDER BY ID) AS rec_index,ID FROM TestTable(nolock) WHERE BID = @bID
SET @rowcount=0
SET @step=1
SELECT @rowcount=COUNT(*) FROM #tmpTest AS tt
WHILE(@step<=@rowcount)
BEGIN
	SELECT @id=Id
	FROM #tmpTest  AS tt
	WHERE @step=rec_index
	UPDATE TestTable
    SET
		UpdateUser = @UpdateUser,
		UpdateTime = @UpdateTime,
	WHERE  ID =@id
	SET @step=@step+1
END

cpu飆高小結

對於cpu飆高分2類,應用服務器和db服務器的飆高原因優先排查點不同

對於應用服務器,首先排查while true等死循環,其次看多線程問題。不排除其他原因,這里只介紹主要的情況

在本機就可以根據源代碼來調試 

 

如圖所示我們的cpu問題是由於線程過得多導致 因為我的demo用的Threadpool所以一個簡單的ThreadPool.SetMaxThreads(5, 5);即可搞定.

如果上述情況檢查不出你的程序導致cpu飆高的原因,那么就需要借助於其他工具 例如dotTrace.

 

根據自己的程序類型加載不同的探查器

截圖不具有典型性 操作很簡單 沒有什么過多值得介紹的 官方文檔

http://www.jetbrains.com/profiler/features/index.html

我們隨便找個程序 


 

下面我們看看db服務器cpu飆高的一些原因。

 最大的一個坑是隱式數據類型轉換,之所以為坑是因為他不是顯示的出現問題,而是當你的數據分布以及量達到一定條件后才會產生問題。

什么是隱式數據類型轉換:

當我們在語句的where 條件等式的左右提供了不同數據類型的列或者變量,SQL Server在處理等式之前,將其中一端的數據轉換成跟另一端數值的數據類型一致,這個過程叫做隱式數據類型轉換。

比如 char(50)=varchar(50), char(50)=nchar(50), int=float, int=char(20) 這些where 條件的等式都會觸發隱式數據類型轉換。

但是,對於某些數據類型轉換過程中,可以轉換的方向只是單向的。例如:

如果你試圖比較INT和FLOAT的列,INT數據類型必須被轉換成FLOAT型 "CONVERT(FLOAT,C_INT) = C_FLOAT".

如果你試圖比較char和nchar的列,char數據類型必須被轉換成unicode型 "CONVERT(nchar,C_char) = C_nchar"

因此,我們在.net 或者java的程序中,會經常出現由於隱式數據類型轉換而產生的性能問題。

最簡單的 我們來做個試驗

CREATE TABLE [Chongzi_Test] (
[TAB_KEY] [varchar] (5)  NOT NULL ,
[Data] [varchar] (10)  NOT NULL ,
CONSTRAINT [Chongzi_Test_PK] PRIMARY KEY  CLUSTERED 
(
[TAB_KEY]
)  ON [PRIMARY] 
) ON [PRIMARY]
GO

然后插入幾百條數據

我們執行

declare @p1 int
set @p1=0
exec sp_prepexec @p1 output,N'@P0 varchar(5)',N'select TAB_KEY,Data from Chongzi_Test where TAB_KEY = @P0',N'0'
select @p1

然后我們換一種不匹配類型來看一下

declare @p1 int
set @p1=0
exec sp_prepexec @p1 output,N'@P0 nvarchar(4000)',N'select TAB_KEY,Data from Chongzi_Test where TAB_KEY = @P0',N'0'
select @p1

這里出現了一個操作叫做GetRangeThroughConvert(),在這里,SQL Server由於不能直接對varchar(5)的列用nvarchar(4000)的值進行seek,因此,SQL Server必須將nvarchar轉換成varchar。

這個過程中不同的應用場景可以帶來性能的損耗可能會很大,因為在轉換過程中可能會存在表掃描。

例如之前項目中有個nvarchar(max)向varchar(50)轉換,由於包含有特別的字符,如全雙工字符<, 該字符直接轉換成varchar(50)不會那么順利。需要根據參數的實際長度和表結構中定義的字段長度進行比較,如果小於表結構中定義的字段長度,Range依舊會比較小,但是如果大於表結構中定義的字段長度,GetRangeMismatchedTypes函數會把Range設得很寬,查詢很慢。

如圖執行計划是一樣的,返回數據行不同。

 附上c# dbtype 與sql 類型的對應表

AnsiString:VarChar
Binary:VarBinary
Byte:TinyInt
Boolean:Bit
Currency:Money
Date:DateTime
DateTime:DateTime
Decimal:Decimal
Double:Float
Guid:UniqueIdentifier
Int16:SmallInt
Int32:Int
Int64:BigInt
Object:Variant
Single:Real
String:NVarChar
Time:DateTime
AnsiStringFixedLength:Char
StringFixedLength:NChar
Xml:Xml
DateTime2:DateTime2
DateTimeOffset:DateTimeOffset


Crash現象分析

目前還是用老辦法 抓dump分析堆棧

使用過程可以參考我之前的博文 http://www.cnblogs.com/dubing/p/3878591.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM