动态

详情 返回 返回

【深度剖析】MySQL B+樹索引:一次查詢的內部奧秘 - 动态 详情

大家好!今天我們來聊一個 MySQL 核心知識點:B+樹索引的查詢過程。可能你每天都在用SELECT語句查詢數據,但你想過這背後到底發生了什麼嗎?MySQL 是怎麼從成千上萬的數據中快速找到你要的那一條記錄的?帶着這個問題,我們一起揭開 MySQL B+樹索引的神秘面紗!

先説説為什麼 MySQL 選擇 B+樹?

在聊查詢過程前,我們得先明白 MySQL 為啥選 B+樹做索引結構。想象一下,如果數據庫用簡單的數組存儲數據,要找一個值就得從頭遍歷到尾,這也太慢了!

B+樹有幾個超棒的特點:

  1. 它是一個多叉平衡樹,高度很低(通常 3-4 層就能存儲上百萬數據)
  2. 所有數據都存在葉子節點,中間節點只存儲分隔鍵和子節點指針
  3. 葉子節點通過鏈表相連,方便範圍查詢
graph TD
    R["Root"] --> |< 20| N1["Node"]
    R --> |< 50| N2["Node"]
    R --> |> 50| N3["Node"]
    N1 --> L1["Leaf (1-5) 數據..."]
    N1 --> L2["Leaf (6-10) 數據..."]
    N1 --> L3["Leaf (11-20) 數據..."]
    N2 --> L4["Leaf (21-30) 數據..."]
    N2 --> L5["Leaf (31-50) 數據..."]
    L1 -->|鏈表| L2
    L2 -->|鏈表| L3
    L3 -->|鏈表| L4
    L4 -->|鏈表| L5

與其他索引結構相比:

  • 哈希索引:雖然等值查詢速度極快(O(1)),但無法支持範圍查詢和排序
  • 二叉樹:樹高太高,I/O 次數多
  • B 樹:與 B+樹最大的區別在於,B 樹的非葉子節點也存儲完整數據,這導致每個節點能容納的鍵值對更少,相同數據量下 B 樹高度更高,查詢需要更多 I/O 操作。此外,B 樹不支持葉節點間的鏈表,範圍查詢時可能需要回到父節點多次尋路,效率低下

MySQL 的存儲結構:頁是核心

在深入 B+樹查詢前,我們得了解 MySQL 的存儲單位——頁(Page)。

想象一下,如果你有一本書要查找內容,你肯定不是一個字一個字地找,而是一頁一頁地翻。MySQL 也是這樣,它以頁為單位讀取數據。

一個頁通常是 16KB,裏面可能包含很多行記錄。當 MySQL 需要讀取數據時,不是讀取單條記錄,而是將整個頁加載到內存的緩衝池中。

緩衝池管理機制

緩衝池不僅僅是簡單的內存空間,它採用了改進的 LRU(最近最少使用)算法:

  • 新讀取的頁不會直接放入 LRU 列表頭部,而是放入 midpoint 位置(默認是 5/8 處)
  • 頻繁訪問的頁會被移到列表頭部,長時間不用的頁會被淘汰
  • 支持預讀(Read-Ahead)機制,當順序讀取多個頁時,系統會預先加載後續可能用到的頁
graph LR
    disk[(磁盤文件)] --> |讀取| page1[頁1]
    disk --> |讀取| page2[頁2]
    disk --> |讀取| page3[頁3]
    page1 --> buffer[緩衝池LRU]
    page2 --> buffer
    page3 --> buffer
    buffer --> memory[內存]

一個具體例子:員工表查詢

我們創建一個簡單的員工表來演示 B+樹查詢過程:

CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    department VARCHAR(50),
    salary DECIMAL(10,2),
    INDEX idx_age (age)
);

假設表中已有上萬條數據,為了演示完整流程,我們將展示兩種查詢:

-- 使用主鍵索引(聚簇索引)的查詢
SELECT * FROM employees WHERE id = 12345;

-- 使用二級索引的查詢(涉及回表)
SELECT * FROM employees WHERE age = 28;

B+樹索引查詢的完整過程

1. 從緩衝池查找數據頁

MySQL 首先檢查這條數據是否已經在內存的緩衝池(Buffer Pool)中。如果在,直接從內存獲取,非常快!如果不在,就需要從磁盤讀取。

2. 定位表空間文件

如果數據不在內存中,MySQL 會定位到這張表的表空間文件(ibd 文件)。

3. 從 B+樹的根節點開始查找

MySQL 通過數據字典找到表的根頁(root page),這是 B+樹的入口。

flowchart TD
    A[開始查詢 id=12345] --> B{檢查緩衝池}
    B -->|有緩存| C[直接返回結果]
    B -->|無緩存| D[加載根節點頁]
    D --> E[解析頁內B+樹節點]
    E --> F{是否是葉子節點?}
    F -->|是| G[在葉子節點中查找記錄]
    F -->|否| H[根據鍵值確定子節點指針]
    H --> I[加載下一層節點頁]
    I --> E
    G --> J[返回找到的記錄]

