1. 程式人生 > >SQL Server Table Spool優化

SQL Server Table Spool優化

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



    在執行計劃中出現的Spool操作符,往往都具有明顯的效能問題,也意味著資料庫的設計、編碼等可能存在問題,所以本文專門介紹一下這個操作符。


   

Spool介紹

    Spool是記憶體或者磁碟上的快取(cache)或臨時表。SQL Server用這個結構來提升在執行過程中需要多次執行的複雜的子表示式的效能。注意幾個次:一次執行中多次執行、複雜的子表示式。其目的是為了提升效能。

    比如下面的一個演示語句,使用TempDB來建立一個測試表:

USE TempDB
GO
CREATE TABLE dbo.Orders (
	OrderID INT NOT NULL
	,CustomerId INT NOT NULL
	,Total MONEY NOT NULL
	,CONSTRAINT PK_Orders PRIMARY KEY CLUSTERED (OrderID)
	)
GO
;WITH N1 (C)AS (
	SELECT 0	
	UNION ALL	
	SELECT 0
	) -- 2 行
,N2 (C)AS (
	SELECT 0
	FROM N1 AS T1 CROSS JOIN N1 AS T2
	) -- 4 行
,N3 (C) AS (
	SELECT 0
	FROM N2 AS T1
	CROSS JOIN N2 AS T2
	) -- 16 行
,N4 (C)AS (
	SELECT 0
	FROM N3 AS T1
	CROSS JOIN N3 AS T2
	) -- 256 行
,Nums (Num)
AS (
	SELECT row_number() OVER ( ORDER BY ( SELECT NULL ) )
	FROM N4
	)
INSERT INTO dbo.Orders (OrderId ,CustomerId ,Total )
SELECT Num,Num % 10 + 1 ,Num
FROM Nums;

    然後開啟實際執行並執行下面查詢,返回訂單資訊及每個客戶的總銷售額。

USE TempDB
GO
select OrderId, CustomerID, Total
,Sum(Total) over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders

    可以看到如下結果:


    在圖中可以看到,SQL Server對Order 表進行了掃描,並且基於CustomerID進行排序。然後使用Table Spool對結果進行快取。使得後續操作(比如計算總數)中可以直接訪問這些快取資料,同時由於快取值已經排序,可以避免二次排序。

    如果檢查執行計劃中的3個Table Spool,如下圖,可以看到輸出列表是一樣的。說明一個事情,雖然在執行計劃中出現了3次Table Spool,但是它們實際上是相同的Spool/cache,SQL Server只是建立一次並一直使用而已。下圖中只有“節點ID=1”的那個是第一個建立的Table Spool,也就是執行計劃中最上方的那個,而右下角那兩個均有“主節點ID=1”表明是引用節點ID=1的那個Table Spool。




    從技術上細分,spool操作符有兩種:Eager Spool(本文第一張圖)和Lazy Spool(上面演示案例),它們的區別只是在於填充資料的方式:

  • Eager Spool:在spool被呼叫的同時檢索所有資料。
  • Lazy Spool:按需檢索資料。

    其他不常見的Spool 操作符還有:Row Count Spool、Non-Clustered Index Spool。

題外話:SQL Server還使用Spool來實現“Halloween Protection”,簡單地說,就是準備要修改的資料的位置變動了。這種低概率事件在作者工作過程中也確實出現過,一般使用事務控制來避免。這個是一個計算機領域的問題,並非SQL Server獨有,由IBM工程師首先發現,大部分成熟的RDBMS都已經很大程度地避免。至於SQL Server相關內容,可以詳見MSDN部落格:Halloween Protection

下面來簡要分析一下上面的執行計劃,為了描述方便,這裡按照執行計劃順序對操作符進行標號:


First Step:

Step 1:從Orders表中,通過聚集索引(因為表只有一個聚集索引)掃描,讀取所有的OrderId, CustomerID, Total資訊。

Step 2:由於需要根據CustomeID進行分組並使用視窗函式計算(sum() over()),而且CustomerID並沒有在聚集索引鍵上,所以需要進行額外排序。

Step 3:Segment操作,把資料拆成多組,因為視窗函式需要對CustomerID進行分組。

Step 4:Table Spool,並且是Lazy Spool,這個操作在TempDB上建立一個臨時表,並把segment操作基於不同的組返回的資料儲存到這個臨時表中。


Second Step:

Step 1:複用前面生成的Table Spool。

Step 2:對Table Spool的資料,按分組使用流聚合操作符彙總資料,本例中的Sum,按分組計算Total的值,並返回“一行”作為輸出。

Step 3:對上一步的結果集進行計算,如果對XML執行計劃查詢“Compute Scalar”關鍵字,可以看到它在進行一個case when操作:<ScalarOperator ScalarString="CASE WHEN [Expr1004]=(0) THEN NULL ELSE [Expr1005] END">

Step 4:計算標量操作符得到的資料,使用巢狀迴圈操作符,與Third Step中的Table Spool再次匹配,返回符合條件的結果集。

Step 5:把最終的兩個結果集再次使用巢狀迴圈操作,一行一行地匹配。

 Spool 影響

    Spool的初衷是好的,但是它通常又涉及了worktable,使用“set statistics io on/off”命令包住需要執行的語句就可以發現。


    Worktable在OLTP系統中,意味著使用了低效的I/O操作,比如TempDB(簡單來說就是磁碟讀寫)來運算。

    另外,對於Eager Spool,第一次從結果中查詢到所需的資料並放入TempDB之後,後續使用直接從Spool中獲取而不通過表上原有的索引,如果Spool資料集過大,是非常低效的。

    還有,由於spool實際上是複製一份資料的副本儲存在TempDB中,所以空間問題可能會加重。


優化Spool

    從上面一系列例子中,我們得知首先由於語句進行復雜運算,同時需要多次呼叫,引出了Spool以便優化,為了優化上面的語句(首先建議先記錄下一些資訊,比如Set Statistics IO 的資訊),我們從執行計劃著手,首先找開銷最大的部分,即“sort”操作符。從說明上我們可以看到這個操作符是為了對CustomerID排序。從表定義可知這個表只有一個聚集索引。那麼下面我們來對CustomerID加一個非聚集索引,由於SELECT中使用到了Total,所以把total加入Include列。




CREATE INDEX IX_Orders_CustomerID on Orders(CustomerID) INCLUDE(total)

    再次執行語句,這次貌似不錯,排序操作符不見了,但是依舊還存在spool,而且從IO統計來看,數值一樣。感覺還是不夠完美。


    但是在多次嘗試之後,我發現很多方式反而不如這個高效,因此我用上面建立測試環境的語句再建立了一個orders1表,也 就是說我複製了一份環境,然後做對比:

USE TempDB
GO

set statistics io on
select OrderId, CustomerID, Total
,Sum(Total) over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders


select OrderId, CustomerID, Total
,Sum(Total) over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders1
set statistics io off

    開啟執行計劃,然後一次性執行上面語句,這種方式可以用於對比兩個語句的開銷情況,開銷百分比小的,意味著在大部分情況下更佳。下面是本機的執行情況:


    可以看到,orders表也就是按照上面方式優化了的表,跟orders1表(原始環境)對比,分別是27%和73%,而I/O數值來看是一模一樣的:


    那麼有理由相信,即使存在spool操作,也並不一定是低效的。因為我通過改寫語句成下面這種方式來去除了spool,但是放在一起執行時發現不如上面沒改寫僅調整索引的語句:

set statistics io on
select OrderId, CustomerID, Total,
(select Sum(Total)  from orders b where a.CustomerId=b.CustomerId)--over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders a

set statistics io off

    為了避免讀者認為orders是調整過索引的寫法,我再次使用orders1表,三個語句放在一次執行,注意這裡orders1是原始環境:

USE TempDB
GO

set statistics io on
select OrderId, CustomerID, Total
,Sum(Total) over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders


select OrderId, CustomerID, Total
,Sum(Total) over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders1

select OrderId, CustomerID, Total,
(select Sum(Total)  from orders1 b where a.CustomerId=b.CustomerId)--over(partition by CustomerID) as [Total Customer Sales] 
from dbo.Orders1 a
set statistics io off

得到的結果:



總結

    在這篇文章中,我得到幾個結論:

1. 不能僅靠單一“值”來判斷,在I/O統計數值相等的情況下,可以考慮借用其他手段來評估優化的方案。

2. 我一直跟很多人強調,改索引之前,先檢查語句是否可以改寫。上面我已經嘗試了改寫,但是目前就個人水平而言,還沒有發現單純通過改寫就可以提高這個例子的方法。所以後面才考慮修改索引。

3. 我以前看書的時候看過很多案例,視窗函式在很多環境下,確實極大地提高效率,所以建議SQL Server從業人員也可以優先考慮一下視窗函式。

4. 大膽假設小心求證。

另外,對於Spool的優化,通常確實是通過優化索引來減緩或去除。不過凡是無絕對,多看看具體問題最重要。