幾個問題

  1. 為什麼不建議使用訂單號作為主鍵?
  2. 為什麼要在需要排序的欄位上加索引?
  3. for update 的記錄不存在會導致鎖住全表?
  4. redolog 和 binlog 有什麼區別?
  5. MySQL 如何回滾一條 sql ?
  6. char(50) 和 varchar(50) 效果是一樣的么?

索引知識回顧

對於 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 中,有聚簇索引和普通索引之分,聚簇索引根據主鍵來構建,葉子節點存放的是該主鍵對應的這一行記錄,而普通索引根據申明這個索引時候的列來構建,葉子節點存放的是這一行記錄對應的主鍵的值,而普通索引中還有唯一索引和聯合索引兩個特例,唯一索引在插入和修改的時候會校驗該索引對應的列的值是否已經存在,而聯合索引將兩個列的值按照申明時候的順序進行拼接後在構建索引。

根據以上描述我們可以得到以下信息:

  • 數據是以行為單位存儲在聚簇索引里的,根據主鍵查詢可以直接利用聚簇索引定位到所在記錄,根據普通索引查詢需要先在普通索引上找到對應的主鍵的值,然後根據主鍵值去聚簇索引上查找記錄,俗稱回表。
  • 普通索引上存儲的值是主鍵的值,如果主鍵是一個很長的字元串並且建了很多普通索引,將造成普通索引佔有很大的物理空間,這也是為什麼建議使用 自增ID 來替代訂單號作為主鍵,另一個原因是 自增ID 在 insert 的時候可以保證相鄰的兩條記錄可能在同一個數據塊,而訂單號的連續性在設計上可能沒有自增ID好,導致連續插入可能在多個數據塊,增加了磁碟讀寫次數。
  • 如果我們查詢一整行記錄的話,一定要去聚簇索引上查找,而如果我們只需要根據普通索引查詢主鍵的值,由於這些值在普通索引上已經存在,所以並不需要回表,這個稱為索引覆蓋,在一定程度上可以提高查詢效率,由於聯合索引上通過多個列構建索引,有時候我們可以將需要頻繁查詢的欄位加到聯合索引裡面,例如如果經常需要根據 name 查找 age 我們可以建一個 name 和 age 的聯合索引。
  • 查詢的時候如果在索引上用了函數,將導致無法用到根據之前列上的值構建的索引,索引遵循最左匹配原則,所以如果需要查詢某個列的值中間是否包含某個字元串,將無法利用索引,如果有這種需求可以利用全文索引,而如果查詢是否以某個字元串開頭就可以,聯合索引根據第一個列查詢可以用到索引,僅僅根據第二個列將無法用到索引,查詢的時候用 IN 的效率高於 NOT = 。另外建議將索引的列設置為非空,這個和 NULL 欄位的存儲有關,下文在分析。

存儲格式

有了以上的索引知識我們在來分析數據是怎麼存儲的,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 中是怎麼存儲的。

  • 變長欄位長度列表,該位置用來存儲所申明的變長欄位中非空欄位實際佔有的長度列表,例如有3個非空欄位,其中第一個欄位長度為3,第二個欄位為空,第三個欄位長度為1,則將用 01 03 表示,為空欄位將在下一個位置進行標記。變長欄位長度不能超過 2 個位元組,所以 varchar 的長度最大為 65535。
  • NULL 標誌位,占 1 個位元組,如果對應的列為空則在對應的位上置為 1 ,否則為 0 ,由於該標誌位佔一個位元組,所以列的數量不能超過 255。如果某欄位為空,在後面具體的列數據中將不會在記錄。這種方式也導致了在處理索引欄位為空的時候需要進行額外的操作。
  • 記錄頭信息,固定占 5 位元組,包含下一條記錄的位置,該行記錄總長度,記錄類型,是否被刪除,對應的 slot 信息等
  • 列數據 包含具體的列對應的值,加上兩個隱藏列,事務 ID 列和回滾指針列。如果沒有申明主鍵,還會增加一列記錄內部 ID。

下面我們以《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 個部分組成:

  • 文件頭(File Header) 固定 38 個位元組 (頁的位置,上一頁下一頁位置,checksum , LSN)
  • 數據頁頭( Page Header)固定 56 個位元組 包含slot數目,可重用空間起始地址,第一個記錄地址,記錄數,最大事務ID等
  • 虛擬的最大最小記錄 (Infimum + Supremum Record)
  • 用戶記錄 (User Records) 包含已經刪除的記錄以鏈表的形式構成可重用空間
  • 待分配空間 (Free spaces) 未分配的空間
  • 頁目錄 (Page Directory) slot 信息,下面單獨介紹
  • 文件尾 (File Trailer) 固定8個位元組,用來保證頁的完整性

頁目錄里維護多個 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 . 我們分析下該語句的執行流程:

  1. 初始化 sortbuffer ,用來存放結果集
  2. 找到 city 索引,定位到 city 等於武漢的第一條記錄,獲取主鍵索引ID3. 根據 ID 去主鍵索引上找到對應記錄,取出 city,name,age 欄位放入 sortbuffer
  3. 在 city 索引取下一個 city 等於武漢的記錄的主鍵ID
  4. 重複上面的步驟,直到所有 city 等於武漢的記錄都放入 sortbuffer
  5. 對 sortbuffer 里的數據根據 name 做快速排序
  6. 根據排序結果取前面 1000 條返回

這裡是查詢 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);

這樣查詢過程如下:

  1. 根據 city,name 聯合索引定位到 city 等於武漢的第一條記錄,獲取主鍵索引ID
  2. 根據 ID 去主鍵索引上找到對應記錄,取出 city,name,age 欄位作為結果集返回
  3. 繼續重複以上步驟直到 city 不等於武漢,或者條數大於 1000

