h2database BTree 設計實現與查詢優化思考

2023-06-26 15:01:13

h2database 是使用Java 編寫的開源資料庫,相容ANSI-SQL89。

即實現了常規基於 BTree 的儲存引擎,又支援紀錄檔結構儲存引擎。功能非常豐富(死鎖檢測機制、事務特性、MVCC、運維工具等),資料庫學習非常好的案例。

本文理論結合實踐,通過BTree 索引的設計和實現,更好的理解資料庫索引相關的知識點以及優化原理。

BTree 實現類

h2database 預設使用的 MVStore 儲存引擎,如果要使用 基於 BTree 的儲存引擎,需要特別指定(如下範例程式碼 jdbcUrl)。

以下是常規儲存引擎(BTree 結構) 相關的關鍵類。

  • org.h2.table.RegularTable
  • org.h2.index.PageBtreeIndex (SQL Index 本體實現)
  • org.h2.store.PageStore (儲存層,對接邏輯層和檔案系統)

BTree 的資料結構可以從網上查到詳細的描述和講解,不做過多贅述。

需要特別說明的是:PageStore。我們資料查詢和優化關鍵的快取、磁碟讀取、undo log都是由 PageStore 完成。可以看到詳細的檔案和完整的實現。

BTree add index entry 呼叫鏈

提供索引資料新增的呼叫鏈。同樣的,索引的刪除和查詢都會涉及到,方便 debug 參考。

  1. org.h2.command.dml.Insert#insertRows (Insert SQL 觸發資料和索引新增)
  2. org.h2.mvstore.db.RegularTable#addRow (處理完的資料Row, 執行新增)
  3. org.h2.index.PageBtreeIndex#add (邏輯層增加索引資料)
  4. org.h2.index.PageDataIndex#addTry (儲存層增加索引資料)
  5. org.h2.index.PageDataLeaf#addRowTry (儲存層新增實現)
// 範例程式碼
// CREATE TABLE city (id INT(10) NOT NULL AUTO_INCREMENT, code VARCHAR(40) NOT NULL, name VARCHAR(40) NOT NULL);
public static void main(String[] args) throws SQLException {
    // 注意:MV_STORE=false,MVStore is used as default storage
    Connection conn = DriverManager.getConnection("jdbc:h2:~/test;MV_STORE=false", "sa", "");
    Statement statement = conn.createStatement();
    // CREATE INDEX IDX_NAME ON city(code); 新增資料觸發 BTree 索引新增
    // -- SQL 範例化為:IDX_NAME:16:org.h2.index.PageBtreeIndex
    statement.executeUpdate("INSERT INTO city(code,name) values('cch','長春')");
    statement.close();
    conn.close();
}

Code Insight

結合上述的範例程式碼,從索引新增的流程實現來了解BTree 索引的特性以及使用的注意事項。從底層實現分析索引的執行,對 SQL 索引使用和優化有進一步認識。

表新增資料

 public void addRow(Session session, Row row) {
    // MVCC 控制機制,記錄和比對當前事務的 id
    lastModificationId = database.getNextModificationDataId();
    if (database.isMultiVersion()) {
        row.setSessionId(session.getId());
    }
    int i = 0;
    try {
        // 根據設計規範,indexes 肯定會有一個聚集索引(h2 稱之為scan index)。①
        for (int size = indexes.size(); i < size; i++) {
            Index index = indexes.get(i);
            index.add(session, row);
            checkRowCount(session, index, 1);
        }
        // 記錄當前 table 的資料行數,事務回滾後會相應遞減。
        rowCount++;
    } catch (Throwable e) {
        try {
            while (--i >= 0) {
                Index index = indexes.get(i);
                // 對應的,如果發生任何異常,會移除對應的索引資料。
                index.remove(session, row);
            }
        }
        throw de;
    }
}

① 同Mysql InnoDB 資料儲存一樣, RegularTable 必有,且只有一個聚集索引。以主鍵(或者隱含自增id)為key, 儲存完整的資料。

聚集索引新增資料

  • 索引中的 key 是查詢要搜尋的內容,而其值可以是以下兩種情況之一:它可以是實際的行(檔案,頂點),也可以是對儲存在別處的行的參照。在後一種情況下,行被儲存的地方被稱為 堆檔案(heap file) ,並且儲存的資料沒有特定的順序(根據索引相關的)。
  • 從索引到堆檔案的額外跳躍對讀取來說效能損失太大,因此可能希望將被索引的行直接儲存在索引中。這被稱為聚集索引(clustered index)。
  • 基於主鍵掃描即可唯一確定、並且獲取到資料,聚集索引效能比非主鍵索引少一次掃描
public void add(Session session, Row row) {
    // 索引key 生成 ②
    if (mainIndexColumn != -1) {
        // 如果主鍵非 long, 使用 org.h2.value.Value#convertTo 嘗試把主鍵轉為 long
        row.setKey(row.getValue(mainIndexColumn).getLong());
    } else {
        if (row.getKey() == 0) {
            row.setKey((int) ++lastKey);
            retry = true;
        }
    }

    // 新增行資料到聚集索引 ③
    while (true) {
        try {
            addTry(session, row);
            break;
        } catch (DbException e) {
            if (!retry) {
                throw getNewDuplicateKeyException();
            }
        }
    }
}

② 對於有主鍵的情況,會獲取當前 row 主鍵的值,轉為long value。對於沒有指定主鍵的情況,從當前聚集索引屬性 lastKey 自增得到唯一 key。

只有指定主鍵的情況,才會校驗資料重複(也就是索引key 重複,自增 lastKey 是不會有重複值的問題)。

③ 聚集索引 PageDataIndex 按照BTree 結構查詢對應的key 位置,按照主鍵/key 的順序,將 Row 儲存到page 中。非聚集索引 PageBtreeIndex 也是這樣的處理流程。

這其中涉及到三個問題:

  1. 如何查詢 key 的位置,也就是 BTree 位置的計算?
  2. 如何計算 Row (實際資料)儲存 Page 中的 offsets?
  3. Row 是怎樣寫入到磁碟中的,何時寫入的?

索引資料存取實現

  • B 樹將資料庫分解成固定大小的 塊(block) 或 分頁(page) ,傳統上大小為 4KB(有時會更大),並且一次只能讀取或寫入一個頁面。
  • 每個頁面都可以使用地址或位置來標識,這允許一個頁面參照另一個頁面 —— 類似於指標,但在硬碟而不是在記憶體中。(對應h2 database PageBtreeLeaf 和 PageBtreeNode)
  • 不同於 PageDataIndex ,PageBtreeIndex 按照 column.value 順序來儲存。新增的過程就是比對查詢 column.value,確定在塊(block)中offsets 的下標 x。剩下就是計算資料的offset 並存入下標 x 中。
/**
 * Find an entry. 二分查詢 compare 所在的位置。這個位置儲存 compare 的offset。
 * org.h2.index.PageBtree#find(org.h2.result.SearchRow, boolean, boolean, boolean)
 * @param compare 查詢的row, 對應上述範例 compare.value = 'cch'
 * @return the index of the found row
 */
int find(SearchRow compare, boolean bigger, boolean add, boolean compareKeys) {
    // 目前 page 持有的資料量 ④
    int l = 0, r = entryCount;
    int comp = 1;
    while (l < r) {
        int i = (l + r) >>> 1;
        // 根據 offsets[i],讀取對應的 row 資料 ⑤
        SearchRow row = getRow(i);
        // 比大小 ⑥
        comp = index.compareRows(row, compare);
        if (comp == 0) {
            // 唯一索引校驗 ⑦
            if (add && index.indexType.isUnique()) {
                if (!index.containsNullAndAllowMultipleNull(compare)) {
                    throw index.getDuplicateKeyException(compare.toString());
                }
            }
        }
        if (comp > 0 || (!bigger && comp == 0)) {
            r = i;
        } else {
            l = i + 1;
        }
    }
    return l;
}

④ 每個塊(page)entryCount ,兩個方法初始化。根據塊分配和範例建立初始化,或者 PageStore 讀取塊檔案,從Page Data 解析得到。

