執行計畫是理解查詢最佳化所做選擇的最佳窗口,JOIN 操作類型、使用到的索引、這些索引實際上如何被用,都會寫在這裡。
但有一點必須非常明確的說 : 執行計畫絕對不是效能本身的衡量指標。
SSMS 會顯示兩種不同類型的計畫 : 預估執行計畫與實際執行計畫
問題在於,這兩個名稱其實並不精確。嚴格來說,執行計畫只有一種。
實際執行計畫只是在原本的那份執行計畫上面,再加上查詢真的跑完之後收集到的數據。
我們會從各種來源取得執行計畫 :
DMVs
擴充事件
Query Store
就是我前面提到的三個我最常用的擷取方式,但不論是哪一種擷取方式,只要沒有發生重新編譯,擷取的計畫都會是相同的。
實際執行計畫額外加入的資訊非常有價值,通常都是 :
大說數時候,應該要盡量擷取包含這些資訊的實際執行計畫,這些額外資訊非常有幫助。
但是有例外,因為有些正式環境不一定會讓你真的去執行,所以你會拿不到實際執行計畫,這時候也不用排斥使用不包含執行階段指標的執行計劃來分析。
另外用 Query Store 取回計畫或是查詢計畫快取的時候,取得的計畫不會包含執行階段指標。
擷取有很多種方法,最簡單的方法就是直接用 SSMS 去取得。
但我們也可以用 DMV,直接從記憶體中的計劃快取取出查詢計劃。
如果有啟用 Query Store,那裏也會保存執行計劃。
擴充事件也可以擷取各種類型的執行計劃。
有幾個注意事項要先講 :
因此我建議養成習慣 : 要碼擷取執行計劃,要碼擷取執行階段指標,不要兩個同時做。
隨便跑個查詢可以去看看執行計畫,至於執行計畫那裏面是什麼東西等等再說
SELECT soh.SalesOrderNumber,
p.Name,
sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON sod.SalesOrderID = soh.SalesOrderID
JOIN Production.Product AS p
ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;