由於聯合所以在構建索引的時候,在 city 等於武漢的索引節點中的數據已經是根據 name 進行排序了的,所以這裡只需要直接查詢就可,另外這裡如果加上 city, name, age 的聯合索引,則可以用到索引覆蓋,不行到主鍵索引上進行回表。

總結一下,我們在有排序操作的時候,最好能夠讓排序欄位上建有索引,另外由於查詢第一百萬條開始的一百條記錄,需要過濾掉前面一百萬條記錄,即使用到索引也很慢,所以可以根據 ID 來進行區分,分頁遍歷的時候每次緩存上一次查詢結果最後一條記錄的 id , 下一次查詢加上 id > xxxx limit 0,1000 這樣可以避免前期掃描到的結果被過濾掉的情況。

InnoDB 存儲模型

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 進行刷盤。

  • 0 表示事務提交的時候不會調用 redolog的文件寫入,通過後台線程每秒同步一次。
  • 1 表示事務每次提交都需要調用 fsync 進行刷盤,以防止資料庫宕機導致數據丟失。
  • 2 表示事務提交的時候會寫入文件但是只保證寫入操作系統緩存,不進行 fsync 操作。 redolog 文件只會順序寫,所以磁碟操作性能不會太慢,

在執行更新邏輯的時候還會寫入另外一個日誌: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;

  1. 分配事務 ID ,開啟事務,獲取鎖,沒有獲取到鎖則等待。
  2. 執行器先通過存儲引擎找到 id = 1 的數據頁,如果緩衝池有則直接取出,沒有則去主鍵索引上取出對應的數據頁放入緩衝池。
  3. 在數據頁內找到 id = 1 這行記錄,取出,將 age 改為 30 然後寫入內存
  4. 生成 redolog undolog 到內存,redolog 狀態為 prepare
  5. 將 redolog undolog 寫入文件並調用 fsync
  6. server 層生成 binlog 並寫入文件調用 fsync
  7. 事務提交,將 redolog 的狀態改為 commited 釋放鎖

資料庫使用鎖是為了對共享資源進行並發訪問控制,從而保證數據的完整性和一致性。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 是主鍵索引 ,這種情況將在主鍵索引上的 order_no = 201912102322 這條記錄上加排他鎖。
  • order_no 是普通索引,並且是唯一索引 將會對 普通索引上對應的一套記錄加排他鎖,對主鍵索引上對應的記錄加排他鎖
  • order_no 是普通索引,並且不是唯一索引 將會對 普通索引上 order_no = 201912102322 一條或者多條記錄加鎖,並且對這些記錄對應的主鍵索引上的記錄加鎖。這裡除了加上行鎖外,還會加上間隙鎖,防止其他事物插入 order_no = 201912102322 的記錄,然而如果是唯一索引就不需要間隙鎖,行鎖就可以。
  • order_no 上沒有索引,innoDB 將會在主鍵索引上全表掃描,這裡並沒有加表鎖,而是將所有的記錄都會加上行級排他鎖,而實際上 innoDB 內部做了優化,當掃描到一行記錄後發現不匹配就會把鎖給釋放,當然這個違背了 2PL 原則在事務提交的時候釋放。這裡除了對記錄進行加鎖,還會對每兩個記錄之間的間隙加鎖,所以最終將會保存所有的間隙鎖和 order_no = 201912102322 的行鎖。

order_no = 201912102322 這條記錄不存在的情況下,如果order_no 是主鍵索引,則會加一個間隙鎖,而這個間隙是主鍵索引中 order_no 小於 201912102322 的第一條記錄到大於 201912102322 的第一條記錄。試想一下如果不加間隙鎖,如果其他事物插入了一條 order_no = 201912102322 的記錄,由於 select for update 是當前讀,即使上面那個事物沒有提交,如果在該事物中重新查詢一次就會發生幻讀。

如果沒有索引,則對掃描到的所有記錄和間隙都加鎖,如果不匹配行鎖將會釋放只剩下間隙鎖。回憶一下上面講的數據頁的結果中又一個最大記錄和最小記錄,Infimum 和 Supremum Record,這兩個記錄在加間隙鎖的時候就會用到。

事務

InnoDB 存儲引擎的事務需完全符合 ACID 特性。下面我們一起看下 InnoDB 做了哪些事情。

  • 原子性 : 是指一個事務內的所有操作要麼全部成功要麼全部失敗,資料庫中將 redolog 和 binlog 的寫入採用兩階段提交就是為了保證事務的原子性。另外由於 InnodDB 是按頁進行存儲的,每個頁大小為 16kb 而操作系統的一般以 4KB 為一頁進行讀取,所以可能出現一個 InnoDB 的數據頁只寫了一部分的情況。而 InnoDB 為了防止這種情況的發生採用雙寫機制,除了寫入磁碟上的數據頁還會在共享空間中寫入。而 redolog 按塊存儲,每個塊 512 位元組,正好和扇區大小一樣所以,可以保證原子性,不需要進行雙寫。
  • 一致性 :保證磁碟和緩存的數據一致,binlog 數據和 主庫中的數據一致。
  • 隔離性 : 默認為可重複讀,採用 undolog 來實現。
  • 持久性 : 事務一旦提交,其結果就是永久的,redolog 需要在事務提交前進行刷盤,磁碟採用 RAID 等。

本帳號將持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,非同步、緩存和消息中間件,分散式和微服務,架構學習和進階等學習資料和文章。

推薦閱讀:

相关文章