本文節選自深入淺出分散式基礎架構-資料庫篇 https://url.wx-coder.cn/kl3ms。

資料庫索引

索引(Index)是幫助資料庫系統高效獲取數據的數據結構,資料庫索引本質上是以增加額外的寫操作與用於維護索引數據結構的存儲空間為代價的用於提升資料庫中數據檢索效率的數據結構。索引可以幫助我們快速地定位到數據而不需要每次搜索的時候都遍歷資料庫中的每一行。典型的索引譬如在內存中維護一個二叉查找樹,每個節點分別包含索引鍵值和一個指向對應數據記錄物理地址的指針,這樣就可以運用二叉查找在 O(log2n)的複雜度內獲取到相應數據。

左側為數據記錄的物理地址,右側為查找樹,需要注意的是,邏輯上相鄰的記錄在磁碟上也並不是一定物理相鄰的。實際的資料庫應用中我們往往使用 B+ 樹或者 LSM 來替代二叉查找樹或者紅黑樹來構建索引系統,並且充分利用 虛擬存儲管理 https://url.wx-coder.cn/PeNqS 一節中介紹過的局部性原理、磁碟預讀與頁緩存等概念。

值得一提的是,本節並未涵蓋搜索引擎中常用的與文本索引相關的技術,譬如倒排索引、TF-IDF 等,如果有興趣可以參考本篇搜索引擎 https://url.wx-coder.cn/O07eI 一章。

存儲管理基礎

本部分節選自深入淺出 Linux 操作系統 https://url.wx-coder.cn/Q0AmI 。

計算機存儲設備可被粗略分為內存儲器(Main Memory)與外存儲器(External Memory)兩大類,內存存取速度快,但容量小,價格昂貴,而且不能長期保存數據,在不通電情況下數據會消失;外存儲器存取速度相對較慢,卻可以吃持久化存儲。如果進行更加細緻地劃分,每個計算機系統中的存儲設備都被組織成了一個存儲器層次結構,在這個層次結構中,從上至下,設備變得訪問速度越來越慢、容量越來越大,並且每位元組的造價也越來越便宜。

存儲器層次結構的主要思想是一層上的存儲器作為低一層存儲器的高速緩存。因此,寄存器文件就是 L1 的高速緩存,L1 是 L2 的高速緩存,L2 是 L3 的高速緩存,L3 是主存的高速緩存,而主存又是磁碟的高速緩存。在某些具有分散式文件系統的網路系統中,本地磁碟就是存儲在其他系統中磁碟上的數據的高速緩存。

主存

主存是一個臨時存儲設備,在處理器執行程序時,用來存放程序和程序處理的數據。從物理上來說,主存是由一組動態隨機存取存儲器(DRAM)晶元組成的。從邏輯上來說,存儲器是一個線性的位元組數組,每個位元組都有其唯一的地址(即數組索引),這些地址是從零開始的。一般來說,組成程序的每條機器指令都由不同數量的位元組構成。

現代 DRAM 的結構和存取原理比較複雜,這裡抽象出一個十分簡單的存取模型來說明 DRAM 的工作原理。從抽象角度看,主存是一系列的存儲單元組成的矩陣,每個存儲單元存儲固定大小的數據。每個存儲單元有唯一的地址,現代主存的編址規則比較複雜,這裡將其簡化成一個二維地址:通過一個行地址和一個列地址可以唯一定位到一個存儲單元。

當系統需要讀取主存時,則將地址信號放到地址匯流排上傳給主存,主存讀到地址信號後,解析信號並定位到指定存儲單元,然後將此存儲單元數據放到數據匯流排上,供其它部件讀取。寫主存的過程類似,系統將要寫入單元地址和數據分別放在地址匯流排和數據匯流排上,主存讀取兩個匯流排的內容,做相應的寫操作。這裡可以看出,主存存取的時間僅與存取次數呈線性關係,因為不存在機械操作,兩次存取的數據的「距離」不會對時間有任何影響,例如,先取 A0 再取 A1 和先取 A0 再取 D3 的時間消耗是一樣的。

寄存器與高速緩存

寄存器文件在層次結構中位於最頂部,也就是第 0 級或記為 L0。一個典型的寄存器文件只存儲幾百位元組的信息,而主存里可存放幾十億位元組。然而,處理器從寄存器文件中讀數據的速度比從主存中讀取幾乎要快 100 倍。針對這種處理器與主存之間的差異,系統設計者採用了更小、更快的存儲設備,即高速緩存存儲器(簡稱高速緩存),作為暫時的集結區域,用來存放處理器近期可能會需要的信息。

