14.1. 善用 EXPLAIN

PostgreSQL 會為它收到的每個查詢設計一個查詢計劃。選擇正確的計劃以搭配查詢結構和資料屬性對於提高效能至關重要,所以系統包含一個複雜的計劃程序,試圖選擇好的計劃。您可以使用 EXPLAIN 指令查看計劃程序為每一個查詢所建立的查詢計劃。計劃內容閱讀是一門需要掌握一定經驗的藝術,但本部分主要涵蓋基本知識。

本節中的範例是在使用 9.3 開發原始碼進行 VACUUM ANALYZE 後進行迴歸測試資料庫中提取的。如果您自己嘗試這些範例,應該能夠獲得類似的結果,但您的估計成本和行數可能略有不同,因為 ANALYZE 的統計資訊是隨機樣本而不是精確的,而且因為成本本質上與平台有關。

這些範例使用 EXPLAIN 的預設「文字」輸出格式,該格式緊湊且便於人類閱讀。 如果要將 EXPLAIN 的輸出提供給程式以進行進一步分析,則應使用其機器可讀輸出格式之一(XML、JSON 或 YAML)。

14.1.1. EXPLAIN 基本概念

查詢計劃的結構是計劃節點樹。樹底層的節點是掃描節點:它們從資料表中回傳原始資料列。對於不同的資料表存取方法,存在不同類型的掃描節點:循序掃描、索引掃描和 bitmap 索引掃描。還有非資料表的來源,例如 VALUES 中的 VALUES 子句和 set-returns 函數,它們有自己的掃描節點類型。如果查詢需要對原始資料列進行交叉查詢、彙總、排序或其他操作,則掃描節點上方將有其他節點來執行這些操作。同樣,通常有多種可能的方法來執行這些操作,因此這裡也可以顯示不同的節點類型。EXPLAIN 的輸出對於計劃樹中的每個節點都有一行,顯示基本節點類型以及計劃程序為執行該計劃節點所做的成本估算。可能會顯示從節點的摘要行縮進的其他行,以顯示節點的其他屬性。第一行(最頂層節點的摘要行)具有計劃的估計總執行成本;計劃程序會試圖最小化這個數字。

這裡有一個簡單的例子,只是為了顯示輸出的樣子:

EXPLAIN SELECT * FROM tenk1;

                         QUERY PLAN
-------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..458.00 rows=10000 width=244)

由於此查詢沒有 WHERE 子句,因此它必須掃描資料表的所有資料列,因此規劃程序選擇使用簡單的循序掃描計劃。括號中引用的數字是(從左到右):

  • 估計的啟動成本。這是在輸出階段開始之前花費的時間,例如,在排序節點中進行排序的時間。

  • 估計總成本。這是在假設計劃節點執行完成,即檢索所有可用資料列的情況下評估的。實際上,節點的父節點可能會停止讀取所有可用的資料列(請參閱下面的 LIMIT 範例)。

  • 此計劃節點輸出的估計資料列數量。同樣地,假定節點完全執行。

  • 此計劃節點輸出的資料列估計的平均資料大小(以 byte 為單位)。

成本按照規劃程序的成本參數決定的各個單位計量(見第 19.7.2 節)。傳統做法是以磁碟頁面讀取為單位來衡量成本;也就是說,seq_page_cost 通常設定為 1.0,其他成本參數相對於此來設定。本節中的範例使用預設的成本參數進行。

很重要的是要了解上層節點的成本包括其所有子節點的成本。同樣重要的是要意識到成本只反映了計劃程序所關心的事情。特別是,成本不考慮將結果資料列傳輸到用戶端所花費的時間,這可能是實際經過時間的一個重要因素;但是計劃者忽略了它,因為它不能透過改變計劃來改善它。(我們相信,每個正確的計劃都會輸出相同的資料列集合。)

資料列的數目有點棘手,因為它不是計劃節點處理或掃描的數量,而是節點發出的資料列數量。這通常小於掃描的數量,這是透過在節點上套用的任何 WHHERE 子句條件進行過濾的結果。理想情況下,最上層級資料列數量估計值將近似於查詢實際回傳、更新或刪除的資料列數目。