DECLARE @sql nvarchar(max) = N'SELECT soh.SalesOrderNumber,
p.Name,
sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON sod.SalesOrderID = soh.SalesOrderID
JOIN Production.Product AS p
ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;';
EXEC sys.sp_executesql @sql;
SELECT dest.text AS [查詢文字],
deqp.query_plan AS [執行計畫],
deqs.execution_count AS [執行次數],
deqs.total_elapsed_time AS [累計執行時間],
deqs.last_elapsed_time AS [上次執行時間]
FROM sys.dm_exec_query_stats AS deqs
CROSS APPLY sys.dm_exec_query_plan(deqs.plan_handle) AS deqp
CROSS APPLY sys.dm_exec_sql_text(deqs.sql_handle) AS dest
WHERE dest.text = @sql;
我用這種方式,是要確保只會擷取到這個查詢,查得快還省效能。
這時候執行計畫是個 XML 然後他只是執行計劃本身,不會有執行階段指標。
然後你可以點他,會帶到執行計畫的畫面,但是因為現在是簡單的查詢,XML 會有巢狀層級限制,某些非常大且複雜的查詢就會超過這個限制跑不出來。
然後還有一個方法可以用 DMV 來取得快取鍾某個查詢的最後一次實際執行計畫也就是 sys.dm_exec_query_plan_stats。
不過要用這個 DMV 的話要先啟用輕量級統計分析
ALTER DATABASE SCOPED CONFIGURATION SET
LAST_QUERY_PLAN_STATS = ON;
然後就一樣得用法
SELECT dest.text,
deqps.query_plan,
deqs.execution_count,
deqs.total_elapsed_time,
deqs.last_elapsed_time
FROM sys.dm_exec_query_stats AS deqs
CROSS APPLY
sys.dm_exec_query_plan_stats(deqs.plan_handle) AS deqps
CROSS APPLY
sys.dm_exec_sql_text(deqs.sql_handle) AS dest
WHERE dest.text LIKE 'SELECT soh.SalesOrderNumber,
p.Name,%';
但是這個用法不一定可以拿到實際的執行計畫,要有實際的執行計畫的先決條件是,這個 PLAN 還在 PLAN CACHE 裡。
這個主題比較大會再開一篇來寫
但這邊可以先快速看一下怎麼從 query store 取回執行計畫
SQL Server 2025 預設這個是啟用的,其他版本要去查一下,要用這個功能就一定要啟用
-- 啟用
ALTER DATABASE CURRENT SET QUERY_STORE = ON;
ALTER DATABASE CURRENT SET QUERY_STORE (
OPERATION_MODE = READ_WRITE,
QUERY_CAPTURE_MODE = ALL
);
SELECT qsq.query_id AS [查詢識別碼],
qsq.query_hash AS [查詢雜湊值],
CAST(qsp.query_plan AS XML) AS [執行計畫],
qsqt.query_sql_text AS [查詢文字]
FROM sys.query_store_query AS qsq
JOIN sys.query_store_plan AS qsp
ON qsp.query_id = qsq.query_id
JOIN sys.query_store_query_text AS qsqt
ON qsqt.query_text_id = qsq.query_text_id
WHERE qsqt.query_sql_text LIKE 'SELECT soh.SalesOrderNumber%';
另一種擷取執行計畫的方式,是使用 Extended Events。可以使用幾種不同的事件來擷取執行計畫:
query_post_compilation_showplan:在指定查詢完成編譯程序後發生。query_pre_execution_showplan:在查詢完成最佳化程序後發生。最佳化程序不同於編譯程序,而且在執行流程中發生於編譯之後。query_post_execution_plan_profile:SQL Server 2017 以上版本可用。它使用輕量級查詢分析程序,擷取執行計畫與執行階段指標。query_post_execution_showplan:在查詢執行完成後發生,因此可以同時擷取執行計畫與執行階段指標。用擴充事件去擷取執行計畫非常消耗資源,如果真的要用這個方法,要審慎使用並仔細篩選事件。
最常見的用途,通常是擷取編譯完成後的執行計畫,或者使用其中一種「執行後」方法,同時擷取執行計畫與執行階段指標。
如果可以的話,當要擷取執行階段指標時,建議使用輕量級查詢分析程序,以降低擷取執行計畫所帶來的額外負擔。
接下來要開始理解執行計畫裡面到底是什麼
執行計畫裡面的圖案,這個之後我都統稱運算子。
每個運算子下方會顯示該運算子的邏輯名稱,以及這個操作所代表的實體動作
接著,在每個運算子下方,還會顯示該操作的預估成本。這個成本是由查詢最佳化工具內部計算出來的,用來以抽象方式表示為了滿足查詢需求,預估需要使用多少 CPU、記憶體與 I/O 資源。
這個成本永遠都是預估值,絕對不是任何形式的實際量測值。即使是在包含執行階段指標的執行計畫中,成本也仍然是預估值。

像上面這個圖,20是估計資料列數、22是實際資料列數、0.021s 是花費時間
然後下面這張旁邊會有這種像管線的東西,帶有箭頭,這代表資料流動方向
他還有粗細之分,粗的代表資料流動大、細的就代表小。
滑鼠移動到管線或是運算子上面都可以看到額外詳細資訊。

右鍵運算子選屬性可以看到更多資訊
這很有用等等會說
從邏輯上來看,執行計畫的閱讀方式就像英文書一樣,從左到右。也就是說,計畫中的第一個運算子,是左上角的 SELECT 運算子。接著是第一個 Nested Loops 運算子,再來是第二個 Nested Loops 運算子,依此類推沿著整條線往右看。
第一個運算子其實不是 SELECT,嚴格來說那只是中繼資料
但創造另一個名詞會變得更難理解,所以我還是叫他運算子
至於真正第一運算子,有 NodeID ( 節點識別碼 ) 的,在這裡是最左邊的第一個巢狀迴圈

這種邏輯上的閱讀方式,反映的是查詢引擎內部初始化執行計畫的方式。每個運算子會依序向它後方的運算子要求資料,直到找到資料,並透過其他運算子一路回傳。
第二種是順著資料流來看,也就是跟第一種反著看,從最右邊、最上方的位置開始
在我們一直使用的範例中,這個起點是針對IX_SalesOrderHeader_CustomerID 索引的 Index Seek 運算子。