L1 和 L2 高速緩存是用一種叫做靜態隨機訪問存儲器(SRAM)的硬體技術實現的。比較新的、處理能力更強大的系統甚至有三級高速緩存:L1、L2 和 L3。系統可以獲得一個很大的存儲器,同時訪問速度也很快,原因是利用了高速緩存的局部性原理,即程序具有訪問局部區域里的數據和代碼的趨勢。通過讓高速緩存里存放可能經常訪問的數據的方法,大部分的存儲器操作都能在快速的高速緩存中完成。

磁碟

磁碟是一種直接存取的存儲設備 (DASD)。它是以存取時間變化不大為特徵的。可以直接存取任何字元組,且容量大、速度較其它外存設備更快。磁碟是一個扁平的圓盤(與電唱機的唱片類似),盤面上有許多稱為磁軌的圓圈,數據就記錄在這些磁軌上。磁碟可以是單片的,也可以是由若干碟片組成的盤組,每一碟片上有兩個面。如下圖中所示的 6 片盤組為例,除去最頂端和最底端的外側面不存儲數據之外,一共有 10 個面可以用來保存信息。

當磁碟驅動器執行讀 / 寫功能時。碟片裝在一個主軸上,並繞主軸高速旋轉,當磁軌在讀 / 寫頭 ( 又叫磁頭 ) 下通過時,就可以進行數據的讀 / 寫了。一般磁碟分為固定頭盤 ( 磁頭固定 ) 和活動頭盤。固定頭盤的每一個磁軌上都有獨立的磁頭,它是固定不動的,專門負責這一磁軌上數據的讀 / 寫。活動頭盤 ( 如上圖 ) 的磁頭是可移動的。每一個盤面上只有一個磁頭 ( 磁頭是雙向的,因此正反盤面都能讀寫 )。它可以從該面的一個磁軌移動到另一個磁軌。所有磁頭都裝 在同一個動臂上,因此不同盤面上的所有磁頭都是同時移動的 ( 行動整齊劃一 )。當碟片繞主軸旋轉的時候,磁頭與旋轉的碟片形成一個圓柱體。各個盤面上半徑相 同的磁軌組成了一個圓柱面,我們稱為柱面。因此,柱面的個數也就是盤面上的磁軌數。

磁碟上數據必須用一個三維地址唯一標示:柱面號、盤面號、塊號 ( 磁軌上的盤塊 )。讀 / 寫磁碟上某一指定數據需要下面 3 個步驟: (1) 首先移動臂根據柱面號使磁頭移動到所需要的柱面上,這一過程被稱為定位或查找。 (2) 如上圖 11.3 中所示的 6 盤組示意圖中,所有磁頭都定位到了 10 個盤面的 10 條磁軌上 ( 磁頭都是雙向的 )。這時根據盤面號來確定指定盤面上的磁軌。 (3) 盤面確定以後,碟片開始旋轉,將指定塊號的磁軌段移動至磁頭下。經過上面三個步驟,指定數據的存儲位置就被找到。這時就可以開始讀 / 寫操作了。訪問某一具體信息,由 3 部分時間組成:

  • 查找時間 (seek time) Ts: 完成上述步驟 (1) 所需要的時間。這部分時間代價最高,最大可達到 0.1s 左右。
  • 等待時間 (latency time) Tl: 完成上述步驟 (3) 所需要的時間。由於碟片繞主軸旋轉速度很快,一般為 7200 轉 / 分 ( 電腦硬碟的性能指標之一 , 家用的普通硬碟的轉速一般有 5400rpm( 筆記本 )、7200rpm 幾種 )。因此一般旋轉一圈大約 0.0083s。
  • 傳輸時間 (transmission time) Tt: 數據通過系統匯流排傳送到內存的時間,一般傳輸一個位元組 (byte) 大概 0.02us=2*10^(-8)s

磁碟讀取數據是以盤塊(block)為基本單位的。位於同一盤塊中的所有數據都能被一次性全部讀取出來。而磁碟 IO 代價主要花費在查找時間 Ts 上。因此我們應該盡量將相關信息存放在同一盤塊,同一磁軌中。或者至少放在同一柱面或相鄰柱面上,以求在讀/寫信息時盡量減少磁頭來回移動的次數,避免過多的查找時間Ts。所以,在大規模數據存儲方面,大量數據存儲在外存磁碟中,而在外存磁碟中讀取 / 寫入塊 (block) 中某數據時,首先需要定位到磁碟中的某塊,如何有效地查找磁碟中的數據,需要一種合理高效的外存數據結構。

