從外部查詢看數據庫的內部實現機制

在上一章中,我們簡單的描述了組成一個小型數據庫的核心組成部分,那麼在本章,我會用一些常見的操作,將這些組件串聯起來,讓大家對這些東西如何被有機的組織起來完成了大家的功能的。但需要注意的是,這裏面提到的順序,可能在不同的數據庫內會有些許的變化,因爲這些組件的執行順序,沒有明確的規範和約定要求某個數據庫一定要這樣,更多的只是因爲數據庫發展了這麼多年而形成的約定俗成的執行模式

場景描述,我們有個關係表叫T,有三個行組成pk,cash,col2。總共有”N行”的數據。pk是主鍵,sql路徑過程中,我將依照“誰[做了什麼]”的模式進行解說

好了,下面第一個需要解決的問題:

我需要儘可能快的查找

select*fromTwherepk=100020。這個應該怎麼做?

這是個很簡單的主鍵查詢,在上一篇文章中,我們介紹過“映射”這個概念,在這裏,讓我們將這個查詢應用到一個映射上,來看看我們如何依託映射這種數據結構,來快速的完成這個查詢。

一個映射,一定是有個key,有個value的,主要組織方式有兩類,一類是hash,一類是有序數據(後面我們會經常碰到需要映射的場合:)。我們在這裏,爲了簡化起見,選擇有序數據作爲實現方式。這種方式的時間複雜度一般都是O(logN)

那麼下一個最重要的問題是,我們應該按什麼方式來組織這個key-value,能做到最快的查詢速度呢?

很容易的可以想到,既然我要查詢pk,自然的把pk的值放在key的位置,cash,col2放在value的方式,明顯是查詢最快的方法。於是,我們首先需要建立一個映射,這個映射的key是pk的值,value則是cash+col2的組合,這種組合在不同數據庫實現中是不一樣的,比如使用豎線分割,或者固定數據大小等,核心要保證的是儘可能清晰,節省空間。

那麼,select*fromTwherepk=100020這個查詢就可以被轉譯成一個非常簡單的針對映射的操作了,map.get(100020)

返回的結果就是用戶需要的結果。

我們來看看這條sql走的路徑:

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[申請讀鎖(或使用MVCC)]=>映射[讀取主數據]=>觸發器[觸發讀取事件]=>鎖[釋放讀鎖]

select*fromTwherecash=100。應該怎麼做?

首先最容易想到的就是:遍歷這一百萬行記錄,把cash不等於100的記錄都丟棄。剩下的就是符合要求的咯。

但速度太慢,必須加速,想到加速,理性的反應一定是想辦法空間換時間,沒錯,這裏的索引的核心目的,就是空間換時間。把數據進行重排。

簡單分析一下,一個映射關係,只有按照key進行查詢的時候才能夠做到O(1)或者O(logN)。但對非key則只有O(N)的查詢效率。

那麼如果想加速,就讓希望加速的數據也。享受O(logN)的查詢速度不就好了?所以我們可以建立一個新的映射關係,key是cash,value則是pk,爲了表述方便,我們給他命名爲idx_cash。因爲這種映射是針對原有T表中部分數據的重排,爲了表示方便,我們一般把以pk作爲key的數據,叫做一級索引或主索引,而把以其他列作爲key的數據,叫做二級索引或輔索引。

這樣,再進行cash等於100的查詢的時候,就可以先查輔助idx_cash,以logN的複雜度找到一批pk數據,然後再去,主索引中按照pk去找到度和要求的記錄了,這樣做,速度就能夠得到極大的提升

這條sql走的路徑是:

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[申請讀鎖(或使用MVCC)]=>映射[讀取二級索引]=>映射[讀取主數據]=>觸發器[觸發讀取事件]=>鎖[釋放讀鎖]

可以看到,這條sql因爲沒有寫入,所以沒有走到涉及寫入的那些模塊,在查詢過程中,主要是針對查詢進行各種優化,讓這條查詢可以儘可能的使用高效的索引來降低查詢的延遲。這也是數據庫的重要目的–在不大影響寫入的前提下,提供儘可能快的數據庫查詢。

然後我們再來看另外一個sql的例子

insertintoT(pk,cash,col2)values(100,10,20)

