1. 程式人生 > >MySQL 入門(3):事務隔離

MySQL 入門(3):事務隔離

## 摘要 在這一篇內容中,我將從事務是什麼開始,聊一聊事務的必要性。 然後,介紹一下在InnoDB中,四種不同級別的事務隔離,能解決什麼問題,以及會帶來什麼問題。 最後,我會介紹一下InnoDB解決高併發事務的方式:多版本併發控制。 ## 1 什麼是事務 說到事務,一個最典型的例子就是銀行轉賬:假設A和B的餘額都是100元,此時A要向B轉賬50元。那麼我們的操作流程是這樣的: - 查詢A的餘額,儲存在`balance`中,並判斷`balance`是否大於50元 - 如果是,則`balance`減去50元,寫回資料庫,然後給B的餘額加上50元,寫回資料庫 - 如果不是,返回餘額不足 那麼問題來了,在第一步查詢之後,如果我們馬上再進行一次轉賬,而此時A的餘額還是原來的100元,大於50,系統判斷餘額是充足的,轉賬成功。但是在寫回資料庫的時候,A的餘額還是50元,而B的餘額變成了200元。 相信你也看出來了,問題的核心在於這個流程被人“橫插了一腳”,沒有安安靜靜不被打擾的執行完這個轉賬的流程。 正因為我們希望我們的業務邏輯可以不被打擾,所以我們有了“**事務**”。 那麼,事務需要什麼樣的條件呢? 相信你也或多或少的聽過了**ACID**這一說法。 1.**原子性(Atomicity)**:在通常的語義下,原子性指的是一條語句不可分割。但是在事務中,指的是組成這條事務的所有語句必須要執行完,或者回滾。 2.**一致性(Consistency)**:這裡的一致性和我們說的資料一致性,也有些不太一樣。我們說的資料一致性,一般指的是MySQL和Redis中的資料是一致的,又或者是MySQL主庫和從庫中的資料是一致的。但是在這兒通常指的是事務是否產生了非預期的中間狀態或結果。比如上面銀行轉賬的例子,轉賬之前兩個人的餘額總數是200元,而轉賬完變成了250元。這就是不符合一致性的。 3.**隔離性(Isolation)**:顧名思義,隔離性指的是事務之間應該是互不影響的。在MySQL裡面,事務的隔離被分成了四個級別,我們在後面會詳細介紹。 4.**永續性(Duration)**:這個很容易理解,如果一個事務提交了,資料必須得被儲存,而不能丟失。 ## 2 事務的隔離級別 事務的隔離級別從低到高,分為了**讀未提交**,**讀已提交**,**可重複讀**,**序列化**。 而每個級別的隔離,可能造成的問題有:**髒讀**,**不可重複讀**,**幻讀**。 下面我們來舉例說明,假設我們有一張只有兩個欄位的表,然後插入以下資料: ``` CREATE TABLE `t`( id int, v int, PRIMARY KEY (`id`) )ENGINE=InnoDB; insert into t(id, v) values(0, 0) ``` **注意**,以下的內容全都是基於只有一行資料`(0, 0)`的`表t`。 ### 2.1 讀未提交 此時事務的隔離級別為**讀未提交**: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221255668-1109205081.png) 在圖中可以看出:在T3時刻,事務A查詢到的資料是(0, 1),但是後來事務B回滾了,也就造成了(0, 1)這行資料是**錯誤**的,這被稱為是**髒讀**。 問題的根源在於,事務A讀到了事務B未提交的資料,這也是事務隔離級別**讀未提交**所存在的問題。這樣的事務隔離級別,僅僅能夠保證事務的原子性,但是沒有保證事務的隔離性,是最低級別的事務隔離級別。 ### 2.2 讀已提交 知道了上面的問題是因為事務讀取了尚未提交的資料,那麼我們讓事務的隔離級別變成**讀已提交**,也就是說,此時只能讀取已經提交過的事務。那麼這樣做的話,我們來看看會有什麼問題: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221308752-127438294.png) 我們知道,在**讀已提交**這個隔離級別中,只能查詢到已經提交的資料。那麼在T5時刻,事務B已經提交了,那麼他的更改對於事務A是可見的。 也就是說,在T5時刻,事務A查詢到的資料是`(0, 1)`。但是問題來了,在T2時刻事務A查詢到的資料是`(0,0)`。這種在同一個事務中,查詢同樣的一行資料,卻得到了不同的結果,稱為“**不可重複讀**”。 在**讀已提交**這個事務隔離級別中,問題在於沒能保證在同一個事務中查詢結果是不變的。 ### 2.3 可重複讀 既然在上面我們發現了不能夠在一個事務中保持結果不變的這麼一個問題,那麼我們讓MySQL在事務啟動的那一瞬間,將所有的資料拷貝成一個快照,然後讓這個事務所有的查詢都在這個快照上進行。這樣的話,在同一個事務中,所有的查詢都是一致的。 這樣的事務隔離級別,稱為“**可重複讀**”。 注意,這裡的“**把所有資料拷貝成一個快照**”的說法是**不準確**的,因為這樣做的話,每啟動一個事務,所需要的儲存空間就得增加一倍,顯然是不可能的。但是你可以先這麼理解,在後面的內容我會跟你解釋MySQL是如何做到“快照”這一功能的。 那麼,在“**可重複讀**”這一隔離級別中,又可能會出現什麼樣的問題呢? ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221319043-950130580.png) 在T2時刻,事務A得到的結果是這樣的: | id | v | |----|------| | 0 | 0 | 值得注意的是,我們在T3時刻,在事務B中也插入了一行`v`為`0`的資料,但是因為我們使用的是**可重複讀**這一隔離級別,所以可以推斷,在T5時刻的查詢,並不會找到新插入的這一行資料。 也就是說,在T5時刻,查詢結果還是和T2時刻是一樣的: | id | v | |----|------| | 0 | 0 | 但是,問題來了。因為此時事務A是不知道事務B的存在的,當事務A發現不存在`id`為`1`,`v`為`0`的資料之後,事務A準備插入這一行資料,MySQL會返回這樣的錯誤: ``` ERROR 1062 (23000): Duplicate entry '1' for key 'PRIMARY' ``` 這個報錯的意思是,**主鍵重複了**。然後事務A就很迷惑:明明我查到並不存在這一行資料,但是為什麼我就是無法插入呢? 這就是**幻讀**。 原因和解決辦法我會在後面提到,我們先繼續看看最嚴格的事務隔離級別。 ### 2.4 序列化 **序列化**,顧名思義,就是所有的事務必須得序列執行。 在序列化中,因為事務是按順序執行的,所以不可能會出現上面提到的那些問題。但是問題在於,當事務序列化之後,MySQL不能再併發處理事務了,此時效能極低。 ## 3 多版本併發控制 在**2.3 可重複讀**內容中,我提到了“快照”這一說法。 不過說的不夠準確,因為MySQL確實不可能在事務啟動的一瞬間將所有的資料都備份一遍。 在這裡,我準備介紹一下InnoDB的多版本併發控制(Multi-Version Concurrency Control),簡稱MVCC。 首先明確兩個概念: 首先,每一個事務在啟動的時候都被分配了一個id,這個id由InnoDB分配,是遞增的。 其次,InnoDB會向資料庫中的每一行都新增三個欄位,`DB_TRX_ID`表示插入或者更新這一行的事務id;`DB_ROLL_PTR`是一個指標,指向了`undo log`中的舊版本資料;`DB_ROW_ID`是一個遞增的行id。 我們先來看這張圖: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221341812-1088871054.png) 還是上面提到的表t,他有兩個欄位,`id`和`v`。然後加上了InnoDB自動新增的**指標欄位**和**事務id欄位**,省略了行id欄位。 在最上面的虛線方框外的那行資料,代表了最新的`id`為`0`的資料,此時的`v`為`4`,這行資料是由id為50的事務更改的。 往下看,在這個最新的資料中,指標指向了`id`為`0`,`v`為`3`的一行資料,而這行資料是由id為44的事務更改的。 說到這裡你可能已經明白了,InnoDB每次更新資料,都會把更新這行資料所在的事務的id記錄在`事務id`欄位中,然後把原資料的記憶體地址填入`指標`欄位。也就是說,InnoDB可以根據這裡的指標地址,找到這一行資料的修改歷史記錄以及產生這條記錄的事務id。 那麼這跟我們說的“快照”,有什麼關係呢? 假設現在是“可重複讀”的事務隔離級別,那麼在事務啟動的時候,InnoDB內部會生成一個數組,數組裡面記錄了所有當前活躍(也就是說還在執行沒有提交)的事務id,並進行排序。 那麼在當前事務執行查詢語句的時候,找到的每一行資料都會進行如下的判斷: - 如果這行資料的事務id小於陣列中的最小值,那麼表示這行資料已經在事務啟動之前更新完畢,可以直接返回 - 如果這行資料的事務id大於陣列中的最大值,那麼說明這行資料是在當前事務之後啟動並修改的,那麼這行資料不可見,需要使用指標找上一條資料,直到符合條件返回 - 如果這行資料的事務id位於陣列中的最大最小值中間,那麼還需要判斷這行資料的事務id是否在陣列中,如果在,代表了這個事務還是活躍的,應該使用指標找上一條資料;否則的話,說明這個事務已經提交了,可以直接返回資料 我們來看一個例子: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221356174-1444899392.png) 假設**在此之前**,表t已經有了這麼一行資料,id=0,v=1,是由id為100的事務插入的。 然後假設事務A的id是101,事務B是102,事務C是103。 到了T4時刻,事務C更新了這行資料,資料的歷史版本如下: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221412385-954822228.png) 然後到了T6時刻,事務B準備更新這行資料。**注意**,更新的時候,是不管資料的歷史版本的,一定要更新**最新的**那行資料。這被稱為是“**當前讀**”,意思是InnoDB的更新、插入、刪除操作,是與快照無關的,必須得更新最新的資料。關於這一部分的內容,在下一節會繼續展開介紹。 於是,變成了這樣: ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221419434-1407525074.png) 然後到了T7時刻,準備讀取資料。 在事務B啟動的時候,事務C還沒有啟動,所以陣列為[101, 102],而讀取到的資料版本是102,就是事務B自己做的更新,所以這行資料符合要求,返回。 到了T8時刻,事務A準備讀取資料。因為事務A啟動的時候,陣列為[101],而當前的資料事務id是102,大於100,不符合要求,所以要查詢上一個資料。 但是上一個資料的id是103,也大於101,所以也不符合要求,查詢上一行的資料。 最終,找到了事務id為100的這行資料,返回。 簡單的來講就是: - 未提交的不可見 - 當前事務啟動之後提交的,不可見 - 當前事務修改的,可見 - 當前事務啟動之前提交的,可見 上面的分析過程是基於“可重複讀”,也就是說,檢視是在事務啟動的一瞬間建立的。其實“讀已提交”也是一樣的意思,只不過一致性檢視不是在事務啟動的一瞬間建立的,而是在每一條select語句(也被稱為一致性讀)之前建立的。 還需要補充的是,資料的歷史版本,都被儲存在了`undo log`中,並且InnoDB會判斷當不需要這些舊版本資料的時候,會清理以釋放空間。 此外,所有對`undo log`的更新,都會被儲存在`redo log`中。 ## 寫在最後 首先,謝謝你能看到這裡! 這篇文章鴿了比較久,不好意思,最近事兒實在是太多了。 本來這篇文章打算寫《事務隔離和鎖》的,但是寫著寫著發現內容太多了一些,就打算這篇先把事務隔離相關的內容寫完,下一篇再寫鎖相關的。 如果在這篇文章中有什麼是我理解有誤的,或者是我講的不夠清晰的,歡迎一起交流學習! 下一篇很快送上,這次一定不鴿(笑) PS:如果有其他的問題,也可以在公眾號找到作者。並且,所有文章第一時間會在公眾號更新,歡迎來找作者玩~ ![](https://img2020.cnblogs.com/blog/1998080/202005/1998080-20200510221507999-12737774