4. 逐層向下查找

假設我們的 B+樹有 3 層,使用主鍵查找(id=12345)的過程如下:

第一層(根節點):

  • 頁中存儲了多個鍵值對,例如鍵 1000 指向頁號 A,鍵 5000 指向頁號 B,鍵 10000 指向頁號 C...
  • 系統比較 12345 和這些鍵:12345 > 10000,所以繼續查找頁號 C 對應的子節點

第二層(內部節點):

  • 加載頁號 C 到內存
  • 頁中可能包含鍵 10000 指向頁號 D,鍵 12000 指向頁號 E,鍵 15000 指向頁號 F...
  • 12345 > 12000 且 12345 < 15000,所以繼續查找頁號 E 對應的子節點

第三層(葉子節點):

  • 加載頁號 E 到內存
  • 這一頁包含了實際的數據記錄,系統在頁內進行搜索
  • 找到 id=12345 的完整記錄並返回

5. 數據頁內的查找

當定位到葉子節點的數據頁後,MySQL 不是線性遍歷頁內的所有記錄,而是使用頁目錄(Page Directory)進行查找:

  1. 頁目錄由多個"槽"(slot)組成,每個槽指向一個記錄組的最後一條記錄
  2. 系統先對頁目錄進行二分查找,找到記錄可能所在的記錄組
  3. 然後在該記錄組內從前往後順序查找目標記錄

InnoDB 的聚簇索引與回表操作

在 InnoDB 存儲引擎中,表數據文件本身就是按 B+樹組織的一個索引結構,這個索引的 key 是數據表的主鍵,被稱為"聚簇索引"(也叫主鍵索引)。聚簇索引的葉子節點存放的就是整張表的行記錄數據。

也就是説:數據文件本身就是主鍵索引的一部分!這與其他索引不同,其他索引(稱為二級索引或輔助索引)的葉子節點只存儲索引列和主鍵值,不包含行的全部數據。

如果使用SELECT * FROM employees WHERE age = 28;這樣的查詢,過程會有所不同:

  1. 首先沿着 age 索引的 B+樹查找,定位到 age=28 的記錄
  2. 但二級索引的葉子節點只存儲了索引列(age)和主鍵值(id)
  3. 系統獲取到主鍵值後,需進行"回表"操作:再次查詢主鍵索引(聚簇索引)獲取完整記錄
  4. 實際發生兩次 B+樹查詢:一次在二級索引樹上,一次在聚簇索引樹上
flowchart TD
    A[查詢 age=28] --> B[在age索引B+樹中查找]
    B --> C[找到age=28對應的主鍵id]
    C --> D[通過id在聚簇索引B+樹中查找]
    D --> E[返回完整記錄]

這就是為什麼通過主鍵查詢比二級索引查詢更快—前者只需一次樹的遍歷,後者需要遍歷兩棵樹。

範圍查詢是怎麼工作的?

如果我們執行範圍查詢:

SELECT * FROM employees WHERE age BETWEEN 25 AND 30;

B+樹的優勢就體現出來了:

  1. 系統先定位到第一個滿足條件的記錄(age=25)
  2. 然後利用葉子節點間的鏈表指針,順序掃描直到超出條件(age>30)
  3. 無需回到上層節點重新查找,非常高效

對比下全表掃描與索引範圍掃描的 I/O 差異:

  • 全表掃描:需要讀取整個表的所有頁,假設有 1000 個頁,就需要 1000 次 I/O
  • 索引範圍掃描:假設 age=25 到 age=30 的記錄只分布在 5 個頁上,只需要 5 次 I/O 就能完成查詢

此外,範圍查詢通過葉節點鏈表能實現順序 I/O(連續讀取),而全表掃描可能導致大量隨機 I/O,性能差距更大。

graph LR
    L1["Leaf (age 20-24)"] -->|鏈表| L2["Leaf (age 25-28)"]
    L2 -->|鏈表| L3["Leaf (age 29-35)"]
    L3 -->|鏈表| L4["Leaf (age 36-40)"]

    style L2 fill:#f9f,stroke:#333
    style L3 fill:#f9f,stroke:#333

常見問題及解決方案

問題 1:為什麼有時候創建了索引查詢還是很慢?

原因分析: 可能是出現了回表查詢。當查詢的字段不全在索引中時,MySQL 需要先通過索引找到主鍵值,再通過主鍵索引查找完整記錄。

解決方案: 可以考慮創建覆蓋索引,即索引包含查詢需要的所有字段。例如:

-- 如果經常查詢age和department,可以創建聯合索引
CREATE INDEX idx_age_dept ON employees(age, department);

