作為一個天天都在CRUD的程序員,你有沒有想過,數(shù)據(jù)庫是如何工作的?
我猜,你曾經(jīng)無數(shù)次的翻開講數(shù)據(jù)庫的書籍和文章,但總是看著看著就被勸退,太多的專業(yè)術語把人頭都搞大了。
等等,看這畫風,今天是要賣課的節(jié)奏啊!
NO!絕不賣課,請放心閱讀。
一直以來,我都很想深入學習一下數(shù)據(jù)庫。我這個人學東西,如果只知其然而不知其所以然是非常難受的,啥都想去了解下背后的原理,學編程語言是這樣,學操作系統(tǒng)也是這樣。數(shù)據(jù)庫這個東西天天都在用,所以學習一下背后的原理也是非常實用和有必要的。
但無論是哪本書、哪個視頻教程,一打開就被無數(shù)的專業(yè)術語淹沒,太多概念淹沒了數(shù)據(jù)庫最本質(zhì)最核心的東西。
所以今天,讓我們從一個最最最簡單的模型開始,揭開數(shù)據(jù)庫神秘的一角。
對我們使用者而言,數(shù)據(jù)庫就像是一個黑盒子,你可以往它里面寫入數(shù)據(jù),也可以從它里面讀出數(shù)據(jù)。
讓我們暫時拋卻SQL、網(wǎng)絡連接、數(shù)據(jù)庫等等概念,就來看這個最基本的需求:如果我們來實現(xiàn)一個可以對外提供讀寫數(shù)據(jù)的黑盒子,該怎么做?
假設我們的黑盒子很簡單,里面只有一張表:user_info,用來存儲用戶信息。
表里面也很簡單,只有三個字段,分別記錄用戶的ID、姓名和手機號。
user_id(uint32)
name(char[8])
phone(char[20])
我們可以用一個簡單的結構體(或者一個class)來表示一條數(shù)據(jù):
struct DataRecord{
uint32 user_id;
char name[8];
char phone[20];
};
user_id是一個uint32類型,占4個字節(jié),name占8個字節(jié),phone占20個字節(jié),加起來一條數(shù)據(jù)總共占32個字節(jié)。
我們選擇用一個文件來存儲這些數(shù)據(jù),存儲非常簡單,只需要一條一條的碼在一起就行了,就像這樣:
數(shù)據(jù)存儲方式有了,接下來就是如何來讀寫了,我們來提供兩個函數(shù),分別來插入(insert)和查詢(select)數(shù)據(jù):
// 偽代碼
void insert(Table* table, DataRecord record) {
fp = open(table->file_name);
fseek(fp, FILE_END);
write(fp, sizeof(DataRecord));
close(fp);
}
插入很簡單,直接打開表對應的數(shù)據(jù)文件,然后把文件指針移動到文件尾部,接著追加數(shù)據(jù),最后關閉文件,大功告成。這和把大象關進冰箱的步驟是一樣一樣的。
接下來是查詢,我們提供一個可以通過id來查詢用戶的函數(shù):
// 偽代碼
DataRecord select(uint32 user_id) {
DataRecord result;
fp = open(table->file_name);
while (1) {
if (feof(fp))
break;
DataRecord record;
read(fp, &record, sizeof(DataRecord));
if (record.user_id == user_id) {
result = record;
break;
}
}
close(fp);
return result;
}
查詢也很簡單,我們打開文件,一條一條讀取數(shù)據(jù),然后比較用戶id和給定的參數(shù)是否相同,直到找到符合條件的數(shù)據(jù)返回。
好了,以上,我們就實現(xiàn)了一個最最最基礎的黑盒子:它里面有一張表,然后可以往里面寫數(shù)據(jù),從里面查數(shù)據(jù)。
現(xiàn)在來思考一下:
如果我們寫了很多數(shù)據(jù)進去,比如幾十萬條,我們要查一個指定id的數(shù)據(jù),要按照這樣一條條比對,那不得等到猴年馬月去了?
為什么要一條條比對呢?是因為我們的數(shù)據(jù)全都是一條一條碼在一起的,完全沒有順序,所以要查詢,只能這樣一條條檢查——全表掃描。
如果我們的數(shù)據(jù)是有順序的呢?
假如我們插入數(shù)據(jù)的時候,是按照id從小到大排列著,這樣我們就能用二分法快速找到指定id的數(shù)據(jù)了。
看上面這張圖,假設我們要查找id為9的數(shù)據(jù),我們可以讀取第一條數(shù)據(jù)的id是1,就知道id為9的數(shù)據(jù)肯定在它后面。然后再讀取最后一條數(shù)據(jù)id是12,就知道id為9的數(shù)據(jù)肯定在它前面,然后選擇中間的數(shù)據(jù)讀取,如此二分查找,很快就能鎖定目標,不用每條數(shù)據(jù)都讀取了。
因為每條數(shù)據(jù)都是32個字節(jié),所以可以非常方便定位任意一條數(shù)據(jù)的位置:第n條數(shù)據(jù)的位置在32*(n-1)偏移處。
通過改變數(shù)據(jù)的存儲組織形式,我們可以把數(shù)據(jù)查找的時間復雜度從O(N)下降到O(LogN)。
但如此一來,查找是變快了,但插入就麻煩了。以前的時候,我們直接把數(shù)據(jù)塞到文件最后就拍拍屁股走人了。但現(xiàn)在不行了,我們得把數(shù)據(jù)按照順序,插入到合適的位置。
最麻煩的是,我們的數(shù)據(jù)是一條一條挨個碼在一起的,如果中間某個位置要插入數(shù)據(jù),就得把那個位置及其以后的數(shù)據(jù)通通往后移動,這就涉及到大量的數(shù)據(jù)復制移動,開銷非常大。
要是每來一條insert操作就數(shù)據(jù)大量遷徙,那不得累得半死?
(其實不止插入,刪除數(shù)據(jù)delete也同樣如此麻煩)
就沒有一種辦法,可以同時插入快,查詢也快嗎?
仔細思考一下,前面我們把數(shù)據(jù)按順序一條一條碼在一起,查起來為什么快?
是因為做了排序以后,數(shù)據(jù)的存儲位置有一條隱含的信息:如果id比我們要找的小,那我們要找的肯定在它后面;反之,如果id比我們要找的大,那我們要找的肯定在它前面。
之所以有這個規(guī)律,是因為我們按id的大小進行了排序存儲。
那如果現(xiàn)在回到最開始的那種方式,不排序了,還是一條一條順序來寫入,就沒有這個信息了。
但如果,我們在每條數(shù)據(jù)記錄中增加一些額外的信息,用來指示id比它小的在哪里,id比它大的又在哪里,是不是就能順著這些額外的信息“順藤摸瓜”找到你要找的數(shù)據(jù)呢?
或許,聰明的你已經(jīng)看出來了,這是按照“二叉樹”的形式在記錄數(shù)據(jù)位置信息。
但實際上,二叉樹也才兩個分叉,如果數(shù)據(jù)量很大的話,這棵樹就會很高很瘦。
而每一次走入一個分支,就對應著一次文件I/O,所以在實際使用中,不會使用二叉樹,而是使用開了非常多個叉的樹——B樹或者B+樹。
如果用B樹或者B+樹來將文件中的數(shù)據(jù)在邏輯上組織起來,要查找數(shù)據(jù)就會快得多。
用id來查找數(shù)據(jù)問題解決了,但如果要用name來查找又該怎么辦呢?
想一想,如果另外有一個文件,記錄了每個name和這個name對應的數(shù)據(jù)記錄在文件中的偏移位置,就像這樣:
user_id數(shù)據(jù)位置(偏移)xuanyuan0shuaidi31april63dibingfa95
有了這個東西,是不是就簡單很多了?只需要在這里搜一遍,拿到數(shù)據(jù)的位置,然后打開文件,定位到對應的偏移,一下就讀出來了。
我們可以另外準備一個文件,同樣使用B樹或者B+樹的形式來組織上面表格中的對應關系。
聰明的你可能已經(jīng)看出來了,這玩意兒其實就是索引。當然實際中的數(shù)據(jù)庫系統(tǒng)的索引實現(xiàn)或多或少有一些差別,但道理是通用的。
原文鏈接:
https://mp.weixin.qq.com/s/wUwJ4sPapE74AbOE2j5hMw