接著,資料會透過各個運算子往左流動。資料在運算子之間流動的方式,取決於處理模式。處理模式有兩種:row mode 與 batch mode。
row mode 會一次移動單一資料列
batch mode 一次移動一批資料
運算子的描述是不錯的一個理解運算子的起點
阿但是中文翻譯的通常都很爛,所以最好去查查或是問 AI。
例如說這個巢狀迴圈,他其實是 JOIN 運算子的一種,JOIN 還有其他三種;而但凡是 JOIN 的操作,就需要兩組資料輸入,所以他寫那個什麼每個頂端(外部)輸入資料,然後底部又輸入資料的意思是 :
上面那一路資料每來一筆,SQL Server 就拿這一筆去下面那一路資料找符合條件的資料,找到就輸出。
而且他還寫掃描,這很容易讓人誤會,那個不是執行計劃裏面的 SCAN,他是去下面那一列找資料的意思。
用中文的話就是這樣沒辦法,但現在有 AI 了,結圖問 AI 就好了。
從前面到這裡,已經知道怎麼擷取執行計畫、執行計劃裏面有什麼物件、執行計劃該怎麼閱讀
然後到這一步,你會發現執行計劃裏面有太多的運算子、太多的屬性,不可能全部看完。
當然現在有 AI 的話這是有機會的,但一樣再次強調,如果有機敏資料,不建議整包丟給 AI。
所以基於這些原因,我們通常都會從一些指標或線索開始看,我一般來說看執行計劃的時候會先看下列幾個項目 :
就是最左上角那個,他會包含關於執行計劃本身的中繼資料,但並不是所有計畫都有這個運算子。
至於為什麼第一步看這個呢,因為再他的屬性裡面可以看到很多東西
首先,快取的計劃大小,會顯示這份執行計畫在記憶體中占用的大小
QueryHash 可以視為某個查詢的指紋,可以用來搜尋相似的查詢。
QueryPlanHash 也是相同的概念。
MemoryGrantInfo 可以用來了解 SQL Server 認為這份計劃需要配置多少記憶體。
最後最下面有警告資訊
以上這些資訊都描述執行計劃如何被編譯、編譯時的設定,以及其他相關背景。
因此我第一個都從這裡開始看。
這是用來指出某些可能影響查詢效能的淺再問題。
他不一定自動代表真的有問題,是表示可能存在問題
我們一直以來用的那個範例警告,我在前面有說過那是什麼,簡而言之是 SQL Server 覺得轉換 convert 會對估計有影響,但其實在我們的查詢裡面是沒有影響的,所以那是個虛假的警告。
另外還有一種警告符號
--執行
SELECT pv.OnOrderQty,
a.City
FROM Purchasing.ProductVendor AS pv,
Person.Address AS a
WHERE a.City = 'Tulsa';

