1. 程式人生 > >SQL Server Hash Warning 優化

SQL Server Hash Warning 優化

本系列屬於 SQL Server效能優化案例分享 專題


    最近遇到伺服器CPU持續居高問題,通過計數器的檢查,初步斷定存在語句效能問題,然後有針對性地抓取問題語句(來龍去脈將會在另外一篇文章解釋,本文關注Hash Warning),其執行計劃如下:


    因為這是生產環境,所以下面例子把表名替換成A/B/C/D等表。程式碼很簡單,大概樣子如下:
SELECT bo.Scode ,d.PCode ,o.OrderNo ,bo.BNo ,d.OCode ,SUM(d.Qty) AS Qty
FROM dbo.A bo WITH (NOLOCK)
INNER JOIN B o WITH (NOLOCK) ON bo.OrderNo = o.OrderNo
INNER JOIN C d WITH (NOLOCK) ON bo.BNo = d.BNo
INNER JOIN D ct WITH (NOLOCK) ON o.City = ct.City
INNER JOIN E tcc ON ct.CloseTypeID = tcc.ID --產品表  
WHERE o.CancelFlag = 0 AND o.CancelByABC = 0 AND bo.STATUS = 0 AND tcc.ValidFlag = 1 AND ct.ValidFlag = 1
	AND o.PTime IN ( '2018-03-01 12:00:00','2018-03-01 14:00:00','2018-03-01 16:00:00'
			,'2018-03-01 20:00:00','2018-03-01 21:00:00','2018-03-01 23:30:00' ,'2018-03-02 01:00:00' ) --查詢條件         
		AND bo.Scode IN (N'xxxx') --倉庫程式碼       
	GROUP BY d.PCode,d.OCode,bo.Scode,o.OrderNo,bo.BNo

Hash Warning解釋

    先來解釋一下Hash Warning,當優化器在執行查詢過程中,可能會遇到臨時儲存大量資料。在優化器優化過程中,以“預估”影響行數為開銷的主要依據。預估過程會粗略計算每個操作符所需的資源,包括CPU、記憶體空間等,如果預估發生錯誤,那麼就可能出現資源分配過多或者過少的情況。特別是記憶體資源(專業術語叫記憶體授予,memory grant),如果出現記憶體分配過少,有些操作就會拆分到磁碟上執行,此時記憶體中部分暫存的資料會移到TempDB上以便釋放記憶體容納更多的資料。當這部分在TempDB上的資料再次被需要時,就需要從磁碟讀取。

    很明顯,在TempDB中的資料使用會比在快取中慢得多。這種差距特別在ETL過程中,可能從原來的幾分鐘變成幾小時。


Hash Warning 事件

    在Hash過程中,當Build階段的輸入沒有足夠的可用記憶體時,會發生雜湊遞迴(Hash recursion),從而導致輸入拆分成多個獨立處理的分割槽。如果某些分割槽依舊沒有足夠的可用記憶體,就會再次以這種方式拆分到子分割槽。這個過程持續到每個分割槽都有足夠的可用記憶體或者已經達到最大的遞迴層級。
    這是最常見的一種拆分,優化器很鍾愛Hash join,因為在兩個未排序的資料集關聯時,Hash Join非常高效,同時也可以用於去重(如distinct)和分組聚集(group by)。


Hash warning常規處理

    Hash Join對於未排序的大資料量關聯是非常高效,但是它需要消耗大量的記憶體。而Hash 拆分通常意味著優化器預估不準確,這個又通常由缺少統計資訊或統計資訊過時導致。所以如果出現Hash Warning,首先應該更新統計資訊或建立缺失統計資訊。

    如果問題依舊,可能就要考慮採取其他Join型別,但是優化器通常選擇的Join演算法是最優的,所以此時你要思考“為什麼會選擇Hash Join”及“如何幫助優化器選擇其他型別的關聯演算法?”

    通俗地說:當關聯的兩表中,沒有可用的覆蓋索引或者一個大表與一個非常小的表關聯時,可能就會使用Hash Join。

    在SQL Server中,有三大類關聯演算法:Hash 、MergeLoop(巢狀迴圈) Join,關聯均涉及兩個資料集,稱為內表和外表,在圖形化執行計劃中,位於Join操作符的右上方的為外表(Outer table),右下方的為內表(Inner table)。這兩個術語在超連結中可能能幫得上忙。

    

實操

    前面粗略介紹了一下Hash Join和Hash Warning,下面來嘗試處理一下語句,由於表上索引過多,就不全部列出來。先來看原來的執行計劃,雖然警告出現在Hash Match,但是隻能說,這個是“結果”,那麼因果關係裡面的因才是我們需要關注的,即應該找到根源。根源往往來至於更加底層(也就是執行計劃操作符的右方),我們看看分別是一個巢狀迴圈操作符和一個索引查詢操作符。但是更常見的問題源自於直接訪問資料的操作符,即表掃描、索引/聚集索引掃描、索引/聚集索引查詢這類上面。所以繼續往右看,這個時候發現了一個業務熱點表的索引查詢。同時滑鼠移到箭頭上時發現實際行數和估計行數相差較大:    



    滑鼠移到這個索引查詢圖表上,可以看到下圖。由於分析過程資料在變動,所以截圖前後略有變動,那麼這個611是怎麼來的?同時圓框部分的“Predicate”和“Seek Predicate”又是什麼?

    簡單來說,Predicate是用於判斷表示式的布林值:TRUE/FALSE/NULL(UNKNOWN)也稱為條件表示式。通常用於WHERE、Having和Join中。比如select xxx from xxxx where id=5這種,這裡可能讀者會有點困惑:難道不是查詢id=5的值嗎?其實Predicate更準確來說是一個統稱,它還可以細分為:seek predicate和residual(殘留?殘餘?沒所謂了,就那麼個意思) predicate兩種,上圖中上方的“predicate”實際上就是residual predicate。Residual Predicate往往是一個隱藏的效能開銷,因為predicate需要對通過seek predicate查出來的資料再次進行表示式的二次校驗。

    知道了這些資訊之後,我首先想到的就是去除residual predicate,那怎麼去除?首先要知道為什麼出現。通過檢查操作符用到的索引,發現原索引是大概下面樣子:也可以用這個語句來:獲取索引定義

