jubo
sql
postgresql
EXPLAIN
調查 postgres 的效能瓶頸WITH
Queries (Common Table Expressions, CTE) 及它的 RECURSIVE
modifierLATERAL
subquery 的差異:information_source: random()
與 generate_series()
皆是 postgres 內建的 function
random()
- random value in the range 0.0 <= x < 1.0generate_series()
- 用來產生 series data。你也可以真的用它來產生 time series data,可參考此文章 或以下 code snippets:
DISTINCT ON
postgres 獨有的功能 SELECT DISTINCT ON (expression)
可直接完成這題:
SELECT DISTINCT ON (expression)
會根據 expression 來分組,留下每一組的「第一個 row」ORDER BY
,否則你無法預測第一個 row 是誰修改一下前面的語法,重新插入百萬筆資料之後再 query 一次…此時會發現,query 時間來到了「秒」的數量級:
EXPLAIN
查看執行計畫RDBMS 在執行你的 SQL statement 之前,planner 會先算出一個 cost 最低的 query plan,然後再使用該 plan 執行你的語句。
EXPLAIN
可以讓我們看到該 plan 長什麼樣,且不會真的執行。
使用方式為在你的 query 前加 EXPLAIN
:
query plan 會以 tree of plan nodes 的結構呈現。此 plan 告訴我們它將會執行三個計畫節點 (plan node),執行順序由 leaf node 開始依序為:Seq Scan、Sort 及 Unique。
其中,每個 plan node 會輸出 cost, rows, width 資訊。以 Seq Scan 的 (cost=0.00..15406.00 rows=1000000 width=10)
為例:
cost=0.00..15406.00
- 0.00
指此 node 可以開始輸出資料前的啟動成本 (start-up cost),且會包含 child node 的總啟動成本;15406.00
則是估計此 node 完成操作需要花費的成本 (total cost)。cost 越高,可預期實際執行時間就越長
:information_source: 何謂 cost?
postgres 已定義好的一些 CPU 執行與 I/O 等動作的權重 (20.7.2. Planner Cost Constants),給 planner 估計時做加總。另有一篇 start-up cost vs. total cost 解釋的很清楚。
rows=1000000
- 估計此節點完成後會輸出 1000000 rows 給 parent nodewidth=10
- 估計每一個 row 的大小為 10 bytes故從這份 query plan 我們大概能了解到此 query 的行為:
ORDER BY
給的條件做排序 (Sort)DISTINCT ON
的條件做唯一值過濾 (Unique)ANALYZE
and BUFFERS
但其實只看 cost,還是只能猜,此時可再打開兩個選項了解更多執行細節:
ANALYZE
- 實際執行此 query,取得實際執行時間及每個 plan node 實際輸出的 rowsBUFFERS
- 有多少資料是在 cache 中找到、又有多少是從 disk IO 中讀取(寫入):information_source: see more EXPLAIN
options
actual time
- 每個 plan node 的實際執行時間 (ms),一樣包含 start-up and totalBuffers:
shared hit
- 有多少 data block 來自 memory cache (1 data block = 8kb by default)read
- 有多少 data block 來自 disk IOwrite
- 為了處理此 plan node,有多少 data block 需寫入 diskExecution Time
- 總執行時間query plan 越來越複雜,直接看有點累了,找一些視覺化工具來幫忙判讀:
(https://explain.dalibo.com/plan/eJP)
延伸閱讀
最直覺想到的是,加 index 如何?針對我們的 ORDER BY
條件加 composite index 看看 (死馬當活馬醫):
確實加速了 :rocket: (1668.240 ms -> 546.301 ms)!
但仔細看會發現,加速的原因其實是因為所有的資料都已經在 buffer 裡了 (Buffers: shared hit=1003332
),因為沒有額外做 disk I/O 所以快。但實際上,Index Scan 還是輸出了所有的 rows 給 Unique node,在 Unique node 的階段才找出我們想要的那 21 筆資料。
:information_source: postgres 會盡可能地將 index 的資訊儲存在記憶體裡面提升效率。以此例來說,primary key (id) 已經在 memory,後來加上的 composite key 也將剩餘兩個 columns 的資訊給 cache 起來了。
該如何再進一步提升效率?有沒有辦法不要看完整張表來找到特定的資料?
小插曲,在問 google 的過程中發現,stackoverflow 很多有關 postgres 的最佳解答都是這位眯眼哥:
也有其他人發現總是這位眯眼哥在回答 postgres 的問題:
那原來眯眼哥就是 postgres 的其中一位 contributor :rocket:
我們可以從 Erwin 的回答中獲得一些 hints。以下的問答他都詳細地解釋了各種考量之下、可以嘗試的優化方向:
WITH RECURSIVE
與 LATERAL
在 Erwin 的回答中提到,需運用到 WITH RECURSIVE
與 LATERAL
語法的支援,來要求 postgres 模擬所謂的 loose indexscan。
拼拼湊湊,把我們的 query 改成以下、然後執行:
(https://explain.dalibo.com/plan/vaJ)
好了,下課了各位 :coffee:
WITH RECURSIVE
query 與 LATERAL
subquery為了解讀 WITH RECURSIVE
query 與 LATERAL
subquery 施展了什麼魔法,需要先帶大家分別了解一下:
WITH
query 與 WITH RECURSIVE
query 的差別LATERAL
subquery 的差別WITH
query vs. WITH RECURSIVE
queryWITH
query
WITH
query 用來產生暫時的 table(s),輔助主要 query 的查詢。
:information_source: WITH
quries 在官方文件中又稱為 Common Table Expression (CTE)。以下會使用 CTE 來表示一段會輸出 temporary table 的語句。
借個 official doc 上的範例:
regional_sales
會先根據 region
產生 (L1-4)top_regions
再根據 regional_sales
產生 (L5-8)top_regions
產生想要的結果 (L10-16)(example from 7.8.1. SELECT
in WITH
)
WITH RECURSIVE
query
而 WITH RECURSIVE
query 的目的也是產生一個 temporary table,但運作原理是採用迭代的方式。語法為:
其中:
UNION
會移除重複的 rows;UINON ALL
則保留:information_source: NOTE
Strictly speaking, this process is iteration not recursion, but RECURSIVE is the terminology chosen by the SQL standards committee.
舉個例子,假設有以下 employees
table:
(example from Learn PostgreSQL Recursive Query By Example)
想要取出員工 id=2 及他的所有下屬,可以這樣下:
讓我們一步步拆解,non-recursive term (R0) 的 result set 為:
並且暫時存放在 temporary working table subordinates
中給 recursive term 參考引用。
接著使用 recursive term 迭代 (R1):
我們將 employees
table 與 subordinates
(此時僅有一筆 Megan Berry
) 做 JOIN,找出主管為 Megan Berry
的資料。得到新的 result set 為:
而此新的 result set (4 rows) 會取代掉 subordinates
的內容作為下次迭代之用。而前一次的 result set (1 row) 也會先放進 temporary intermediate table。
此時再實行一次 recursive term (R2),尋找主管 id 為 6, 7, 8, 9 的員工,可以得到以下 result set:
一樣,此 result set (5 rows) 會取代掉 subordinates
、並將前一次的 result set (4 rows) 放到 temporary intermediate table。
此時再執行一次 recursive term (R3),會發現 result set 為空了 (因為沒有員工的主管是 16, 17, 18, 19, 20),故 recursive quey 終止。
最後,統整 subordinates
(5 rows) 與 temporary intermediate table 裡 (共 1 + 4 rows) 的內容至 subordinates
供後續 query 使用。最後的執行結果:
LATERAL
subquery再次感謝 Erwin,在另一篇回應中詳細解釋了 subquery 與
LATERAL
subquery 的差異與適用情境。
subquery
subquery 顧名思義一定是在 main query 中、有個 sub query 會先去執行,然後拿到結果給 main query 使用,但使用上有些限制。
所謂的限制,是它回傳的 result set 只能是 single column (但可以是 single row or multiple rows),然後再作為主要 query 的條件來使用。例如:
LATERAL
subquery
而 LATERAL
subquery 則是 for loop 一張 outer-table 的 rows,並在迭代中執行 subquery,最後將每一個 subquery 的結果與 outer-table 的 row JOIN 起來。
這邊借一個例子簡單示意:
(完整的例子可參考 Understanding LATERAL joins in PostgreSQL)
LATERAL
subquery 會 loop t_wishlist
每一個 row 作為參考,執行 LATERAL (...)
內的 subquery。所以以此例來說 LATERAL 內的 subquery 會被執行 3 次,每次執行時 w.desired_price
參考到的值分別是 450, 60 與 1500。
有了上述的基礎之後,來一步步看我們的 1ms-query 怎麼運作:
group_id
會是最小、且 ts
最大的那一筆而已。這一筆結果會先被暫存到 res
中給後續每一次迭代參考
:information_source: 字串型態比大小
這邊運用到字串比大小的技巧,而字串比的是他們的 code points (numerical value)。以 ASCII 編碼來看, "A" 的十進位值為 65,小於 "a" 的值 97,故 "a" > "A"。若有多個字元,則先比第一個字元,相等的話再比下一個字元,e.g. "Aa" > "AA"。
res
裡只有一筆資料,也就是這裡的 LATERAL
只會根據 res
裡那筆資料執行一次 subquery 而已group_id
比 res.group_id
還大、且 ts 最大的那一筆res
,此時再重複 step 2.,直到 step 3. 為 empty result set上述的語法,更細緻地控制 postgres 的處理方式,避免從 disk/memory 輸出了一堆不需要的 rows 才能進行運算;而是更有效地先利用 index 來定位資料,然後僅輸出必要的 rows 進行處理。
EXPLAIN
探索效能瓶頸WITH
, WITH RECURSIVE
, subquery
, LATERAL subquery
)以「撈出每一個組別最新的資料」這個需求來說
You probably don't want to hear this, but the best option to speed up
SELECT DISTINCT
is to avoidDISTINCT
to begin with. In many cases (not all!) it can be avoided with better database-design or better queries.
Erwin Brandstetter
commentted on How to speed up select distinct?
是否有更好的 schema/table design 來避開使用 DISTINCT ON
、或上述維護性較差的語法來 query?是否能避免 OLTP 系統有大海撈針的行為?
可以考慮
新資料進來的時候,規劃一張 table (e.g. latest_records
)「只儲存」每個組別最新的那筆資料。當前端要資料時,後端直接從 latest_records
裡取出來吐給前端即可。
此時若還有效能瓶頸,改進的手段就比較容易理解了:
latest_records
加 cache layer
WHERE
/LIMIT
/OFFSET
條件來避免存取不必要的資料:information_source: OLAP vs. OLTP: What’s the Difference?
我們的系統流量雖然不及 B2C,但就性質上也應該比較接近 OLTP、而非 OLAP。故我們應該要對 query 讀取效率有較高的自我要求。