這是一次寫入,但執行的過程,一定會與大家的預期略有不同,我們來看看:)

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[申請寫鎖,同時鎖住主數據和輔助索引數據]=>映射[讀取主索引,判斷該值是否存在]=>預寫式日誌[寫入數據日誌]=>映射[寫入數據,如果不存在]=>觸發器[觸發寫入事件]=>映射[根據觸發器,更新二級索引]=>觸發器[觸發二級索引寫入事件]=>預寫式日誌[標記該條記錄全部寫入完成]=>鎖[釋放寫鎖]

可以看到,寫入與讀取,最明顯的差別就在於需要申請寫鎖,以及需要寫預寫式日誌(WAL)。

同時,這裏還有個現象,需要讓大家予以重視,那就是對於insert語義來說,數據庫需要額外的做一次“查詢”操作,以判斷該值是否存在,如果存在則丟主鍵衝突異常。

這種操作,就是關係數據庫中一個很重要的概念:約束,的具體表現形式了。這種約束,在一些老的數據庫更新模式中不會成爲瓶頸,但對於新式的LSMTree實現的插入類操作來說,就有可能是個性能的瓶頸點了。爲此,tokuDB裏面也針對這個場景做過一些優化。

在後面介紹LSMTree系列映射的時候,會再次細緻的針對這個問題進行原理性分析。這裏,只需要大家有個印象,就是,每一種操作,都有其固有的代價。寫軟件,更多的時候是找到共性的東西,並把合適的功能放在合適的地方,更多的時候要多問問:這個功能,別的地方能不能做呢?如果不行,是不是真的有很多人在使用呢?如果都是肯定的,那麼這就應該是我們的系統中應該擁有的功能。如果不是,那麼沒必要爲本來已經很複雜的系統增加過多的功能,讓他獨立出去就好了。

再來看一個更復雜的例子:

一天,李雷在英語課上把韓梅梅的鋼筆弄壞了,要賠給她100元。

我們來用數據庫模擬一下這個過程:

假定李雷賬戶是pk=1,韓梅梅的賬戶是pk=2

begintransaction;

{查看李雷是否有一百元}

selectcashfromTwherepk=1;

{確定有足夠的錢,減少李雷的錢}

updateTsetcash=cach-100wherepk=1;

{給韓梅梅增加一百元}

updateTsetcach=cash+100wherepk=2;

commit;

這裏,要完成一筆交易,在真實的世界裏,可能就是李雷從錢包裏拿出100元的紙鈔交給韓梅梅而已。

但是,對於數據庫來說,他卻沒辦法用一步操作來完成我們所希望的操作。所以,他只能使用“鎖”來進行訪問控制,來模擬減錢加錢的這個模型。想必各位在數據庫原理的大部頭上都看過這麼個例子吧?不過我寫這些東西的主要目標就是讓大家快速的抓住主線,從而更容易的擴展旁支的內容,我們會在後面更細緻的討論事務的問題。

begintransaction;

預寫式日誌[聲明一個事務的唯一標記]

selectcashfromTwherepk=1;

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[申請讀鎖]=>映射[讀取主數據]=>觸發器[觸發讀取事件]

updateTsetcash=cach-100wherepk=1;

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[讀鎖升級爲寫鎖]=>映射[讀取主數據pk=1]=>預寫式日誌[寫入數據日誌,添加事務的唯一標記]=>映射[寫入數據]=>觸發器[觸發寫入事件]=>映射[根據觸發器,更新二級索引]=>觸發器[觸發二級索引寫入事件]

updateTsetcach=cash+100wherepk=2;

sql解析器[sql->sql解析->AST]=>執行優化器[AST->執行優化->executionplan執行計劃]=>鎖[讀鎖升級爲寫鎖]=>映射[讀取主數據pk=2]=>預寫式日誌[寫入數據日誌,添加事務的唯一標記]=>映射[寫入數據]=>觸發器[觸發寫入事件]=>映射[根據觸發器,更新二級索引]=>觸發器[觸發二級索引寫入事件]

commit;

預寫式日誌[標明該事務提交]

好了,以上是三種最常見的數據庫操作使用我們上面關鍵的組件的方法,裏面可能有些地方的順序在不同數據庫內的做法不同,也有些時候,一些場景會能夠使用MVCC來替換讀寫鎖的操作從而能夠進一步的提升並行度,不過那些不是我們今天要關注的主題,如果你看完了這篇文章以後,能夠對數據庫的運轉狀態有一個粗淺的認識,那麼我想我的目標就達到了:)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章