所以警告有兩種圖案 紅色叉叉 跟 黃色驚嘆號
但這沒什麼關係因為只要有警告,就去看他為什麼警告就好,不是說紅色黃色哪一個比較嚴重。
阿在這裡的紅色叉叉是因為我在寫 join 的時候故意不寫 join 條件,所以他給一個警告。
雖然這是估計,但這還是最佳化工具用來做決策的依據
在上一個例子中可以看到這一個運算子的成本是 91%,是整份執行計劃裏面成本最高的。
如果真的要調整,最可能優先關注的就是這個地方。
但是並不是說問題一定出在這裡,因為這都是估計。
由於管線代表資料移動,因此辨識出大量資料移動的位置,可以幫助我們解讀執行計畫,進而找出最可能造成效能問題的原因。
在查看管線時,另一個需要注意的重點,是資料流動的變化型態:它是從粗管線變成細管線,還是相反,從細管線逐漸變粗
管線變粗或變細,代表資料量在某個階段發生明顯變化。這個變化點值得去檢查,但不代表一定要改 SQL。
第一種情況,也就是粗管線變細,代表資料在較後面的階段才被過濾掉。這表示 SQL Server 前面可能先讀取或處理了大量資料,之後才把不需要的資料排除。這種情況下,索引可能會有幫助,或者也可能需要調整查詢寫法。
第二種情況,則是隨著查詢執行推進,資料量逐漸被放大。資料移動量增加,通常也代表更多 I/O 與更多記憶體資源被使用。這同樣可能是需要調整查詢寫法的位置。
情況 1 由粗變細 :
這代表前面處理很多資料,到後面才過濾掉
也就是說有很多資料其實一開始根本不用處理或是 SQL Server 太晚才把不需要的資料丟掉。
例如寫
WHERE YEAR( OrderDate ) = 2026
這超爛,會讓大量資料先被掃出來,然後丟要 year(),才過濾
改成
WHERE OrderDate >= '20260101'
AND OrderDate < '20270101'
這樣就會讓過濾提早發生,避免效能浪費
情況 2 由細變粗 :
這通常發生在 join,像是
Customer 1 筆
JOIN Order 100 筆
JOIN OrderDetail 1000 筆
這不一定有錯,因為資料本來就需要這些名細
但是要確保這個東西的確是你需要、必要的然後要記得
原則概念就是,JOIN TABLE 的資料筆數,再可以符合想要的結果的時候,資料數量越少越好。
如果你不知道某個運算子是什麼,那它就應該引起你的注意,因為理解它有助於你更清楚知道查詢是如何被處理的。
不理解為什麼某運算子會在這個情況下使用也應該要引起注意。
假設這兩個你不知道是什麼,滑鼠移動上去看可以看描述,還是不懂就 google 或問 AI。
這個計算純量是 SQL Server 再這個步驟中,根據資料列裡現有的值,計算出一個新的值。
因為我們的 範例語法是這樣
SELECT soh.SalesOrderNumber,
p.Name,
sod.OrderQty
FROM Sales.SalesOrderHeader AS soh
JOIN Sales.SalesOrderDetail AS sod
ON sod.SalesOrderID = soh.SalesOrderID
JOIN Production.Product AS p
ON p.ProductID = sod.ProductID
WHERE soh.CustomerID = 30052;
但在 AdventureWorks2022 裡,SalesOrderNumber 不是普通欄位,而是計算欄位。
所以 SQL Server 必須用這個計算純量的步驟來產生這個值。
把這個計算純量屬性打開來可以看到這個
Scalar Operator(
isnull(
N'SO' + CONVERT(nvarchar(23),
[AdventureWorks2022].[Sales].[SalesOrderHeader].[SalesOrderID]
as [soh].[SalesOrderID], 0),
N'*** ERROR ***'
)
)
這也是前面警告來源的原因
順帶一題由此可知,再建立 table 的時候,我不是很喜歡把運算這種東西當成一個 table 欄位,因為之後你每次用這 table 都會跑一次這個。
這是一個重要線索,原因是 : 他代表資料移動。
資料移動越多,就表示需要更多磁碟與記憶體存取,而這經常是造成效能問題的原因。因此,在執行計畫中尋找 Scan,是一種可以更快速理解問題可能位置的方法。
SELECT *
FROM Production.UnitMeasure AS um;
但是如果足夠理解什麼是索引,你很自然的就知道,上面這種查詢語法,唯一能滿足他的方式就是只有 SCAN,所以這時候你不能說什麼效能問題+索引,你要去改語法,除非你真的迫切需要 * 號。

再看一次這個運算子,再說一次預估資料是 20、實際資料是22、預估值是 110%
這個案例中,這算已經足夠接近,不需要特別擔心。
實際上通常要看到 300% 以上或反過來只有 20%以下才值得注意。
但從圖上看只會看到資料數量的預估差異,還有很多可以去屬性裡面看

例如說這個,估計重新繫結,估計是19.3468,而下面實際是0。
這種差異可能是各種效能問題的重要指標,因此我會把這類比較當作線索之一。
痾簡單說是 : 外層資料每換一個新的參數值,內層運算子就必須重新初始化、重新找資料,這個動作就叫 Rebind。
很複雜的說是巢狀迴圈、Spool、內部輸入重新執行有關的東西,這之後會寫。
以上這些線索看完之後,可以快速的找到執行計畫中可能存在的問題。
但是這些線索只能幫助我們走到某個程度。
簡單的線索判讀之後,剩下困難的部分,就要真正去理解執行計畫中正在發生什麼。
NodeID 可以讓我們知道運算子被初始化的順序,進而幫助我們理解查詢是如何被處理的。NodeID 值的參照,有助於理解整份執行計畫。