回到我們的例子:

EXPLAIN SELECT * FROM tenk1;

                         QUERY PLAN
-------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..458.00 rows=10000 width=244)

這些數字非常直觀。如果你這樣做:

SELECT relpages, reltuples FROM pg_class WHERE relname = 'tenk1';

你會發現 tenk1 有 358 個磁碟頁面和 10000 個資料列。估計的成本計算為(磁碟頁讀取 _ * _seq_page_cost)+(資料列掃描 * cpu_tuple_cost)。預設的情況下,seq_page_cost 為 1.0,cpu_tuple_cost 為 0.01,因此估計成本為(358 * _ _1.0)+(10000 * 0.01)= 458。

現在讓我們修改查詢加入 WHERE 條件:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 7000;

                         QUERY PLAN
------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..483.00 rows=7001 width=244)
   Filter: (unique1 < 7000)

請注意,EXPLAIN 輸出顯示 WHERE 子句作為附加到 Seq Scan 計劃節點的「filter」條件應用。這意味著計劃節點檢查它掃描的每一筆資料的條件,並僅輸出通過該條件的那些資料列。由於 WHERE 子句,輸出資料列的估計已經減少。只是,掃描仍然需要讀取所有 10000 筆資料,因此成本並沒有降低;實際上它已經上升了一點(確切地說是 10000 * cpu_operator_cost)以反映檢查 WHERE 條件所花費的額外 CPU 時間。

此查詢將回傳的實際筆數為 7000,但筆數估計值僅為近似值。如果您嘗試複製此實驗,您可能會得到略微不同的估計;此外,它可能在每個 ANALYZE 指令之後改變,因為 ANALYZE 産成的統計訊息來自於資料表的隨機樣本。

現在,我們加上更多條件限制:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;

                                  QUERY PLAN
------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=5.07..229.20 rows=101 width=244)
   Recheck Cond: (unique1 < 100)
   ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
         Index Cond: (unique1 < 100)

規劃程序決定使用兩個步驟的計劃:子計劃節點讀取索引以查詢與索引條件匹配的資料列位置,然後上層計劃節點實際從資料表本身中提取這些資料列。單獨獲取資料列比按順序讀取它們要昂貴得多,但由於不是必須讀取該資料表的所有頁面,因此這仍然比循序掃描便宜。(使用兩個計劃層級的原因是上層計劃節點在讀取之前將索引標示的資料列位置排序為物理順序,以最小化單獨提取的成本。節點名稱中提到的「bitmap」是排序機制。)

現在讓我們為 WHERE 子句增加另一個條件:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND stringu1 = 'xxx';

                                  QUERY PLAN
------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=5.04..229.43 rows=1 width=244)
   Recheck Cond: (unique1 < 100)
   Filter: (stringu1 = 'xxx'::name)
   ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
         Index Cond: (unique1 < 100)

增加的條件 stringu1 ='xxx' 減少了輸出資料列數的估計,但不是成本,因為我們仍然必須讀取同一組資料列。請注意,stringu1 子句無法作為索引條件套用,因為此索引僅在 unique1 欄位上。而是將其作為過濾器套用於索引檢索的資料列。因此,成本實際上略有上升,以反映這種額外的檢查。

在某些情況下,規劃程序更喜歡「簡單」的索引掃描計劃:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 = 42;

                                 QUERY PLAN
-----------------------------------------------------------------------------
 Index Scan using tenk1_unique1 on tenk1  (cost=0.29..8.30 rows=1 width=244)
   Index Cond: (unique1 = 42)

在這種類型的計劃中,資料列按索引順序讀取,這使得它們讀取起來更加昂貴,但是很少有人覺得資料列位置進行排序的額外成本是不值得的。對於只獲取一個資料列的查詢,您通常會看到此計劃類型。它也經常用於具有與索引順序匹配的 ORDER BY 條件的查詢,因為這樣就不需要額外的排序步驟來滿足 ORDER BY。

