Spanner會為每條SQL生成一個或多個查詢計劃,并選擇數據庫認為最優的那個查詢計劃去執行,同一個SQL,不同的查詢計劃最終的效率可能是千差萬別的,理解查詢計劃是SQL優化的基本必備技能。
Spanner本身有官方文檔幫助大家理解查詢計劃,但是講得比較精簡,如果對Spanner不熟悉,可能理解起來比較困難,本文是這篇文檔的擴展,但是會更淺顯易懂、詳細,有一些總結與延伸。
本文不會講什么:
- 查詢運算符詳解(Query Operators),請自行參閱Spanner文檔
本文會講什么:
- 理解Spanner如何執行一個查詢計劃
- 如何看懂GCP Console下獲取的Spanner的查詢計劃
- 如何基于查詢計劃作出優化
一、查詢計劃如何被執行
Spanner是分布式數據庫,因此一個數據庫實例(Instance)是分布在多臺server的,因此一條SQL可能意味著需要多臺server配合才能產生最終結果。
client連接到Spanner,Spanner將SQL解析為查詢計劃(Query Plan),并選擇一臺server作為root server
,Spanner將plan發送到root server,其他需要參與query的server稱為remote server,均被root server協調,它們接收root server下發的subplan,然后將查詢結果返回給root server,最終由root server返回給client。
Root server本身也參與query,因此理論上有一部分subplan會下發給自己,也就是說root server本身也可以扮演remote server。
Root Server下發subplan到各個Remote Server并從Remote Server收集結果的行為,在查詢計劃中稱為Distributed Union
。
由于每臺server都負責保存多個splits,因此每臺remote server收到subplan后,會將subplan再次分割為一到多個splits的查詢計劃,下發給特定的split,每個split獨立執行自己的計劃并返回結果給server,這個過程在查詢計劃中稱為Local Distributed Union
。
總結一下:
Root Server負責:
1. 下發subplan到其他參與的server
2. 等待所有server返回subplan結果給自己
3. 匯總各個server的執行結果,如果需要的話,進行進一步處理
4. 將匯總后的執行結果返回給client
Remote Server負責:
1. 接收Root Server下發的subplan
2. 將subplan拆分成1個或多個分片的subplan并執行
3. 匯總各個分片執行的結果
4. 返回匯總的結果給Root Server
二、解讀查詢計劃
1. Example — 簡單查詢計劃
下圖是摘自Spanner官方文檔的查詢計劃
圖中箭頭由下往上,表示的是結果返回順序,而查詢計劃的分發順序恰恰相反,應該由上往下。
下發階段
圖中的查詢計劃表示SQL被解析為查詢計劃,發送給Root Server,Root Server進行Distriubted Union
將subplan下發給Remote Server并等待最終結果。
Serialize Result
與Aggregate
都是對結果進行處理的運算符,因此下發期間可以忽略。
Remote Server(s)收到Root Server的subplan后,將subplan拆分為特定split(s)的查詢計劃,交給特定的split(s)執行,也就是Local Distributed Union
。
在Local Distributed Union
下就是每個split會進行的查詢計劃,此時查詢計劃分發完畢,我們開始從下往上讀,解讀執行與返回過程。
執行與返回階段
每個split執行Table Scan
,從Songs表讀取SingerId。
每一個被讀出的SingerId都會被Filter
操作符根據SingerId<100的條件過濾,只有滿足條件的,才會往上返回。
被Filter
返回的數據會在Remote Server進行Local Distributed Union
,也就是結果集的合并,并且再往上返回。
所有Remote Server都會將結果返回給Aggregator
操作符,進行結果集的聚合。
聚合后的結果被Serialize Result
操作符組合為最終返回格式,這個操作符是每個查詢計劃都會有的,負責將查詢出的數據轉換為要發送回client的格式。
轉換為最終格式的數據,進行Distributed Union
,返回SQL執行的最后結果。
整個查詢計劃結束
2. Example — 復雜查詢計劃
上面的簡單查詢計劃只包括一元操作符,下面講一下包含二元操作符的查詢計劃,比如進行Join操作。
注意:下圖生成的查詢計劃有個前提條件—— Albums表是Singers表的子表,兩者是Interleave關系。
下發階段
任何查詢計劃的分發都是差不多的,只有4個操作符涉及分發,那就是Distributed Union
、Distributed Cross Apply
、Distributed Outer Apply
和Local Distributed Union
,因此這里不再講一遍。
執行與返回階段
最底部是兩個并排的查詢計劃,應該從左往右看,左邊是input,右邊是對input進行map處理,也就是說,查詢計劃是從下往上執行,從左往右執行。
先對Albums表進行Table Scan
查出SingerId、AlbumId、AlbumTitle三個字段。
Table Scan的結果會返回給Cross Apply
操作符,此操作符對結果進行map,也就是為每個結果執行一次Index Scan
。
Index Scan
查出SongName返回給Cross Apply
。
Cross Apply
將Table Scan與Index Scan的結果進行Join,實際上Cross Apply
操作符就是進行nested loop join,由于兩個參與Join的表是Interleave的,所以此Cross Apply只需要在本Remote Server上執行,否則應使用Distributed Cross Apply
(將在下一個例子中說明)。
Join后的結果被Serialize Result
轉換為返回格式。
Local Distributed Union
整合此Remote Server上的所有Results返回給Root Server。
Root Server進行Distributed Union
將最終結果返回。
整個查詢計劃結束。
3. Example — Distributed Cross Apply查詢計劃
上圖中的SQL需要讀2張表,一張是索引SongsBySongName,一張是數據表Songs,索引無法Interleave,所以索引和數據可以分別處于不同的分片,那么要實現這個SQL,就不能使用
(Local) Cross Apply
,而需要使用Distributed Cross Apply
,因此最頂層的操作符是Distributed Cross Apply
。
下發階段
Root Server的Distributed Cross Apply
會等待Distributed Union
后進行Create Batch
的結果作為input,當Distributed Cross Apply
收到Create Batch
的結果作為input后,再下發plan給Remote Server,做map操作。
這里注意,下發其實被分為了兩個階段,左邊先執行完,Distributed Cross Apply才會進行右邊的下發。
執行與返回階段
Remote Server將plan分配給多個splits進行Index Scan
。
Index Scan的結果被Filter
過濾符合條件的返回。
Local Distributed Union
匯總本臺server上的數據發回給Root Server。
Root Server使用Distributed Union
匯總Remote Server發來的數據。
Serialize Result
格式化數據。
Create Batch
操作符代表創建中間表,因為涉及到跨server的join,因此需要創建中間表。
將Create Batch創建的中間表作為input發給Distributed Cross Apply
操作符。
Distributed Cross Apply
下發查詢到Remote Server(進行Map)。
Batch Scan
讀取中間表并返回給Cross Apply
。
Cross Apply
根據Batch Scan結果進行Join并通過Serialize Result
、Local Distributed Union
后返回給Root Server。
Distributed Cross Apply
根據返回結果完成Join,返回SQL執行結果。
整個查詢計劃結束。
4. 從GCP Console解讀查詢計劃
在GCP Console中可以方便地獲得查詢計劃,但不是圖片形式,沒有左右關系,因此我們需要將Console中的text展示的查詢計劃,在腦袋中轉換為圖片版的。
每行計劃的開頭都有一個小標記。
轉換原則是:
-
垂直箭頭則表示上下關系。比如:
上下關系 -
人字型箭頭代表這是一個接收多個參數的操作符,比如Hash Join
Hash Join是二元操作符 直角箭頭代表是父操作符的輸入參數,比如圖中兩個Distributed Union不是上下級關系,而是兄弟關系,作為Hash Join的下級操作符,也就是Hash Join的輸入參數,兩個Distributed Union應該是左右排列。
-
對于應該左右排列的操作符,越上面出現,越左邊,從左往右依次排放。
左右關系
因此上圖應該是如下:
三、SQL優化
我們可以根據查詢計劃對SQL進行優化,但是在優化之前務必盡量讀懂查詢計劃,因此需要了解每個操作符的意義,在進行下面的閱讀前最好能夠先閱讀Spanner操作符文檔。
1. 為什么用了索引還是慢查詢?
大家都知道全表掃描是嚴格禁止的(數據量特別小的表不在討論范圍),導致慢查詢甚至拖垮數據庫,于是往查詢上面加索引,結果加了索引還是慢查詢。
為什么會出現這種情況,是因為大家忽略了導致慢查詢的根本原因——大量磁盤IO導致CPU和內存被大量占用,全表掃描不用說,一定是大量的磁盤IO,把表依次讀一遍,實際上索引建得不好,也會有這種情況。
在Spanner中,索引也是表,索引不過是只存儲部分字段的表而已,可以理解為一個比數據表更小的表,如果查詢條件不能利用索引的最左前綴原則
,那么這個索引就只能被全索引掃描,Spanner會將索引全部掃描一遍,利用Filter返回符合條件的行,對CPU和內存的占用極大。
比如為users表建立一個 (user_name,email) 的索引,卻使用這個索引進行 SELECT user_name FROM users WHERE email = xxx 查詢,由于查詢條件不包括user_name,因此無法使用這個Index進行Filter Scan
,也就是無法直接定位到索引所在數據頁,而需要讀取整個索引,進行Filter
操作,也就是全表掃描
(索引也是表,因此對表和索引的全掃描都可以稱為全表掃描)。
從Spanner的查詢計劃中可以看到是否對一個索引或者一個表使用了全表掃描,如下圖:
如果索引中有100萬條記錄,那么100萬條都會被讀入內存進行Filter,CPU和內存壓力比較大,會出現慢查詢,因此
對于查詢計劃中的 Full Scan 需要根據SQL運行頻率、表大小進行評估,在必要的情況下建立更合適的索引避免 Full Scan
。與Full Scan相對應的是
Filter Scan
,也就是直接定位到索引數據所在數據頁,只讀取符合條件的索引。注意,Filter Scan與Filter是完全不一樣的,詳見官方文檔。
2. Apply Join與Hash Join的選擇
Apply Join
也就是Nested Loop Join,接收一組記錄作為input,然后分別對每條input進行Join,具體原理可以在網上搜到,這里就不多說。
操作符Cross Apply
即代表Apply Join,它好處是,input越小,需要進行的join記錄數越少,讀取越少,速度越快。
可以說Apply Join是基于記錄的(record-based)。
Hash Join
與Apply Join相反,Hash Join是基于集合(set-based)的,對于參與Join的兩張表,會選擇更小的那一張,完全加載入內存,建立一個Hash表,再讀取另一張表,匹配Hash完成Join。
可以通過這篇文章理解Hash Join:《如何在分布式數據庫中實現 Hash Join?》
綜上所述,Hash Join適合需要整張表參與的大數據集的Join,而Apply Join適合記錄較少的Join。
如果WHERE條件篩選后只有少量記錄,那么Apply Join是更好的選擇,此時如果選擇Hash Join,即使某張表被篩選出少量記錄,另一張表還是會被全表讀取,效率非常低。
3. 本機Join可減少開銷
本機的Join比Distributed Join更快、開銷更少,比如Cross Apply比Distributed Cross Apply更快,因此對于常用的Join,優化思路是進行本機Join避免Distributed Join。
Interleave是記錄co-located的強保證,因此必要的情況下,可以使用Interleave提升Join效率。
但是要注意Interleave的co-located保證也導致熱點不能被分散,因此需要綜合業務考慮后再決定是否使用Interleave。
4. 測試比Explaination更重要
查詢計劃不是萬能的,特別是僅僅使用Spanner Console的Explaination Only
功能,是看不到最終掃描行數和執行效率的,對于查詢計劃的分析僅僅限于理論,理論必須結合實踐,因此非常有必要在測試環境模擬足夠的數據量去進行測試、調優、驗證。