CMU 15-445 資料庫課程第三課文字版 - 儲存1
熟肉影片地址: - CMU資料庫管理系統課程[熟肉]3.資料庫儲存結構1(上) - CMU資料庫管理系統課程[熟肉]3.資料庫儲存結構1(下)
1. 課程大綱
這門課主要是關於如何開發一個功能全面的資料庫管理系統,而不是如何編寫複雜的 SQL 查詢以及設計出最合理的關係模型資料庫表。這門課會告訴你從低往上設計一個數據庫管理系統需要的這些技術棧層: - 磁碟管理(Disk Manager) - 快取池管理(Buffer Pool Manager) - 訪問方法(Access Method) - 操作執行(Operator Execution) - 查詢計劃(Query Plan)
涉及的主題包括: - 關係資料庫(Relational Databases,這個前面兩節課已經過了一遍,咱們這個系列從第三節課開始,這部分就省略了) - 儲存(Storage) - 執行(Execution) - 併發控制(Concurrency Control) - 恢復(Recovery) - 分散式資料庫(Distributed Databases) - 其他一些有意思的實戰(Potpourri)
2. 儲存介質與為何不用 mmap
面向磁碟的資料庫系統是這樣一個系統,軟體假定資料庫的主要搜尋位置在磁碟上。這意味著執行一個查詢,它可能要訪問不在記憶體中的資料,它需要將資料從 non-volatile 儲存(例如磁碟)載入到 volatile 儲存(例如記憶體)中。
計算機儲存層次結構如上圖所示,越往上,大小越小,成本越昂貴但是訪問速度越快,越往下,大小越大,成本越低但是訪問速度更慢,這些從頂部往下包括:
- Volatile 儲存:斷電後,儲存的資料會丟失。支援位元組訪問,即可以直接讀取取任意位元組大小的資料,也可以直接更新。隨機訪問與順序訪問的速度差不多。
- CPU 暫存器
- CPU 快取(L1,L2,L3)
- DRAM(Dynamic Random Access Memory 動態隨機存取儲存器)
- non-volatile 儲存:斷電後,儲存的資料不會會丟失。不支援位元組訪問,只支援塊訪問,即如果你要讀取某一位元組的資料,必須將這位元組所在的塊或者頁(page)的資料一起讀取出來,並且一起更新。隨機訪問速度遠小於順序讀取,所以對於 non-volatile 儲存必須儘量用順序訪問。
- SSD(Solid State Disk,固態硬碟)
- HDD(Hard Disk Drive,硬碟驅動器):這裡他經常管這塊叫做 Spinning Disk Hard Drive,其實就是機械硬碟,一個磁頭在磁碟上面不斷移動尋找資料。
- Network Storage(網路儲存):像是 AWS 的 EBS 以及 S3 這種網路儲存服務。
在這門課中,不會關心 CPU 暫存器以及快取,將 DRAM 看做記憶體(Memory),將 SSD、HDD、Network Storage 看做磁碟(Disk)。我們只關心記憶體和磁碟。
這裡其實還有一些新型的儲存,可能打破這些邊界,例如: - Non-volatile Memory:可以像記憶體一樣支援位元組訪問,同時掉電也不會丟失資料。目前釋出的產品是 Intel® Optane™ Memory:http://www.intel.com/content/www/us/en/products/details/memory-storage/optane-memory.html - Fast Network Storage:快速網路儲存,例如 NAS(Network Attached Storage) 這些新型儲存可能會打破現有的設計,但是他們還沒有被大幅度的採用,這門課還不會涉及這些。
下面我們再來看看訪問這些不同儲存大概的耗時(網上各種數字很多,我們只要關注數量級即可)
因此,我們資料庫管理系統的目標,雖然我們想要儲存一個超過可用記憶體容量的資料庫,但是我們想給應用程式提供一種錯覺,即我們有足夠的記憶體將整個資料庫儲存在記憶體中,用一些快取,預先計算一些資料,允許不同的執行緒或不同的查詢同時執行來避免每次讀或寫的時候都因為寫入或者讀取磁碟導致執行效率低。
我們要在這門課上設計的是一個基於磁碟持久化的 DBMS,如上圖所示: - 最下面一層是磁碟,放著單個檔案或者多個檔案構成的資料庫檔案。 - 用不同的塊或頁(Page)來劃分檔案中的內容,頁是比較學術的說法。 - 同時檔案中還包含頁表或者頁目錄(Directory),類似於檔案內容與頁與檔案位置的索引。 - 在上層記憶體中,有一個緩衝池(Buffer Pool),裡面使用一些快取演算法將檔案中的頁和頁目錄快取在記憶體中。 - 最上層是執行引擎(Execution Engine),直接將讀取頁的請求傳送給緩衝池,緩衝池不存在就會從磁碟查詢,根據頁目錄定位到頁,載入到緩衝池,返回記憶體中頁的地址。之後執行引擎用這個記憶體中的地址做一些事情。
對於前面提到的記憶體與緩衝池這一層,想那些學過作業系統或者對作業系統熟悉的人可能知道,作業系統有類似的機制,為啥不通過系統呼叫使用作業系統現有的機制實現緩衝池呢?例如 mmap() (記憶體對映檔案的系統呼叫),我們來看下上圖所示的這個場景: - 假設我們磁碟上有四頁,實體記憶體最多容納兩頁 - 記憶體分配是先 reverse 虛擬記憶體,虛擬記憶體是很大的,基本用不完。在實際使用的時候,commit 對映實際的實體記憶體。對於 mmap,是先將檔案對映到程序的虛擬地址空間,實際使用到這個地址的時候,如果不在記憶體中(也就是沒有實際對映實體記憶體),就會發生缺頁中斷(Page Fault),需要阻塞等待載入這頁實際對映到實體記憶體中。 - 假設我們先讀取的是第一頁,在虛擬記憶體中查詢我們發現第一頁實際沒有對映實體記憶體,發生了缺頁中斷,阻塞載入磁碟第一頁資料到記憶體 - 之後讀取的是第三頁,和上一步一樣 - 如果這時候我們讀取第二頁,實體記憶體不夠了,我們需要刪除記憶體中的某一頁。這個快取過期策略,我們是全權交給系統了,但是系統並不知道我們的業務,可能不如我們自己管理做得好
如果我們使用 mmap(),我們就是將這種快取過期以及記憶體管理全權交給作業系統來執行了,作業系統並不知道我們的業務場景,以及哪些快取被過期掉是更加合適的。從應用程式的角度來看,我可能需要讀取不在記憶體中的東西,也就會發生缺頁中斷,我們可以將它交給另一個執行緒來做,不阻塞當前執行緒,這樣當前執行緒就可以去處理其他請求,這樣可以增加吞吐量。對於只讀的場景這樣的優化已經足夠了,但是如果還涉及寫入的話,這個優化也就不夠用了:
因為作業系統被告知要寫一個數據,作業系統不會管是否合適(比如批量寫,聚合記憶體塊在一起的,減少記憶體中斷)就直接寫了。
我們還可以通過一些系統呼叫來優化:
還有一些系統呼叫可以讓我們來告訴作業系統如何操作 mmap 記憶體:
- madvise:告訴作業系統你會怎麼讀取,是隨機讀取還是順序讀取
- mlock:防止作業系統將某些頁與實體記憶體解綁
- msync:告訴作業系統將某些頁刷入磁碟
主流資料庫(比如 MySQL,Postgres,Oracle,sqlserver 等等)都沒有使用 mmap,那些在使用的大部分也正在考慮替換掉 mmap。
所以,我們總結下:
如果你將記憶體管理全權交給作業系統,作業系統可能會做出一些對於你的效能有損的決策,DBMS 通常都想自己控制記憶體管理策略,這樣就可以帶來: - 根據當前資料庫儲存格式以及使用者請求,決策髒頁刷入磁碟的順序,提高效率 - 根據儲存結構以及使用者請求,快取預取提高查詢速度 - 根據儲存結構優化緩衝過期策略 - 多執行緒管理
3. 資料庫檔案儲存結構-儲存管理(Storage Manager)
資料庫其實就是磁碟上的一個或者多個檔案,例如 sqlite 整個資料庫就在一個檔案中,大多數其他的資料庫例如 MySQL 則是分了不同檔案儲存。sqlite 本身就是輕量級的系統,MySQL 這種一般考慮儲存效能以及限制等會劃分成不同的檔案,在現在的檔案系統中,檔案大小限制已經不太是一個考慮因素了,你甚至可以有超 PB 的檔案,但是像是那種老的檔案系統例如 fat32 那種,最大隻支援 2GB 大小。我們這裡提到了檔案系統(File System),檔案系統其實就是作業系統給我們提供的操作檔案的 api,我們通過這些 api 操作檔案(檔案其實就是一堆儲存上面的塊)。大部分資料庫是依賴作業系統提供的檔案系統的,曾經有一些廠商提供了自己原生的檔案系統專門用於自己的資料庫產品使用,但是這樣就喪失了遷移以及部署在雲上的靈活性。
我們這裡要實現的就是DBMS的儲存管理器(Storage Manager) 儲存管理器負責管理上面提到的資料庫檔案,我們在這裡會向作業系統傳送讀和寫請求,作業系統會排程這些讀寫。一些比較高階的系統,還會優化這些系統排程,即在這檔案系統上抽象出來一箇中介層,在這個中階層管理本由作業系統的管理的排程,比如這個中階層知道會有多執行緒發多請求寫互相臨近的塊,這個中階層會組合在一起形成一個寫請求。這裡我們就不做這個了,太複雜了。
在這些檔案裡面,是由頁(page)組織起來的,由一組頁組成。我們會以頁的維度記錄這些頁的讀寫(例如哪些頁還有空間可以儲存新資料,哪些頁裡面有髒資料還沒刷入磁碟,哪些頁已經完全滿了)
頁其實就是固定大小的資料塊,頁中儲存資料庫中所有相關的資料,例如元組資料、元資料、索引還有日誌記錄等等。但是我們總是儘量將內容儲存在單個頁中,並且頁需要是自包含的,即關於如何解釋和理解頁內容,所需要的所有資訊都必須儲存在頁本身中。這樣,即使丟失任何一頁,也不會影響其他任何一頁的解析和使用。如果你把元資料儲存與元組資料分開儲存在不同的頁,如果元資料頁丟失或者損壞了,那麼元組資料頁也就無法解析了。這種自包含的設計,對於容災更好。
雖然不同種類的資料(例如元組資料、元資料、索引還有日誌記錄)都是儲存在頁中,但是在同一頁內一般不會儲存不同型別的資料。有一些研究型的資料庫可能會在同一頁中儲存不同型別的資料,但是大部分系統都沒有這麼做。
每個頁都有唯一的識別符號即頁 ID(Page ID)。我們使用這個唯一識別符號形成一個中間層,在這個中間層維護 Page ID 與實際儲存的檔案的位置的對映,通過 Page ID 就能定位到對應的檔案位置並讀取這個頁。同時如果我們移動了頁(例如我們想擴充套件儲存,壓縮儲存使用等等),Page ID 不會變,只是修改了檔案位置,這比直接使用檔案位置儲存要方便很多。這樣 DBMS 的其它層就不用關心這個,只記錄 Page ID 就可以,我們在修改頁位置的時候不用告知其他層同時修改。
我們有很多不同的層面有頁這個概念: - 硬體儲存頁(Hardware Page):通常是 4KB。也就是如果你寫的資料在 4KB 以內,那麼就能保證原子性,不會發生寫了一部分掉電導致只寫入了一部分成功的情況,只會要麼全成功或者全寫入失敗 - 作業系統頁(OS Page):通常也是 4KB - 資料庫頁(Database Page):通常是 512~16KB,例如 MySQL InnoDB 頁大小預設 16KB,目前 8.0 可以配置成 4KB 8KB 16KB 32KB 64KB。在一些比較高階的系統,你甚至可以對於不同型別的頁設定不同的頁大小,例如元組資料頁大小比較小,索引頁大小調整的比較大。
將資料庫頁調大,那麼單頁就可以儲存更多的資料,那麼後面會提到的頁目錄大小就會降低,頁目錄是用來找所有頁的位置的儲存,一般會一直存在於記憶體中,頁目錄越小,那麼 CPU 快取命中率越高。並且更多的資料在同一頁上面,讀取資料也是一頁一頁讀取,這樣對於快取預取也有好處。但是相應的,寫入也是一頁一頁刷入,帶來的寫入消耗也更大。這就是為啥那些商用的資料庫允許你調整頁大小的原因。
那麼如何設計檔案頁結構呢,一般有三種: - 堆檔案組織(Heap File Organization):這是最常用的,我們也會主要研究這種。每種關係儲存到一個單獨的檔案,檔案中的記錄是無順序的。 - 順序檔案組織(Sequential File Organization):所有檔案裡面的記錄內容,都按照記錄的某個屬性值的一定順序排序好。 - 雜湊雜湊檔案組織(Hashing File Organization):使用記錄的某些屬性計算雜湊值,決定儲存在檔案的哪個位置。
資料庫堆檔案是一個無序的頁面集合,其中元組資料可以隨機順序儲存。其實關係模型中,也沒有對元組定義任何順序。我們所要實現的就是針對頁操作的增刪改查 API,以及遍歷所有頁的 API。
同時,我們需要一些元資料追蹤哪些頁還有剩餘空間,這樣在我們需要插入新資料的時候就能快速定位到在哪裡插入。
一般我們通過頁目錄(Page)這種方式設計堆檔案中的結構,但是首先我們先來通過連結串列(Linked List)設計下檔案結構來看下為何這種方式是愚蠢的。
如果使用連結串列設計的話,一般我們會維護一個 Header 頁,在這個頁 裡面我們維護兩個指標,一個指標是指向所有還有剩餘空間的頁連結串列的指標,另一個指標指向的是已經被填充滿的頁連結串列指標。這兩個連結串列都是雙向連結串列,我們可能順序遍歷也可能逆序遍歷:順序遍歷一般用於找合適的位置比如找還有足夠容納我們要插入資料空間的頁,逆序遍歷一般用於移動。這並不是一個比較好的設計方式,主要是插入與更新的效率比較低
這是使用更多的設計方式,即頁目錄。我們維護一個專門儲存目錄的頁,在這個目錄中維護頁 ID 到具體檔案位置的偏移量的對映,可以簡單把它理解成一個 key 為頁 ID value 為檔案位置偏移的雜湊表。並且在這裡還會維護頁的空閒空間資訊,這樣在插入的時候,我們可以直接通過頁目錄直接定位到要插入的頁。但是這樣也帶來了原子更新的問題,即頁的空閒空間資訊與插入資料是否在一個原子操作內。但是由於這個兩個頁的操作,硬體層面上我是很難保證兩頁更新是原子性的,所以我們需要額外的機制在資料庫重啟的時候檢查是否有這些未完成的寫入,這在後面講恢復與日誌的章節的時候會說到。
4. 頁佈局(Page Layout)
每個頁都有頁頭(Header),在 Header 中一般包含: - 頁大小 - 校驗和(CheckSum):這個可能會用來檢查是否有未完成的寫入(例如寫一半就宕機了) - DBMS 版本:建立這個頁的資料庫管理系統的版本,這個一般用於向前相容使用,比如在某個版本後頁佈局發生了變化,我們可以通過這個 DBMS 版本讓這個頁的解析走不同的分支。 - 壓縮相關資訊(Compression Information):如果對頁面做了壓縮,需要標註一些資訊,例如使用的演算法,是 lz4 還是 gzip 等等。 - 事務可見性相關資訊(Transaction Visibility):對於實現事務可見性需要的一些資訊,例如是哪個事務修改了這個頁的內容,以及修改後的內容在當前時間點對於誰可見等等。
頁需要是自包含的,即關於如何解釋和理解頁內容,所需要的所有資訊都必須儲存在頁本身中。這樣,即使丟失任何一頁,也不會影響其他任何一頁的解析和使用。如果你把元資料儲存與元組資料分開儲存在不同的頁,如果元資料頁丟失或者損壞了,那麼元組資料頁也就無法解析了。這種自包含的設計,對於容災更好。
在頁內部,我們有兩種方式儲存元組資訊: - 面向元組儲存(Tuple-oriented):即頁中儲存元組的資料 - 面向日誌儲存(Log-oriented):即儲存修改元組的日誌而不是元組資料本身
首先我們來看看面向元組的儲存方式設計,首先看一個很糟糕的設計: 假設所有元組的位元組長度都是一樣的,那麼實現方式比較簡單,基本就是在頭維護一個元組數量(這樣我們就能直接跳轉到要插入下一個元組的檔案位置偏移,如果元組大小不一樣,那麼這裡維護的就是要插入的檔案位置偏移),如果有新的元組插入,則根據元組數量計算出要插入的位置插入然後更新頭部計數。當需要刪除一個元組的時候,假設刪除的是 Tuple 2: 我們可以把 Tuple 2 本來佔用的空間標記為未使用,而不是將 Tuple 2 之後的所有元組資料都向前移動。但是這帶來了其他的問題,即儲存碎片: - 我想再插入新的資料的時候,假設所有元組的位元組長度都是一樣的,我可以插入到原來 Tuple 2 的位置,但是我怎麼知道這個位置有空閒的空間呢?這樣就需要引入額外的記錄 - 如果元組的位元組長度不一樣呢?就更麻煩了
所以這種設計很不好,沒人會這麼做。一般採用槽頁(Slotted Pages)這種設計: 這種設計被大部分資料庫所採用,雖然在細節上有些不同,但是大致是這麼個結構。在開頭還是前面提到的頁頭,之後跟著兩種儲存結構 - 槽陣列(slot array):從前向後寫,在這個陣列中的元素記錄所有元組在檔案中的起始位置偏移。槽陣列可以解耦元組位置與外部訪問,相當於前面提到的間接層。我們可能在頁內部移動元組(比如更新元組導致元組長度改變會標記刪除原始元組在最後追加新的元組資料),通過這個槽陣列外部就可以不關心這個位置變化了。 - 實際元組資料:從後向前寫
思考下:我們是否可以在這一頁中儲存來自於不同表的資料呢?實際上沒有人這麼做,首先是需要額外的元資料記錄每個元組所屬的表,然後是表資料訪問一般具有區域性性,即我們訪問表的某一條資料,那麼之後這個表這個記錄他周圍的資料也可能稍後會訪問到,把他們放在同一頁裡面一起讀取效率更高,如果在一頁中混合了不同表的資料就喪失了這個區域性性。
還有就是如果一個元祖或者一個元祖的某個屬性資料大小超過一頁的大小,那我們應該怎麼做?這個下節課會講到。這節課還是假設某個元組的資料只會存在於單頁上。
5. 元組結構(Tuple Layout)
元組其實就是一個位元組序列,DBMS 負責解析這個位元組序列。 每個元組都有一個頭,包含元組的元資料,例如: - 元組的長度 - 可見性資訊(Visibility info):例如是哪個事務那個查詢最後修改的這個元組,其他事務或者查詢根據這個資訊以及事務隔離級別設定來決定是否能看到這個元組。 - NULL 點陣圖(NULL BitMap):通過點陣圖標記哪些列是 NULL 值
我們一般不將模式資訊(例如有哪些屬性列,列是什麼格式的資料,是否可以為 NULL 等等)儲存在每個元組的頭部,這樣會冗餘太多資料造成儲存浪費並且一頁內包含更少的資料導致更新與讀取效率下降。
然後是元組資料,我們通常按照建立表語句中的屬性順序去儲存元組資料,大部分系統是這麼做的。有的系統會對於屬性進行重排序,讓它能更適應記憶體對齊(例如 8 位元組對齊)增加訪問效率(記憶體與磁碟儲存訪問一般都是記憶體對齊的訪問)。
DBMS 的其它層,如何在這種儲存結構下定位一個元組的資料呢? 一般所有元組都會被分配一個唯一 ID,這個 ID 中會直接或者間接包頁 ID 資訊和槽(或者偏移量)資訊,直接包含即從 ID 中直接就可以看出頁 ID 以及槽資訊,間接包含則是需要解碼或者查詢另一個元資料表來解析出頁 ID 以及槽資訊。可能還會包含檔案位置資訊用於定位去哪個目錄或者檔案位置去尋找頁目錄定位頁 ID 對應的檔案位置偏移量等等。這樣,我們可以通過頁 ID 查詢頁目錄找出頁對應的檔案以及偏移量,根據槽資訊讀取頁中的槽陣列找到元組的位置進行讀取。
微信搜尋“乾貨滿滿張雜湊”關注公眾號,加作者微信,每日一刷,輕鬆提升技術,斬獲各種offer
- Berkley CS162 作業系統第一課文字版-課程介紹
- CMU 15-445 資料庫課程第三課文字版 - 儲存1
- 為什麼我建議需要定期重建資料量大但是效能關鍵的表
- 通過例項程式驗證與優化談談網上很多對於Java DCL的一些誤解以及為何要理解Java記憶體模型
- 全網最硬核 Java 新記憶體模型解析與實驗 - 4. Java 新記憶體訪問方式與實驗
- 全網最硬核 Java 新記憶體模型解析與實驗單篇版(不斷更新QA中)
- 全網最硬核 Java 新記憶體模型解析與實驗 - 3. 硬核理解記憶體屏障(CPU 編譯器)
- 為什麼我建議線上高併發量的日誌輸出的時候不能帶有程式碼位置
- 關於 Java 18 你想知道的一切
- 如何在 Spring Boot 優雅關閉加入一些自定義機制
- 為什麼我建議在複雜但是效能關鍵的表上所有查詢都加上 force index
- 硬核 - Java 隨機數相關 API 的演進與思考(上)
- Inside Java Newscast #1 深度解讀
- SpringCloud升級之路2020.0.x版-44.避免鏈路資訊丟失做的設計(2)
- SpringCloud升級之路2020.0.x版-44.避免鏈路資訊丟失做的設計(1)
- SpringCloud升級之路2020.0.x版-43.為何 SpringCloudGateway 中會有鏈路資訊丟失
- SpringCloud升級之路2020.0.x版-42.SpringCloudGateway 現有的可供分析的請求日誌以及缺陷
- SpringCloud升級之路2020.0.x版-41. SpringCloudGateway 基本流程講解(3)
- SpringCloud升級之路2020.0.x版-41. SpringCloudGateway 基本流程講解(2)
- SpringCloud升級之路2020.0.x版-41. SpringCloudGateway 基本流程講解(1)