Database Storage
Database Storage
這堂課會專注在 disk-oriented 的 DBMS 上,即假設所有的資料都會被存在 non-volatile 的硬碟上。
電腦的儲存結構是 hierarchical 的,越靠近上方的儲存設備離 CPU 越近,速度也越快,如下圖所示 :
速度的比較如下 :
volatile 與 non-volatile 的區別如下 :
- volatile :
- 當電源關閉時,資料會遺失
- 支持快速的 random access,可以由 byte address 直接存取
- non-volatile :
- 當電源關閉時,資料不會遺失
- 資料主要以 block 或是 page 的形式存在硬碟上
- 如果我們想要存取某個資料,我們需要將 page 讀進記憶體中,然後再透過 offset 來存取我們想要的資料
- 通常擅於使用 sequential access 來存取資料
除此之外,也有一種較為少見的持久性記憶體,像是 Intel 的 Optane,但不在本課程的討論範圍內。
Disk-Oriented DBMS Overview
在資料庫中,所有的資料都會在硬碟上固定大小的 page 中, 第一頁是目錄頁,可以告訴我們那些檔案被放在哪裡。
當 execution engine 需要存取某個資料時, buffer pool 會先從 disk 中讀取目錄,並且依據目錄中的資訊把 page 讀進記憶體中, 最後再返還給 execution engine 這個 page 的 pointer。
DBMS vs OS
DBMS 希望可以管理超過記憶體大小的資料,並且在讀取時不要有停頓。
從 high-level 的設計來看,DBMS 就像 virtual memory 一樣,有一個很大的 address space 和作業系統從硬碟引入 page 的地方。
作業系統提供了 mmap
這個函式,可以讓我們把硬碟中的資料映射到記憶體中,但是基於正確性以及性能的考慮,我們幾乎不會在 DBMS 中使用 mmap
,特別是需要寫入時 (read-only 可以考慮使用),主要會包括以下幾個問題 :
- transaction 的安全性 : OS 會在任何時間點把 dirty page 寫回硬碟,影響 DBMS 的 ACID
- IO 停頓 : DBMS 不知道哪些頁面在內存中,在 page fault 時,OS 會使線程停頓
- 錯誤處理 : 難以驗證頁面,任何訪問都可能導致 DBMS 必須處理 SIGBUS 信號
- 性能問題 : OS 無法知道 DBMS 的查詢,因此無法最佳化處理順序
我們可以改為使用以下的方式 :
madvise
: 告訴 OS 我們需要 哪些頁面mlock
: 告訴 OS 哪些頁面不能被寫入msync
: 通知 OS 將某些記憶體寫到硬碟上
DBMS 幾乎不會使用 OS 自帶的硬碟管理機制,這是因為 DBMS 可以比 OS 知道更多的事情,因此可以更好的提升效率,包括 :
- 將 dirty page 寫回硬碟的時機
- prefetch
- buffer replacement policy (當 buffer pool 滿了時,要釋放哪些頁面)
- thread / process scheduling
File Storage
DBMS 會將所有資料儲存成一個或多個檔案,OS 並不會知道怎麼解讀這些檔案。
storage manager 會負責管理這些檔案,追蹤那些資料被寫到了哪個 page 上,以及那些 page 還有空間等等。
Database Page
DBMS 會將一個或多個檔案中固定大小的區域稱為 page,page 可以包含不同種類的資料,像是 table 的資料、索引、log 等等,不同種類的資料通常不會被存在一個 page 中。
每個頁面都有一個獨立的 identifier,DBMS 會透過 indirection layer 將 page id 與 file path 跟 offset 對應起來。
在 DBMS 中有三種不同的 page :
- Hardware page : 通常是 4kb
- OS page : 4kb
- Database page : 1-16kb
不同的 DBMS 有不同的 page size, 像是 MySQL 使用 16kb,而 PostgreSQL 使用 8kb。
同時 DBMS 也有不同的儲存頁面的方式,常見的有 :
- Heap File Organization
- Tree File Organization
- Sequential / Sorted File Organization (ISAM)
- Hashing File Organization
Database Heap File
heap file 是一個無序的集合,裡面的 tuple 都是隨機排序的, 我們需要知道怎麼找到 DBMS 需要的 page 的位置。
有兩種方式可以定位一個 page :
- Linked List
- Page Directory
Linked List
有一個 header page 儲存指向 free page 和 data page 的指標,但如果要找到某個 page 需要從頭開始找。
範例如下圖所示 :
Page Directory
儲存一個目錄頁,用於告訴我們那些 page 是存在哪裡,DBMS 需要保證 directory page 跟 data page 是同步的。
範例如下圖所示 :
Page Layout
每個 page 會被分成 header 跟 data 兩個部分,如下圖所示 :
Page Header
在 header 中會包含以下資訊 :
- page size
- DBMS version
- checksum
- transaction visibility
- compression information
有些資料庫也會儲存一些統計值等等。
Page Data
對於 data 部分,有以下幾種儲存方式 :
- Tuple-oriented storage
- Log-structured storage
- Index-organize storage
Tuple-oriented Storage
Tuple-oriented storage 是一種最常見的儲存方式,將每個 tuple 儲存在 page 中。
有兩種常見的方式可以儲存 tuple :
- Strawman Idea
- Slotted Page
Strawman Idea
在 header 中記錄 tuple 的數量,並持續往下寫入 tuple。
這個方法有兩個明顯的缺點
- 當刪除 tuple 時,每次新增新的 tuple 就需要遍歷整個 page
- 當欄位有可變長度的時會無法處理
Slotted Page
在 header 中會有一個 slot array,記錄每個 tuple 的 offset 以及長度。
- 新增 tuple 時,先新增一個 slot 到 slotted array 中,然後再寫入 tuple,並由兩端向中間生長,這樣當兩者相遇的時候就知道 page 已經滿了
- 刪除 tuple 時,只需要將 slot array 中的 slot 移除即可
- 如果我們要進行壓縮,可以將所有的 tuple 移到一端,然後更新 slot array
Log-structured Storage
在 tuple-oriented storage 中,有一些問題無法被解決
- fragmentation : 當 tuple 被刪除時,會造成空洞,或者是只有一個 tuple 的 page
- useless disk IO : 當更新 tuple 時,會需要 load 一整個 page
- random disk IO : 當一次要更新很多 tuple 時,會需要讀取很多個不同的 page
log-structured storage 是一種解決這些問題的方式,它不會更新原本的 page,而是將更新的內容寫到新的 page 上,這種方式原本被稱作 log-structured merge tree (LSM tree)。
在這個儲存模式下,DBMS 不會再維護 tuple,而是將所有的變更按照時間順序寫到 log 中,然後再依照順序寫到 disk 上,要注意的是,一旦寫到 disk 上,就無法再更改,一般情況下會在 page 被填滿時才會寫到 disk 上,但在 transaction commit 時也會直接寫到 disk 上。
這種方式的優點在於更新、刪除的速度都很快,但是如果遇到查詢的話,就需要從頭開始讀取 log, 因此通常會搭配 index 來使用。
為了減少所需的空間,DBMS 會定期將 log 進行壓縮,並且將相同的操作合併在一起。 在經過合併後,我們就不需要維護原本的時間順序了 (因為每個 key 都只剩下一個 value),DBMS 會將其依照 id 來進行排序,這種資料結構叫做 Sorted String Table (SSTables)。
壓縮的方法有兩種 :
- universal compaction
- level compaction
Log-structured storage 因為 RockDB 的出現而變得相當常見,但也有一些問題 :
- Write Amplification
- Compaction Overhead
Index-organized Storage
不管是 tuple-oriented storage 還是 log-structured storage,都需要透過 index 來進行查詢。 在 index-organized storage 中,我們會將 tuple 當成 index 中的值, 並且使用類似 slot page 的方式來儲存,同時依據 key 來進行排序。
Tuple Layout
在 tuple-oriented storage 中,tuple 也是由 header 和 data 兩個部分組成。
通常情況下,資料擺放的順序會是 create table 時的順序,但是為了提高效能,DBMS 有可能會將關聯的表的內容先 join,這種模式叫做 denormaliza,好處是可以減少 IO 次數,缺點是會增加更新時的複雜度。
DBMS 會將每個 tuple 設定一個 id 代表他在資料庫中的具體位置,通常是由 page_id + offset/slot 組合而成,如 postgres 中的 ctid。
-- postgres 16
CREATE TABLE test (id INT, name VARCHAR(10));
INSERT INTO test VALUES (1, 'a'), (2, 'b'), (3, 'c');
SELECT ctid, * FROM test;
-- result
ctid | id | name
-------+----+------
(0,1) | 1 | a
(0,2) | 2 | b
(0,3) | 3 | c
(3 rows)
-- ctid 代表 (page, slot) 的位置,如 (0,1) 代表 page 0 的第一個 slot
DELETE FROM test WHERE id = 2;
SELECT ctid, * FROM test;
-- result
ctid | id | name
-------+----+------
(0,1) | 1 | a
(0,3) | 3 | c
(2 rows)
-- 可以看到 id = 2 的 tuple 被刪除了,但 id = 3 的 ctid 仍然是 (0,3)
INSERT INTO test VALUES (2, 'b');
SELECT ctid, * FROM test;
-- result
ctid | id | name
-------+----+------
(0,1) | 1 | a
(0,3) | 3 | c
(0,4) | 2 | b
(3 rows)
-- 可以看到 id = 2 的 ctid 是 (0,4)
VACUUM FULL test;
SELECT ctid, * FROM test;
-- result
ctid | id | name
-------+----+------
(0,1) | 1 | a
(0,2) | 3 | c
(0,3) | 2 | b
(3 rows)
-- 執行 VACUUM FULL 之後,ctid 會重新排序
Tuple Header
header 包含了 NULL 的 bitmap 以及 transaction visibility,data 則是真正的資料。
Tuple Data
DBMS 需要保證這些資料是對齊的,以免出現一些非預期的行為。
如果讀到邊界的資料,會依據電腦的架構有不同的處理方式
- Perform Extra Reads : 執行兩次讀取並且合併
- Random Reads : 隨機返回不正確的結果
- Reject : 拒絕讀取
要處理這個問題,有兩種解決方式 :
- padding
- reordering
Padding
在每個 tuple 的後面加上 padding,使其對齊。
Reordering
將資料重新排序 (可能需要再填充),使其對齊。
Data Representation
Integer
使用 C/C++ 原生的 type 來表示,是固定長度的。
如 INTEGER
、BIGINT
、SMALLINT
、TINYINT
Variable Precision Number
同樣使用 C/C++ 原生的 type 來表示,也是固定長度,但是跟 C/C++ 一樣,會遇到精確度的問題。
#include <stdio.h>
int main(int argc, char* argv[]) {
float x = 0.1;
float y = 0.2;
printf("x+y = %.20f\n", x+y)
printf("0.3 = %.20f\n", 0.3)
}
// =>
// x+y = 0.30000001192092895508
// 0.3 = 0.29999999999999998890
如 FLOAT
Fixed Precision Number
如果需要精確的數字,可以使用 fixed precision number,但是在計算上會比較慢,通常每個 DBMS 都會自己實作 (如加減法和儲存方式),所需的空間會依照精確度而有所不同。
// postgresql NUMERIC
typedef unsigned char NumericDigit;
typedef struct {
int ndigits; // # of Digits
int weight; // Weight of 1st Digit
int scale; // Scale Factor
int sign; // Positive/Negative/NaN
NumericDigit * digits; // Digit Storage
} numeric;
如 NUMERIC
、DECIMAL
NULL Data Type
有三個常見 的方式來表示 NULL :
- NULL column bitmap header : 在 header 中有一個 bitmap 來表示哪些 column 是 NULL (最常見)
- special value : 用一個特殊的值來表示 NULL 如 INT32_MIN
- Per attribute NULL flag : 在每一個屬性前加入一個 flag 來表示是否為 NULL,會破壞 alignment (非常少見)
Large Value
大多數 DBMS 不支援大於 page size 的 tuple,所以我們必須將資料儲存在獨立的 overflow / toast page,並且在 tuple 中儲存一個 pointer 指向這個 page。
External Value Store
有些資料庫可以存取外部的檔案,這些檔案會被放在本地的路徑並儲存 URL,資料庫不能做任何更改,也不保證 transaction。