-- 使用這個查詢就能直接從索引獲取數據,不需回表
SELECT age, department FROM employees WHERE age = 28;

問題 2:如何判斷查詢是否用了索引?

解決方案: 使用 EXPLAIN 命令分析 SQL 執行計劃

EXPLAIN SELECT * FROM employees WHERE age = 28;

EXPLAIN 結果中的關鍵字段:

  • "key"列:顯示使用的索引名稱,如果為 NULL 則表示未使用索引
  • "rows"列:估計需要掃描的行數,越少越好
  • "type"列:顯示連接類型,從好到差依次是:

    • system/const:通過主鍵或唯一索引定位單條記錄
    • ref:使用非唯一索引查找
    • range:範圍查詢
    • index:全索引掃描
    • ALL:全表掃描,意味着沒有使用索引

例如,如果看到 type=ALL,即使有創建索引也説明沒有被使用。

問題 3:為什麼有時候明明有索引,MySQL 卻不使用?

原因分析:

  • 如果表很小,全表掃描可能更快
  • 如果查詢的數據量超過表的大約 20-30%,MySQL 認為全表掃描更高效
  • 如果索引字段使用了函數或運算
  • 如果索引列使用了LIKE '%關鍵詞%'(前綴通配符)
  • 如果使用OR條件且部分列無索引
  • 如果索引列的數據分佈極度傾斜(如 90%是同一個值)

解決方案:
確保 SQL 語句不對索引字段做函數運算:

-- 不會使用索引
SELECT * FROM employees WHERE YEAR(birth_date) = 1990;

-- 改寫後會使用索引
SELECT * FROM employees WHERE birth_date BETWEEN '1990-01-01' AND '1990-12-31';

B+樹在實際場景中的表現

讓我們看一個真實場景:假設我們的 employees 表有 100 萬條記錄,使用 InnoDB 引擎,默認頁大小 16KB。

對於主鍵 id 索引:

  • 考慮到 InnoDB 的頁結構開銷,實際每個索引頁能存儲的索引項通常少於理論值
  • 假設中間節點每個索引項約 20 字節(整數 id + 子頁指針)
  • 考慮頁結構開銷後,一個頁實際可能存儲約 700 個索引項
  • 若是 3 層 B+樹,大致可索引約 3.43 億條記錄(700³)

但需注意,實際存儲容量會受到多種因素影響:

  • 不同數據類型大小不同
  • 可變長度字段會影響每頁存儲的記錄數
  • 中間節點和葉子節點的結構不完全相同

知識總結

知識點 描述 重要性
B+樹結構 多路平衡樹,所有數據在葉子節點,中間節點存儲分隔鍵和指針,葉節點通過鏈表相連 ⭐⭐⭐⭐⭐
聚簇索引 InnoDB 中,數據文件本身就是主鍵索引的一部分,葉子節點包含完整行數據 ⭐⭐⭐⭐⭐
MySQL 的基本 I/O 單位,默認 16KB,包含多條記錄 ⭐⭐⭐⭐
緩衝池 內存中的數據緩存,使用改進的 LRU 算法管理,減少磁盤 I/O ⭐⭐⭐⭐
查詢過程 從根節點開始,根據鍵值比較逐層向下,最後在頁內使用頁目錄快速定位 ⭐⭐⭐⭐⭐
回表查詢 通過二級索引找到主鍵值,再經由聚簇索引獲取完整記錄 ⭐⭐⭐⭐
覆蓋索引 索引包含查詢所需的所有字段,避免回表 ⭐⭐⭐⭐
範圍查詢 利用葉節點鏈表高效進行範圍掃描,減少 I/O 次數並實現順序讀取 ⭐⭐⭐⭐
頁目錄 加速頁內記錄查找的數據結構,通過"槽"指向不同記錄組 ⭐⭐⭐
執行計劃 通過 EXPLAIN 分析查詢執行情況,特別關注 type、key、rows 等字段 ⭐⭐⭐⭐

通過理解 MySQL B+樹索引的工作原理,我們能更好地設計數據庫結構和優化查詢語句。下次當你執行一條 SELECT 語句時,你就能清楚地知道 MySQL 在背後做了哪些工作!


感謝您耐心閲讀到這裏!如果覺得本文對您有幫助,歡迎點贊 👍、收藏 ⭐、分享給需要的朋友,您的支持是我持續輸出技術乾貨的最大動力!

如果想獲取更多 Java 技術深度解析,歡迎點擊頭像關注我,後續會每日更新高質量技術文章,陪您一起進階成長~

user avatar u_17021563 头像 liujiaxiaobao 头像 doge_king 头像 shanliangdeshou_ccwzfd 头像 daixiaoyulq 头像 qianhedehuangdou 头像 fanqiemao 头像 huopodeyaokongqi_c3jobz 头像 xiaoyi_ces 头像 euphoria 头像 shuirongshui 头像
点赞 11 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.