[譯]數據庫是如何工作(二)回到原點 算法基礎
很久之前(在一個遙遠的銀河系中。。。),開發者不得不完全地知道他們編碼時所有的細節。他們對算法和數據結構必須要十分理解,因為他們接受不了浪費慢速計算機的CPU和內存的時間。
在這部分,我會提醒你一些概念,因為他們對理解數據庫必不可少。我也會介紹數據庫索引的概念。
O(1) vs O(n2)
現在,很多開發者不關系時間復雜度。。。 他們是對的! 但當你處理茫茫大的數據時(我不是在說數千),或者如果你再和毫秒在戰鬥時,理解這個這個概念即為重要。你知道嗎,數據庫是要處理以上兩者情況!我不會耽誤你很多時間,只是過個概念。這會幫助我們理解基於成本優化(Cost-Based Optimization) 的概念。
概念
時間復雜度是用來觀察算法在給定數量的數據的情況下會耗費多長時間。為了描述這個復雜度,計算機科學們使用數學的大O表示法。這個符號與一個函數一起使用,這個函數用於描述一個算法在給定數量的輸入數據下需要執行多少次操作。
舉個栗子,當我講這算法在“O(some_function())”時,這意味著在一定數量的數據中,算法需要執行 some_funtion(a_certain_amount_of_data) 次操作。
更重要的不是數據量,而是當數量的量增大時操作次數增加的方式。而時間復雜的雖然沒有給出準確的操作數,但是它依然是一個很好的想法。
在這圖中,你可以看到不同類型的復雜度的走向。我用對數坐標去繪制的。換句話說,這些數據是從 1 到 10億 的快速增長的。我們可以看到:
- O(1) 或者說是常數的復雜度保持不變(否則它也不會稱為常數復雜度)
- O(log(n)) 即便 10億的數據,也能保持較低的操作數
- 最可怕的復雜度是 O(n2) 它操作數迅速爆炸
- 其他的兩個復雜度類型也迅速增長
一些例子
在數據量較少的時候,O(1) 和 O(n2) 的差異可以基本忽略不算。舉個例子*1,假設你有一個算法,需要處理 2000 個元素
- O(1) 的算法需要 1 次操作
- O(log(n)) 的算法需要 7 次操作
- O(n) 的算法需要 2,000 次操作
- O(n*log(n))的算法需要 14,000 次操作
- O(n2) 的算法需要 4,000,000 次操作
O(1) 和 O(n2) 看起來相差很多(4百萬),但是你最多失去的時候更多只有 2ms,知識一眨眼的時間。實際上,當前處理器可以處理每秒數億次操作。這就是很多IT項目中性能和優化不是問題的原因。 正如我說的,面對十分龐大的數據時知曉時間復雜度這個概念還是非常重要的。如果這算法需要處理 1,000,000 個元素(這對數據庫來講也不是個很大的數據)
- O(1) 的算法需要 1 次操作
- O(log(n)) 的算法需要 14 次操作
- O(n) 的算法需要 1,000,000 次操作
- O(n*log(n))的算法需要 14,000,000 次操作
- O(n2) 的算法需要 1,000,000,000,000 次操作
我不用算也知道 O(n2) 的算法可以讓你有時間喝杯咖啡(甚至是第二杯),如果在數據量上再加多個0,就可以有時間小睡一下了。
更深入地看
再給你一些概念:
- 在一個好的哈希表中搜索得出一個元素,復雜度是 O(1)
- 在一個好的平衡樹中搜索得出一個結果,復雜度是 O(log(n))
- 在一個數組中搜索得出結果,復雜度是 O(n)
- 最好的排序算法,復雜度是 O(n*log(n))
- 一個差的排序算法,復雜度是 O(n2)
註意:在下一個部分,我們將看到這些算法和數據結構 有多種的復雜度類型:
- 平均情況下
- 最好的情況
- 最壞的情況下
時間復雜度通常使用最快的情況 我只會談及時間復雜度,但是復雜度也能用於:
- 算法的內存消耗
- 算法的磁盤的I/O消耗
當然,還有比 n2 更可怕的時間復雜度,如:
- N^4: 太糟糕了。我將會提到有這種復雜度的一些算法
- 3^n: 這也很糟糕。在這篇文章的中間部分我們將會看到有一個算法有這種復雜度(並且在很多數據庫中也使用這種算法)。
- factorial n : 即使數據量很少,你也永遠不會得到你的結果
- n^n : 如果你的算法最終有這種復雜度,你該問問自己是否適合做IT
註意:我沒有給你大O符號的真正定義,只是個概念。你可以去維基百科閱讀這篇文章關於大O的真正定義。
合並排序
當你需要對一個集合進行排序的時候你要做什麽?什麽?你調用 sort() 函數 。。。 ok,好的答案。。。但是想了解數據庫,你必須明白 sort() 函數是如何工作的。 有幾個很好的排序算法,但是我將專註於最重要的一個:合並排序。你現在可能不明白為什麽排序數據如何有用,也要在查詢優化部分才去做。此外,明白合並排序將會幫助我們在之後理解一個普通數據庫的操作叫合並關聯(merge join)
合並
像很多有用的算法一樣,合並排序是基本一個技巧的:合並兩個長度是 N/2 的已排序的數組到一個長度為 N 的數組中,只需要 N 次操作。這個操作叫合並。 我們用一個簡單的例子來看看這是什麽意思
從上面的圖中可以看到,最想最終能構造出這長度為8的有序數組,你只需在那2個長度是4的有序數組中遍歷一次。而由於那兩個數組已經排序了,所以可以這樣做:
1) 比較兩個數組中的當前元素 (開始的時候,當前元素就是第一個元素了)
2) 把兩個元素中數字最小的放到 最終數組(長度為8的) 中
3) 已被提取最小數字的數組訪問下一個元素
4) 重復 1,2,3 直到有個數組訪問到的最後一個元素
5) 然後你把另外一個數組的剩余的元素都放在最終數組中去。 這樣做是可行的,因為兩個長度是4的數組都是已排序的,因此你不需要從這些數組中來回進行訪問。 現在我們已經理解了這個技巧,這是我的合並排序的偽代碼。
array mergeSort(array a)
if(length(a)==1)
return a[0];
end if
//遞歸調用
[left_array right_array] := split_into_2_equally_sized_arrays(a);
array new_left_array := mergeSort(left_array);
array new_right_array := mergeSort(right_array);
//將兩個有序的數組合並成一個大數組
array result := merge(new_left_array,new_right_array);
return result;
合並排序將一個問題分解成較小的問題,然後找到較小問題的結果再去獲取最初的問題的結果(註意:這種算法叫分而治之)。如果你不明白這種算法,不要害怕;我第一次看到它的時候也不名表。我對這類的算法會把它分成2個部分去看,這可能會幫助到你。
- 切分階段會把數組切分成更小的數組
- 排序階段會把小數組放在一起(使用合並),以形成更大的數組。
切分階段
在切分的階段,會用3個步將數組會被切分到單個元素的數組。正式步驟數應該是 log(N)(因為 N=8 ,log(N) = 3) 我怎樣知道的? 我是天才 一句話:數字。想一下,每個步驟都將初始數組的大小除以 2。步數是可以將初始數組除以2的次數。這是對數的精確定義(在以 2 為底的對數中)。
排序階段
在排序階段,你可以從單個元素開始排序。在每一步中,你可以執行多次的合並,總成本(每次合並的成本)是 N=8 次操作
- 第一步,有4次合並,每次合並要用 2 個操作。
- 第一步,有2次合並,每次合並要用 4 個操作。
- 第三步,有1次合並,每次合並要用 8 個操作。
因為有 log(N) 步,所以總共要 N*log(N) 個操作。
合並排序的力量
為什麽這算法恐怖如斯? 因為:
- 你可以對算法進行修改,以便減少內容占用。這方法是不會創建新數組的但你可以直接修改輸入數組。
註意:這種算法叫原地排序(我國亦有書稱為內排序)
你可以對這算法進行修改,以便用磁盤空間來減少內容占用同時也不會有巨大的磁盤 I/O 損失。想法就是只對加載到內存的數據進行處理。這很重要,特別是當你的內存緩沖區僅有100MB而要對幾GB的數據進行排序。 註意:這種算法叫外排序
你可以對這算法進行修改,可以讓他在多線程/線程/服務器中使用
例如:分布式合並排序就是 Hadoop(大數據框架)的一個關鍵組件
- 這算法可以銅化金(笑傲江湖的一個人名梗吧)原文是鉛變成黃金。(!真實的故事)
這排序算法是絕大多數(可能不是所有)數據庫會使用的,但不是為一種算法。 如果你想知道更多,你可以到看這篇論文,這論文說的數據庫中常見排序算法的優缺點。
數組、樹、哈希表
現在我們了解了時間復雜度和排序的概念,我也必須在告訴你3種數據結構。這挺重要的,因為他們也是現代數據庫的支柱,我還會介紹數據庫索引的概念。
數組
二維數組是最簡單的數據結構。表可以看作是一個數組。
例如:
這個二維數組是一個包含行和列的表:
- 每一行就是一個對象
- 每一列描述這些對象的特征
- 每一列存儲某種同一類型的狀態(整數、字符串、日期...)
雖然這很容易存儲和可視化數據,但當你需要尋找一個特種的值時,它就顯得很糟糕。
例如,如果你想找到所有在英國工作的人,你不得不查看每行看看這個人是不是屬於英國的。這會耗費 n 個的操作(N 就是行數)這不算太差,但有更快的方式嗎?這就輪到樹的發揮了。
註意:大多數現代數據庫會提供高級數組來高效存儲表格,比如 堆組織表(heap-organized tables) 或者是索引組織表(index-organized tables)。但它不能改變在特定條件下 的按列進行快速搜索的問題。
樹和數據庫索引
二叉搜索樹是具有特殊屬性的二叉樹,每個節點的鍵(key) 都必須是滿足
- 大於左子樹的所有鍵
- 小於右子樹所有的鍵
下面讓我們來看看二叉樹可視化後是什麽一回事
概念
這棵樹有 N=15 個元素。假設我要找鍵值為208的結點:
- 我會從 (鍵值是136的)根結點開始找,因為 136 < 208, 所以我會去找該結點的右子樹
- 因為 398 > 208 ,所以我去找該結點的左子樹
- 因為 250 > 208 , 所以我去找該結點的左子樹
- 因為 200 < 208 ,所以我去找該結點的右子樹。 但鍵值為200的結點沒有右子樹了,所以是該樹不存在鍵值為208的結點(因為如果它確實存在,它肯定就在200的右子樹中)
現在,假設我要找鍵值為40的結點
- 我會從 (鍵值是136的)根結點開始找,因為 136 > 40, 所以我會去找該結點的左子樹
- 因為 80 > 40 ,所以我去找該結點的左子樹
- 40 = 40 , 所以結點是存在的。我可以從這個結點中提取行ID(這屬性不在圖中),然後通過這個ID去找到表中對應的行。
- 知曉了行ID 讓我們能精確地知道數據放在表的哪個位置,因此我們能立即獲取到。
最後,這兩次搜索都用了 樹的層數 次,如果你仔細閱讀了合並排序那部分,你應該會知道這是 log(N) 級別的時間復雜度。搜索的成本的 log(n),還不錯
回到問題
但這東西是挺抽象的,還是回到我們原來的問題吧。不用那些愚蠢的整數,想象下用字符串去表示上面那個表的人的國家。假設你有一個表有一個“國家(country)”的列(column):
- 如果你想知道有誰在英國工作
- 你查找樹去獲得英國的結點
- 在英國的結點裏,你會找到一些英國工人的行的位置
這種搜索只花費你 log(N) 次操作,而如果直接用數組搜索就要用 O(N) 此操作了。你剛才想到的東西就是 數據庫索引。 你可以為任何一組列(1個字符串,1個整數,2個字符串,1個整數和1個字符串,日期) 構建索引,只要你有一個函數去對比它們的鍵(keys)來建立鍵與鍵之間的順序(數據庫任何基本類型都能這樣)
B+ 樹索引
就像你看到那些,這裏多了很多結點(之前的兩倍以上)。其實,有額外的結點叫“決策結點”(decision nodes? 應該是藍色的部分)這會幫你找到正確的結點(存儲了相關表中的行的位置)。但搜索的復雜度仍然是 O(log(N)) (還是同一個級別)。差別最大的是最底部的葉子結點和後繼結點都連在一起了。 使用這 B+ 樹,如果在找 40 到 100 之間的所有值:
- 你只需去找 40(或者是在40後最接近的值,因為40可能不存在 ) ,就是你之前搜索樹那樣
- 然後通過連接找 40結點的後繼結點,直到搜到 100 結點。
假設你找到了 M個後繼結點而樹有 N 個結點。這特征結點的搜索就像之前的樹那些會耗費 log(N) 次操作。但一旦你找到這個結點了,你就能在 M 次操作內通過他們的連接知道 M 個後繼結點了。這搜索只花費 M+log(N) 次操作,相對於之前的樹要用 N 次操作。此外,你不用去讀完整的樹(只需讀 M+log(N) 個結點),這意味著磁盤用得更少。如果 M 很少(比如是 200個)而 N 很大(有1,000,000行)兩個算法就有很大的不同了。
但這也會帶來新的問題,如果在數據庫中添加或者修改一行(所以對應的B+樹中去索引):
- 你不得不在B+樹中保持兩個節點間的順序,否則你無法在混亂中找到節點
- 你必須要底部的結點保持在盡可能的層數,要不然 log(N) 的復雜度可能會變成 O(N) (比如全部都在右子樹)
總之,B+樹需要自排序和自平衡。值得慶幸的是,可以通過智能刪除和智能插入的操作是實現。但這也帶來一個成本,在B+中插入和刪除都會是 log(N) 。這就是為何你會聽到,使用太多索引不是好主意。其實,索引會減慢在表中插入/更新/刪除行的速度,這是因為每條索引數據庫都需要耗費 log(N) 的操作為進行更新維護。還有,添加索引意味著事務管理器有更多的負載(我們在文件的最後能看到這個管理器)
更多細節,你可以看維基百科的 B+樹的B+樹的文章。如果你想要在一個數據庫中實現B+s樹的例子你可以看這篇文章和這篇文章,這兩篇文章都是 MySQL 的核心開發者寫的。這兩篇文章都關註 innoDB(MySQL引擎) 怎樣處理索引
註意:讀者告訴我,因為要底層化,所以 B+ 樹需要完全平衡
哈希表
我們最好一個重要的數據結果就是哈希表。這是非常有用的當你想快速尋找值。此外,明白哈希表會幫主我們再之後理解數據庫一個基本連接操作叫哈希連接(hash join)。這數據結構也被數據庫用來存儲一些內部數據(像是鎖表和緩沖池,我們會在後面的內容中看到這兩個概念) 哈希表是能用鍵(key)快速尋找到元素的數據結構。要創建哈希表你需要定義:
- 元素的鍵
- 鍵的哈希函數。這函數會計算出哈希值從而元素的一堆位置(叫桶 buckets)
- 對比鍵的函數。一旦你找到正確的桶,你就必須用這個函數比對從而找到正確的元素
一個簡單的例子
這哈希表有10個桶。我很懶,所以只畫了5個桶,但我知道你們很聰明,所有我讓你想象其他5個桶。我使用的哈希函數是將鍵 模10(即key % 10)。換句話說,要找到桶我只要用元素的鍵(key)的最後一位數字
- 如果最後一個數字是0,搜索元素會桶0中介素
- 如果最後一個數字是1,搜索元素會桶1中結束
- 如果最後一個數字是2,搜索元素會桶2中結束
我使用的比較函數僅僅是2個整數間的是否相等。 假設你想元素78:
- 哈希函數計算出78的哈希碼,即8
- 它在桶8中查找,它找到的第一個元素是78
- 它會返回元素78
- 搜索僅耗費2個操作(1個用於計算哈希值,另一個用於查找桶內的元素)
現在,假設你想獲得元素59
- 哈希函數計算59的哈希碼,即9
- 它在桶9中查找,它找到的第一個元素是99.由於99!= 59,元素99不是正確的元素
- 使用相同的邏輯,它查看第二個元素(9),第三個元素(79),...和最後一個元素(29)
- 該元素不存在
- 搜索耗費為7次操作
一個好的哈希函數
如你所見,不同的值查找的成本的不一致的,這取決於你要找的值。 如果我現在講哈希函數改成對鍵模 1,000,000 (即取最後6位),上面的第二次搜索也只花費1次操作,因為 000059 中沒有元素。所以真正的挑戰是尋找一個好的哈希函數來創建只包含很少元素的桶。 在我的例子中,找到一個好的哈希函數很容易。但那只是一個簡單的例子,找一個好的哈希函數是很困難的,尤其是遇到(鍵)key是:
- 字符串(如:人的姓氏)
- 2個字符串(如:人的姓和名)
- 2個字符串和一個日期(如:人的姓名+生日)
- 。。。
好的散列函數,會讓哈希表搜索在 O(1)
數組 vs 哈希表
為什麽不用數組 恩,你問了個好的問題
- 哈希表能在內存中半加載,其他的桶可以放在磁盤上
- 一個數組你必須要在內存中開辟一片連續的空間。如果你加載一個很大的表,這是很難有足夠多的連續空間的
- 哈希表你可以選擇你想要的鍵(如:國家和人的姓氏)
更多的信息,你可以讀我的文章,java HashMap 一個高效的哈希表的實現;在這篇文章中你不需要理解 java 的概念
[譯]數據庫是如何工作(二)回到原點 算法基礎