幾句閑話:08年還是大二的時候,在導師的建議下,讀了布朗大學Edward Sciore教授做的一個教學RDBMS,叫simpledb,然后和同學一起照貓畫虎實現一個C#版本的,當初只是為了了解RDBMS的實現原理。幾年后,電面時幾次被問到當初的這個小東西,發現自己竟然有好多細節忘記了。不由生出心思,再讀一遍。或許,RDBMS不想以前那么火了,Nosql在大行其道,不過,想起在《Readings in database》里面有一篇論文叫《What goes around, comes around》,復習一些,還是會有些裨益吧。而且,simpledb麻雀雖小,五臟俱全,對於了解RDBMS的底層實現還是挺有益處的。核心代碼約6000多行,代價也不算很大了(這次的文章基於的是自己重現的C#版本的代碼)。
步入正題了。
simpledb下總共有15個包:buffer, file, index, log, materialize, metadata, multibuffer, opt, parse, planner, query, record, remote, server, tx。
圖1 SimpleDB 文件的結構
----------Add on 2012.8.29-----------
偶然翻到之前的資料,看到一張simpledb各個模塊的邏輯圖,覺得對於了解整個RDBMS的結構頗有益處,就在這里補上
-------------------------------------------
本文先從最底層的存儲說起,buffer & file。這里的log也算是底層的:log包內只提供在磁盤塊級別對int和string的讀寫,不關心log的內容表意。但是由於其倒序讀寫的特殊數據結構,下一篇來單獨說它。
file包內的文件結構如下:
圖2 file包的類圖
> Block
是最基礎的磁盤存儲單元,也是系統中存取數據的最小單元。所有的數據,都是通過flush到Block上,進而實現的持久化。Block的成員參數包括:所屬文件名filename,在所屬文件中的編號blknum。Block與文件的關系,可以用下圖來示意:
圖3 Block與文件的關系
一個文件由整數個Block來組成。Block的編號從0開始。新建一個文件指出,文件是空的,隨着系統的需求,向文件中附加一個Block,使文件的大小擴張。
> Page
是系統運行時實際數據的保存位置。一個Page包含一個byte類型的contents數組,系統讀寫數據的時候,都是先將數據存儲到這個contents數組,然后,通過這個數組持久化到一個Block中,或者傳遞給別的內存對象。通常一個Block的大小為4K,byte數組長度為4096。但是,要注意的是,Page自己,沒有綁定某一個Block。
圖4 Page與Block的關系
有兩點要注意的:
1、讀寫數據的時候,要考慮到多個線程並發訪問同一個文件的情況,為保持一致性,采用lock加鎖的機制,實現互斥的訪問。threadLock是一個為lock加鎖提供的“樁”。第一次寫的時候,用的是"lock(this);",這樣的話,只是鎖住線程自己,沒辦法做到線程之間的干擾,后來debug時,改成設置一個加鎖的“樁”。
2、Page中的read,write,append,並沒有真正地實現底層數據的讀寫,而是通過其包裝的一個FileMgr的對象,調用了FileMgr的read,write,append方法。這三個方法,實際是Block與Page之間的操作:將Block中數據獨到Page中,將Page中數據寫到Block中,將Page中的數據附加到指定的文件中(即寫入指定文件的塊中)。Page只需要關心在contents數組上,數據的讀寫:setInt,setString,getInt,getString 就可以了。
> FileMgr
是一個工具類。具體實現了數據在內存與磁盤之間的轉移。
數據庫的存儲結構,在這里可以初見端倪。
dbDirectory是數據庫目錄名,新建一個數據庫 testdb 后,會在指定目錄下,創建一個testdb 命名的目錄。之后,數據庫中的表,都保存在該目錄下。
openFiles作為一個字典,保存了文件名與文件流的映射,有點像句柄列表的意思。openFiles維護了當前數據庫下打開的文件。
getFile是對openFiles操作的唯一方法。輸入是一個文件名,輸出時一個文件流。read、write、append都需要通過getFile獲取一個文件流。如果文件存在,就打開,返回文件流;如果文件不存在,就創建一個新的文件。getFile包含了文件創建,因而是private的。
FileMgr下有兩個公共方法:
size() 返回的是指定的文件中Block的個數。文件中Block的編號從0開始,這里返回的size,對於一個要向文件中新添加的Block來說,也就是Block的編號。size()通常被各種工具類調用,獲取文件的結束位置。
isNew()返回數據庫文件夾是否創建。在系統初始化的時候被調用。
read、write、append三個方法是在全系統中,真正將內存數據與磁盤數據轉化的。首先加鎖,然后通過getFile獲取指定文件對應的文件流對象fs,再通過fs實現byte[] 與Block的 數據往來。
buffer包內的文件結構如下:
圖5 buffer包的類圖
Buffer包提供了simpledb自己的一套緩沖機制,實現了自己的緩沖池和緩沖調度算法。
> Buffer
是系統中其他上層的類直接使用的對象,也就是說,Log、Transaction等需要讀寫數據的時候,首先創建一個Buffer對象,然后,利用這個對象來進行讀寫。
通過類圖可以看到,Buffer中既包含了一個Page對象contents,又包含了一個Block對象blk。聯系前面的Page,可以知道Page里面核心是一個byte型數組contents,是具體磁盤塊的內存映射,而blk是一個磁盤塊對象,這里可以看出,Buffer保持了這種內存數據與磁盤數據的映射。
成員參數中logSequenceNumber初始為-1,Buffer被寫入數據的時候被改變,記錄該寫操作對應日志記錄的LSN;modiffiedBy初始為-1,有寫入操作的時候被改變,記錄該寫入操作的事務編號,就是記下被哪個事務修改的。從這兩處可以發現,這里只對寫入操作做日志,讀操作不做任何記錄,直接讀,也是對系統性能的考慮。
getInt(),getSring()兩個方法,直接包裝的contents的對應方法,不做贅述,setInt(),setString()包裝了contents的對應方法,只是增加了對事務編號,Lsn的記錄。由於在Page類中的對應方法都考慮了並發、加鎖的問題,所以在Buffer這個級別,直接調用,不用再考慮。
flush()確認當前buffer被修改過后,調用contents的write()方法,將數據寫入blk中,同時將modifiedBy復位為-1。注意這里體現了WAL(Write Ahead Log),先調用LogMgr的flush(),將日志寫入文件,然后再調用write()將數據寫入文件。
assignToBlock(Block b)將指定的Block對象讀到當前Buffer中。先調用flush(),保證了如果當前Buffer中含有臟數據,則先將臟數據寫入磁盤;然后將blk引用指向目標Block對象,並將該Block的數據讀到contents中。
assignToNew(string filename, PageFormatter fmtr) 按照指定的頁格式格式化contents對象,並將該Page對象附加到指定的filename文件下。理解這個有點費勁,參看了后面的代碼,format方法,實際上是向Page中寫入一些實際數據之外的附加信息,如標記位等,這樣format后,contents中實際上有內容了,然后調用的append方法,將contents中的數據附加到filename文件中,同時將寫入數據所在的磁盤塊的引用返回給blk。這樣,就明白了。當然,這一套之前,要處理原有buffer中臟數據,如前所述。
總結上上面兩個assign*方法:Buffer沒有重寫構造函數,而且blk定義的時候就被初始化為null,統觀整個Buffer類中的代碼,對blk的值操作的只有兩個assign*方法。而只有assignToNew帶有了filename參數,因為顯然的事實是Buffer綁定的Block必須屬於某一個文件,所以得到的結論是:新打開的文件,最先被調用的assignToNew,綁定文件,取該文件中的一個塊到緩沖;之后,依次從文件塊中讀數據到緩沖的時候,用的是assignToBlock。
最后,來說一下Buffer的pins標記,以及pin()和unpin()方法。
Buffer中的pin(),unpin()方法,只是修改pins的值。通過判斷pins的值,來標記Buffer是否被使用。結合了BasicBufferManager來看,通過pin和unpin,管理了緩沖池中的Buffer片,實現了對Buffer的分配和回收。詳情留到后面的都BasicBufferMgr中講。
> BasicBufferMgr
是基本的緩沖調度器,管理緩沖池中Buffer的pin和unpin。不考慮忙等待和任何的調度策略。
BasicBufferMgr維護了一個Buffer數組作為緩沖池,用numAvailable標記當前可用的Buffer個數。
緩沖池作為一種臨界資源,需要被互斥地訪問。因為申請緩沖片的時候,多個線程同時申請的時候,可用Buffer數依次減少,並發會導致幻影問題出現,所以需要設置threadLock,加鎖的“樁”。同時,操作緩沖池bufferPool的方法,都需要用lock加鎖,保證互斥訪問。
首先看構造函數,輸入的參數是numbuffs,即緩沖池中緩沖片的個數。在構造函數中,bufferpool數組被初始化。
再看關於bufferpool的遍歷:
findExistingBuffer,輸入一個Block兌現的引用,遍歷緩沖池的緩沖片,看是否有Buffer已經分配給該Block。
chooseUnpinnedBuffer,遍歷整個緩沖池,查看是有有未使用的緩沖片。
這兩個方法只是對bufferpool的讀操作,沒有用lock加鎖。為什么讀就不加鎖了?因為這兩個方法是在pin,unpin的內部被調用的,在調用的時候,臨界區已經只剩下一個線程在讀bufferpool了,故不用再次加鎖。
然后是操作bufferpool的幾個方法:
pin() 將一個給參數中的Block分配一個Buffer對象。先要加鎖。然后調用findingExistingBuffer,查看是否已經該Block對象分配過Buffer。未找到,則調用chooseUnpinnedBuffer(),嘗試找一個未使用過的Buffer。如果未找到,則返回null;如果找到,則將該buffer分配給Block;之后修改numAvailable數量,調用Buffer自己的pin,修改Buffer自己的計數器pins。最后,返回設置好的Buffer對象。
pinNew() 與pin()不用之處在於pinNew是從一個新的文件filename中獲取一個Block,而pin則是使用輸入參數中指定得到Block。如此,pinNew在使用的時候,用的是Buffer的assignToNew方法。緩沖片分配的方法與pin類似。由於是從一個全新的文件中得到Block,所以不存在“查看是否已經該Block對象分配過Buffer”的情況。
unpin()收回指定的緩沖片。加鎖后,只涉及Buffer對象自己的pins值的修改,以及numAvailable值的修改。
flushAll() 遍歷整個緩沖池bufferpool,將每個緩沖片中的數據都刷寫到對應的磁盤塊上。
> BufferMgr
是系統中其他模塊可以公開訪問的緩沖管理器。提供了與BasicBufferMgr相同的方法,只是不同之處在於,增加了請求緩沖片時候的忙等待,使得pin和pinNew不會返回空值。
忙等待機制:如果當前沒有可用的緩沖片,請求線程進入一個等待序列,當有可用緩沖片的時候,請求線程從等待隊列中移除。請求線程的等待時間有一個閾值,超過閾值之后,系統會跑出一個異常BufferAbortException。
設置的等待時間的閾值是10s,線程等待時間超過了10s,便自動退出。
pin()使用try {} catch(){} 處理異常拋出;最開始,記錄時間戳:long timestamp = DateTime.Now.Ticks; 然后,調用BasicBufferMgr的pin()方法,申請緩沖片,如果成功則返回,否則,線程進入忙等待。繼續等待的條件有2:A,緩沖片未申請到,B,等待未超時。waitTooLong()方法不停檢測是否超時。等待期間,使用了Monitor。Monitor只是為了阻塞當前線程,阻塞最長MAX_TIME的時間。
pinNew()同pin類似,除了忙等待之外,具體實現,參見前面的BasicBufferMgr.pinNew()。
unpin()在釋放緩沖片的時候,同時喚醒阻塞線程。
其他方法,基本都是包裝的BasicBufferMgr,不再重復。
> PageFormatter
是一個接口,用來初始化一個數據塊。只有一個方法,format,如前面提到,format方法就是向文件塊(Block)中寫入一些輔助信息,將磁盤塊格式化成指定的形式。PageFormatter喲兩個實現BTPageFormatter,RecordPageFormatter,分別是B+樹頁面格式化器和數據記錄頁面格式化器。
> BufferAbortException
是一個異常類,不多贅述。
--
The end。