⑤ 反序列化過程,從page 檔案位元組碼(4k的位元組陣列),根據協定讀取資料並範例化為 row 物件。參考: org.h2.index.PageBtreeIndex#readRow(org.h2.store.Data, int, boolean, boolean) 。

⑥ 全型別支援大小比對,具體的規則參考:org.h2.index.BaseIndex#compareRows

⑦ 如果資料中存在重複的鍵值,則不能建立唯一索引、UNIQUE 約束或 PRIMARY KEY 約束。h2database 相容多種資料庫模式,MySQL NULL 非唯一,MSSQLServer NULL 唯一,僅允許出現一次。

private int addRow(SearchRow row, boolean tryOnly) {
	// 計算資料所佔位元組的長度
	int rowLength = index.getRowSize(data, row, onlyPosition);
	// 塊大小,預設 4k
	int pageSize = index.getPageStore().getPageSize();
	// 塊檔案可用的 offset 獲取
	int last = entryCount == 0 ? pageSize : offsets[entryCount - 1];
	if (last - rowLength < start + OFFSET_LENGTH) {
		// 校驗和嘗試分配計算,這其中就涉及到分割頁面生長 B 樹的過程 ⑧
	}
	// undo log 讓B樹更可靠 ⑨
	index.getPageStore().logUndo(this, data);
	if (!optimizeUpdate) {
		readAllRows();
	}

	int x = find(row, false, true, true);
	// 新索引資料的offset 插入到 offsets 陣列中。使用 System.arraycopy(x + 1) 來挪動資料。
	offsets = insert(offsets, entryCount, x, offset);
	// 重新計算 offsets,寫磁碟就按照 offsets 來寫入資料。
	add(offsets, x + 1, entryCount + 1, -rowLength);
	// 追加實際資料 row
	rows = insert(rows, entryCount, x, row);
	entryCount++;
	// 標識 page.setChanged(true);
	index.getPageStore().update(this);
	return -1;
}

⑧如果你想新增一個新的鍵,你需要找到其範圍能包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以反映新的鍵範圍分割區

⑨為了使資料庫能處理異常崩潰的場景,B 樹實現通常會帶有一個額外的硬碟資料結構:預寫式紀錄檔(WAL,即 write-ahead log,也稱為 重做紀錄檔,即 redo log)。這是一個僅追加的檔案,每個 B 樹的修改在其能被應用到樹本身的頁面之前都必須先寫入到該檔案。當資料庫在崩潰後恢復時,這個紀錄檔將被用來使 B 樹恢復到一致的狀態。

實踐總結

  • 查詢優化實質上就是存取資料量的優化,磁碟IO 的優化。

  • 如果資料全部快取到記憶體中,實際上就是計算量的優化,CPU 使用的優化。

  • 索引是有序的,實際上就是指塊檔案內的 offsets 是以陣列形式體現的。 特殊的是,在h2database 中,offsets陣列元素也是有序的(例如:[4090, 4084, 4078, 4072, 4066, 4060, 4054, 4048, 4042]),應該是方便磁碟順序讀,防止磁碟碎片化

  • 理論上,聚集索引掃描 IO 比 BTree 索引要多,因為同樣的塊檔案內,BTree 索引 儲存的資料量更大,所佔的塊檔案更少。如果一個table 列足夠少,聚集索引掃描效率更高。

    建表需要謹慎,每個列的欄位長度儘可能的短,來節省頁面空間

  • 合理使用覆蓋索引查詢,避免回表查詢。  如述範例,select id from city where code = 'cch' ,掃描一次 BTree 索引即可得到結果。如果 select name from city where code = 'cch', 需要掃描一次 BTree 索引得到索引key (主鍵),再遍歷掃描聚集索引,根據 key 得到結果。

  • 合理的使用快取,讓磁碟IO 的影響降到最低。  比如合理設定快取大小,冷熱資料區分查詢等。

其他知識點

  • 分支因子為 500 的 4KB 頁面的四層樹可以儲存多達 256TB 的資料)。(在 B 樹的一個頁面中對子頁面的參照的數量稱為 分支因子(branching factor)

參考

ddia/ch3.md B樹

作者:京東物流 楊攀

內容來源:京東雲開發者社群