通俗易懂的MySQL事務及MVCC原理
一、事務簡介與四大特性
事務指的是一組命令操作,在執行的過程中,要么全部成功,要么全部失敗。
由引擎層支持事務,MyISAM就不支持事務,而InnoDB是支持事務的。
事務具有以下四大特性(ACID):
- 原子性(Atomicity):指事務不可分割,要么全部成功,要么全部失敗,不可能存在部分成功或部分失敗的情況。如果執行某一條語句失敗后,將會觸發之前所有執行過的語句的回滾,因此靠的是undo log。
- 一致性(Consistency):在事務執行前后,數據的完整性沒有遭到破壞。一致性是mysql追求的最終目標,需要數據庫層面與應用層面同時來維護。需要先滿足原子性、隔離性與持久性,同時也需要應用層面做保障,即在應用層面對數據進行檢驗。
- 隔離性(Isolation):事務之前是隔離的,并發執行的事務之間不存在互相影響,mysql通過鎖以及MVCC來保證隔離性。
- 持久性(Durability):事務一旦提交,那么對數據的操作就是永久性的,即使接下來數據庫宕機也不會有影響。mysql是通過redo log來實現宕機恢復的,而binlog主要是用來誤刪恢復與主從復制的。
簡單羅列了一下4種特性以及對應的實現方式,有關ACID詳細的實現原理,會另開篇幅!
二、臟讀、不可重復讀與幻讀
當事務存在并發時,就會產生以下問題。
臟讀
即讀取到別的事務未提交的數據。
A事務讀取B事務尚未提交的數據,此時如果B事務發生錯誤并執行回滾操作,那么A事務讀取到的數據就是臟數據。
就好像原本的數據比較干凈、純粹,此時由于B事務更改了它,這個數據變得不再純粹。
這個時候A事務立即讀取了這個臟數據,但事務B良心發現,又用回滾把數據恢復成原來干凈、純粹的樣子,而事務A卻什么都不知道,最終結果就是事務A讀取了此次的臟數據,稱為臟讀。
這種情況常發生于轉賬與取款操作中
不可重復讀
即某個事務前后多次讀取,數據內容不一致。
事務A在執行讀取操作,由整個事務A比較大,前后讀取同一條數據需要經歷很長的時間 。
而在事務A第一次讀取數據,比如此時讀取了小明的年齡為20歲,事務B執行更改操作,將小明的年齡更改為30歲,此時事務A第二次讀取到小明的年齡時,發現其年齡是30歲,和之前的數據不一樣了,也就是數據不重復了,系統不可以讀取到重復的數據,成為不可重復讀。
幻讀
即某個事務前后多次讀取,讀到的數據總量不一致。
事務A在執行讀取操作,需要兩次統計數據的總量,前一次查詢數據總量后,此時事務B執行了新增數據的操作并提交后,這個時候事務A讀取的數據總量和之前統計的不一樣,就像產生了幻覺一樣,平白無故的多了幾條數據,稱為幻讀。
三、事務隔離級別
事務隔離級別,就是在不同程度上解決以上的問題。
有四種隔離級別,分別是
- 讀未提交(Read Uncommitted)
- 讀已提交(Read Committed)
- 可重復讀(Repeatable Read)
- 串行化(Serializable)
讀未提交
在這種隔離級別下,所有事務能夠讀取其他事務未提交的數據。
讀取其他事務未提交的數據,會造成臟讀。因此在該種隔離級別下,不能解決臟讀、不可重復讀和幻讀。
讀未提交可能會產生臟讀的現象,那么怎么解決臟讀呢?那就是使用讀已提交。
讀已提交
在這種隔離級別下,所有事務只能讀取其他事務已經提交的內容。
能夠徹底解決臟讀的現象。但在這種隔離級別下,會出現一個事務的前后多次的查詢中卻返回了不同內容的數據的現象,也就是出現了不可重復讀。
這是大多數數據庫系統默認的隔離級別,例如Oracle和SQL Server,但mysql不是。
已提交可能會產生不可重復讀的現象,我們可以使用可重復讀。
可重復讀
在這種隔離級別下,所有事務前后多次的讀取到的數據內容是不變的。
也就是某個事務在執行的過程中,不允許其他事務進行update操作,但允許其他事務進行add操作,造成某個事務前后多次讀取到的數據總量不一致的現象,從而產生幻讀。
這才是mysql的默認事務隔離級別
可重復讀依然會產生幻讀的現象,此時我們可以使用串行化來解決。
串行化
在這種隔離級別下,所有的事務順序執行,所以他們之間不存在沖突,從而能有效地解決臟讀、不可重復讀和幻讀的現象。
但是安全和效率不能兼得,串行化會大大降低數據庫的性能,一般不使用這種級別。
下面用一張表格來表示他們能夠解決的問題,x代表未解決,√代表能夠解決。
當然,以上所說的隔離級別及當前級別存在的問題只是一種規范,不同的數據庫廠商可以有不同的實現。
例如在mysql的可重復讀的級別上,使用臨鍵鎖的方式就已經解決了幻讀的問題。
四、MVCC
mysql為了實現以上隔離級別,提出了LBCC(Lock-Based Concurrent Control,基于鎖的并發控制)與MVCC(Multi-Version Concurrent Control,基于多版本的并發控制)。
在LBCC中,讀寫沖突,會使用諸如記錄鎖、間隙鎖與臨鍵鎖等鎖來實現數據的并發安全,因此讀寫性能不高。關于鎖的分類,可以參考我的另外一篇文章談談鎖的類型
在MVCC中,讀寫不沖突,記錄每一行的多個版本,來避免在多個事務之間的競爭。以空間換時間的思路,極大地提高了讀寫性能。
MVCC主要靠undo log版本鏈與ReadView來實現。
先對undo log有一個基本的認識
Undo log
undo log主要用于事務回滾時恢復原來的數據
mysql在執行sql語句時,會將一條邏輯相反的日志保存到undo log中。因此,undo log中記錄的也是邏輯日志。
當sql語句為insert時,會在undo log中記錄本次插入的主鍵id。等事務回滾時,delete此id即可。
當sql語句為update時,會在undo log中記錄修改前的數據。等事務回滾時,再執行一次update,得到原來的數據。
當sql語句為delete時,會在undo log中記錄刪除前的數據。等事務回滾時,insert原來的數據即可。
數據庫事務四大特性中的原子性,即事務具有不可分割性,要么全部成功,要么全部失敗,其底層就靠undo log實現。在某一步執行失敗時,會對之前事務的語句進行回滾。
對數據庫中的日志完全不熟悉的話,可以看我的另外一篇文章數據庫日志——binlog、redo log、undo log掃盲
行的隱藏列
在數據庫中的每一行上,除了存放真實的數據以外,還存在著3個隱藏列——row_id、trx_id與roll_pointer。
row_id,行號
如果當前表有整數類型的主鍵,則row_id就是主鍵的值。
如果沒有整數類型的主鍵,則mysql會按照字段順序選擇一個非空的整數類型的唯一索引作為row_id。
如果mysql沒有找到,則會自動生成一個自動增長的整數作為row_id。
那row_id和今天的MVCC有什么關系呢?
不能說一點沒有吧,只能說毫無關系。
trx_id,事務號
當一個事務開始執前,mysql會為這個事務分配一個全局自增的事務id。
之后該事務對當前行進行的增、刪、改操作時,都會將自己的事務id記錄到trx_id中。
roll_pointer,回滾指針
事務對當前行進行改動時,會將舊數據寫入進undo log中,再將新數據寫入當前行,且當前行的roll_pointer指向剛才那個undo log,因此可以通過roll_pointer找到該行的前一個版本。
當一直有事務對該行改動時,就會一直生成undo log,最終將會形成undo log版本鏈。
Undo log版本鏈
一開始,我們使用以下語句創建一個stduent表
- CREATE TABLE `student` (
- `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
- `name` VARCHAR ( 255 ) NOT NULL,
- `age` INT ( 11 ) NOT NULL,
- PRIMARY KEY ( `id` ) USING BTREE
- ) ENGINE = INNODB;
現在開啟第1個事務,事務id為1,執行以下插入語句。
- INSERT INTO student VALUES ( 1, "a", 24 );
那么當前行的一個示意圖如下:
因為該數據是新插入的,因此它的roll_pointer指向的undo log為空。
接著開啟第2個事務,分配的事務id是2,執行以下修改命令。
- UPDATE student SET NAME = 'b' WHERE id = 1;
現在的示意圖變為:
當開啟第3個事務,分配到事務id是3,執行以下修改命令。
- UPDATE student SET age = 25 WHERE id = 1;
示意圖變為:
每一個事務對該行改動時,都會生成一個undo log,用于保存之前的版本,之后再將新版本的roll_pointer指向剛才生成的undo log。
因此roll_pointer可以將這些不同版本的undo log串聯起來,形成undo log版本鏈。
ReadView
首先需要理解一下快照讀與當前讀
快照讀:簡單的select查詢,即不包括 select ... lock in share mode, select ... for update,可能會讀到數據的歷史版本。
當前讀:以下語句都是當前讀,總是讀取最新版本,會對讀取的最新版本加鎖。
- select ... lock in share mode
- select ... for update
- insert
- update
- delete
在事務執行每一個快照讀或事務初次執行快照讀時,會生成一致性視圖,即ReadView。
ReadView的作用是,判斷undo log版本鏈中的哪些數據對當前事務可見。
ReadView包含以下幾個重要的參數:
m_ids
在創建ReadView的那一刻,mysql中所有未提交的事務id集合。
min_trx_id
m_ids中的最小值
max_trx_id
mysql即將為下一個事務分配的事務id,并不是m_ids中的最大值。
creator_trx_id
即創建此ReadView的事務id
簡要的示意圖如下:
那么事務在執行快照讀時,可以通過以下的規則來確定undo log版本鏈上的哪個版本數據可見。
如果當前undo log的版本的trx_id
如果當前undo log的版本的trx_id≥max_trx_id,說明該版本對應的事務在生成ReadView之后才開始的,因此是不可見的。
如果當前undo log的版本的trx_id∈[min_trx_id,max_trx_id),如果在這個范圍里,還要判斷trx_id是否在m_ids中:
在m_ids中,說明版本對應的事務未提交,因此是不可見的。
不在m_ids中,說明版本對應的事務已經提交,因此是可見的。
如果當前undo log的版本的trx_id=creator_trxt_id,說明事務正在訪問自己修改的數據,因此是可見的。
當undo log版本鏈表的頭結點數據被判定為不可見時,則利用roll_pointer找到上一個版本,再進行判斷。如果整個鏈表中都沒有找到可見的數據,則代表當前的查詢找不到數據。
MVCC在四種隔離級別下的區別
在Read Uncommitted級別下,事務總是讀取到最新的數據,因此根本用不到歷史版本,所以MVCC不在該級別下工作。
在Serializable級別下,事務總是順序執行。寫會加寫鎖,讀會加讀鎖,完全用不到MVCC,所以MVCC也不在該級別下工作。
真正和MVCC兼容的隔離級別是Read Committed(RC)與Repeatable Read(RR)
MVCC在RC與RR級別下的區別,在于生成ReadView的頻率不同。
在RC級別下,當前事務總是希望讀取到別的事務已經提交的數據,因此當前事務事務會在執行每一次快照讀的情況下都會去生成ReadView,實時更新m_ids,及時發現那些已經提交的事務。
在RR級別下,當前事務當然也能夠讀取到別的事務已經提交的數據,但為了避免不可重復讀,因此只會在執行第一次快照讀的情況下去生成ReadView,之后的快照讀會一直沿用該ReadView。
舉個栗子:
在RC級別下
一開始,事務id為1的事務往表里插入了一條數據,版本鏈如下:
這個時候,開啟事務id為2的事務,關閉自動提交模式。先執行一次select *查詢,生成的ReadView如下
- m_ids={2},min_trx_id=2,max_trx_id=3,creator_trx_id=2
由于該條數據的trx_id
因此,事務2能直接查到該數據。
現在開啟事務3,事務id為3,將該條數據的name改為b,并自動提交,版本鏈如下:
這個時候,事務2再次select *查詢,由于處于RC級別下,會再次生成ReadView,此時的ReadView如下:
- m_ids={2},min_trx_id=2,max_trx_id=4,creator_trx_id=2
由于最新版本的trx_id∈[2,4)且trx_id不在m_ids中,說明該版本的數據已經提交,因此是可見的,所以事務2能查到最新的數據。
而處于RR級別下:
事務2再次select *查詢時,不會生成ReadView,而是沿用第一次生成的ReadView:
- m_ids={2},min_trx_id=2,max_trx_id=3,creator_trx_id=2
由于最新版本的trx_id≥max_trx_id,說明該版本對應的事務在生成ReadView之后才開始的,因此是不可見的。
所以沿著roll_pointer找到上一個版本,上一個版本的trx_id
所以,事務2只能查詢到舊版本的數據,兩次的查詢一致,避免了不可重復讀。