搞懂MySQL InnoDB事務ACID實現原理

GrimMjx發表於2019-03-24

前言

  說到資料庫事務,想到的就是要麼都做修改,要麼都不做。或者是ACID的概念。其實事務的本質就是鎖和併發和重做日誌的結合體。那麼,這一篇主要講一下InnoDB中的事務到底是如何實現ACID的。

  • 原子性(atomicity)
  • 一致性(consistency)
  • 隔離性(isolation)
  • 永續性(durability)

一.隔離性

  其實隔離性的實現原理就是,因而隔離性也可以稱為併發控制、鎖等。事務的隔離性要求每個讀寫事務的物件對其他事務的操作物件能互相分離。再者,比如操作緩衝池中的LRU列表,刪除,新增、移動LRU列表中的元素,為了保證一致性那麼就要鎖的介入。InnoDB使用鎖為了支援對共享資源進行併發訪問,提供資料的完整性和一致性。那麼到底InnoDB支援什麼樣的鎖呢?我們先來看下InnoDB的鎖的介紹:

InnoDB中的鎖

  你可能聽過各種各樣的InnoDB的資料庫鎖,gap鎖啊,共享鎖,排它鎖,讀鎖,寫鎖等等。但是InnoDB的標準實現的鎖只有2類,一種是行級鎖,一種是意向鎖。

  InnoDB實現瞭如下兩種標準的行級鎖

  • 共享鎖(讀鎖 S Lock),允許事務讀一行資料
  • 排它鎖(寫鎖 X Lock),允許事務刪除一行資料或者更新一行資料

  行級鎖中,除了S和S相容,其他都不相容。

  InnoDB支援兩種意向鎖(即為表級別的鎖):

  • 意向共享鎖(讀鎖 IS Lock),事務想要獲取一張表的幾行資料的共享鎖,事務在給一個資料行加共享鎖前必須先取得該表的IS鎖。
  • 意向排他鎖(寫鎖 IX Lock),事務想要獲取一張表中幾行資料的排它鎖,事務在給一個資料行加排他鎖前必須先取得該表的IX鎖。

  首先解釋一下意向鎖,以下為意向鎖的意圖解釋:

The main purpose of IX and IS locks is to show that someone is locking a row, or going to lock a row in the table.

  大致意思是加意向鎖為了表明某個事務正在鎖定一行或者將要鎖定一行資料。

  首先申請意向鎖的動作是InnoDB完成的,怎麼理解意向鎖呢?例如:事務A要對一行記錄r進行上X鎖,那麼InnoDB會先申請表的IX鎖,再鎖定記錄r的X鎖。在事務A完成之前,事務B想要來個全表操作,此時直接在表級別的IX就告訴事務B需要等待而不需要在表上判斷每一行是否有鎖。意向排它鎖存在的價值在於節約InnoDB對於鎖的定位和處理效能。另外注意了,除了全表掃描以外意向鎖都不會阻塞。

鎖的演算法

  InnoDB有3種行鎖的演算法:

  • Record Lock:單個行記錄上的鎖
  • Gap Lock:間隙鎖,鎖定一個範圍,而非記錄本身
  • Next-Key Lock:結合Gap Lock和Record Lock,鎖定一個範圍,並且鎖定記錄本身。主要解決的問題是RR隔離級別下的幻讀

  這裡主要講一下Next-Key Lock。mysql預設隔離級別RR下,這時預設採用Next-Key locks。這種間隙鎖的目的就是為了阻止多個事務將記錄插入到同一範圍內從而導致幻讀。注意了,如果走唯一索引,那麼Next-Key Lock會降級為Record Lock。前置條件為事務隔離級別為RR且sql走的非唯一索引、主鍵索引。前置條件為事務隔離級別為RR且sql走的非唯一索引、主鍵索引。前置條件為事務隔離級別為RR且sql走的非唯一索引、主鍵索引。重要的事情說三遍。如果不是則根本不會有gap鎖!先舉個例子來講一下Next-Key Lock。

  首先建立一張表:

