對於 MySQL 資料庫而言,數據是存儲在文件里的,而為了能夠快速定位到某張表裡的某條記錄進行查詢和修改,我們需要將這些數據以一定的數據結構進行存儲,這個數據結構就是我們說的索引。回憶一下我們大學裡學過的演算法與數據結構,能夠支持快速查找的數據結構有:順序數組、哈希、搜索樹。
數組要求 insert 的時候保證有序,這樣查找的時候可以利用二分查找法達到 O(log(N)) 的時間複雜度,對範圍查詢支持也很好,但是 insert 的時候如果不是在數組尾部,就需要摞動後面所有的數據,時間複雜度為O(N)。所以有序數組只適合存儲靜態數據,例如幾乎很少變動的配置數據,或者是歷史數據。這裡應該會有人有疑問:我用另外一種線性數據結構鏈表來替代數組不就可以解決數組因為要移動數據導致太慢的問題了么,要回答這個問題我們需要了解操作系統讀取文件的流程,磁碟 IO 是一個相對很慢的操作,為了提高讀取速度,我們應該盡量減少磁碟 IO 操作,而操作系統一般以 4kb 為一個數據頁讀取數據,而 MySQL 一般為 16kb 作為一個數據塊,已經讀取的數據塊會在內存進行緩存,如果多次數據讀取在同一個數據塊,則只需要一次磁碟 IO ,而如果順序一致的記錄在文件中也是順序存儲的,就可以一次讀取多個數據塊,這樣範圍查詢的速度也可以大大提升,顯然鏈表沒有這方面的優勢。
類似於 jdk 中的 hashmap,哈希表通過一個特定的哈希函數將 key 值轉換為一個固定的地址,然後將對應的 value 放到這個位置,如果發生哈希碰撞就在這個位置拉出一個鏈表,由於哈希函數的離散特性,所以經過哈希函數處理後的 key 將失去原有的順序,所以哈希結構的索引無法滿足範圍查詢,只適合等值查詢的情況例如一些緩存的場景。
二叉樹在極端情況下會變成線性結構,也就是每個節點都只有左子節點或者只有右子節點,這樣就無法利用二分查找只能從第一個節點開始向後遍歷了,所以為了維持 O(log(N)) 的時間複雜度,我們需要在插入節點的時候對節點進行調整以保證樹的平衡,所以平衡二叉樹插入的時間複雜度也是 O(log(N)),二叉樹只有兩個子節點,如果數據量很大則樹就很高,樹的每一層一般不在同一個數據塊中存儲,為了盡量的減少磁碟讀寫次數,我們用N叉樹來代替二叉樹,在 MySQL 中這個 N 一般為 1200,這樣樹高是 4 的話也可以存儲億級別的數據,而且樹的前面兩層一般都在內存中,MySQL 中用到的 B+ 樹,一般用非葉子節點構建索引,而葉子節點用來存儲具體的值。
在 InnoDB 中,有聚簇索引和普通索引之分,聚簇索引根據主鍵來構建,葉子節點存放的是該主鍵對應的這一行記錄,而普通索引根據申明這個索引時候的列來構建,葉子節點存放的是這一行記錄對應的主鍵的值,而普通索引中還有唯一索引和聯合索引兩個特例,唯一索引在插入和修改的時候會校驗該索引對應的列的值是否已經存在,而聯合索引將兩個列的值按照申明時候的順序進行拼接後在構建索引。
根據以上描述我們可以得到以下信息:
有了以上的索引知識我們在來分析數據是怎麼存儲的,InnoDB 存儲引擎的邏輯存儲結構從大到小依次可以分為:表空間、段、區、頁、行。
表空間作為存儲結構的最高層,所有數據都存放在表空間中,默認情況下用一個共享表空間 ibdata1 ,如果開啟了 innodb_file_per_table 則每張表的數據將存儲在單獨的表空間中,也就是每張表都會有一個文件,
表空間由各個段構成,InnoDB存儲引擎由索引組織的,而索引中的葉子節點用來記錄數據,存儲在數據段,而非葉子節點用來構建索引,存儲在索引段,而回滾段我們在後面分析鎖的時候在聊。
區是由連續的頁組成,任何情況下一個區都是 1MB ,
一個區中可以有多個頁,每個頁默認為 16KB ,所以默認情況下一個區中可以包含64個連續的頁,頁的大小是可以通過 innodb_page_size 設置,頁中存儲的是具體的行記錄。一行記錄最終以二進位的方式存儲在文件里,我們要能夠解析出一行記錄中每個列的值,存儲的時候就需要有固定的格式,至少需要知道每個列佔多少空間,而 MySQL 中定義了一些固定長度的數據類型,例如 int、tinyint、bigint、char數組、float、double、date、datetime、timestamp 等,這些欄位我們只需要讀取對應長度的位元組,然後根據類型進行解析即可,對於變長欄位,例如 varchar、varbinary 等,需要有一個位置來單獨存儲欄位實際用到的長度,當然還需要頭信息來存儲元數據,例如記錄類型,下一條記錄的位置等。下面我們以 Compact 行格式分析一行數據在 InnoDB 中是怎麼存儲的。
下面我們以《MySQL 技術內幕》第二版中的例子分析下一行記錄在表空間具體的存儲結構。
CREATE TABLE mytest( t1 varchar(10), t2 varchar(10), t3 char(10), t4 varchar(10) ) engine = innodb;
insert into mytest VALUES(a,bb,bb,ccc); insert into mytest VALUES(d,NULL,NULL,fff);
該表定義了 3 個變長欄位和 1 個定長欄位,然後插入兩行記錄,第二行記錄包含空值,我們打開表空間 mytest.ibd 文件,轉換為 16 進位,並定位到如下內容:
// 第一行記錄 03 02 01 為變長欄位長度列表,這裡是倒序存放的,分別對應 ccc、bb、a 的長度。 00 表示沒有為空的欄位 00 00 10 00 2c 為記錄頭 00 00 00 2b 68 00 沒有申明主鍵,維護內部 ID 00 00 00 00 06 05 事務ID 80 00 00 00 32 01 10 回滾指針 61 第一列 a 的值 62 62 第二列 bb 的值 62 62 20 20 20 20 20 20 20 20 第三列 bb 的值,固定長度 char(10) 以20進行填充 63 63 63 第四列 ccc 的值
// 第二行記錄 03 01 為變長欄位長度列表,這裡是倒序存放的,分別對應 fff、a 的長度,第二列位空。 06 轉換為二進位為 00000110 表示第二列和第三列為空 00 00 20 ff 98 為記錄頭 00 00 00 2b 68 01 沒有申明主鍵,維護內部 ID 00 00 00 00 06 06 事務ID 80 00 00 00 32 01 10 回滾指針 64 第一列 d 的值 65 65 65 第四列 fff 的值
到此,我們了解了一個數據行是怎麼存儲的,然而數據行並不是存儲引擎管理的最小存儲單位,索引只能夠幫助我們定位到某個數據頁,每一次磁碟讀寫的最小單位為也是數據頁,而一個數據頁內存儲了多個數據行,我們需要了解數據頁的內部結構才能知道存儲引擎怎麼定位到某一個數據行。InnoDB 的數據頁由以下 7 個部分組成:
頁目錄里維護多個 slot ,一個 slot 包含多個行記錄。每個 slot 占 2 個位元組,記錄這個 slot 里的行記錄相對頁初始位置的偏移量。由於索引只能定位到數據頁,而定位到數據頁內的行記錄還需要在內存中進行二分查找,而這個二分查找就需要藉助 slot 信息,先找到對應的 slot ,然後在 slot 內部通過數據行中記錄頭裡的下一個記錄地址進行遍歷。每一個 slot 可以包含 4 到 8 個數據行。如果沒有 slot 輔助,鏈表本身是無法進行二分查找的。
排序有好多種演算法來實現,在 MySQL 中經常會帶上一個 limit ,表示從排序後的結果集中取前 100 條,或者取第 n 條到第 m 條,要實現排序,我們需要先根據查詢條件獲取結果集,然後在內存中對這個結果集進行排序,如果結果集數量特別大,還需要將結果集寫入到多個文件里,然後單獨對每個文件里的數據進行排序,然後在文件之間進行歸併,排序完成後在進行 limit 操作。沒錯,這個就是 MySQL 實現排序的方式,前提是排序的欄位沒有索引。
CREATE TABLE `person` ( `id` int(11) NOT NULL, `city` varchar(16) NOT NULL, `name` varchar(16) NOT NULL, `age` int(11) NOT NULL, `addr` varchar(128) DEFAULT NULL, PRIMARY KEY (`id`), KEY `city` (`city`) ) ENGINE=InnoDB;
select city,name,age from person where city=武漢 order by name limit 100 ;
使用 explain 發現該語句會使用 city 索引,並且會有 filesort . 我們分析下該語句的執行流程:
這裡是查詢 city,name,age 3個欄位,比較少,如果查詢的欄位較多,則多個列如果都放入 sortbuffer 將佔有大量內存空間,另一個方案是只區出待排序的欄位和主鍵放入 sortbuffer 這裡是 name 和 id ,排序完成後在根據 id 取出需要查詢的欄位返回,其實就是時間換取空間的做法,這裡通過 max_length_for_sort_data 參數控制,是否採用後面的方案進行排序。
另外如果 sortbuffer 里的條數很多,同樣會佔有大量的內存空間,可以通過參數 sort_buffer_size 來控制是否需要藉助文件進行排序,這裡會把 sortbuffer 里的數據放入多個文件里,用歸併排序的思路最終輸出一個大的文件。
以上方案主要是 name 欄位沒有加上索引,如果 name 欄位上有索引,由於索引在構建的時候已經是有序的了,所以就不需要進行額外的排序流程只需要在查詢的時候查出指定的條數就可以了,這將大大提升查詢速度。我們現在加一個 city 和 name 的聯合索引。
alter table person add index city_user(city, name);
這樣查詢過程如下:
由於聯合所以在構建索引的時候,在 city 等於武漢的索引節點中的數據已經是根據 name 進行排序了的,所以這裡只需要直接查詢就可,另外這裡如果加上 city, name, age 的聯合索引,則可以用到索引覆蓋,不行到主鍵索引上進行回表。
總結一下,我們在有排序操作的時候,最好能夠讓排序欄位上建有索引,另外由於查詢第一百萬條開始的一百條記錄,需要過濾掉前面一百萬條記錄,即使用到索引也很慢,所以可以根據 ID 來進行區分,分頁遍歷的時候每次緩存上一次查詢結果最後一條記錄的 id , 下一次查詢加上 id > xxxx limit 0,1000 這樣可以避免前期掃描到的結果被過濾掉的情況。
InnoDB 通過一些列後台線程將相關操作進行非同步處理,如下圖所示,同時藉助緩衝池來減小 CPU 和磁碟速度上的差異。當查詢的時候會先通過索引定位到對應的數據頁,然後檢測數據頁是否在緩衝池內,如果在就直接返回,如果不在就去聚簇索引中通過磁碟 IO 讀取對應的數據頁並放入緩衝池。一個數據頁會包含多個數據行。緩存池通過 LRU 演算法對數據頁進行管理,也就是最頻繁使用的數據頁排在列表前面,不經常使用的排在隊尾,當緩衝池滿了的時候會淘汰掉隊尾的數據頁。從磁碟新讀取到的數據頁並不會放在隊列頭部而是放在中間位置,這個中間位置可以通過參數進行修。緩衝池也可以設置多個實例,數據頁根據哈希演算法決定放在哪個緩衝池。
InnoDB 在更新數據的時候會採用 WAL 技術,也就是 Write Ahead Logging ,這個日誌就是 redolog 用來保證資料庫宕機後可以通過該文件進行恢復。這個文件一般只會順序寫,只有在資料庫啟動的時候才會讀取 redolog 文件看是否需要進行恢復。該文件記錄了對某個數據頁的物理操作,例如某個 sql 把某一行的某個列的值改為 10 ,對應的 redolog 文件格式可能為:把第5個數據頁中偏移量為99的位置寫入一個值 10 。redolog 不是無限大的,他的大小是可以配置的,並且是循環使用的,例如配置大小為 4G ,一共 4 個文件,每個文件 1G 。 首先從第一個文件開始順序寫,寫到第四個文件後在從第一個文件開始寫,類似一個環,用一個後台線程把 redolog 里的數據同步到聚簇索引上的數據頁上。寫入 redolog 的時候不能將沒有同步到數據頁上的記錄覆蓋,如果碰到這種情況會停下來先進行數據頁同步然後在繼續寫入 redolog 。另外執行更新操作的時候,會先更新緩衝池裡的數據頁,然後寫入 redolog, 這個時候真正存儲數據的地方還沒有更新,也就是說這時候緩衝池中的數據頁和磁碟不一致,這種數據頁稱為臟頁,當臟頁由於內存不足或者其他原因需要丟棄的時候,一定要先將該臟頁對應的redolog 刷新到磁碟里的真實數據頁,不然下次查詢的時候由於 redolog 沒有同步到磁碟,而查詢直接通過索引定位到數據頁就會查詢出臟數據。
更新的時候先從磁碟或者緩衝池中讀取對應的數據頁,然後對數據頁里的數據進行更改並生成 redolog 到對應的緩衝池(redolog buffer)進行緩存,當事務提交的時候將緩存寫入到 redolog 的物理磁碟文件上。這裡由於操作系統的文件寫入 InnoDB 並沒有使用 O_DIRECT 直接寫入到文件,為了保證性能而是先寫入操作系統的緩存,之後在進行 flush ,所以事務提交的時候 InnoDB 需要在調用一次 fsync 的系統調用來確保數據落盤。為了提高性能 InnoDB 可以通過參數 innodb_flush_log_at_trx_commit 來控制事務提交時是否強制刷盤。
innodb_flush_log_at_trx_commit 參數值默認為 1 ,事務每次提交都需要調用 fsync 進行刷盤。
在執行更新邏輯的時候還會寫入另外一個日誌:undolog 。這個文件存儲在共享表空間中,也就是即使打開了 innodb_file_per_table 參數,所有的表的 undolog 都存儲在同一個文件里。該文件主要用來做事務回滾和 MVCC 。undolog 是邏輯日誌,也就是他不是記錄的將物理的數據頁恢復到之前的狀態,而是記錄的和原 sql 相反的 sql , 例如 insert 對應 delete , delete 對應 insert ,update 對應另外一個 update 。事務回滾很好理解,執行相反的操作回滾到之前的狀態,而 MVCC 是指鏡像讀,當一個事務需要查詢某條記錄,而該記錄已經被其他事務修改,但該事務還沒提交,而當前事務可以通過 undolog 計算到之前的值。這裡我們只需要知道和 redolog 一樣, undolog 也是需要在執行 update 語句的時候在事務提交前需要寫入到文件的。另外 undolog 的寫入也會有對應的 redolog ,因為 undolog 也需要持久化,通過 WAL 可以提高效率。這裡可以總結下,在事務提交的時候要保證 redolog 寫入到文件里,而這個 redolog 包含 主鍵索引上的數據頁的修改,以及共享表空間的回滾段中 undolog 的插入。 另外 undolog 的清理通過一個後台線程定時處理,清理的時候需要判斷該 undolog 是否所有的事務都不會用到。
熟悉 MySQL 的都知道,他通過 binlog 來進行高可用,也就是通過 binlog 來將數據同步到集群內其他的 MySQL 實例。binlog 和 redolog 的區別是,他是在存儲引擎上層 Server 層寫入的,他記錄的是邏輯操作,也就是對應的 sql ,而 redolog 記錄的底層某個數據頁的物理操作,redolog 是循環寫的,而binlog 是追加寫的,不會覆蓋以前寫的數據。而binlog 也需要在事務提交前寫入文件。binlog 的寫入頁需要通過 fsync 來保證落盤,為了提高 tps ,MySQL 可以通過參數 sync_binlog 來控制是否需要同步刷盤,該策略會影響當主庫宕機後備庫數據可能並沒有完全同步到主庫數據。由於事務的原子性,需要保證事務提交的時候 redolog 和 binlog 都寫入成功,所以 MySQL 執行層採用了兩階段提交來保證 redolog 和 binlog 都寫入成功後才 commit,如果一方失敗則會進行回滾。
下面我們理一下一條 update 語句的執行過程:
update person set age = 30 where id = 1;
資料庫使用鎖是為了對共享資源進行並發訪問控制,從而保證數據的完整性和一致性。InnoDB 中鎖的最小粒度為行,和 jdk 中的 ReadWriteLock 一樣,InnoDB提供了共享鎖和排他鎖,分別用來讀和寫。共享鎖之間可以兼容,其他都互斥。根據加鎖的範圍,可以分為:全局鎖、表級鎖、行鎖。全局鎖會把整個資料庫實例加鎖,命令為 flush tables withs read lock , 將使資料庫處於只讀狀態,其他數據寫入和修改表結構等語句會阻塞,一般在備庫上做全局備份使用。而表級鎖有兩種,一種是表鎖,命令為 lock table with read/write ,和讀寫鎖一樣,另外一種是元數據鎖,也叫意向鎖,不需要顯示申明,當執行修改表結構,加索引的時候會自動加元數據寫鎖,對錶進行增刪改查的時候會加元數據讀鎖。這樣當兩條修改語句的事務之間元數據鎖都是讀鎖不互斥,但是修改表結構的時候執行更新由於互斥就需要阻塞。還有一種行級鎖稱為間隙鎖,他鎖定的是兩條記錄之間的間隙,防止其他事務往這個間隙插入數據,間隙鎖是隱式鎖,是存儲引擎自己加上的。
普通的 select 操作都是非鎖定讀,如果存在事務衝突,會利用 undolog 獲取新事務操作之前的鏡像返回,在讀已提交的隔離級別下,會獲取新事務修改前的最新的一份已經提交的數據,而在可重複讀的隔離級別下,會讀取該事務開始時的數據版本。當有多個事務並發操作同一行記錄時,該記錄會同時存在多個 undolog ,每個 undolog 就是一個版本,這種模式稱為多版本並發控制(MVCC) ,該模式能夠極大的提高資料庫的性能,想一想,如果基於鎖來控制的話,當對某個記錄進行修改的時候,另一個事務將需要等待,不管他是要讀取還是寫入,MVCC 允許寫入的時候還能夠進行讀操作,這對大部分都是查詢操作的應用來說極大的提高了 tps 。
有時候我們在查詢的時候需要顯示的給記錄加鎖來保證一致性,select for update 將對掃描到的記錄加上排他鎖,而 select in share lock 將對掃描的記錄加上共享鎖。這兩個語句必須在一個事物內,也就是需要顯示開啟事物,begin transaction; 當事物提交的時候會釋放鎖。具體加鎖的邏輯我們後面在分析。另外所有的鎖定讀都是當前讀,也就是讀取當前記錄的最新版本,不會利用 undolog 讀取鏡像。另外所有的 insert、update、delete 操作也是當前讀,update、delete 會在更新之前進行一次當前讀,然後加鎖,而 insert 因為會觸發唯一索引檢測,也會包含一個當前讀。
在主鍵設置為自增長的情況下,該表會維護一個計數器,每個插入操作都會先獲取這個計數器的當前值,然後加 1 作為新的主鍵,顯然這個計數器是一個共享變數需要加排他鎖,而這個鎖不需要等到事物提交後才釋放,他在 sql 語句插入完成後就會釋放,新版本的 innoDB 採用互斥量來實現提高了插入速度。
臟讀是指事務A對某個數據頁進行了更改,但是並沒有提交,這個數據就成為臟數據,這裡稍微和上面提到的臟頁做下區分,臟頁是指內存中已經更改但是還沒有刷新到磁碟的數據,臟頁是正常的,而臟讀是指一個事物讀取了另外一個事物沒有提交的數據,如果另外一個數據對這個數據又進行了更改,則出現數據一致性,臟讀違背了資料庫的隔離性。臟讀目前只能出現在讀未提交這個隔離級別下,目前 MySQL 默認的隔離級別為可重複讀。
不可重複讀是指一個事務先後兩次讀取同一條記錄的結果不一樣,因為第二次讀取的時候可能其他事務已經進行更改並提交,不可重複讀只發生在隔離級別為讀未提交和讀已提交里。
丟失更新是指兩個事務同時更新某一條記錄,導致其中一個事務更新失效,理論上任何一個隔離級別都不會發生丟失更新,因為更新的時候會加上排他鎖,但是應用中卻經常發生,例如一個計數器應用,事務A查詢計數器的值 v=5,在內存中加 1 寫入到資料庫,在寫入之前另外一個事務讀取到計數器的值 v=5 ,然後加 1 寫入資料庫,這樣本來應該為 7 , 現在卻是 6 ,這是因為 我們是先讀取在寫入,而讀取和寫入對資料庫而言是兩個操作,並不是一個原子操作,這裡可以通過把查詢的記錄加上排他鎖 select for update 來防止丟失更新現象。當然這裡直接將 sql 改為 v = v + 1 也可以。
死鎖是指兩個或兩個以上事務因爭奪資源而互相等待的情況,InnoDB 提供了死鎖檢測和超時機制來防止死鎖的影響,死鎖檢測是非常耗 CPU 的,當很多個事務同時競爭同一個資源的時候,例如搶購的時候扣商品份額,或者支付的時候所有的訂單都會用到一個公共賬戶,同一個資源競爭的事務越多,死鎖檢測越耗 CPU 。為了減少這種情況的影響,建議盡量在業務層減少熱點的產生,例如將熱點賬戶拆分成若個個同樣功能的賬戶,萬一發生高並發,建議在應用層做限流或者排隊,當然也可以在資料庫層做排隊,這個需要修改資料庫源碼。
InnoDB的加鎖過程比較複雜,大致可以記住一個原則是:將所有掃描到的記錄都加鎖,範圍查詢會加間隙鎖,然後加鎖過程按照兩階段鎖 2PL 來實現,也就是先加鎖,然後所有的鎖在事物提交的時候釋放。怎麼加鎖和資料庫的隔離級別有關,然而我們一般很少更改 MySQL 的隔離級別,所以下面我們均按照可重複讀的隔離級別進行分析,另外一個因素是查詢條件中是否包含索引,是主鍵索引還是普通索引,是否是唯一索引等。我們以下面這條 sql 語句來分析加鎖過程。
select * from trade_order where order_no = 201912102322 for update;
order_no = 201912102322 這條記錄不存在的情況下,如果order_no 是主鍵索引,則會加一個間隙鎖,而這個間隙是主鍵索引中 order_no 小於 201912102322 的第一條記錄到大於 201912102322 的第一條記錄。試想一下如果不加間隙鎖,如果其他事物插入了一條 order_no = 201912102322 的記錄,由於 select for update 是當前讀,即使上面那個事物沒有提交,如果在該事物中重新查詢一次就會發生幻讀。
如果沒有索引,則對掃描到的所有記錄和間隙都加鎖,如果不匹配行鎖將會釋放只剩下間隙鎖。回憶一下上面講的數據頁的結果中又一個最大記錄和最小記錄,Infimum 和 Supremum Record,這兩個記錄在加間隙鎖的時候就會用到。
InnoDB 存儲引擎的事務需完全符合 ACID 特性。下面我們一起看下 InnoDB 做了哪些事情。
本帳號將持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,非同步、緩存和消息中間件,分散式和微服務,架構學習和進階等學習資料和文章。
推薦閱讀: