思考數據庫調優維度選擇
下面進入了 SQL 性能優化篇,關注如何提升 SQL 查詢的效率。
其實關於數據庫調優的知識點非常分散。不同的 DBMS,不同的公司,不同的職位,不同的項目遇到的問題都不盡相同。為了能讓你對數據庫調優有一個整體的概覽,我把這些知識點做了一個梳理。
數據庫調優的目標
簡單來說,數據庫調優的目的就是要讓數據庫運行得更快,也就是說響應的時間更快,吞吐量更大。
不過隨着用戶量的不斷增加,以及應用程序復雜度的提升,我們很難用“更快”去定義數據庫調優的目標,因為用戶在不同時間段訪問服務器遇到的瓶頸不同,比如雙十一促銷的時候會帶來大規模的並發訪問;還有用戶在進行不同業務操作的時候,數據庫的事務處理和 SQL 查詢都會有所不同。因此我們還需要更加精細的定位,去確定調優的目標。那么如何確定呢?
用戶的反饋
用戶是我們的服務對象,因此他們的反饋是最直接的。雖然他們不會直接提出技術建議,但是有些問題往往是用戶第一時間發現的。我們要重視用戶的反饋,找到和數據相關的問題。
日志分析
我們可以通過查看數據庫日志和操作系統日志等方式找出異常情況,通過它們來定位遇到的問題。
除了這些具體的反饋以外,我們還可以通過監控運行狀態來整體了解服務器和數據庫的運行情況。
服務器資源使用監控
通過監控服務器的 CPU、內存、I/O 等使用情況,可以實時了解服務器的性能使用,與歷史情況進行對比。
數據庫內部狀況監控
在數據庫的監控中,活動會話(Active Session)監控是一個重要的指標。通過它,你可以清楚地了解數據庫當前是否處於非常繁忙的狀態,是否存在 SQL 堆積等。
除了活動會話監控以外,我們也可以對事務、鎖等待等進行監控,這些都可以幫助我們對數據庫的運行狀態有更全面的認識。
對數據庫進行調優,都有哪些維度可以進行選擇?
我們需要調優的對象是整個數據庫管理系統,它不僅包括 SQL 查詢,還包括數據庫的部署配置、架構等。從這個角度來說,我們思考的維度就不僅僅局限在 SQL 優化上了。
聽起來比較復雜,但其實我們可以一步步通過下面的步驟進行梳理。
第一步,選擇適合的 DBMS
我們之前講到了 SQL 陣營和 NoSQL 陣營。在 RDBMS 中,常用的有 Oracle,SQL Server 和 MySQL 等。如果對事務性處理以及安全性要求高的話,可以選擇商業的數據庫產品。這些數據庫在事務處理和查詢性能上都比較強,比如采用 SQL Server,那么單表存儲上億條數據是沒有問題的。如果數據表設計得好,即使不采用分庫分表的方式,查詢效率也不差。
除此以外,你也可以采用開源的 MySQL 進行存儲,我們之前講到過,它有很多存儲引擎可以選擇,如果進行事務處理的話可以選擇 InnoDB,非事務處理可以選擇 MyISAM。
NoSQL 陣營包括鍵值型數據庫、文檔型數據庫、搜索引擎、列式存儲和圖形數據庫。這些數據庫的優缺點和使用場景各有不同,比如列式存儲數據庫可以大幅度降低系統的 I/O,適合於分布式文件系統和 OLAP,但如果數據需要頻繁地增刪改,那么列式存儲就不太適用了。
DBMS 的選擇關系到了后面的整個設計過程,所以第一步就是要選擇適合的 DBMS。如果已經確定好了 DBMS,那么這步可以跳過,但有時候我們要根據業務需求來進行選擇。
第二步,優化表設計
選擇了 DBMS 之后,我們就需要進行表設計了。RDBMS 中,每個對象都可以定義為一張表,表與表之間的關系代表了對象之間的關系。如果用的是 MySQL,我們還可以根據不同表的使用需求,選擇不同的存儲引擎。
除此以外,還有一些優化的原則可以參考:
- 表結構要盡量遵循第三范式的原則。這樣可以讓數據結構更加清晰規范,減少冗余字段,同時也減少了在更新,插入和刪除數據時等異常情況的發生。
- 如果分析查詢應用比較多,尤其是需要進行多表聯查的時候,可以采用反范式進行優化。反范式采用空間換時間的方式,通過增加冗余字段提高查詢的效率。
- 表字段的數據類型選擇,關系到了查詢效率的高低以及存儲空間的大小。一般來說,如果字段可以采用數值類型就不要采用字符類型;字符長度要盡可能設計得短一些。針對字符類型來說,當確定字符長度固定時,就可以采用 CHAR 類型;當長度不固定時,通常采用 VARCHAR 類型。
數據表的結構設計很基礎,也很關鍵。好的表結構可以在業務發展和用戶量增加的情況下依然發揮作用,不好的表結構設計會讓數據表變得非常臃腫,查詢效率也會降低。
第三步,優化邏輯查詢
當我們建立好數據表之后,就可以對數據表進行增刪改查的操作了。這時我們首先需要考慮的是邏輯查詢優化,什么是邏輯查詢優化呢?
SQL 查詢優化,可以分為邏輯查詢優化和物理查詢優化。
邏輯查詢優化就是通過改變 SQL 語句的內容讓 SQL 執行效率更高效,采用的方式是對 SQL 語句進行等價變換,對查詢進行重寫。重寫查詢的數學基礎就是關系代數。
SQL 的查詢重寫包括了子查詢優化、等價謂詞重寫、視圖重寫、條件簡化、連接消除和嵌套連接消除等。
比如我們在講解 EXISTS 子查詢和 IN 子查詢的時候,會根據小表驅動大表的原則選擇適合的子查詢。在 WHERE 子句中會盡量避免對字段進行函數運算,它們會讓字段的索引失效。
我舉一個例子,假設我想對商品評論表中的評論內容進行檢索,查詢評論內容開頭為 abc 的內容都有哪些,如果在 WHERE 子句中使用了函數,語句就會寫成下面這樣:
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'
我們可以采用查詢重寫的方式進行等價替換:
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE comment_text LIKE 'abc%'
發現在數據量大的情況下,第二條 SQL 語句的查詢效率要比前面的高很多,執行時間為前者的 1/10。
第四步,優化物理查詢
物理查詢優化是將邏輯查詢的內容變成可以被執行的物理操作符,從而為后續執行器的執行提供准備。它的核心是高效地建立索引,並通過這些索引來做各種優化。
要知道索引不是萬能的,我們需要根據實際情況來創建索引。那么都有哪些情況需要考慮呢?
- 如果數據重復度高,就不需要創建索引。通常在重復度超過 10% 的情況下,可以不創建這個字段的索引。比如性別這個字段(取值為男和女)。
- 要注意索引列的位置對索引使用的影響。比如我們在 WHERE 子句中對索引字段進行了表達式的計算,會造成這個字段的索引失效。
- 要注意聯合索引對索引使用的影響。我們在創建聯合索引的時候會對多個字段創建索引,這時索引的順序就很重要了。比如我們對字段 x, y, z 創建了索引,那么順序是 (x,y,z) 還是 (z,y,x),在執行的時候就會存在差別。
- 要注意多個索引對索引使用的影響。索引不是越多越好,因為每個索引都需要存儲空間,索引多也就意味着需要更多的存儲空間。此外,過多的索引也會導致優化器在進行評估的時候增加了篩選出索引的計算時間,影響評估的效率。
查詢優化器在對 SQL 語句進行等價變換之后,還需要根據數據表的索引情況和數據情況確定訪問路徑,這就決定了執行 SQL 時所需要消耗的資源。
SQL 查詢時需要對不同的數據表進行查詢,因此在物理查詢優化階段也需要確定這些查詢所采用的路徑,具體的情況包括:
- 單表掃描:對於單表掃描來說,我們可以全表掃描所有的數據,也可以局部掃描。
- 兩張表的連接:常用的連接方式包括了嵌套循環連接、HASH 連接和合並連接。
- 多張表的連接:多張數據表進行連接的時候,順序很重要,因為不同的連接路徑查詢的效率不同,搜索空間也會不同。我們在進行多表連接的時候,搜索空間可能會達到很高的數據量級,巨大的搜索空間顯然會占用更多的資源,因此我們需要通過調整連接順序,將搜索空間調整在一個可接收的范圍內。
物理查詢優化是在確定了邏輯查詢優化之后,采用物理優化技術(比如索引等),通過計算代價模型對各種可能的訪問路徑進行估算,從而找到執行方式中代價最小的作為執行計划。在這個部分中,我們需要掌握的重點是對索引的創建和使用。
第五步,使用 Redis 或 Memcached 作為緩存
除了可以對 SQL 本身進行優化以外,我們還可以請外援提升查詢的效率。
因為數據都是存放到數據庫中,我們需要從數據庫層中取出數據放到內存中進行業務邏輯的操作,當用戶量增大的時候,如果頻繁地進行數據查詢,會消耗數據庫的很多資源。如果我們將常用的數據直接放到內存中,就會大幅提升查詢的效率。
鍵值存儲數據庫可以幫我們解決這個問題。
常用的鍵值存儲數據庫有 Redis 和 Memcached,它們都可以將數據存放到內存中。
從可靠性來說,Redis 支持持久化,可以讓我們的數據保存在硬盤上,不過這樣一來性能消耗也會比較大。而 Memcached 僅僅是內存存儲,不支持持久化。
從支持的數據類型來說,Redis 比 Memcached 要多,它不僅支持 key-value 類型的數據,還支持 List,Set,Hash 等數據結構。 當我們有持久化需求或者是更高級的數據處理需求的時候,就可以使用 Redis。如果是簡單的 key-value 存儲,則可以使用 Memcached。
通常我們對於查詢響應要求高的場景(響應時間短,吞吐量大),可以考慮內存數據庫,畢竟術業有專攻。傳統的 RDBMS 都是將數據存儲在硬盤上,而內存數據庫則存放在內存中,查詢起來要快得多。不過使用不同的工具,也增加了開發人員的使用成本。
第六步,庫級優化
庫級優化是站在數據庫的維度上進行的優化策略,比如控制一個庫中的數據表數量。另外我們可以采用主從架構優化我們的讀寫策略。
如果讀和寫的業務量都很大,並且它們都在同一個數據庫服務器中進行操作,那么數據庫的性能就會出現瓶頸,這時為了提升系統的性能,優化用戶體驗,我們可以采用讀寫分離的方式降低主數據庫的負載,比如用主數據庫(master)完成寫操作,用從數據庫(slave)完成讀操作。
除此以外,我們還可以對數據庫分庫分表。當數據量級達到億級以上時,有時候我們需要把一個數據庫切成多份,放到不同的數據庫服務器上,減少對單一數據庫服務器的訪問壓力。如果你使用的是 MySQL,就可以使用 MySQL 自帶的分區表功能,當然你也可以考慮自己做垂直切分和水平切分。
什么情況下做垂直切分,什么情況下做水平切分呢?
如果數據庫中的數據表過多,可以采用垂直分庫的方式,將關聯的數據表部署在一個數據庫上。
如果數據表中的列過多,可以采用垂直分表的方式,將數據表分拆成多張,把經常一起使用的列放到同一張表里。
如果數據表中的數據達到了億級以上,可以考慮水平切分,將大的數據表分拆成不同的子表,每張表保持相同的表結構。比如你可以按照年份來划分,把不同年份的數據放到不同的數據表中。2017 年、2018 年和 2019 年的數據就可以分別放到三張數據表中。
采用垂直分表的形式,就是將一張數據表分拆成多張表,采用水平拆分的方式,就是將單張數據量大的表按照某個屬性維度分成不同的小表。
但需要注意的是,分拆在提升數據庫性能的同時,也會增加維護和使用成本。
我們該如何思考和分析數據庫調優這件事
做任何事情之前,我們都需要確認目標。在數據庫調優中,我們的目標就是響應時間更快,吞吐量更大。利用宏觀的監控工具和微觀的日志分析可以幫我們快速找到調優的思路和方式。
雖然每個人的情況都不一樣,但我們同樣需要對數據庫調優這件事有一個整體的認知。在思考數據庫調優的時候,可以從三個維度進行考慮。
選擇比努力更重要
在進行 SQL 調優之前,可以先選擇 DBMS 和數據表的設計方式。你能看到,不同的 DBMS 直接決定了后面的操作方式,數據表的設計方式也直接影響了后續的 SQL 查詢語句。
可以把 SQL 查詢優化分成兩個部分,邏輯查詢優化和物理查詢優化
雖然 SQL 查詢優化的技術有很多,但是大方向上完全可以分成邏輯查詢優化和物理查詢優化兩大塊。邏輯查詢優化就是通過 SQL 等價變換提升查詢效率,直白一點就是說,換一種查詢寫法執行效率可能更高。物理查詢優化則是通過索引和表連接方式等技術來進行優化,這里重點需要掌握索引的使用。
通過外援來增強數據庫的性能
單一的數據庫總會遇到各種限制,不如取長補短,利用外援的方式。
另外通過對數據庫進行垂直或者水平切分,突破單一數據庫或數據表的訪問限制,提升查詢的性能。
范式設計
范式是數據表設計的基本原則,又很容易被忽略。很多時候,當數據庫運行了一段時間之后,我們才發現數據表設計得有問題。重新調整數據表的結構,就需要做數據遷移,還有可能影響程序的業務邏輯,以及網站正常的訪問。所以在開始設置數據庫的時候,我們就需要重視數據表的設計。
數據庫的設計范式都包括哪些
我們在設計關系型數據庫模型的時候,需要對關系內部各個屬性之間聯系的合理化程度進行定義,這就有了不同等級的規范要求,這些規范要求被稱為范式(NF)。你可以把范式理解為,一張數據表的設計結構需要滿足的某種設計標准的級別。
目前關系型數據庫一共有 6 種范式,按照范式級別,從低到高分別是:1NF(第一范式)、2NF(第二范式)、3NF(第三范式)、BCNF(巴斯 - 科德范式)、4NF(第四范式)和 5NF(第五范式,又叫做完美范式)。
數據庫的范式設計越高階,冗余度就越低,同時高階的范式一定符合低階范式的要求,比如滿足 2NF 的一定滿足 1NF,滿足 3NF 的一定滿足 2NF,依次類推。
這么多范式是不是都要掌握呢?一般來說數據表的設計應盡量滿足 3NF。但也不絕對,有時候為了提高某些查詢性能,我們還需要破壞范式規則,也就是反規范化。
數據表中的那些鍵
范式的定義會使用到主鍵和候選鍵(因為主鍵和候選鍵可以唯一標識元組),數據庫中的鍵(Key)由一個或者多個屬性組成。我總結了下數據表中常用的幾種鍵和屬性的定義:
- 超鍵:能唯一標識元組的屬性集叫做超鍵。
- 候選鍵:如果超鍵不包括多余的屬性,那么這個超鍵就是候選鍵。
- 主鍵:用戶可以從候選鍵中選擇一個作為主鍵。
- 外鍵:如果數據表 R1 中的某屬性集不是 R1 的主鍵,而是另一個數據表 R2 的主鍵,那么這個屬性集就是數據表 R1 的外鍵。
- 主屬性:包含在任一候選鍵中的屬性稱為主屬性。
- 非主屬性:與主屬性相對,指的是不包含在任何一個候選鍵中的屬性。
通常,我們也將候選鍵稱之為“碼”,把主鍵也稱為“主碼”。因為鍵可能是由多個屬性組成的,針對單個屬性,我們還可以用主屬性和非主屬性來進行區分。
舉個簡單的例子
我們之前用過 NBA 的球員表(player)和球隊表(team)。這里我可以把球員表定義為包含球員編號、姓名、身份證號、年齡和球隊編號;球隊表包含球隊編號、主教練和球隊所在地。
對於球員表來說,超鍵就是包括球員編號或者身份證號的任意組合,比如(球員編號)(球員編號,姓名)(身份證號,年齡)等。
候選鍵就是最小的超鍵,對於球員表來說,候選鍵就是(球員編號)或者(身份證號)。
主鍵是我們自己選定,也就是從候選鍵中選擇一個,比如(球員編號)。
外鍵就是球員表中的球隊編號。
在 player 表中,主屬性是(球員編號)(身份證號),其他的屬性(姓名)(年齡)(球隊編號)都是非主屬性。
從 1NF 到 3NF
1NF 指的是數據庫表中的任何屬性都是原子性的,不可再分。這很好理解,我們在設計某個字段的時候,對於字段 X 來說,就不能把字段 X 拆分成字段 X-1 和字段 X-2。事實上,任何的 DBMS 都會滿足第一范式的要求,不會將字段進行拆分。
2NF 指的數據表里的非主屬性都要和這個數據表的候選鍵有完全依賴關系。所謂完全依賴不同於部分依賴,也就是不能僅依賴候選鍵的一部分屬性,而必須依賴全部屬性。
舉一個沒有滿足 2NF 的例子
比如說我們設計一張球員比賽表 player_game,里面包含球員編號、姓名、年齡、比賽編號、比賽時間和比賽場地等屬性,這里候選鍵和主鍵都為(球員編號,比賽編號),我們可以通過候選鍵來決定如下的關系:
(球員編號, 比賽編號) → (姓名, 年齡, 比賽時間, 比賽場地,得分)
上面這個關系說明球員編號和比賽編號的組合決定了球員的姓名、年齡、比賽時間、比賽地點和該比賽的得分數據。
但是這個數據表不滿足第二范式,因為數據表中的字段之間還存在着如下的對應關系:
(球員編號) → (姓名,年齡)
(比賽編號) → (比賽時間, 比賽場地)
也就是說候選鍵中的某個字段決定了非主屬性。也可以理解為,對於非主屬性來說,並非完全依賴候選鍵。
這樣會產生怎樣的問題呢?
- 數據冗余:如果一個球員可以參加 m 場比賽,那么球員的姓名和年齡就重復了 m-1 次。一個比賽也可能會有 n 個球員參加,比賽的時間和地點就重復了 n-1 次。
- 插入異常:如果我們想要添加一場新的比賽,但是這時還沒有確定參加的球員都有誰,那么就沒法插入。
- 刪除異常:如果我要刪除某個球員編號,如果沒有單獨保存比賽表的話,就會同時把比賽信息刪除掉。
- 更新異常:如果我們調整了某個比賽的時間,那么數據表中所有這個比賽的時間都需要進行調整,否則就會出現一場比賽時間不同的情況。
為了避免出現上述的情況,我們可以把球員比賽表設計為下面的三張表。
球員 player 表包含球員編號、姓名和年齡等屬性;
比賽 game 表包含比賽編號、比賽時間和比賽場地等屬性;
球員比賽關系 player_game 表包含球員編號、比賽編號和得分等屬性。
這樣的話,每張數據表都符合第二范式,也就避免了異常情況的發生。某種程度上 2NF 是對 1NF 原子性的升級。1NF 告訴我們字段屬性需要是原子性的,而 2NF 告訴我們一張表就是一個獨立的對象,也就是說一張表只表達一個意思。
3NF 在滿足 2NF 的同時,對任何非主屬性都不傳遞依賴於候選鍵。也就是說不能存在非主屬性 A 依賴於非主屬性 B,非主屬性 B 依賴於候選鍵的情況。
我們用球員 player 表舉例子,這張表包含的屬性包括球員編號、姓名、球隊名稱和球隊主教練。現在,我們把屬性之間的依賴關系畫出來,如下圖所示:
你能看到球員編號決定了球隊名稱,同時球隊名稱決定了球隊主教練,非主屬性球隊主教練就會傳遞依賴於球員編號,因此不符合 3NF 的要求。
如果要達到 3NF 的要求,需要把數據表拆成下面這樣:
球員表的屬性包括球員編號、姓名和球隊名稱;
球隊表的屬性包括球隊名稱、球隊主教練。
總結一下,
1NF 需要保證表中每個屬性都保持原子性;
2NF 需要保證表中的非主屬性與候選鍵完全依賴;
3NF 需要保證表中的非主屬性與候選鍵不存在傳遞依賴。
總結
關系型數據庫的設計都是基於關系模型的,在關系模型中存在着 4 種鍵,這些鍵的核心作用就是標識。
在這些概念的基礎上,我又講了 1NF,2NF 和 3NF。我們經常會與這三種范式打交道,利用它們建立冗余度小、結構合理的數據庫。
有一點需要注意的是,這些范式只是提出了設計的標准,實際上設計數據表時,未必要符合這些原則。
一方面是因為這些范式本身存在一些問題,可能會帶來插入,更新,刪除等的異常情況,
另一方面,它們也可能降低會查詢的效率。這是為什么呢?因為范式等級越高,設計出來的數據表就越多,進行數據查詢的時候就可能需要關聯多張表,從而影響查詢效率。
反范式設計
作為數據庫的設計人員,理解范式的設計以及反范式優化是非常有必要的。
BCNF(巴斯范式)
如果數據表的關系模式符合 3NF 的要求,就不存在問題了嗎?
我們來看下這張倉庫管理關系 warehouse_keeper 表:
在這個數據表中,一個倉庫只有一個管理員,同時一個管理員也只管理一個倉庫。我們先來梳理下這些屬性之間的依賴關系。
倉庫名決定了管理員,管理員也決定了倉庫名,同時(倉庫名,物品名)的屬性集合可以決定數量這個屬性。
這樣,我們就可以找到數據表的候選鍵是(管理員,物品名)和(倉庫名,物品名),
然后我們從候選鍵中選擇一個作為主鍵,比如(倉庫名,物品名)。
在這里,主屬性是包含在任一候選鍵中的屬性,也就是倉庫名,管理員和物品名。非主屬性是數量這個屬性。
如何判斷一張表的范式呢?
我們需要根據范式的等級,從低到高來進行判斷。
-
首先,數據表每個屬性都是原子性的,符合 1NF 的要求;
-
其次,數據表中非主屬性”數量“都與候選鍵全部依賴,(倉庫名,物品名)決定數量,(管理員,物品名)決定數量,因此,數據表符合 2NF 的要求;
-
最后,數據表中的非主屬性,不傳遞依賴於候選鍵。因此符合 3NF 的要求。
既然數據表已經符合了 3NF 的要求,是不是就不存在問題了呢?
我們來看下下面的情況:
- 增加一個倉庫,但是還沒有存放任何物品。根據數據表實體完整性的要求,主鍵不能有空值,因此會出現插入異常;
- 如果倉庫更換了管理員,我們就可能會修改數據表中的多條記錄;
- 如果倉庫里的商品都賣空了,那么此時倉庫名稱和相應的管理員名稱也會隨之被刪除。
你能看到,即便數據表符合 3NF 的要求,同樣可能存在插入,更新和刪除數據的異常情況。
這種情況下該怎么解決呢?
首先我們需要確認造成異常的原因:主屬性倉庫名對於候選鍵(管理員,物品名)是部分依賴的關系,這樣就有可能導致上面的異常情況。人們在 3NF 的基礎上進行了改進,提出了BCNF,也叫做巴斯 - 科德范式,它在 3NF 的基礎上消除了主屬性對候選鍵的部分依賴或者傳遞依賴關系。
根據 BCNF 的要求,我們需要把倉庫管理關系 warehouse_keeper 表拆分成下面這樣:
倉庫表:(倉庫名,管理員)
庫存表:(倉庫名,物品名,數量)
這樣就不存在主屬性對於候選鍵的部分依賴或傳遞依賴,上面數據表的設計就符合 BCNF。
反范式設計
盡管圍繞着數據表的設計有很多范式,但事實上,我們在設計數據表的時候卻不一定要參照這些標准。
我們在之前已經了解了越高階的范式得到的數據表越多,數據冗余度越低。但有時候,我們在設計數據表的時候,還需要為了性能和讀取效率違反范式化的原則。反范式就是相對范式化而言的,換句話說,就是允許少量的冗余,通過空間來換時間。
如果我們想對查詢效率進行優化,有時候反范式優化也是一種優化思路。
比如我們想要查詢某個商品的前 1000 條評論,會涉及到兩張表。
商品評論表 product_comment,對應的字段名稱及含義如下:
用戶表 user,對應的字段名稱及含義如下:
實驗數據:模擬兩張百萬量級的數據表
為了更好地進行 SQL 優化實驗,我們需要給用戶表和商品評論表隨機模擬出百萬量級的數據。我們可以通過存儲過程來實現模擬數據。
下面是給用戶表隨機生成 100 萬用戶的代碼:
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user`(IN start INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');
DECLARE date_temp DATETIME;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, interval RAND()*60 second);
INSERT INTO user(user_id, user_name, create_time)
VALUES((start+i), CONCAT('user_',i), date_temp);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
我用 date_start 變量來定義初始的注冊時間,時間為 2017 年 1 月 1 日 0 點 0 分 0 秒,
然后用 date_temp 變量計算每個用戶的注冊時間,新的注冊用戶與上一個用戶注冊的時間間隔為 60 秒內的隨機值。
然后使用 REPEAT … UNTIL … END REPEAT 循環,對 max_num 個用戶的數據進行計算。
在循環前,我們將 autocommit 設置為 0,這樣等計算完成再統一插入,執行效率更高。
然后我們來運行 call insert_many_user(10000, 1000000); 調用存儲過程。這里需要通過 start 和 max_num 兩個參數對初始的 user_id 和要創建的用戶數量進行設置。運行結果:
能看到在 MySQL 里,創建 100 萬的用戶數據用時 1 分 37 秒。
接着我們再來給商品評論表 product_comment 隨機生成 100 萬條商品評論。
這里我們設置為給某一款商品評論,比如 product_id=10001。評論的內容為隨機的 20 個字母。以下是創建隨機的 100 萬條商品評論的存儲過程:
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_product_comments`(IN START INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE date_start DATETIME DEFAULT ('2018-01-01 00:00:00');
DECLARE date_temp DATETIME;
DECLARE comment_text VARCHAR(25);
DECLARE user_id INT;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, INTERVAL RAND()*60 SECOND);
SET comment_text = substr(MD5(RAND()),1, 20);
SET user_id = FLOOR(RAND()*1000000);
INSERT INTO product_comment(comment_id, product_id, comment_text, comment_time, user_id)
VALUES((START+i), 10001, comment_text, date_temp, user_id);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
同樣的,我用 date_start 變量來定義初始的評論時間。這里新的評論時間與上一個評論的時間間隔還是 60 秒內的隨機值,商品評論表中的 user_id 為隨機值。我們使用 REPEAT … UNTIL … END REPEAT 循環,來對 max_num 個商品評論的數據進行計算。
然后調用存儲過程,運行結果如下:
MySQL 一共花了 2 分 7 秒完成了商品評論數據的創建。
反范式優化實驗對比
如果我們想要查詢某個商品 ID,比如 10001 的前 1000 條評論,需要寫成下面這樣:
SELECT p.comment_text, p.comment_time, u.user_name FROM product_comment AS p
LEFT JOIN user AS u
ON p.user_id = u.user_id
WHERE p.product_id = 10001
ORDER BY p.comment_id DESC LIMIT 1000
運行結果(1000 條數據行):
運行時長為 0.395 秒,查詢效率並不高。
這是因為在實際生活中,我們在顯示商品評論的時候,通常會顯示這個用戶的昵稱,而不是用戶 ID,因此我們還需要關聯 product_comment 和 user 這兩張表來進行查詢。當表數據量不大的時候,查詢效率還好,但如果表數據量都超過了百萬量級,查詢效率就會變低。
這是因為查詢會在 product_comment 表和 user 表這兩個表上進行聚集索引掃描,然后再嵌套循環,這樣一來查詢所耗費的時間就有幾百毫秒甚至更多。對於網站的響應來說,這已經很慢了,用戶體驗會非常差。
如果我們想要提升查詢的效率,可以允許適當的數據冗余,也就是在商品評論表中增加用戶昵稱字段,在 product_comment 數據表的基礎上增加 user_name 字段,就得到了 product_comment2 數據表。
你可以在百度網盤中下載這三張數據表 product_comment、product_comment2 和 user 表,密碼為 n3l8。
這樣一來,只需單表查詢就可以得到數據集結果:
SELECT comment_text, comment_time, user_name FROM product_comment2 WHERE product_id = 10001 ORDER BY comment_id DESC LIMIT 1000
運行結果(1000 條數據):
優化之后只需要掃描一次聚集索引即可,運行時間為 0.039 秒,查詢時間是之前的 1/10。 你能看到,在數據量大的情況下,查詢效率會有顯著的提升。
反范式存在的問題 & 適用場景
從上面的例子中可以看出,反范式可以通過空間換時間,提升查詢的效率,但是反范式也會帶來一些新問題。
在數據量小的情況下,反范式不能體現性能的優勢,可能還會讓數據庫的設計更加復雜。比如采用存儲過程來支持數據的更新、刪除等額外操作,很容易增加系統的維護成本。
比如用戶每次更改昵稱的時候,都需要執行存儲過程來更新,如果昵稱更改頻繁,會非常消耗系統資源。
那么反范式優化適用於哪些場景呢?
在現實生活中,我們經常需要一些冗余信息,比如訂單中的收貨人信息,包括姓名、電話和地址等。每次發生的訂單收貨信息都屬於歷史快照,需要進行保存,但用戶可以隨時修改自己的信息,這時保存這些冗余信息是非常有必要的。
當冗余信息有價值或者能大幅度提高查詢效率的時候,我們就可以采取反范式的優化。
此外反范式優化也常用在數據倉庫的設計中,因為數據倉庫通常存儲歷史數據,對增刪改的實時性要求不強,對歷史數據的分析需求強。這時適當允許數據的冗余度,更方便進行數據分析。
簡單總結下數據倉庫和數據庫在使用上的區別:
- 數據庫設計的目的在於捕獲數據,而數據倉庫設計的目的在於分析數據;
- 數據庫對數據的增刪改實時性要求強,需要存儲在線的用戶數據,而數據倉庫存儲的一般是歷史數據;
- 數據庫設計需要盡量避免冗余,但為了提高查詢效率也允許一定的冗余度,而數據倉庫在設計上更偏向采用反范式設計。
總結
BCNF是基於 3NF 進行的改進。你能看到設計范式越高階,數據表就會越精細,數據的冗余度也就越少,在一定程度上可以讓數據庫在內部關聯上更好地組織數據。但有時候我們也需要采用反范進行優化,通過空間來換取時間。
范式本身沒有優劣之分,只有適用場景不同。沒有完美的設計,只有合適的設計,我們在數據表的設計中,還需要根據需求將范式和反范式混合使用。
索引的概覽
提起優化 SQL,你可能會把它理解為優化索引。簡單來說這也不算錯,索引在 SQL 優化中占了很大的比重。索引用得好,可以將 SQL 查詢的效率提升 10 倍甚至更多。但索引是萬能的嗎?既然索引可以提升效率,只要創建索引不就好了嗎?實際上,在有些情況下,創建索引反而會降低效率。
索引是萬能的嗎?
首先我們需要了解什么是索引(Index)。數據庫中的索引,就好比一本書的目錄,它可以幫我們快速進行特定值的定位與查找,從而加快數據查詢的效率。
索引就是幫助數據庫管理系統高效獲取數據的數據結構。
如果我們不使用索引,就必須從第 1 條記錄開始掃描,直到把所有的數據表都掃描完,才能找到想要的數據。既然如此,如果我們想要快速查找數據,就只需要創建更多的索引就好了呢?
其實索引不是萬能的,在有些情況下使用索引反而會讓效率變低。
索引的價值是幫我們從海量數據中找到想要的數據,如果數據量少,那么是否使用索引對結果的影響並不大。
在數據表中的數據行數比較少的情況下,比如不到 1000 行,是不需要創建索引的。
另外,當數據重復度大,比如高於 10% 的時候,也不需要對這個字段使用索引。
我之前講到過,如果是性別這個字段,就不需要對它創建索引。
這是為什么呢?
如果你想要在 100 萬行數據中查找其中的 50 萬行(比如性別為男的數據),一旦創建了索引,你需要先訪問 50 萬次索引,然后再訪問 50 萬次數據表,這樣加起來的開銷比不使用索引可能還要大。
實驗 1:數據行數少的情況下,索引效率如何
我在百度網盤上提供了數據表,heros_without_index.sql 和 heros_with_index.sql,提取碼為 wxho。
在第一個數據表中,除了自增的 id 以外沒有建立額外的索引。第二張數據表中,我對 name 字段建立了唯一索引。
heros 數據表一共有 69 個英雄,數據量很少。當我們對 name 進行條件查詢的時候,我們觀察一下創建索引前后的效率。
SELECT id, name, hp_max, mp_max FROM heros_without_index WHERE name = '劉禪'
運行結果(1 條數據,運行時間 0.072s):
我對 name 字段建立索引后,再進行查詢:
SELECT id, name, hp_max, mp_max FROM heros_with_index WHERE name = '劉禪'
運行結果(1 條數據,運行時間 0.080s):
你能看到運行結果相同,但是創建了 name 字段索引的效率比沒有創建索引時效率更低。在數據量不大的情況下,索引就發揮不出作用了。
實驗 2:性別(男或女)字段真的不應該創建索引嗎?
如果一個字段的取值少,比如性別這個字段,通常是不需要創建索引的。那么有沒有特殊的情況呢?
下面我們來看一個例子,假設有一個女兒國,人口總數為 100 萬人,男性只有 10 個人,也就是占總人口的 10 萬分之 1。
女兒國的人口數據表 user_gender 見百度網盤中的 user_gender.sql。其中數據表中的 user_gender 字段取值為 0 或 1,0 代表女性,1 代表男性。
如果我們要篩選出這個國家中的男性,可以使用:
SELECT * FROM user_gender WHERE user_gender = 1
運行結果(10 條數據,運行時間 0.696s):
你能看到在未創建索引的情況下,運行的效率並不高。如果我們針對 user_gender 字段創建索引呢?
SELECT * FROM user_gender WHERE user_gender = 1
同樣是 10 條數據,運行結果相同,時間卻縮短到了 0.052s,大幅提升了查詢的效率。
其實通過這兩個實驗你也能看出來,索引的價值是幫你快速定位。如果想要定位的數據有很多,那么索引就失去了它的使用價值,比如通常情況下的性別字段。不過有時候,我們還要考慮這個字段中的數值分布的情況,在實驗 2 中,性別字段的數值分布非常特殊,男性的比例非常少。
我們不僅要看字段中的數值個數,還要根據數值的分布情況來考慮是否需要創建索引。
索引的種類有哪些?
雖然使用索引的本質目的是幫我們快速定位想要查找的數據,但實際上,索引有很多種類。
從功能邏輯上說,索引主要有 4 種,分別是普通索引、唯一索引、主鍵索引和全文索引。
普通索引是基礎的索引,沒有任何約束,主要用於提高查詢效率。
唯一索引就是在普通索引的基礎上增加了數據唯一性的約束,在一張數據表里可以有多個唯一索引。
主鍵索引在唯一索引的基礎上增加了不為空的約束,也就是 NOT NULL+UNIQUE,一張表里最多只有一個主鍵索引。
全文索引用的不多,MySQL 自帶的全文索引只支持英文。我們通常可以采用專門的全文搜索引擎,比如 ES(ElasticSearch) 和 Solr。
其實前三種索引(普通索引、唯一索引和主鍵索引)都是一類索引,只不過對數據的約束性逐漸提升。在一張數據表中只能有一個主鍵索引,這是由主鍵索引的物理實現方式決定的,因為數據存儲在文件中只能按照一種順序進行存儲。但可以有多個普通索引或者多個唯一索引。
按照物理實現方式,索引可以分為 2 種:聚集索引和非聚集索引。我們也把非聚集索引稱為二級索引或者輔助索引。
聚集索引可以按照主鍵來排序存儲數據,這樣在查找行的時候非常有效。
舉個例子,如果是一本漢語字典,我們想要查找“數”這個字,直接在書中找漢語拼音的位置即可,也就是拼音“shu”。這樣找到了索引的位置,在它后面就是我們想要找的數據行。
非聚集索引又是什么呢?
在數據庫系統會有單獨的存儲空間存放非聚集索引,這些索引項是按照順序存儲的,但索引項指向的內容是隨機存儲的。也就是說系統會進行兩次查找,第一次先找到索引,第二次找到索引對應的位置取出數據行。
非聚集索引不會把索引指向的內容像聚集索引一樣直接放到索引的后面,而是維護單獨的索引表(只維護索引,不維護索引指向的數據),為數據檢索提供方便。
我們還以漢語字典為例,如果想要查找“數”字,那么按照部首查找的方式,先找到“數”字的偏旁部首,然后這個目錄會告訴我們“數”字存放到第多少頁,我們再去指定的頁碼找這個字。
聚集索引指表中數據行按索引的排序方式進行存儲,對查找行很有效。只有當表包含聚集索引時,表內的數據行才會按找索引列的值在磁盤上進行物理排序和存儲。
每一個表只能有一個聚集索引,因為數據行本身只能按一個順序存儲。
聚集索引與非聚集索引的原理不同,在使用上也有一些區別:
- 聚集索引的葉子節點存儲的就是我們的數據記錄,非聚集索引的葉子節點存儲的是數據位置。非聚集索引不會影響數據表的物理存儲順序。
- 一個表只能有一個聚集索引,因為只能有一種排序存儲的方式,但可以有多個非聚集索引,也就是多個索引目錄提供數據檢索。
- 使用聚集索引的時候,數據的查詢效率高,但如果對數據進行插入,刪除,更新等操作,效率會比非聚集索引低。
實驗 3:使用聚集索引和非聚集索引的查詢效率
還是針對剛才的 user_gender 數據表,我們來看下使用聚集索引和非聚集索引的查詢效率有什么區別。在 user_gender 表中,我設置了 user_id 為主鍵,也就是聚集索引的字段是 user_id。這里我們查詢下 user_id=90001 的用戶信息:
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001
運行結果(1 條數據,運行時間 0.043s):
我們再直接對 user_name 字段進行條件查詢,此時 user_name 字段沒有創建索引:
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
運行結果(1 條數據,運行時間 0.961s):
你能看出對沒有建立索引的字段進行條件查詢,查詢效率明顯降低了。
然后我們對 user_name 字段創建普通索引,進行 SQL 查詢:
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
運行結果(1 條數據,運行時間 0.050s):
通過對這 3 次 SQL 查詢結果的對比,我們可以總結出以下兩點內容:
- 對 WHERE 子句的字段建立索引,可以大幅提升查詢效率。
- 采用聚集索引進行數據查詢,比使用非聚集索引的查詢效率略高。如果查詢次數比較多,還是盡量使用主鍵索引進行數據查詢。
除了業務邏輯和物理實現方式,索引還可以按照字段個數進行划分,分成單一索引和聯合索引。
索引列為一列時為單一索引;多個列組合在一起創建的索引叫做聯合索引。
創建聯合索引時,我們需要注意創建時的順序問題,因為聯合索引 (x, y, z) 和 (z, y, x) 在使用的時候效率可能會存在差別。
這里需要說明的是聯合索引存在最左匹配原則,也就是按照最左優先的方式進行索引的匹配。
比如剛才舉例的 (x, y, z),如果查詢條件是 WHERE x=1 AND y=2 AND z=3,就可以匹配上聯合索引;如果查詢條件是 WHERE y=2,就無法匹配上聯合索引。
實驗 4:聯合索引的最左原則
還是針對 user_gender 數據表,我們把 user_id 和 user_name 字段設置為聯合主鍵,然后看下 SQL 查詢效率有什么區別。
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001 AND user_name = 'student_890001'
運行結果(1 條數據,運行時間 0.046s):
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_id = 900001
運行結果(1 條數據,運行時間 0.046s):
我們再來看下普通的條件查詢是什么樣子的:
SELECT user_id, user_name, user_gender FROM user_gender WHERE user_name = 'student_890001'
運行結果(1 條數據,運行時間 0.943s):
你能看到當我們使用了聯合索引 (user_id, user_name) 的時候,在 WHERE 子句中對聯合索引中的字段 user_id 和 user_name 進行條件查詢,或者只對 user_id 進行查詢,效率基本上是一樣的。
當我們對 user_name 進行條件查詢時,效率就會降低很多,這是因為根據聯合索引的最左原則,user_id 在 user_name 的左側,如果沒有使用 user_id,而是直接使用 user_name 進行條件查詢,聯合索引就會失效。
總結
使用索引可以幫助我們從海量的數據中快速定位想要查找的數據,不過索引也存在一些不足,比如占用存儲空間、降低數據庫寫操作的性能等,如果有多個索引還會增加索引選擇的時間。當我們使用索引時,需要平衡索引的利(提升查詢效率)和弊(維護索引所需的代價)。
在實際工作中,我們還需要基於需求和數據本身的分布情況來確定是否使用索引,盡管索引不是萬能的,但數據量大的時候不使用索引是不可想象的,畢竟索引的本質,是幫助我們提升數據檢索的效率。
索引的原理
如何評價索引的數據結構設計好壞
數據庫服務器有兩種存儲介質,分別為硬盤和內存。
內存屬於臨時存儲,容量有限,而且當發生意外時(比如斷電或者發生故障重啟)會造成數據丟失;
硬盤相當於永久存儲介質,這也是為什么我們需要把數據保存到硬盤上。
雖然內存的讀取速度很快,但我們還是需要將索引存放到硬盤上,這樣的話,當我們在硬盤上進行查詢時,也就產生了硬盤的 I/O 操作。相比於內存的存取來說,硬盤的 I/O 存取消耗的時間要高很多。
我們通過索引來查找某行數據的時候,需要計算產生的磁盤 I/O 次數,當磁盤 I/O 次數越多,所消耗的時間也就越大。如果我們能讓索引的數據結構盡量減少硬盤的 I/O 操作,所消耗的時間也就越小。
二叉樹的局限性
二分查找法是一種高效的數據檢索方式,時間復雜度為 O(log2n),是不是采用二叉樹就適合作為索引的數據結構呢?
我們先來看下最基礎的二叉搜索樹(Binary Search Tree),搜索某個節點和插入節點的規則一樣,我們假設搜索插入的數值為 key:
- 如果 key 大於根節點,則在右子樹中進行查找;
- 如果 key 小於根節點,則在左子樹中進行查找;
- 如果 key 等於根節點,也就是找到了這個節點,返回根節點即可。
舉個例子,我們對數列(34,22,89,5,23,77,91)創造出來的二分查找樹如下圖所示:
但是存在特殊的情況,就是有時候二叉樹的深度非常大。比如我們給出的數據順序是 (5, 22, 23, 34, 77, 89, 91),創造出來的二分搜索樹如下圖所示:
你能看出來第一個樹的深度是 3,也就是說最多只需 3 次比較,就可以找到節點,而第二個樹的深度是 7,最多需要 7 次比較才能找到節點。
第二棵樹也屬於二分查找樹,但是性能上已經退化成了一條鏈表,查找數據的時間復雜度變成了 O(n)。為了解決這個問題,人們提出了平衡二叉搜索樹(AVL 樹),它在二分搜索樹的基礎上增加了約束,每個節點的左子樹和右子樹的高度差不能超過 1,也就是說節點的左子樹和右子樹仍然為平衡二叉樹。
這里說一下,常見的平衡二叉樹有很多種,包括了平衡二叉搜索樹、紅黑樹、數堆、伸展樹。
平衡二叉搜索樹是最早提出來的自平衡二叉搜索樹,當我們提到平衡二叉樹時一般指的就是平衡二叉搜索樹。事實上,第一棵樹就屬於平衡二叉搜索樹,搜索時間復雜度就是 O(log2n)。
剛才提到過,數據查詢的時間主要依賴於磁盤 I/O 的次數,如果我們采用二叉樹的形式,即使通過平衡二叉搜索樹進行了改進,樹的深度也是 O(log2n),當 n 比較大時,深度也是比較高的,比如下圖的情況:
每訪問一次節點就需要進行一次磁盤 I/O 操作,對於上面的樹來說,我們需要進行 5 次 I/O 操作。雖然平衡二叉樹比較的效率高,但是樹的深度也同樣高,這就意味着磁盤 I/O 操作次數多,會影響整體數據查詢的效率。
針對同樣的數據,如果我們把二叉樹改成 M 叉樹(M>2)呢?當 M=3 時,同樣的 31 個節點可以由下面的三叉樹來進行存儲:
什么是 B 樹
如果用二叉樹作為索引的實現結構,會讓樹變得很高,增加硬盤的 I/O 次數,影響數據查詢的時間。因此一個節點就不能只有 2 個子節點,而應該允許有 M 個子節點 (M>2)。
B 樹的出現就是為了解決這個問題,B 樹的英文是 Balance Tree,也就是平衡的多路搜索樹,它的高度遠小於平衡二叉樹的高度。在文件系統和數據庫系統中的索引結構經常采用 B 樹來實現。
B 樹的結構如下圖所示:
B 樹作為平衡的多路搜索樹,它的每一個節點最多可以包括 M 個子節點,M 稱為 B 樹的階。同時你能看到,每個磁盤塊中包括了關鍵字和子節點的指針。
如果一個磁盤塊中包括了 x 個關鍵字,那么指針數就是 x+1。對於一個 100 階的 B 樹來說,如果有 3 層的話最多可以存儲約 100 萬的索引數據。對於大量的索引數據來說,采用 B 樹的結構是非常適合的,因為樹的高度要遠小於二叉樹的高度。
一個 M 階的 B 樹(M>2)有以下的特性:
-
根節點的兒子數的范圍是 [2,M]。
-
每個中間節點包含 k-1 個關鍵字和 k 個孩子,孩子的數量 = 關鍵字的數量 +1,k 的取值范圍為 [ceil(M/2), M]。
-
葉子節點包括 k-1 個關鍵字(葉子節點沒有孩子),k 的取值范圍為 [ceil(M/2), M]。
-
假設中間節點節點的關鍵字為:Key[1], Key[2], …, Key[k-1],且關鍵字按照升序排序,即 Key[i]<Key[i+1]。
此時 k-1 個關鍵字相當於划分了 k 個范圍,也就是對應着 k 個指針,即為:P[1], P[2], …, P[k],
其中 P[1] 指向關鍵字小於 Key[1] 的子樹,P[i] 指向關鍵字屬於 (Key[i-1], Key[i]) 的子樹,P[k] 指向關鍵字大於 Key[k-1] 的子樹。
-
所有葉子節點位於同一層。
上面那張圖所表示的 B 樹就是一棵 3 階的 B 樹。我們可以看下磁盤塊 2,里面的關鍵字為(8,12),它有 3 個孩子 (3,5),(9,10) 和 (13,15),你能看到 (3,5) 小於 8,(9,10) 在 8 和 12 之間,而 (13,15) 大於 12,剛好符合剛才我們給出的特征。
然后我們來看下如何用 B 樹進行查找。假設我們想要查找的關鍵字是 9,那么步驟可以分為以下幾步:
- 我們與根節點的關鍵字 (17,35)進行比較,9 小於 17 那么得到指針 P1;
- 按照指針 P1 找到磁盤塊 2,關鍵字為(8,12),因為 9 在 8 和 12 之間,所以我們得到指針 P2;
- 按照指針 P2 找到磁盤塊 6,關鍵字為(9,10),然后我們找到了關鍵字 9。
你能看出來在 B 樹的搜索過程中,我們比較的次數並不少,但如果把數據讀取出來然后在內存中進行比較,這個時間就是可以忽略不計的。而讀取磁盤塊本身需要進行 I/O 操作,消耗的時間比在內存中進行比較所需要的時間要多,是數據查找用時的重要因素,B 樹相比於平衡二叉樹來說磁盤 I/O 操作要少,在數據查詢中比平衡二叉樹效率要高。
什么是 B+ 樹
B+ 樹基於 B 樹做出了改進,主流的 DBMS 都支持 B+ 樹的索引方式,比如 MySQL。
B+ 樹和 B 樹的差異在於以下幾點:
- 有 k 個孩子的節點就有 k 個關鍵字。也就是孩子數量 = 關鍵字數,而 B 樹中,孩子數量 = 關鍵字數 +1。
- 非葉子節點的關鍵字也會同時存在在子節點中,並且是在子節點中所有關鍵字的最大(或最小)。
- 非葉子節點僅用於索引,不保存數據記錄,跟記錄有關的信息都放在葉子節點中。而 B 樹中,非葉子節點既保存索引,也保存數據記錄。
- 所有關鍵字都在葉子節點出現,葉子節點構成一個有序鏈表,而且葉子節點本身按照關鍵字的大小從小到大順序鏈接。
下圖就是一棵 B+ 樹,階數為 3,根節點中的關鍵字 1、18、35 分別是子節點(1,8,14),(18,24,31)和(35,41,53)中的最小值。每一層父節點的關鍵字都會出現在下一層的子節點的關鍵字中,因此在葉子節點中包括了所有的關鍵字信息,並且每一個葉子節點都有一個指向下一個節點的指針,這樣就形成了一個鏈表。
比如,我們想要查找關鍵字 16,B+ 樹會自頂向下逐層進行查找:
- 與根節點的關鍵字 (1,18,35) 進行比較,16 在 1 和 18 之間,得到指針 P1(指向磁盤塊 2)
- 找到磁盤塊 2,關鍵字為(1,8,14),因為 16 大於 14,所以得到指針 P3(指向磁盤塊 7)
- 找到磁盤塊 7,關鍵字為(14,16,17),然后我們找到了關鍵字 16,所以可以找到關鍵字 16 所對應的數據。
整個過程一共進行了 3 次 I/O 操作,看起來 B+ 樹和 B 樹的查詢過程差不多,但是 B+ 樹和 B 樹有個根本的差異在於,B+ 樹的中間節點並不直接存儲數據。這樣的好處都有什么呢?
-
首先,B+ 樹查詢效率更穩定。
因為 B+ 樹每次只有訪問到葉子節點才能找到對應的數據,而在 B 樹中,非葉子節點也會存儲數據,這樣就會造成查詢效率不穩定的情況,有時候訪問到了非葉子節點就可以找到關鍵字,而有時需要訪問到葉子節點才能找到關鍵字。
-
其次,B+ 樹的查詢效率更高,這是因為通常 B+ 樹比 B 樹更矮胖(階數更大,深度更低),查詢所需要的磁盤 I/O 也會更少。同樣的磁盤頁大小,B+ 樹可以存儲更多的節點關鍵字。
-
不僅是對單個關鍵字的查詢上,在查詢范圍上,B+ 樹的效率也比 B 樹高。這是因為所有關鍵字都出現在 B+ 樹的葉子節點中,並通過有序鏈表進行了鏈接。而在 B 樹中則需要通過中序遍歷才能完成查詢范圍的查找,效率要低很多。
總結
磁盤的 I/O 操作次數對索引的使用效率至關重要。雖然傳統的二叉樹數據結構查找數據的效率高,但很容易增加磁盤 I/O 操作的次數,影響索引使用的效率。因此在構造索引的時候,我們更傾向於采用“矮胖”的數據結構。
B 樹和 B+ 樹都可以作為索引的數據結構,在 MySQL 中采用的是 B+ 樹,B+ 樹在查詢性能上更穩定,在磁盤頁大小相同的情況下,樹的構造更加矮胖,所需要進行的磁盤 I/O 次數更少,更適合進行關鍵字的范圍查詢。
Hash索引的底層原理
Hash 本身是一個函數,又被稱為散列函數,它可以幫助我們大幅提升檢索數據的效率。
打個比方,Hash 就好像一個智能前台,你只要告訴它想要查找的人的姓名,它就會告訴你那個人坐在哪個位置,只需要一次交互就可以完成查找,效率非常高。大名鼎鼎的 MD5 就是 Hash 函數的一種。
Hash 算法是通過某種確定性的算法(比如 MD5、SHA1、SHA2、SHA3)將輸入轉變為輸出。相同的輸入永遠可以得到相同的輸出,假設輸入內容有微小偏差,在輸出中通常會有不同的結果。
如果你想要驗證兩個文件是否相同,那么你不需要把兩份文件直接拿來比對,只需要讓對方把 Hash 函數計算得到的結果告訴你即可,然后在本地同樣對文件進行 Hash 函數的運算,最后通過比較這兩個 Hash 函數的結果是否相同,就可以知道這兩個文件是否相同。
動手統計 Hash 檢索效率
我們知道 Python 的數據結構中有數組和字典兩種,
數組檢索數據類似於全表掃描,需要對整個數組的內容進行檢索;
字典是由 Hash 表實現的,存儲的是 key-value 值,對於數據檢索來說效率非常快。
對於 Hash 的檢索效率,我們來個更直觀的認知。下面我們分別看一下采用數組檢索數據和采用字典(Hash)檢索數據的效率到底有怎樣的差別。
實驗 1
在數組中添加 10000 個元素,然后分別對這 10000 個元素進行檢索,最后統計檢索的時間。
代碼如下:
import time
# 插入數據
result = []
for i in range(10000):
result.append(i)
# 檢索數據
time_start=time.time()
for i in range(10000):
temp = result.index(i)
time_end=time.time()
print('檢索時間', time_end-time_start)
運行結果:
檢索時間為 1.2436728477478027 秒
實驗 2
采用 Hash 表的形式存儲數據,即在 Python 中采用字典方式添加 10000 個元素,然后檢索這 10000 個數據,最后再統計一下時間。代碼如下:
import time
# 插入數據
result = {}
for i in range(1000000):
result[i] = i
# 檢索數據
time_start=time.time()
for i in range(10000):
temp = result[i]
time_end=time.time()
print('檢索時間:',time_end-time_start)
運行結果:
檢索時間為 0.0019941329956054688 秒。
你能看到 Hash 方式檢索差不多用了 2 毫秒的時間,檢索效率提升得非常明顯。這是因為 Hash 只需要一步就可以找到對應的取值,算法復雜度為 O(1),而數組檢索數據的算法復雜度為 O(n)。
MySQL 中的 Hash 索引
采用 Hash 進行檢索效率非常高,基本上一次檢索就可以找到數據,而 B+ 樹需要自頂向下依次查找,多次訪問節點才能找到數據,中間需要多次 I/O 操作,從效率來說 Hash 比 B+ 樹更快。
我們來看下 Hash 索引的示意圖:
鍵值 key 通過 Hash 映射找到桶 bucket。在這里桶(bucket)指的是一個能存儲一條或多條記錄的存儲單位。一個桶的結構包含了一個內存指針數組,桶中的每行數據都會指向下一行,形成鏈表結構,當遇到 Hash 沖突時,會在桶中進行鍵值的查找。
Hash 沖突呢?
如果桶的空間小於輸入的空間,不同的輸入可能會映射到同一個桶中,這時就會產生 Hash 沖突,如果 Hash 沖突的量很大,就會影響讀取的性能。
通常 Hash 值的字節數比較少,簡單的 4 個字節就夠了。在 Hash 值相同的情況下,就會進一步比較桶(Bucket)中的鍵值,從而找到最終的數據行。
Hash 值的字節數多的話可以是 16 位、32 位等,比如采用 MD5 函數就可以得到一個 16 位或者 32 位的數值,32 位的 MD5 已經足夠安全,重復率非常低。
我們模擬一下 Hash 索引。關鍵字如下所示,每個字母的內部編碼為字母的序號,比如 A 為 01,Y 為 25。我們統計內部編碼平方的第 8-11 位(從前向后)作為 Hash 值:
Hash 索引與 B+ 樹索引的區別
我們之前講到過 B+ 樹索引的結構,Hash 索引結構和 B+ 樹的不同,因此在索引使用上也會有差別。
-
Hash 索引不能進行范圍查詢,而 B+ 樹可以。這是因為 Hash 索引指向的數據是無序的,而 B+ 樹的葉子節點是個有序的鏈表。
-
Hash 索引不支持聯合索引的最左側原則(即聯合索引的部分索引無法使用),而 B+ 樹可以。
對於聯合索引來說,Hash 索引在計算 Hash 值的時候是將索引鍵合並后再一起計算 Hash 值,所以不會針對每個索引單獨計算 Hash 值。因此如果用到聯合索引的一個或者幾個索引時,聯合索引無法被利用。
-
Hash 索引不支持 ORDER BY 排序,因為 Hash 索引指向的數據是無序的,因此無法起到排序優化的作用,而 B+ 樹索引數據是有序的,可以起到對該字段 ORDER BY 排序優化的作用。
同理,我們也無法用 Hash 索引進行模糊查詢,而 B+ 樹使用 LIKE 進行模糊查詢的時候,LIKE 后面前模糊查詢(比如 % 開頭)的話就可以起到優化作用。
對於等值查詢來說,通常 Hash 索引的效率更高,不過也存在一種情況,就是索引列的重復值如果很多,效率就會降低。這是因為遇到 Hash 沖突時,需要遍歷桶中的行指針來進行比較,找到查詢的關鍵字,非常耗時。所以,Hash 索引通常不會用到重復值多的列上,比如列為性別、年齡的情況等。
總結
講了 Hash 索引的底層原理,能看到 Hash 索引存在着很多限制,相比之下在數據庫中 B+ 樹索引的使用面會更廣,不過也有一些場景采用 Hash 索引效率更高,比如在鍵值型(Key-Value)數據庫中,Redis 存儲的核心就是 Hash 表。
另外 MySQL 中的 Memory 存儲引擎支持 Hash 存儲,如果我們需要用到查詢的臨時表時,就可以選擇 Memory 存儲引擎,把某個字段設置為 Hash 索引,比如字符串類型的字段,進行 Hash 計算之后長度可以縮短到幾個字節。當字段的重復度低,而且經常需要進行等值查詢的時候,采用 Hash 索引是個不錯的選擇。
另外 MySQL 的 InnoDB 存儲引擎還有個“自適應 Hash 索引”的功能,就是當某個索引值使用非常頻繁的時候,它會在 B+ 樹索引的基礎上再創建一個 Hash 索引,這樣讓 B+ 樹也具備了 Hash 索引的優點。