哈希索引

哈希索引即是基於哈希技術,如上圖所示,我們將一系列的最終的鍵值通過哈希函數轉化為存儲實際數據桶的地址數值。值本身存儲的地址就是基於哈希函數的計算結果,而搜索的過程就是利用哈希函數從元數據中推導出桶的地址。

  • 添加新值的流程,首先會根據哈希函數計算出存儲數據的地址,如果該地址已經被佔用,則添加新桶並重新計算哈希函數。
  • 更新值的流程則是先搜索到目標值的地址,然後對該內存地址應用所需的操作。

哈希索引會在進行相等性測試(等或者不等)時候具有非常高的性能,但是在進行比較查詢、Order By 等更為複雜的場景下就無能為力。

B-Tree

B-Tree 與 B+Tree

在數據結構與演算法/查找樹 https://url.wx-coder.cn/9PnzG 一節中我們介紹了 B-Tree 的基本概念與實現,這裡我們繼續來分析下為何 B-Tree 相較於紅黑樹等二叉查找樹會更適合於作為資料庫索引的實現。一般來說,索引本身也很大,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁碟上。這樣的話,索引查找過程中就要產生磁碟 I/O 消耗,相對於內存存取,I/O 存取的消耗要高几個數量級,所以評價一個數據結構作為索引的優劣最重要的指標就是在查找過程中磁碟 I/O 操作次數的漸進複雜度。換句話說,索引的結構組織要盡量減少查找過程中磁碟 I/O 的存取次數。

根據 B-Tree 的定義,可知檢索一次最多需要訪問 h 個節點。資料庫系統的設計者巧妙利用了磁碟預讀原理,將一個節點的大小設為等於一個頁,這樣每個節點只需要一次 I/O 就可以完全載入。每次新建節點時,直接申請一個頁的空間,這樣就保證一個節點物理上也存儲在一個頁里,加之計算機存儲分配都是按頁對齊的,就實現了一個節點只需一次 I/O。而檢索的時候,一次檢索最多需要 h-1 次 I/O(根節點常駐內存),其漸進複雜度為 $O(h)=O(log_dN)O(h)=O(log_dN)$,實際應用中,出度 d 是非常大的數字,通常超過 100,因此 h 非常小(通常不超過 3)。而紅黑樹這種結構,h 明顯要深的多。由於邏輯上很近的節點(父子)物理上可能很遠,無法利用局部性,所以紅黑樹的 I/O 漸進複雜度也為 O(h),效率明顯比 B-Tree 差很多。

B+Tree 是 的變種,有著比 B-Tree 更高的查詢性能,其相較於 B-Tree 有了如下的變化:

  • 有 m 個子樹的節點包含有 m 個元素(B-Tree 中是 m-1)。
  • 根節點和分支節點中不保存數據,只用於索引,所有數據都保存在葉子節點中。
  • 所有分支節點和根節點都同時存在於子節點中,在子節點元素中是最大或者最小的元素。
  • 葉子節點會包含所有的關鍵字,以及指向數據記錄的指針,並且葉子節點本身是根據關鍵字的大小從小到大順序鏈接。

一般在資料庫系統或文件系統中使用的 B+Tree 結構都在經典 B+Tree 的基礎上進行了優化,增加了順序訪問指針:

如上圖所示,在 B+Tree 的每個葉子節點增加一個指向相鄰葉子節點的指針,就形成了帶有順序訪問指針的 B+Tree。做這個優化的目的是為了提高區間訪問的性能,例如下圖中如果要查詢 key 為從 3 到 8 的所有數據記錄,當找到 3 後,只需順著節點和指針順序遍歷就可以一次性訪問到所有數據節點,極大提到了區間查詢效率。

索引順序

B-Tree 索引可以很好地用於單行、範圍或者前綴掃描,他們只有在查找使用了索引的最左前綴(Leftmost Prefix)的時候才有用。不過 B-Tree 索引存在一些限制:

  • 如果查找不從索引列的最左邊開始,索引就無法使用;同樣,不能查找字元串結尾;
  • 不能跳過索引中的列;
  • 不能使用任何在第一個範圍條件右邊的列作為條件;

因此 B-Tree 的列順序非常重要,上述使用規則都和列順序有關。對於實際的應用,一般要根據具體的需求,創建不同列和不同列順序的索引。假設有索引 Index(A,B,C):

