查詢優化:
現代數據庫都使用一種基於成本優化(參見第一部分)的方式進行優化查詢,這種方式的思路是給每種基本運算設定一個成本,然后采用某種運算順序總成本最小的方式進行查詢,得到最優的結果。
為簡化理解,對數據庫的查詢重點放在查詢時間復雜度上,而不考慮CPU消耗,內存占用與磁盤I/O,且相比與CPU消耗,數據庫瓶頸也更多在磁盤I/O。
索引
B+樹、bitmap index等都是常見的索引實現方式,不同的索引實現有不同的內存消耗、I/O以及CPU占用。一些現代數據庫還可以創建臨時索引。
獲取(數據)方式:
在進行連接查詢操作之前,需要先獲取數據,以下是常見的獲取方式(數據獲取的關鍵在磁盤I/O,故在衡量獲取方式時,考察量也應在此)。
全掃描
全掃描(full scan or scan),即掃描整個表或者所有索引,全表掃描的磁盤I/O明顯高於全索引掃描。
范圍掃描
AGE字段有索引,當sql使用謂詞where age < 20 and age >20時(between and會在上面查詢解析階段改成<和>),便會使用范圍索引。參見第一部分知,范圍掃描的復雜度為log(N)+M,N是索引中的數據量,M是搜索范圍內行數,可見范圍掃描比全索引掃描有更低的磁盤I/O。
唯一掃描
如果想只需要從索引中取一個值,可用唯一掃描(unique scan)。
根據rowid獲取
當要查詢索引行相關的列時,便會用到rowid,比如查詢age=28(age上有索引,name無索引)的人的名字:
Select name from person where age = 28;
以上的查詢會按照:查詢索引列age,過濾出age=28的所有行,然后按照查詢出來的行號查name列,即先讀索引再讀表。但下面列子就不用讀表了(name有索引):
Select location.street from person, localtion where person.name = person.name;
該方式在數據量不大時是比較有效的,但當數據量很大時,相當於全掃描了。
其他獲取方式
連接
獲取到數據后對數據進行連接運算,這里介紹三種連接方式:merge join, hash join, nested loop join,以及引入inner relation和outer relation兩個概念。關系數據庫中定義了“關系”的定義,它可以是:一個表,一個索引以及前面運算的結果。
連接兩個關系時,數據庫連接運算處理兩個關系方式可能不同,本文定義:
連接運算符左邊的關系稱為outer relation;
連接運算符右邊的關系稱為inner relation。
比如a join b,a稱為outer relation(常看見的是外表說法),b稱為inner relation(常看見的是內表說法)。多數情況下 a join b 與b join a的成本是不一樣的。該部分假定outer relation有N個元素,inner relation有M個元素(實際情況下,這些信息數據庫通過統計可以知道,如上部分)。
嵌套循環連接(Nested loop join):
Fig. 11
一般分為兩個步驟:
- 讀取outer relation 每一行
- 檢查inner relation中的每一行是否匹配連接
偽代碼:
nested_loop_join(array outer, array inner) for each row a in outer for each row b in inner if (match_join_condition(a,b)) write_result_in_output(a,b) end if end for end for
顯然時間復雜度為(N*M)。從磁盤I/O考慮,算法需要從磁盤讀N+N*M行。可知,當M足夠小時,只需要讀N+M次,這樣就可以把讀取結果放到內存中,所以一般情況下都會將小的relation作為inner relation。
當然這雖然改善了磁盤I/O,時間復雜度並沒有變化。如果進一步優化磁盤I/O,還可以考慮將inner relation用索引來替換。
考慮盡可能將inner relation放到內存,做一個改進,基本思路:
- 不逐行讀取兩個關系,而是分組讀取,將組信息放到內存中;
- 對比(內存中)的組間行,保留符合連接條件的行
- 依次加載其他組直至對比兩關系中所有組。
偽代碼
// improved version to reduce the disk I/O. nested_loop_join_v2(file outer, file inner) for each bunch ba in outer // ba is now in memory for each bunch bb in inner // bb is now in memory for each row a in ba for each row b in bb if (match_join_condition(a,b)) write_result_in_output(a,b) end if end for end for end for end for
該版本相比之前版本時間復雜度沒有變化,但磁盤I/O明顯變小了:number_of_bunches_for(outer)+ number_of_bunches_for(outer) * number_of_ bunches_for(inner),而且可知增加分組的大小,即每次讀取更多數據,還能繼續減小讀取次數。
哈希連接(hash join)
哈希連接更加復雜,但大多場合中比循環嵌套連接成本更低。
Fig. 12
基本思路:
- 獲取inner relation中的所有元素
- (根據inner relation中的元素)構建一個常住內存hash table
- 逐個獲取outer relation所有元素
- 計算每個元素的哈希值(利用哈希函數計算哈希表),與inner relation中的元素逐個比較,以確定inner relation對應哪個bucket
- 確定bucket與outer relation對應關系(buckt是否存在outer relation中元素)
分析其時間復雜度:inner relation分為x個buckets,outer relation與buckets對比的次數取決於buckets中的元素個數。哈希函數對各個關系中的元素是均勻分布的,也就是說buckets的大小是相同的。
時間復雜度:(M/X) * N + cost_to_create_hash_table(M) + cost_of_hash_function*N,當hash函數創建足夠小的buckets時,比如buckets只有一個元素,那么時間復雜度可以為(M+N)。
內存占用更小磁盤I/O更小版本:
- 對inner 和 outer relation都創建一個hash table
- 把創建的hash tables放入磁盤
- compare the 2 relations bucket by bucket (with one loaded in-memory and the other read row by row)
Merge join
Merge join是唯一產生排序結果的連接查詢。
排序
在最開始介紹過歸並排序,可以看到歸並排序是一個很好的算法(當然如果不考慮內存情況下會有更好的算法,比如hash join)。但在以下條件時,一般會選擇merge join。
- 某個關系(表中)已經排好序
- 某個關系連接條件建有索引
- 連接條件產生的是中間結果,而該中間結果已經排序.
Fig. 13
Merge的過程和前面介紹的merge sort很相似,但是不會逐個讀取兩個關系元素,只會選擇符合連接條件的元素。基本思路如下:
- 對比兩個relations的當前元素;
- 如果兩個元素相等,取出該元素,對比下面的元素;
- 如果兩個元素不相等,將較小元素進入下一次對比。
- 重復以上,直到兩個relations都處理到最后一個元素。
以上思路是在倆relations已經排好序且任一關系中不存在相同元素的簡化模型下,具體的要復雜的多。
時間復雜度,如果兩個relations已經排序好,復雜度為N+M;如果需先排序再連接,復雜度為(N*log(N)+M*log(M))。
偽代碼
mergeJoin(relation a, relation b) relation output integer a_key:=0; integer b_key:=0; while (a[a_key]!=null and b[b_key]!=null) if (a[a_key] < b[b_key]) a_key++; else if (a[a_key] > b[b_key]) b_key++; else //Join predicate satisfied write_result_in_output(a[a_key],b[b_key]) //We need to be careful when we increase the pointers if (a[a_key+1] != b[b_key]) b_key++; end if if (b[b_key+1] != a[a_key]) a_key++; end if if (b[b_key+1] == a[a_key] && b[b_key] == a[a_key+1]) b_key++; a_key++; end if end if end while
算法比較選擇:
- 內存的占用:如果沒有足夠的內存,基本要告別強大的 hash join ( 至少也告別全內存 hash join)。
- 2個關系的數據量:比如要連接的兩個表,一個數據量特別巨大,一個又很小很小,這時候 nested loop join 的效果要比 hash join 好,因為 hash join 給那個數據量巨大的表創建 hash 表就很費事。 如果兩個表都有巨量的數據, nested loop join 連接方式的 CPU 負載會比較大;
- 索引的方式: 如果連接的兩個關系都有 B+樹索引,那肯定是 merge join 效果最好;
- 結果是否需要排序: 如果希望這次連接得到一個排序的結果(這樣就可以使用 merge join 方式實現下一個連接),或者查詢本身(有 ORDER BY/GROUP BY/DISTINCT 運算符)要求的排序的結果;如果是這個情況,即使當前要連接的 2 個關系本身並沒有排好序, 依然建議選擇稍微有點費事的 merge join(可以給出排序的結果);
- 連接的 2 個關系本身已經排序: 這個情況,必須用 merge join;
- 連接類型: 如果是等值連接(比如: tableA.col1 = tableB.col2)?或者是內連接、外連接、笛卡爾積、自連接?有些連接方式可能不能處理這些不同類型的連接;
- 數據的分布: 如果連接條件的數據扭曲了(比如要連接表 PERSON 連接條件是列“姓”,但是意味的是很多人的姓是相同的),這個情況如果使用 hash join 一定會帶來災難,對吧?因為哈希函數計算后各個 buckets 上數據的分布肯定存在巨大的問題 (有些 bucket 很小,只有一兩個元素;而有些 buckets 太大,好幾千的元素) 。
下一篇將有一個簡單的例子簡要說明改過程。