mysql> show create table m_test_db.M;
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                     |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| M     | CREATE TABLE `M` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` varchar(45) DEFAULT NULL,
  `name` varchar(45) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `IDX_USER_ID` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8 |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

  首先session A去拿到user_id為26的X鎖,用force index,強制走這個非唯一輔助索引,因為這張表裡的資料很少。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from m_test_db.M force index(IDX_USER_ID) where user_id = '26' for update;
+----+---------+-------+
| id | user_id | name  |
+----+---------+-------+
|  5 | 26      | jerry |
|  6 | 26      | ketty |
+----+---------+-------+
2 rows in set (0.00 sec)

  然後session B插入資料

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into m_test_db.M values (8,25,'GrimMjx');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

  明明插入的資料和鎖住的資料沒有毛線關係,為什麼還會阻塞等鎖最後超時呢?這就是Next-Key Lock實現的。畫張圖你就明白了。

 

  gap鎖鎖住的位置,不是記錄本身,而是兩條記錄之間的間隔gap,其實就是防止幻讀(同一事務下,連續執行兩句同樣的sql得到不同的結果),為了保證圖上3個小箭頭中間不會插入滿足條件的新記錄,所以用到了gap鎖防止幻讀。

  簡單的insert會在insert的行對應的索引記錄上加一個Record Lock鎖,並沒有gap鎖,所以並不會阻塞其他session在gap間隙裡插入記錄。不過在insert操作之前,還會加一種鎖,官方文件稱它為insertion intention gap lock,也就是意向的gap鎖。這個意向gap鎖的作用就是預示著當多事務併發插入相同的gap空隙時,只要插入的記錄不是gap間隙中的相同位置,則無需等待其他session就可完成,這樣就使得insert操作無須加真正的gap lock。

  Session A插入資料

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into m_test_db.M values (10,25,'GrimMjx');
Query OK, 1 row affected (0.00 sec)

  Session B插入資料,完全沒有問題,沒有阻塞。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into m_test_db.M values (11,27,'Mjx');
Query OK, 1 row affected (0.00 sec)

死鎖

  瞭解了InnoDB是如何加鎖的,現在可以去嘗試分析死鎖。死鎖的本質就是兩個事務相互等待對方釋放持有的鎖導致的,關鍵在於不同Session加鎖的順序不一致。不懂死鎖概念模型的可以先看一幅圖:

  左鳥執行緒獲取了左肉的鎖,想要獲取右肉的鎖,右鳥的執行緒獲取了右肉的鎖。右鳥想要獲取左肉的鎖。左鳥沒有釋放左肉的鎖,右鳥也沒有釋放右肉的鎖,那麼這就是死鎖。

  接下來還用剛才的那張M表來分析一下資料庫死鎖,比較好理解:

四種隔離級別

  那麼按照最嚴格到最鬆的順序來講一下四種隔離級別

1.Serializable(可序列化)

  最高事務隔離級別。主要用在InnoDB儲存引擎的分散式事務。強制事務排序,序列化執行事務。不需要衝突控制,但是慢速裝置。但是根據Jim Gray在《Transaction Processing》一書中指出,Read Committed和Serializable的開銷幾乎是一樣的,甚至Serializable更優。

   Session A設定隔離級別為Serializable,並開始事務執行一句sql

mysql> select @@tx_isolation;
+----------------+
| @@tx_isolation |
+----------------+
| SERIALIZABLE   |
+----------------+
1 row in set, 1 warning (0.00 sec)
mysql
> start transaction; Query OK, 0 rows affected (0.00 sec)
mysql
> select * from m_test_db.M; +----+---------+-------+ | id | user_id | name | +----+---------+-------+ | 1 | 20 | mjx | | 2 | 21 | ben | | 3 | 23 | may | | 4 | 24 | tom | | 5 | 26 | jerry | | 6 | 26 | ketty | | 7 | 28 | kris | +----+---------+-------+ 7 rows in set (0.00 sec)

  Session Binsert一條資料,超時。

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into m_test_db.M values (9,30,'test');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

2.Repeatable read(可重複讀)

  一個事務按相同的查詢條件讀取以前檢索過的資料,其他事務插入了滿足其查詢條件的新資料。產生幻讀。InnoDB儲存引擎在RR隔離級別下,已經使用Next-Key Lock演算法避免了幻讀。瞭解概念即可。InnoDB使用MVCC來讀取資料,RR隔離級別下,總是讀取事務開始時的行資料版本。

  Session A 檢視id=1的資料

mysql> set tx_isolation='repeatable-read';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from m_test_db.M where id =1;
+----+---------+---------+
| id | user_id | name    |
+----+---------+---------+
|  1 | 20      | GrimMjx |
+----+---------+---------+
1 row in set (0.01 sec)

  Session B 修改id=1的資料

mysql> set tx_isolation='repeatable-read';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update m_test_db.M set name = 'Mjx';
Query OK, 7 rows affected (0.00 sec)
Rows matched: 7  Changed: 7  Warnings: 0

  然後現在Session A 再檢視一下id=1的資料,資料還是事務開始時候的資料

mysql> select * from m_test_db.M where id =1;
+----+---------+---------+
| id | user_id | name    |
+----+---------+---------+
|  1 | 20      | GrimMjx |
+----+---------+---------+
1 row in set (0.00 sec)

3.Read Committed(讀已提交)

  事務從開始直到提交之前,所做的任何修改對其他事務都是不可見的。InnoDB使用MVCC來讀取資料,RC隔離級別下,總是讀取被鎖定行最新的快照資料。

  Session A 檢視id=1的資料

mysql> set tx_isolation='read-committed';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from m_test_db.M where id =1;
+----+---------+------+
| id | user_id | name |
+----+---------+------+
|  1 | 20      | Mjx  |
+----+---------+------+
1 row in set (0.00 sec)

  Session B 修改id=1的name並且commit。

mysql> set tx_isolation='repeatable-read';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update m_test_db.M set name = 'testM' where id =1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

// 注意,這裡commit了!
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

  Session A 再查詢id=1的記錄,發現資料已經是最新的資料。

mysql> select * from m_test_db.M where id =1;
+----+---------+-------+
| id | user_id | name  |
+----+---------+-------+
|  1 | 20      | testM |
+----+---------+-------+
1 row in set (0.00 sec)

 

4.Read Uncommitted(讀未提交)

  事務中的修改,即使沒有提交,對其他事務也都是可見的。

  Session A 檢視一下id=3的資料,沒有commit。

mysql> set tx_isolation='read-uncommitted';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from m_test_db.M where id =3;
+----+---------+------+
| id | user_id | name |
+----+---------+------+
|  3 | 23      | may  |
+----+---------+------+
1 row in set (0.00 sec)

  Session B 修改id=3的資料,但是沒有commit!

mysql> set tx_isolation='read-uncommitted';
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update m_test_db.M set name = 'GRIMMJX' where id = 3;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

  Session A再次檢視則看到了新的結果

mysql> select * from m_test_db.M where id =3;
+----+---------+---------+
| id | user_id | name    |
+----+---------+---------+
|  3 | 23      | GRIMMJX |
+----+---------+---------+
1 row in set (0.00 sec)

  這裡花了很多筆墨來介紹隔離性,這是比較重要,需要靜下心來學習的特性。所以也是放在第一個的原因。

 

二.原子性、一致性、永續性

  事務隔離性由鎖實現,原子性、一致性和永續性由資料庫的redo log和undo log。redo log稱為重做日誌,用來保證事務的原子性和永續性,恢復提交事務修改的頁操作。undo log來保證事務的一致性,undo回滾行記錄到某個特性版本及MVCC功能。兩者內容不同。redo記錄物理日誌,undo是邏輯日誌。

redo

  重做日誌由重做日誌緩衝(redo log buffer)和重做日誌檔案(redo log file)組成,前者是易失的,後者是持久的。InnoDB通過Force Log at Commit機制來實現永續性,當commit時,必須先將事務的所有日誌寫到重做日誌檔案進行持久化,待commit操作完成才算完成。

  當事務提交時,日誌不寫入重做日誌檔案,而是等待一個事件週期後再執行fsync操作,由於並非強制在事務提交時進行一次fsync操作,顯然這可以提高資料庫效能。

  請記住3點:

  • 重做日誌是在InnoDB層產生的
  • 重做日誌是物理格式日誌,記錄的是對每個頁的修改
  • 重做日誌在事務進行中不斷被寫入

undo

  事務回滾和MVCC,這就需要undo。undo是邏輯日誌,只是將資料庫邏輯的恢復到原來的樣子,但是資料結構和頁本身在回滾之後可能不同。例如:使用者執行insert 10w條資料的事務,表空間因而增大。使用者執行ROLLBACK之後,會對插入的資料回滾,但是表空間大小不會因此收縮。

  實際的做法就是做與之前想法的操作,insert對應delete,update對應反向update來實現原子性。

  InnoDB中MVCC的實現就是靠undo,舉個經典的例子:Bob給Smith轉100元,那麼就存在以下3個版本,RR隔離級別下,對於快照資料,總是讀事務開始的行資料版本見黃標。RC隔離級別下,對於快照資料,總是讀最新的一份快照資料見紅標。

 

  undo log會產生redo log,因為undo log需要永續性保護 

 

 

參考:

官網手冊,這裡多說一句,你會發現姜承堯的MySQL InnoDB書上的很多內容都是官方手冊的翻譯,無論是看原始碼還是學習新框架,最好看原汁原味的。

官方手冊:https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html

http://hedengcheng.com/?p=771#_%E7%BB%84%E5%90%88%E4%B8%89%EF%BC%9Aid%E9%9D%9E%E5%94%AF%E4%B8%80%E7%B4%A2%E5%BC%95+RC

https://www.cnblogs.com/crazylqy/p/7773492.html

https://www.zhihu.com/question/51513268/answer/127777478

相關文章