背景
在使用數據庫時,經常會有開發者有這樣的疑問:“我的表對應字段已經創建了索引,為什么這個SQL 語句執行還是這么慢?” 雖然數據庫SQL 執行慢有很多原因,但是對於PostgreSQL DBA 來說,好像有個共識,遇到用戶慢SQL優化的問題,先拿EXPLAIN 命令查看下對應的查詢計划,從而可以快速定位慢在哪里。這就引出了本文的主角—PostgreSQL 的EXPLAIN 命令。
EXPLAIN 語法
在PostgreSQL 中,EXPLAIN 命令可以輸出SQL 語句的查詢計划,具體語法如下:
EXPLAIN [ ( option [, ...] ) ] statement EXPLAIN [ ANALYZE ] [ VERBOSE ] statement where option can be one of: ANALYZE [ boolean ] VERBOSE [ boolean ] COSTS [ boolean ] BUFFERS [ boolean ] TIMING [ boolean ] SUMMARY [ boolean ] FORMAT { TEXT | XML | JSON | YAML }
其中:
- ANALYZE 選項為TRUE 會實際執行SQL,並獲得相應的查詢計划,默認為FALSE。如果優化一些修改數據的SQL 需要真實的執行但是不能影響現有的數據,可以放在一個事務中,分析完成后可以直接回滾。
- VERBOSE 選項為TRUE 會顯示查詢計划的附加信息,默認為FALSE。附加信息包括查詢計划中每個節點(后面具體解釋節點的含義)輸出的列(Output),表的SCHEMA 信息,函數的SCHEMA 信息,表達式中列所屬表的別名,被觸發的觸發器名稱等。
- COSTS 選項為TRUE 會顯示每個計划節點的預估啟動代價(找到第一個符合條件的結果的代價)和總代價,以及預估行數和每行寬度,默認為TRUE。
- BUFFERS 選項為TRUE 會顯示關於緩存的使用信息,默認為FALSE。該參數只能與ANALYZE 參數一起使用。緩沖區信息包括共享塊(常規表或者索引塊)、本地塊(臨時表或者索引塊)和臨時塊(排序或者哈希等涉及到的短期存在的數據塊)的命中塊數,更新塊數,擠出塊數。
- TIMING 選項為TRUE 會顯示每個計划節點的實際啟動時間和總的執行時間,默認為TRUE。該參數只能與ANALYZE 參數一起使用。因為對於一些系統來說,獲取系統時間需要比較大的代價,如果只需要准確的返回行數,而不需要准確的時間,可以把該參數關閉。
- SUMMARY 選項為TRUE 會在查詢計划后面輸出總結信息,例如查詢計划生成的時間和查詢計划執行的時間。當ANALYZE 選項打開時,它默認為TRUE。
- FORMAT 指定輸出格式,默認為TEXT。各個格式輸出的內容都是相同的,其中XML | JSON | YAML 更有利於我們通過程序解析SQL 語句的查詢計划,為了更有利於閱讀,我們下文的例子都是使用TEXT 格式的輸出結果。
EXPLAIN 的輸出結構
閱讀到這里,我們已經知道了如何使用EXPLAIN 命令。接下來,我們將學習如何理解EXPLAIN 的輸出,從而快速地定位問題。
以下面的輸出為例(該例子選自PostgreSQL官方文檔),我們來分析下EXPLAIN 命令輸出的結構:
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------------------- Sort (cost=717.34..717.59 rows=101 width=488) (actual time=7.761..7.774 rows=100 loops=1) Sort Key: t1.fivethous Sort Method: quicksort Memory: 77kB -> Hash Join (cost=230.47..713.98 rows=101 width=488) (actual time=0.711..7.427 rows=100 loops=1) Hash Cond: (t2.unique2 = t1.unique2) -> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244) (actual time=0.007..2.583 rows=10000 loops=1) -> Hash (cost=229.20..229.20 rows=101 width=244) (actual time=0.659..0.659 rows=100 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 28kB -> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244) (actual time=0.080..0.526 rows=100 loops=1) Recheck Cond: (unique1 < 100) -> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0) (actual time=0.049..0.049 rows=100 loops=1) Index Cond: (unique1 < 100) Planning time: 0.194 ms Execution time: 8.008 ms
從上面的例子來看,EXPLAIN 命令的輸出可以看做是一個樹形結構,我們稱之為查詢計划樹,樹的每個節點包括對應的節點類型,作用對象以及其他屬性例如cost、rows、width 等。如果只顯示節點類型,上面的例子可以簡化為如下結構:
Sort └── Hash Join ├── Seq Scan └── Hash └── Bitmap Heap Scan └── Bitmap Index Scan
為了幫助大家更好地理解,我們簡要介紹下PostgreSQL中SQL 執行的一些特點:
- 按照查詢計划樹從底往上執行
- 基於火山模型(參考文檔火山模型介紹)執行,即可以簡單理解為每個節點執行返回一行記錄給父節點(Bitmap Index Scan 除外)
在了解了這些特點之后,我們會對EXPLAIN 命令的輸出有一個整體結構的概念。其實EXPLAIN 輸出的就是一個用戶可視化的查詢計划樹,可以告訴我們執行了哪些節點(操作),並且每個節點(操作)的代價預估是什么樣的。接下來,我們將詳細去闡述每個節點的類型和具體干了哪些事情。
節點類型
在EXPLAIN 命令的輸出結果中可能包含多種類型的執行節點,我們可以大體分為幾大類(參考彭智勇、彭煜瑋編著的《PostgreSQL 數據庫內核分析》):
- 控制節點(Control Node)
- 掃描節點(ScanNode)
- 物化節點(Materialization Node)
- 連接節點(Join Node)
為了更有針對性,本文只介紹掃描節點。
掃描節點
掃描節點,簡單來說就是為了掃描表的元組,每次獲取一條元組(Bitmap Index Scan除外)作為上層節點的輸入。當然嚴格的說,不光可以掃描表,還可以掃描函數的結果集、鏈表結構、子查詢結果集等。
目前在PostgreSQL 中支持:
- Seq Scan,順序掃描
- Index Scan,基於索引掃描,但不只是返回索引列的值
- IndexOnly Scan,基於索引掃描,並且只返回索引列的值,簡稱為覆蓋索引
- BitmapIndex Scan,利用Bitmap 結構掃描
- BitmapHeap Scan,把BitmapIndex Scan 返回的Bitmap 結構轉換為元組結構
- Tid Scan,用於掃描一個元組TID 數組
- Subquery Scan,掃描一個子查詢
- Function Scan,處理含有函數的掃描
- TableFunc Scan,處理tablefunc 相關的掃描
- Values Scan,用於掃描Values 鏈表的掃描
- Cte Scan,用於掃描WITH 字句的結果集
- NamedTuplestore Scan,用於某些命名的結果集的掃描
- WorkTable Scan,用於掃描Recursive Union 的中間數據
- Foreign Scan,用於外鍵掃描
- Custom Scan,用於用戶自定義的掃描
其中,我們將重點介紹最常用的幾個:Seq Scan、Index Scan、IndexOnly Scan、BitmapIndex Scan、BitmapHeap Scan。
Seq Scan
Seq Scan 是全表順序掃描,一般查詢沒有索引的表需要全表順序掃描,例如下面的EXPLAIN 輸出:
postgres=> explain(ANALYZE,VERBOSE,BUFFERS) select * from class where st_no=2; QUERY PLAN -------------------------------------------------------------------------------------------------------- Seq Scan on public.class (cost=0.00..26.00 rows=1 width=35) (actual time=0.136..0.141 rows=1 loops=1) Output: st_no, name Filter: (class.st_no = 2) Rows Removed by Filter: 1199 Buffers: shared hit=11 Planning time: 0.066 ms Execution time: 0.160 ms
其中:
- Seq Scan on public.class 表明了這個節點的類型和作用對象,即在class 表上進行了全表掃描
- (cost=0.00..26.00 rows=1 width=35) 表明了這個節點的代價估計,這部分我們將在下文節點代價估計信息中詳細介紹
- (actual time=0.136..0.141 rows=1 loops=1) 表明了這個節點的真實執行信息,當EXPLAIN 命令中的ANALYZE選項為on時,會輸出該項內容,具體的含義我們將在下文節點執行信息中詳細介紹
- Output: st_no, name 表明了SQL 的輸出結果集的各個列,當EXPLAIN 命令中的選項VERBOSE 為on時才會顯示
- Filter: (class.st_no = 2) 表明了Seq Scan 節點之上的Filter 操作,即全表掃描時對每行記錄進行過濾操作,過濾條件為class.st_no = 2
- Rows Removed by Filter: 1199 表明了過濾操作過濾了多少行記錄,屬於Seq Scan 節點的VERBOSE 信息,只有EXPLAIN 命令中的VERBOSE 選項為on 時才會顯示
- Buffers: shared hit=11 表明了從共享緩存中命中了11 個BLOCK,屬於Seq Scan 節點的BUFFERS 信息,只有EXPLAIN 命令中的BUFFERS 選項為on 時才會顯示
- Planning time: 0.066 ms 表明了生成查詢計划的時間
- Execution time: 0.160 ms 表明了實際的SQL 執行時間,其中不包括查詢計划的生成時間
Index Scan
Index Scan 是索引掃描,主要用來在WHERE 條件中存在索引列時的掃描,如上面Seq Scan 中的查詢如果在st_no 上創建索引,則EXPLAIN 輸出如下:
postgres=> explain(ANALYZE,VERBOSE,BUFFERS) select * from class where st_no=2; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------ Index Scan using no_index on public.class (cost=0.28..8.29 rows=1 width=35) (actual time=0.022..0.023 rows=1 loops=1) Output: st_no, name Index Cond: (class.st_no = 2) Buffers: shared hit=3 Planning time: 0.119 ms Execution time: 0.060 ms (6 rows)
其中:
- Index Scan using no_index on public.class 表明是使用的public.class 表的no_index 索引對表進行索引掃描的
- Index Cond: (class.st_no = 2) 表明索引掃描的條件是class.st_no = 2
可以看出,使用了索引之后,對相同表的相同條件的掃描速度變快了。這是因為從全表掃描變為索引掃描,通過Buffers: shared hit=3 可以看出,需要掃描的BLOCK(或者說元組)少了,所以需要的代價也就小了,速度也就快了。
IndexOnly Scan
IndexOnly Scan 是覆蓋索引掃描,所需的返回結果能被所掃描的索引全部覆蓋,例如上面Index Scan中的SQL 把“select * ” 修改為“select st_no” ,其EXPLAIN 結果輸出如下:
postgres=> explain(ANALYZE,VERBOSE,BUFFERS) select st_no from class where st_no=2; QUERY PLAN ---------------------------------------------------------------------------------------------------------------------------- Index Only Scan using no_index on public.class (cost=0.28..4.29 rows=1 width=4) (actual time=0.015..0.016 rows=1 loops=1) Output: st_no Index Cond: (class.st_no = 2) Heap Fetches: 0 Buffers: shared hit=3 Planning time: 0.058 ms Execution time: 0.036 ms (7 rows)
其中:
- Index Only Scan using no_index on public.class 表明使用public.class 表的no_index 索引對表進行覆蓋索引掃描
- Heap Fetches 表明需要掃描數據塊的個數。
雖然Index Only Scan 可以從索引直接輸出結果。但是因為PostgreSQL MVCC 機制的實現,需要對掃描的元組進行可見性判斷,即檢查visibility MAP 文件。當新建表之后,如果沒有進行過vacuum和autovacuum操作,這時還沒有VM文件,而索引並沒有保存記錄的版本信息,索引Index Only Scan 還是需要掃描數據塊(Heap Fetches 代表需要掃描的數據塊個數)來獲取版本信息,這個時候可能會比Index Scan 慢。
BitmapIndex Scan 與BitmapHeap Scan
BitmapIndex Scan 與Index Scan 很相似,都是基於索引的掃描,但是BitmapIndex Scan 節點每次執行返回的是一個位圖而不是一個元組,其中位圖中每位代表了一個掃描到的數據塊。而BitmapHeap Scan一般會作為BitmapIndex Scan 的父節點,將BitmapIndex Scan 返回的位圖轉換為對應的元組。這樣做最大的好處就是把Index Scan 的隨機讀轉換成了按照數據塊的物理順序讀取,在數據量比較大的時候,這會大大提升掃描的性能。
我們可以運行set enable_indexscan =off; 來指定關閉Index Scan ,上文中Index Scan 中SQL 的EXPLAIN 輸出結果則變為:
postgres=> explain(ANALYZE,VERBOSE,BUFFERS) select * from class where st_no=2; QUERY PLAN ----------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on public.class (cost=4.29..8.30 rows=1 width=35) (actual time=0.025..0.025 rows=1 loops=1) Output: st_no, name Recheck Cond: (class.st_no = 2) Heap Blocks: exact=1 Buffers: shared hit=3 -> Bitmap Index Scan on no_index (cost=0.00..4.29 rows=1 width=0) (actual time=0.019..0.019 rows=1 loops=1) Index Cond: (class.st_no = 2) Buffers: shared hit=2 Planning time: 0.088 ms Execution time: 0.063 ms (10 rows)
其中:
- Bitmap Index Scan on no_index 表明使用no_index 索引進行位圖索引掃描
- Index Cond: (class.st_no = 2) 表明位圖索引的條件為class.st_no = 2
- Bitmap Heap Scan on public.class 表明對public.class 表進行Bitmap Heap 掃描
- Recheck Cond: (class.st_no = 2) 表明Bitmap Heap Scan 的Recheck操作 的條件是class.st_no = 2,這是因為Bitmap Index Scan 節點返回的是位圖,位圖中每位代表了一個掃描到的數據塊,通過位圖可以定位到一些符合條件的數據塊(這里是3,Buffers: shared hit=3),而Bitmap Heap Scan 則需要對每個數據塊的元組進行Recheck
- Heap Blocks: exact=1 表明准確掃描到數據塊的個數是1
至此,我們對這幾種主要的掃描節點有了一些認識。一般來說:
- 大多數情況下,Index Scan 要比 Seq Scan 快。但是如果獲取的結果集占所有數據的比重很大時,這時Index Scan 因為要先掃描索引再讀表數據反而不如直接全表掃描來的快。
- 如果獲取的結果集的占比比較小,但是元組數很多時,可能Bitmap Index Scan 的性能要比Index Scan 好。
- 如果獲取的結果集能夠被索引覆蓋,則Index Only Scan 因為不用去讀數據,只掃描索引,性能一般最好。但是如果VM 文件未生成,可能性能就會比Index Scan 要差。
上面的結論都是基於理論分析得到的結果,但是其實PostgreSQL 的EXPLAIN 命令中輸出的cost,rows,width 等代價估計信息中已經展示了這些掃描節點或者其他節點的預估代價,通過對預估代價的比較,可以選擇出最小代價的查詢計划樹。
代價估計信息
從上文可知,EXPLAIN 命令會在每個節點后面顯示代價估計信息,包括cost、rows、width,這里將一一介紹。
在PostgreSQL 中,執行優化器會基於代價估計自動選擇代價最小的查詢計划樹。而在EXPLAIN 命令的輸出結果中每個cost 就是該執行節點的代價估計。它的格式是xxx..xxx,在.. 之前的是預估的啟動代價,即找到符合該節點條件的第一個結果預估所需要的代價,在..之后的是預估的總代價。而父節點的啟動代價包含子節點的總代價。
而在本文開頭講述PostgreSQL DBA 對慢SQL 的常見診斷方法就是使用EXPLAIN 命令,分析其中哪個節點cost (或者下文的 actual time )最大,通過快速優化它達到優化慢SQL 的目的。
那cost 是怎么計算而來的呢?簡單來說,是PostgreSQL 根據周期性收集到的統計信息(參考PostgreSQL · 特性分析 · 統計信息計算方法),按照一個代價估計模型計算而來的。其中會根據以下幾個參數來作為代價估計的單位(詳見PostgreSQL 官方文檔):
- seq_page_cost
- random_page_cost
- cpu_tuple_cost
- cpu_index_tuple_cost
- cpu_operator_cost
- parallel_setup_cost
- parallel_tuple_cost
其中,seq_page_cost 和random_page_cost 可以使用ALTER TABLESPACE 對每個TABLESPACE 進行修改。
代價估計信息中的其他兩個,rows 代表預估的行數,width 代表預估的結果寬度,單位為字節。兩者都是根據表的統計信息預估而來的。
真實執行信息
當EXPLAIN 命令中ANALYZE 選項為on時,會在代價估計信息之后輸出真實執行信息,包括:
- actual time 執行時間,格式為xxx..xxx,在.. 之前的是該節點實際的啟動時間,即找到符合該節點條件的第一個結果實際需要的時間,在..之后的是該節點實際的執行時間
- rows 指的是該節點實際的返回行數
- loops 指的是該節點實際的重啟次數。如果一個計划節點在運行過程中,它的相關參數值(如綁定變量)發生了變化,就需要重新運行這個計划節點。
這里需要注意的是,代價估計信息一般是和真實執行信息比較相近的,即預估代價和實際時間成正比且返回結果集的行數相近。但是由於統計信息的時效性,有可能找到的預估代價最小的性能卻很差,這就需要開發者調整參數或者主動執行vacuum analyze 命令對表的統計信息進行及時更新,保證PostgreSQL 的執行優化器能夠找到相對較優的查詢計划樹。
總結
至此,我們分析了EXPLAIN 命令的語法和輸出的整個結構,可以發現它的輸出實際上是PostgreSQL 查詢計划樹的一個可視化的摘要。在執行優化器內部,就是依靠這些信息找到最優的查詢計划樹並執行。但是執行優化器不是萬能的,由於統計信息不准確或者代價估計模型的問題,PostgreSQL 有可能得到一個性能比較差的查詢計划樹並執行。這需要做一些case by case 的優化,感興趣的同學可以參考《WHY IS MY INDEX NOT BEING USED》 和《RDS for PG加入Plan Hint功能》 等相關文章。
參考資料
- https://www.postgresql.org/docs/10/using-explain.html
- 彭智勇、彭煜瑋編著的《PostgreSQL 數據庫內核分析》
- http://mysql.taobao.org/monthly/2016/05/09/
- https://www.postgresql.org/docs/10/runtime-config-query.html#RUNTIME-CONFIG-QUERY-CONSTANTS
- http://www.postgresonline.com/journal/archives/78-Why-is-my-index-not-being-used.html
- https://yq.aliyun.com/articles/3072
- https://www.dalibo.org/_media/understanding_explain.pdf