如果在 WHERE 中引用的幾個欄位上有單獨的索引,則查詢規劃器可能會選擇使用索引的 AND 及 OR 組合:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;

                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=25.08..60.21 rows=10 width=244)
   Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
   ->  BitmapAnd  (cost=25.08..25.08 rows=10 width=0)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
               Index Cond: (unique1 < 100)
         ->  Bitmap Index Scan on tenk1_unique2  (cost=0.00..19.78 rows=999 width=0)
               Index Cond: (unique2 > 9000)

但這需要存取兩個索引,因此與僅使用一個索引並將另一個條件視為過濾器相比,它不一定更好。如果您改變所涉及的範圍,您將看到相應的計劃變更。

以下是顯示 LIMIT 效果的範例:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;

                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Limit  (cost=0.29..14.48 rows=2 width=244)
   ->  Index Scan using tenk1_unique2 on tenk1  (cost=0.29..71.27 rows=10 width=244)
         Index Cond: (unique2 > 9000)
         Filter: (unique1 < 100)

這是與上面相同的查詢,但是我們增加了一個 LIMIT,以便不需要檢索所有資料列,並且計劃程序改變了想要做什麼的想法。請注意,「索引掃描」節點的總成本和資料列數量顯示為執行完成。但是,限制節點預計僅檢索這些資料列中的五分之一後停止,因此其總成本僅為五分之一,這是查詢的實際估計成本。此計劃優於將 Limit 節點加到上一個計劃,因為 Limit 無法避免支付 bitmap 掃描的啟動成本,因此使用該方法的總成本將超過 25 個單位。

讓我們嘗試使用我們一直在討論的欄位來交叉查詢兩個資料表:

EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;

                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Nested Loop  (cost=4.65..118.62 rows=10 width=488)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.47 rows=10 width=244)
         Recheck Cond: (unique1 < 10)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0)
               Index Cond: (unique1 < 10)
   ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..7.91 rows=1 width=244)
         Index Cond: (unique2 = t1.unique2)

在此計劃中,我們有一個巢狀循環的交叉查詢節點,其中有兩個資料表掃描作為輸入或子節點。節點摘要行的縮進反映了計劃樹狀結構。交叉查詢的第一個或「外部」子節點是一個類似於我們之前看到的 bitmap 掃描。它的成本和行數與我們從SELECT ... WHERE unique1 <10 得到的相同,因為我們在該節點上使用了WHERE 子句 unique1 <10。 t1.unique2 = t2.unique2 子句尚未相關,因此它不會影響外部掃描的行數。巢狀循環交叉查詢節點將為從外部子節點獲取的每一行運行其第二個或“內部”子節點一次。來自當下外部交叉查詢資料列的欄位值可以插入內部掃瞄;在這裡,來自外部交叉查詢資料列的 t1.unique2 值是可用的,因此我們得到一個類似於我們在上面看到的簡單「SELECT ... WHERE t2.unique2 = 常數」的情況。 (估計的成本實際上比上面看到的要低一些,因為在 t2 上重複索引掃描期間預計會發生快取。)然後根據成本確定循環節點的成本。外部交叉查詢掃描,每個外部交叉查詢資料列重複一次內部交叉查詢掃描(此處為10 * 7.91),加上一點 CPU 時間進行交叉查詢處理。

在此範例中,交叉查詢的輸出資料列計數與兩個掃描的資料列計數的乘積相同,但在所有情況下都不是這樣,因為可以有其他 WHERE 子句提及兩個資料表,因此只會用於交叉查詢的節點,不論其他輸入任何掃描。這是一個例子:

EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t2.unique2 < 10 AND t1.hundred < t2.hundred;

                                         QUERY PLAN
---------------------------------------------------------------------------------------------
 Nested Loop  (cost=4.65..49.46 rows=33 width=488)
   Join Filter: (t1.hundred < t2.hundred)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=4.36..39.47 rows=10 width=244)
         Recheck Cond: (unique1 < 10)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..4.36 rows=10 width=0)
               Index Cond: (unique1 < 10)
   ->  Materialize  (cost=0.29..8.51 rows=10 width=244)
         ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.29..8.46 rows=10 width=244)
               Index Cond: (unique2 < 10)