CREATE NONCLUSTERED INDEX [IX_XXX] ON [dbo].[XXX]
(
	[A] ASC,
	[B] ASC,
	[C] ASC,
	[D] ASC,
	[E] ASC,
	[F] ASC
)
INCLUDE ( 	[G],
	[H],
	[I])
    但是語句中用到了INCLUDE中的H(需要分別代表什麼無所謂了,重點看思路),另外C/D兩個在語句中用到了,通過這個索引相對的統計資訊(在表的“統計資訊”資料夾下可以找到)發現,如果按現有索引定義的順序,那麼語句中用到的非首列的統計資訊估算非常不準確,因此我嘗試修改索引列順序,但是保證首列不變,這個太重要了。通過修改了之後,再次執行,發現確實有效,結果如下圖:     

    residual predicate已經消失。不過經過對比,提升不大。然後我們繼續回到實際行數和預估行數差異大的問題上。首先實際行數中的值是怎麼來的,通過檢查語句,可以看到該表(目前我們只集中在一個表上)的篩選條件,拋開與其他表的關聯條件,可以看到符合where條件的行數剛好是10890行。


    接下來考慮一下611或者613行的預估數量怎麼來的?這個預估數量是從統計資訊來的。再回頭看看hash warning的產生條件。可以做一個大膽假設:問題應該是統計資訊出現了問題。

    為了驗證這個假設,我們檢查一下統計資訊,如下圖:


    這個時候我發現一個異常,因為之前統計表總行數的時候有75239行,但是這裡只有64348行。所以我認為統計資訊本身就不夠準確。

    這裡附上一個指令碼,查詢統計資訊的情況:

SELECT 
        OBJECT_NAME(stats.object_id) AS TableName, 
        stats.name AS StatisticsName,
        stats_properties.last_updated,
        stats_properties.rows_sampled, 
        stats_properties.rows, 
        stats_properties.unfiltered_rows,
        stats_properties.steps,
        stats_properties.modification_counter
FROM sys.stats stats
OUTER APPLY sys.dm_db_stats_properties(stats.object_id, stats.stats_id) as   stats_properties
WHERE OBJECT_NAME(stats.object_id) = 'XXX'--表名,如果不篩選則為全庫
ORDER BY modification_counter desc  ;
    手動更新統計資訊:
UPDATE STATISTICS  表 統計資訊名 WITH FULLSCAN  --注意表跟統計資訊中間是空格,不帶其他符號
    再次開啟可以看到已經準確了:


    另外通過檢查,原有語句中有兩個表僅用於篩選資料,並不會出現在SELECT和GROUP BY中,所以我把它們從JOIN中移到WHERE裡面,以EXISTS來改寫。發現單純通過改寫語句,Hash Match就不再出現,而是使用巢狀迴圈。把兩個語句放在一起並開啟實際執行計劃,發現改寫之後,開銷比原有語句降低:


    當然,這也或多或少歸功於統計資訊的更新,但是由於沒有做全庫備份,而且作為生產環境也不適合備份還原太頻繁,所以統計資訊是否有很大的影響,這裡無從得知,但是還是可以看出,改寫的力量。

    到現在為止,其實已經達到了目的,減少了Hash Warning,這個部分可能還可以進一步優化,但是可以看看總結裡面的結論,我認為問題並不在這裡,所以目前為止就不再花更多精力在Hash Warning上面。


總結

    文章很長,讀起來很費勁,正如我一邊研究一邊記錄和整理一樣,但是在這個過程中,我再次體會到一直依賴的一個原則:優化程式碼時,先檢查程式碼是否有改寫的空間。如果沒有,再去考慮索引/統計資訊,而不應該像很多人一樣一開始就盲目進入索引“優化”。

    除了改寫程式碼,還發現了統計資訊問題。我會找時間寫一篇統計資訊相關的內容,來思考為什麼在這裡會出現問題,因為系統每天重建一次索引,按道理這個索引相關的統計資訊應該是比較準確的,統計資訊的過快過時(out-date)我已經不是第一次遇到了。回到本文,我給這個問題的結論是:統計資訊的不準確,導致了優化器預估記憶體授予(memory grant )過少,導致hash 遞迴,拆分資料到TempDB進行運算,從而引發Hash Warning。

    分析執行計劃,除了找開銷最大的操作符,還要多看一下其源頭是否才是問題的根源。

    國外專家的常規建議是通過修改索引改寫程式碼來減緩甚至去掉hash warning。但是我認為,除了這兩點之外,統計資訊的問題也是一個因素。由於萬物互聯,沒有辦法簡單說明誰影響了誰,但是可以確定的是,索引跟統計資訊是密切關聯,索引設計不合理,統計資訊就容易出問題。但是如果統計資訊如果不準確,哪怕索引是合理的,優化器也可能不會選擇這個索引。

    簡化來說:Hash Warning→統計資訊不準確→索引不合理?統計資訊沒維護?統計資訊衰減過快?→檢查程式碼→檢查索引和統計資訊維護。除此之外,其他檢查也可以在無效時檢查一下:伺服器資源?伺服器資源配置?CPU配置等等。

    關於Hash warning的問題,可以看一下另外一篇關聯文章:為什麼SQL Server統計資訊過快過時?這個應該就是問題的根源。