# 使用索引
A>5 AND A<10 - 最左前綴匹配
A=5 AND B>6 - 最左前綴匹配
A=5 AND B=6 AND C=7 - 全列匹配
A=5 AND B IN (2,3) AND C>5 - 最左前綴匹配,填坑

# 不能使用索引
B>5 - 沒有包含最左前綴
B=6 AND C=7 - 沒有包含最左前綴

# 使用部分索引
A>5 AND B=2 - 使用索引 A 列
A=5 AND B>6 AND C=2 - 使用索引的 A 和 B 列

使用索引對結果進行排序,需要索引的順序和 ORDER BY 子句中的順序一致,並且所有列的升降序一致(ASC/DESC)。如果查詢連接了多個表,只有在 ORDER BY 的列引用的是第一個表才可以(需要按序 JOIN)。

# 使用索引排序
ORDER BY A - 最左前綴匹配
WHERE A=5 ORDER BY B,C - 最左前綴匹配
WHERE A=5 ORDER BY B DESC - 最左前綴匹配
WHERE A>5 ORDER BY A,B - 最左前綴匹配

# 不能使用索引排序
WHERE A=5 ORDER BY B DESC,C ASC - 升降序不一致
WHERE A=5 ORDER BY B,D - D 不在索引中
WHERE A=5 ORDER BY C - 沒有包含最左前綴
WHERE A>5 ORDER BY B,C - 第一列是範圍條件,無法使用 BC 排序
WHERE A=5 AND B IN(1, 2) ORDER BY C - B 也是範圍條件,無法用 C 排序

LSM Tree

B-Tree 這種資料庫索引方式是傳統關係型資料庫中主要的索引構建方式,然而 BTree 通常會存在寫操作吞吐量上的瓶頸,其需要大量的磁碟隨機 IO,很顯然,大量的磁碟隨機 IO 會嚴重影響索引建立的速度。對於那些索引數據大的情況(例如,兩個列的聯合索引),插入速度是對性能影響的重要指標,而讀取相對來說就比較少。譬如在一個無緩存的情況下,B-Tree 首先需要進行一次磁碟讀寫將磁碟頁讀取到內存中,然後進行修改,最後再進行一次 IO 寫回到磁碟中。

LSM Tree 則採取讀寫分離的策略,會優先保證寫操作的性能;其數據首先存儲內存中,而後需要定期 Flush 到硬碟上。LSM-Tree 通過內存插入與磁碟的順序寫,來達到最優的寫性能,因為這會大大降低磁碟的尋道次數,一次磁碟 IO 可以寫入多個索引塊。HBase, Cassandra, RockDB, LevelDB, SQLite 等都是基於 LSM Tree 來構建索引的資料庫;LSM Tree 的樹節點可以分為兩種,保存在內存中的稱之為 MemTable, 保存在磁碟上的稱之為 SSTable。

LSM-tree 的主要思想是劃分不同等級的樹。以兩級樹為例,可以想像一份索引數據由兩個樹組成,一棵樹存在於內存,一棵樹存在於磁碟。內存中的樹可以可以是 AVL Tree 等結構;因為數據大小是不同的,沒必要犧牲 CPU 來達到最小的樹高度。而存在於磁碟的樹是一棵 B-Tree。

數據首先會插入到內存中的樹。當內存中的樹中的數據超過一定閾值時,會進行合併操作。合併操作會從左至右遍歷內存中的樹的葉子節點與磁碟中的樹的葉子節點進行合併,當被合併的數據量達到磁碟的存儲頁的大小時,會將合併後的數據持久化到磁碟,同時更新父親節點對葉子節點的指針。

之前存在於磁碟的葉子節點被合併後,舊的數據並不會被刪除,這些數據會拷貝一份和內存中的數據一起順序寫到磁碟。這會操作一些空間的浪費,但是,LSM-Tree 提供了一些機制來回收這些空間。磁碟中的樹的非葉子節點數據也被緩存在內存中。數據查找會首先查找內存中樹,如果沒有查到結果,會轉而查找磁碟中的樹。有一個很顯然的問題是,如果數據量過於龐大,磁碟中的樹相應地也會很大,導致的後果是合併的速度會變慢。一個解決方法是建立各個層次的樹,低層次的樹都比 上一層次的樹數據集大。假設內存中的樹為 c0, 磁碟中的樹按照層次一次為 c1, c2, c3, ... ck-1, ck。合併的順序是 (c0, c1), (c1, c2)...(ck-1, ck)

推薦閱讀:

相关文章