在上一章中,我們簡單的描述了組成一個小型數據庫的核心組成部分,那么在本章,我會用一些常見的操作,將這些組件串聯起來,讓大家對這些東西如何被有機的組織起來完成了大家的功能的。但需要注意的是,這里面提到的順序,可能在不同的數據庫內會有些許的變化,因為這些組件的執行順序,沒有明確的規范和約定要求某個數據庫一定要這樣,更多的只是因為數據庫發展了這么多年而形成的約定俗成的執行模式
場景描述,我們有個關系表叫T,有三個行組成 pk,cash,col2。總共有”N行”的數據。 pk是主鍵,sql路徑過程中,我將依照 “誰[做了什么]” 的模式進行解說
好了,下面第一個需要解決的問題:
我需要盡可能快的查找
select * from T where pk= 100020。這個應該怎么做?
這是個很簡單的主鍵查詢,在上一篇文章中,我們介紹過“映射” 這個概念,在這里,讓我們將這個查詢應用到一個映射上,來看看我們如何依托映射這種數據結構,來快速的完成這個查詢。
一個映射,一定是有個key, 有個value的,主要組織方式有兩類,一類是hash,一類是有序數據(后面我們會經常碰到需要映射的場合:)。 我們在這里,為了簡化起見,選擇有序數據作為實現方式。這種方式的時間復雜度一般都是O(logN)
那么下一個最重要的問題是,我們應該按什么方式來組織這個key-value,能做到最快的查詢速度呢?
很容易的可以想到,既然我要查詢pk,自然的把pk的值放在key的位置,cash,col2 放在value的方式,明顯是查詢最快的方法。于是,我們首先需要建立一個映射,這個映射的key是pk的值,value則是cash+col2的組合,這種組合在不同數據庫實現中是不一樣的,比如使用豎線分割,或者固定數據大小等,核心要保證的是盡可能清晰,節省空間。
那么,select * from T where pk= 100020這個查詢就可以被轉譯成一個非常簡單的針對映射的操作了,map.get(100020)
返回的結果就是用戶需要的結果。
我們來看看這條sql走的路徑:
sql解析器[sql->sql解析->AST] => 執行優化器[AST->執行優化->execution plan執行計劃] => 鎖[申請讀鎖(或使用MVCC)] => 映射[讀取主數據] =>觸發器[觸發讀取事件] =>鎖[釋放讀鎖]
select * from T where cash= 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->執行優化->execution plan執行計劃] => 鎖[申請讀鎖(或使用MVCC)] => 映射[讀取二級索引] => 映射[讀取主數據] =>觸發器[觸發讀取事件] =>鎖[釋放讀鎖]
可以看到,這條sql 因為沒有寫入,所以沒有走到涉及寫入的那些模塊,在查詢過程中,主要是針對查詢進行各種優化,讓這條查詢可以盡可能的使用高效的索引來降低查詢的延遲。 這也是數據庫的重要目的– 在不大影響寫入的前提下,提供盡可能快的數據庫查詢。
然后我們再來看另外一個sql的例子
insert into T (pk,cash,col2) values (100,10,20)
這是一次寫入,但執行的過程,一定會與大家的預期略有不同,我們來看看:)
sql解析器[sql->sql解析->AST] => 執行優化器[AST->執行優化->execution plan執行計劃] => 鎖[申請寫鎖,同時鎖住主數據和輔助索引數據] => 映射[讀取主索引,判斷該值是否存在] =>預寫式日志[寫入數據日志]=> 映射[寫入數據,如果不存在] =>觸發器[觸發寫入事件] => 映射[根據觸發器,更新二級索引] => 觸發器[觸發二級索引寫入事件] =>預寫式日志[標記該條記錄全部寫入完成]=>鎖[釋放寫鎖]
可以看到,寫入與讀取,最明顯的差別就在于需要申請寫鎖,以及需要寫預寫式日志(WAL)。
同時,這里還有個現象,需要讓大家予以重視,那就是對于insert語義來說,數據庫需要額外的做一次“查詢”操作,以判斷該值是否存在,如果存在則丟主鍵沖突異常。
這種操作,就是關系數據庫中一個很重要的概念:約束,的具體表現形式了。這種約束,在一些老的數據庫更新模式中不會成為瓶頸,但對于新式的LSMTree實現的插入類操作來說,就有可能是個性能的瓶頸點了。為此,tokuDB里面也針對這個場景做過一些優化。
在后面介紹LSMTree系列映射的時候,會再次細致的針對這個問題進行原理性分析。這里,只需要大家有個印象,就是,每一種操作,都有其固有的代價。寫軟件,更多的時候是找到共性的東西,并把合適的功能放在合適的地方,更多的時候要多問問: 這個功能,別的地方能不能做呢? 如果不行,是不是真的有很多人在使用呢? 如果都是肯定的,那么這就應該是我們的系統中應該擁有的功能。如果不是,那么沒必要為本來已經很復雜的系統增加過多的功能,讓他獨立出去就好了。
再來看一個更復雜的例子:
一天,李雷在英語課上把韓梅梅的鋼筆弄壞了,要賠給她100元。
我們來用數據庫模擬一下這個過程:
假定李雷賬戶是pk=1 , 韓梅梅的賬戶是pk=2
begin transaction;
{查看李雷是否有一百元}
select cash from T where pk = 1;
{確定有足夠的錢,減少李雷的錢}
update T set cash = cach-100 where pk = 1;
{給韓梅梅增加一百元}
update T set cach = cash+100 where pk =2;
commit;
這里,要完成一筆交易,在真實的世界里,可能就是李雷從錢包里拿出100元的紙鈔交給韓梅梅而已。
但是,對于數據庫來說,他卻沒辦法用一步操作來完成我們所希望的操作。 所以,他只能使用“鎖”來進行訪問控制,來模擬減錢加錢的這個模型。想必各位在數據庫原理的大部頭上都看過這么個例子吧? 不過我寫這些東西的主要目標就是讓大家快速的抓住主線,從而更容易的擴展旁支的內容,我們會在后面更細致的討論事務的問題。
begin transaction ;
預寫式日志[聲明一個事務的唯一標記]
select cash from T where pk = 1;
sql解析器[sql->sql解析->AST] => 執行優化器[AST->執行優化->execution plan執行計劃] => 鎖[申請讀鎖] => 映射[讀取主數據] =>觸發器[觸發讀取事件]
update T set cash = cach-100 where pk = 1;
sql解析器[sql->sql解析->AST] => 執行優化器[AST->執行優化->execution plan執行計劃] => 鎖[讀鎖升級為寫鎖] => 映射[讀取主數據pk=1] => 預寫式日志[寫入數據日志,添加事務的唯一標記] => 映射[寫入數據] =>觸發器[觸發寫入事件] => 映射[根據觸發器,更新二級索引] => 觸發器[觸發二級索引寫入事件]
update T set cach = cash+100 where pk =2;
sql解析器[sql->sql解析->AST] => 執行優化器[AST->執行優化->execution plan執行計劃] => 鎖[讀鎖升級為寫鎖] => 映射[讀取主數據pk=2] => 預寫式日志[寫入數據日志,添加事務的唯一標記] => 映射[寫入數據] =>觸發器[觸發寫入事件] => 映射[根據觸發器,更新二級索引] => 觸發器[觸發二級索引寫入事件]
commit;
預寫式日志[標明該事務提交]
好了,以上是三種最常見的數據庫操作使用我們上面關鍵的組件的方法,里面可能有些地方的順序在不同數據庫內的做法不同,也有些時候,一些場景會能夠使用MVCC來替換讀寫鎖的操作從而能夠進一步的提升并行度,不過那些不是我們今天要關注的主題,如果你看完了這篇文章以后,能夠對數據庫的運轉狀態有一個粗淺的認識,那么我想我的目標就達到了:)