歡迎光臨
每天分享高質量文章

MySQL DBA親授MySQL InnoDB事務ACID實現原理

說到資料庫事務,想到的就是要麼都做修改,要麼都不做,或者是 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 走的非唯一索引、主鍵索引。如果不是則根本不會有 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 操作之前,還會加一種鎖,官方文件稱它為 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 表來分析一下資料庫死鎖,比較好理解:

 

四種隔離級別

 

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

 

①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

 

②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)

 

③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)

 

④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 需要持久性保護 。

 

只要你堅持,一步一步來,總歸會成功的。切忌,學技術急不來,快就是穩,穩就是快。


    已同步到看一看
    赞(0)

    分享創造快樂