條件 t1.hundred < t2.hundred 無法在 tenk2_unique2 索引中進行測試,因此它套用於 join 節點。這會減少連接節點的估計輸出資料列數,但不會更改任何輸入掃描。

請注意,此處規劃程序已選擇透過在其上放置 Materialize 計劃節點來「具體化」交叉查詢的內部關係。這意味著 t2 索引掃描將只執行一次,即使 nested-loop join 節點需要讀取該資料十次,對於來自外部關係的每一筆資料一次。Materialize 節點在讀取資料時將資料保存在記憶體中,然後在每次後續傳遞時從記憶體中回傳資料。

處理外部交叉查詢時,您可能會看到連接計劃節點同時附加了「Join Filter」和簡單的「Filter」情境。 Join Filter 情境來自外部交叉查詢的 ON 子句,因此交叉查詢過濾條件失敗的資料列仍然可以作為 null-extended 資料列發出。但是在外部交叉查詢之後套用了一個普通的 Filter 條件,因此可以無條件地刪除資料列。在內部交叉查詢中,這些類型的過濾程序之間沒有實質差異。

如果我們稍微改變查詢的過濾條件,我們可能會得到一個非常不同的交叉查詢計劃:

EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Hash Join  (cost=230.47..713.98 rows=101 width=488)
   Hash Cond: (t2.unique2 = t1.unique2)
   ->  Seq Scan on tenk2 t2  (cost=0.00..445.00 rows=10000 width=244)
   ->  Hash  (cost=229.20..229.20 rows=101 width=244)
         ->  Bitmap Heap Scan on tenk1 t1  (cost=5.07..229.20 rows=101 width=244)
               Recheck Cond: (unique1 < 100)
               ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..5.04 rows=101 width=0)
                     Index Cond: (unique1 < 100)

這裡,規劃程序選擇使用 hash 交叉查詢,其中一個資料表的資料列被輸入到記憶體中的 hash 資料表中,之後掃描另一個資料表並且檢查 hash 資料表以匹配每一筆資料。再次注意,縮排如何反映計劃結構:tenk1 上的 bitmap 掃描是 hash 節點的輸入,它建構 hash 資料表。然後返回到 Hash Join 節點,該節點從其外部子計劃中讀取資料列並在 hash 資料表中搜索每一筆資料。

另一種可能的交叉查詢類型是 merge join,如下所示:

EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Merge Join  (cost=198.11..268.19 rows=10 width=488)
   Merge Cond: (t1.unique2 = t2.unique2)
   ->  Index Scan using tenk1_unique2 on tenk1 t1  (cost=0.29..656.28 rows=101 width=244)
         Filter: (unique1 < 100)
   ->  Sort  (cost=197.83..200.33 rows=1000 width=244)
         Sort Key: t2.unique2
         ->  Seq Scan on onek t2  (cost=0.00..148.00 rows=1000 width=244)

合併交叉查詢要求其輸入資料在交叉查詢主鍵上排序。在此計劃中,tenk1 資料使用索引掃描進行排序,以正確的順序存取資料列,但是對於 onek,偏好順序掃描和排序,因為在該資料表中要存取的筆數要多得多。(循序掃描和排序經常擊敗索引掃描以排序多筆資料,因為索引掃描需要非循序磁碟存取。)

查看變形計劃的一種方法是使用第 19.7.1 節中描述的啟用/禁用旗標強制規劃程予忽略它認為最便宜的策略。(這是一個粗略的工具,但很有用。另請參閱第 14.3 節。)例如,如果我們不相信循序掃描和排序是在前一個範例中處理資料表 onek 的最佳方法,我們可以嘗試

SET enable_sort = off;

EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Merge Join  (cost=0.56..292.65 rows=10 width=488)
   Merge Cond: (t1.unique2 = t2.unique2)
   ->  Index Scan using tenk1_unique2 on tenk1 t1  (cost=0.29..656.28 rows=101 width=244)