一文理解MySQL的事務(wù)原則與事務(wù)隔離
事務(wù)是一組不可分組的操作集合,這些操作要么都成功執(zhí)行,要么都取消執(zhí)行。最典型的需要事務(wù)的場景是銀行賬戶間的轉(zhuǎn)賬:假如 A 賬戶要給 B 賬戶轉(zhuǎn)賬 100 元,那么 A 賬戶要扣減 100 元,B 賬戶要增加 100 元,這兩個賬戶的數(shù)據(jù)變更都成功才可算作轉(zhuǎn)賬成功。
本文涉及到MySQL鎖機制的很多知識,對這方面不了解的可以先看博客《快速理解MySQL的鎖機制與死鎖排查》
ACID原則
事務(wù)具有四個原則:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability)。這四個特性簡稱為ACID原則。
原子性(Atomictiy):事務(wù)是一個包含一系列操作的原子操作。事務(wù)的原子性確保這些操作全部完成或者全部失敗。
一致性(Consistency):事務(wù)的執(zhí)行不能破壞數(shù)據(jù)庫數(shù)據(jù)的完整性和一致性,一個事務(wù)在執(zhí)行之前和執(zhí)行之后,數(shù)據(jù)庫都必須處于一致性狀態(tài)。
如果數(shù)據(jù)庫系統(tǒng)在運行過程中發(fā)生故障,有些事務(wù)尚未完成就被迫中斷,這些未完成的事務(wù)對數(shù)據(jù)庫所作的修改有一部分已寫入物理數(shù)據(jù)庫,此時數(shù)據(jù)庫就處于不一致的狀態(tài)。(在合格的數(shù)據(jù)庫產(chǎn)品中,數(shù)據(jù)庫重啟后,會按照一定規(guī)則回滾所有導(dǎo)致不一致的事務(wù))
隔離性(Isolation):數(shù)據(jù)庫允許多個并發(fā)事務(wù)同時對其數(shù)據(jù)進行讀寫和修改的能力,隔離性可以防止多個事務(wù)并發(fā)執(zhí)行時由于交叉執(zhí)行而導(dǎo)致數(shù)據(jù)的不一致。
持久性(Durabilit):事務(wù)處理結(jié)束后,對數(shù)據(jù)的修改就是永久的,即便系統(tǒng)故障也不會丟失。
1. 原子性
原子性(Atomicity)的實現(xiàn)離不開MySQL的事務(wù)日志undo log日志類型,當(dāng)事務(wù)需要回滾的時候需要將數(shù)據(jù)庫狀態(tài)回滾到事務(wù)開始前,即需要撤銷所有已經(jīng)成功執(zhí)行的sql語句。undo log起了關(guān)鍵性作用。
當(dāng)事務(wù)對數(shù)據(jù)庫進行修改時,InnoDB會生成對應(yīng)的undo log;如果事務(wù)執(zhí)行失敗或調(diào)用了rollback,導(dǎo)致事務(wù)需要回滾,便可以利用undo log中的信息將數(shù)據(jù)回滾到修改之前的樣子。
undo log
undo log主要為事務(wù)的回滾服務(wù)。在事務(wù)執(zhí)行的過程中,除了記錄redo log,還會記錄一定量的undo log。undo log記錄了數(shù)據(jù)在每個操作前的狀態(tài),如果事務(wù)執(zhí)行過程中需要回滾,就可以根據(jù)undo log進行回滾操作。單個事務(wù)的回滾,只會回滾當(dāng)前事務(wù)做的操作,并不會影響到其他的事務(wù)做的操作。
undo log的作用:
為了保證數(shù)據(jù)的原子性,記錄事務(wù)發(fā)生之前的一個版本,用于回滾,
通過mvcc和undo log實現(xiàn)innodb事務(wù)可重復(fù)讀和讀取已提交隔離級別。
其中,undo log分為:
insert undo log:insert操作中產(chǎn)生的undo log。
update undo log:delete和update操作產(chǎn)生的undo log。
insert操作
因為insert操作的記錄,只對事務(wù)本身可見,對其他事務(wù)不可見。故undo log可以在事務(wù)提交后直接刪除,不需要進行purge操作,
delete操作
Delete操作在事務(wù)中并不是真正的刪除掉數(shù)據(jù)行,而是一種Delete Mark操作,在記錄上標(biāo)識Delete_Bit,而不刪除記錄。是一種"假刪除",之后通過purge線程刪除。(原因是為了支持MVCC)
update操作
update分為兩種情況:update的列是否是主鍵索引。
如果不是主鍵索引,在undo log中直接反向記錄是如何update的。即update是直接進行的。
如果是主鍵索引,update分兩部執(zhí)行:先刪除該行,再插入一行目標(biāo)行。
與insert undo log不同的,update undo log日志,當(dāng)事務(wù)提交的時候,innodb不會立即刪除undo log, 會將該事務(wù)對應(yīng)的undo log放入到刪除列表中,之后通過purge線程刪除。
因為后續(xù)還可能會用到undo log,例如隔離級別為repeatable read時的MVCC,事務(wù)讀取的都是開啟事務(wù)時的最新提交行版本,只要該事務(wù)不結(jié)束,該行版本就不能刪除(即undo log不能刪除),且undo log分配的頁可重用減少存儲空間和提升性能。
purge線程兩個主要作用是:清理undo頁和清除page里面帶有Delete_Bit標(biāo)識的數(shù)據(jù)行。
2. 一致性
一致性是通過事務(wù)的原子性、持久性和隔離性來保證的。
原子性:語句要么全執(zhí)行,要么全不執(zhí)行,是事務(wù)最核心的特性,事務(wù)本身就是以原子性來定義的。實現(xiàn)主要基于undo log。
持久性:保證事務(wù)提交后不會因為宕機等原因?qū)е聰?shù)據(jù)丟失。實現(xiàn)主要基于redo log。
隔離性:保證事務(wù)執(zhí)行盡可能不受其他事務(wù)影響。InnoDB默認(rèn)的隔離級別是RR,RR實現(xiàn)隔離性主要基于鎖機制(包含next-key lock)、MVCC(包括數(shù)據(jù)的隱藏列、基于undo log的版本鏈、ReadView)。
3. 隔離性
隔離性是指事務(wù)內(nèi)部的操作與其他事務(wù)是隔離的,詳情可以看下面章節(jié)“事務(wù)并發(fā)問題”與“解決事務(wù)并發(fā)問題”。
4. 持久性
持久性是指事務(wù)提交之后,所做的修改就會永久保存,不會因為系統(tǒng)故障導(dǎo)致數(shù)據(jù)丟失,
redo log
在innoDB的存儲引擎中,事務(wù)日志通過redo log和日志緩沖實現(xiàn)。事務(wù)開啟時,事務(wù)中的操作,都會先寫入存儲引擎的日志緩沖中,在事務(wù)提交之前,這些緩沖的redo log都需要提前刷新到磁盤上持久化,這就是日志先行機制(Write-Ahead Logging)。當(dāng)事務(wù)提交之后,在日志緩沖中映射的數(shù)據(jù)文件才會慢慢刷新到磁盤。此時如果數(shù)據(jù)庫崩潰或者宕機,那么當(dāng)系統(tǒng)重啟進行恢復(fù)時,就可以根據(jù)redo log中記錄的日志,把數(shù)據(jù)庫恢復(fù)到崩潰前的一個狀態(tài)。未完成的事務(wù),可以繼續(xù)提交,也可以選擇回滾,這基于恢復(fù)的策略而定。
在系統(tǒng)啟動的時候,存儲引擎就已經(jīng)為redo log分配一塊連續(xù)的存儲空間,以順序追加的方式記錄Redo Log,通過順序IO來改善性能。redo log日志的大小是固定的,即記錄滿了以后就從頭循環(huán)寫。所有的事務(wù)共享redo log的存儲空間,Redo Log按語句的執(zhí)行順序,依次交替的記錄在一起。
緩沖的redo log刷新到磁盤上持久化,刷新頻率由
innodb_flush_log_at_trx_commit變量來控制的:
0 :每秒刷新寫入到磁盤中的,當(dāng)系統(tǒng)崩潰,會丟失1秒鐘的數(shù)據(jù)。
1:事務(wù)每次提交都寫入磁盤。
2:每秒刷新寫入到磁盤中的,但跟0是有區(qū)別的。

事務(wù)并發(fā)問題
1. 臟讀
臟讀是指一個事務(wù)中訪問到了另外一個事務(wù)未提交的數(shù)據(jù)。

如果會話2更新age為 10,但是在commit之前,會話1此時獲取age,那么會獲得的值就是會話2中尚未commit的值。如果會話2再次更新了age或者執(zhí)行了rollback,而會話 1已經(jīng)拿到了age=10的值,這就是臟讀。
2. 不可重復(fù)讀
不可重復(fù)讀是一個事務(wù)讀取同一條記錄2次,得到的結(jié)果不一致。

由于在讀取中間變更了數(shù)據(jù),所以會話1事務(wù)查詢期間的得到的結(jié)果就不一樣了。
3. 幻讀
幻讀是一個事務(wù)讀取2次,得到的記錄條數(shù)不一致。

由于在會話1之間插入了一個新的數(shù)據(jù),所以得到的兩次數(shù)據(jù)就不一樣了。
事務(wù)隔離級別
為了解決上面提到的事務(wù)并發(fā)問題,SQL標(biāo)準(zhǔn)規(guī)定了四個隔離水平:
讀未提交(Read Uncommited)
讀已提交(Read Commited)
可重復(fù)讀(Repeatable Read)
串行化(Serializable)
| 隔離級別 | 臟讀 | 不可重復(fù)讀 | 幻讀 |
|---|---|---|---|
| 讀未提交 | 存在 | 存在 | 存在 |
| 讀已提交 | 不存在 | 存在 | 存在 |
| 可重復(fù)讀 | 不存在 | 不存在 | 存在 |
| 串行化 | 不存在 | 不存在 | 不存在 |
1. 讀未提交(Read Uncommited)
該隔離級別安全最低,允許臟讀。
2. 讀已提交(Read Commited)
存在不可重復(fù)讀取,但不允許臟讀。讀已提交只允許獲取已經(jīng)提交的數(shù)據(jù)。
3. 可重復(fù)讀(Repeatable Read)
保證在事務(wù)處理過程中,多次讀取同一個數(shù)據(jù)時,其值都和事務(wù)開始時刻是一致的,因此該事務(wù)級別禁止不可重復(fù)讀取和臟讀,但是有可能出現(xiàn)幻讀。
4. 串行化(Serializable)
是最嚴(yán)格的事務(wù)隔離級別,它要求所有事務(wù)被串行執(zhí)行,即事務(wù)只能一個接一個的進行處理,不能并發(fā)執(zhí)行。
下面是主流數(shù)據(jù)庫的默認(rèn)隔離級別:
| Database | Default isolation Level |
|---|---|
| Oracle | READ_COMMITTED |
| MySQL | REPETABLE_READ |
| Microsoft SQL Server | READ_COMMITTED |
| PostgreSQL | READ_COMMITTED |
| DB2 | CURSOR STABILITY (a.k.a READ_COMMITTED) |
隔離級別越高,越能保證數(shù)據(jù)的完整性和一致性,但是對并發(fā)性能的影響也越大。對于多數(shù)應(yīng)用程序,可以優(yōu)先考慮把數(shù)據(jù)庫系統(tǒng)的隔離級別設(shè)為Read Committed。它能夠避免臟讀取,而且具有較好的并發(fā)性能。盡管它會導(dǎo)致不可重復(fù)讀、幻讀和第二類丟失更新這些并發(fā)問題,在可能出現(xiàn)這類問題的業(yè)務(wù)場景,可以采用悲觀鎖或樂觀鎖來控制。
解決事務(wù)并發(fā)問題
MySQL、Oracle這樣的數(shù)據(jù)庫中,為了性能的考慮并不是完全按照上面的SQL標(biāo)準(zhǔn)來實現(xiàn)的。
數(shù)據(jù)庫實現(xiàn)事務(wù)隔離的方式,基本可以分為以下兩種。
在讀取數(shù)據(jù)前,對其加鎖,阻止其他事務(wù)對數(shù)據(jù)進行修改。
不用加任何鎖,通過一定機制生成一個數(shù)據(jù)請求時間點的一致性數(shù)據(jù)快照(Snapshot),并用這個快照來提供一定級別(語句級或事務(wù)級)的一致性讀取。從用戶的角度來看,數(shù)據(jù)庫可以提供同一數(shù)據(jù)的多個版本,因此,這種技術(shù)叫做數(shù)據(jù)多版本并發(fā)控制(MultiVersion Concurrency Control,簡稱MVCC或MCC)
MVCC
僅通過鎖來控制實現(xiàn)事務(wù)隔離級別會存在一些問題,比如要實現(xiàn) RC(讀已提交)級別,事務(wù)a更新一行數(shù)據(jù),需要對行(實際是索引記錄)加X鎖,阻塞其它事務(wù)對該行的讀寫,事務(wù)b想要讀取該行必須等到a提交或回滾釋放鎖,這樣的話就會很大程度上限制讀寫的并發(fā)能力。
MVCC是一種無鎖方案,用以解決事務(wù)讀-寫并發(fā)的問題,能夠極大提升讀-寫并發(fā)操作的性能。
為了理解MVCC,需要先了解下版本鏈與ReadView,業(yè)務(wù)場景如下:
創(chuàng)建一個表book,就三個字段,分別是主鍵book_id, 名稱book_name, 庫存stock。然后向表中插入一些數(shù)據(jù):
INSERT INTO book VALUES(1, '數(shù)據(jù)結(jié)構(gòu)', 100);
INSERT INTO book VALUES(2, 'C++指南', 100);
INSERT INTO book VALUES(3, '精通Java', 100);
版本鏈
對于使用InnoDB存儲引擎的表,其主鍵索引記錄中包含了兩個重要的隱藏列:
事務(wù)ID(DB_TRX_ID):每當(dāng)事務(wù)對主鍵索引中的記錄進行修改時,都會把當(dāng)前事務(wù)的事務(wù)id記錄到DB_TRX_ID中。
回滾指針(DB_ROLL_PTR):每當(dāng)事務(wù)對主鍵索引中的記錄進行修改時,都會把該記錄的舊版本記錄到undo日志中,通過DB_ROLL_PTR這個指針可以用來獲取該記錄舊版本的信息。
如果在一個事務(wù)中多次對記錄進行修改,則每次修改都會生成undo日志,并且這些undo日志通過DB_ROLL_PTR指針串聯(lián)成一個版本鏈,版本鏈的頭結(jié)點是該記錄最新的值,尾結(jié)點是事務(wù)開始時的初始值。
例如在表book中做以下修改:
BEGIN;
UPDATE book SET stock = 200 WHERE id = 1;
UPDATE book SET stock = 300 WHERE id = 1;
那么id=1的記錄此時的版本鏈就如下圖所示:

ReadView
對于Read Uncommitted隔離級別的事務(wù)來說,只需要讀取版本鏈上最新版本的記錄即可;對于Serializable隔離級別的事務(wù)來說,InnoDB使用加鎖的方式來訪問記錄;而對于Read Committed和Repeatable Read隔離級別來說,都需要讀取已經(jīng)提交的事務(wù)所修改的記錄,也就是說如果版本鏈中某個版本的修改沒有提交,那么該版本的記錄時不能被讀取的。所以需要確定在Read Committed和Repeatable Read隔離級別下,版本鏈中哪個版本是能被當(dāng)前事務(wù)讀取的。于是ReadView的概念被提出以解決這個問題。
ReadView相當(dāng)于某個時刻表記錄的一個快照,在這個快照中能獲取到與當(dāng)前記錄相關(guān)的事務(wù)中,哪些事務(wù)是已提交的穩(wěn)定事務(wù),哪些是正在活躍的事務(wù),哪些是生成快照之后才開啟的事務(wù)。由此就能根據(jù)可見性比較算法判斷出版本鏈中能被讀取的最新版本記錄。
在MySQL中,Read Committed和Repeatable Read隔離級別下的區(qū)別就是它們生成ReadView的時機不同。
在Read Committed隔離級別下,每次讀取數(shù)據(jù)時都會生成ReadView;而在Repeatable Read隔離級別下只會在事務(wù)首次select數(shù)據(jù)時生成ReadView,之后的讀操作都會使用相同的ReadView。
可見性比較算法
可見性比較算法是基于事務(wù)ID的比較算法。事務(wù)id是遞增分配的,從ReadView中能獲取到生成快照時刻系統(tǒng)中活躍的事務(wù)中最小和最大的事務(wù)id(最大的事務(wù)id實際上是系統(tǒng)中將要分配給下一個事務(wù)的id值),這樣就得到一個活躍事務(wù)id的范圍,稱之為ACTIVE_TRX_ID_RANGE。那么小于這個范圍的事務(wù)id對應(yīng)的事務(wù)都是已提交的穩(wěn)定事務(wù),大于這個范圍的事務(wù)都是在快照生成之后才開啟的事務(wù),而在ACTIVE_TRX_ID_RANGE范圍內(nèi)的事務(wù)中除了正在活躍的事務(wù),也都是已提交的穩(wěn)定事務(wù)。
具體的可見性比較算法過程如下:
首先判斷版本記錄的DB_TRX_ID字段與ReadView的creator_trx_id字段是否相等。如果相等,那就說明該版本的記錄是在當(dāng)前事務(wù)中生成的,自然也就能夠被當(dāng)前事務(wù)讀取;否則進行第2步。
如果版本記錄的DB_TRX_ID字段小于范圍ACTIVE_TRX_ID_RANGE,表明該版本記錄是已提交事務(wù)修改的記錄,即對當(dāng)前事務(wù)可見;否則進行下一步。
版本記錄的DB_TRX_ID字段位于范圍ACTIVE_TRX_ID_RANGE內(nèi):如果該事務(wù)ID對應(yīng)的不是活躍事務(wù)(已經(jīng)提交了),即對當(dāng)前事務(wù)可見;如果該事務(wù)ID對應(yīng)的是活躍事務(wù)(還未提交了),那么對當(dāng)前事務(wù)不可見,則讀取版本鏈中下一個版本記錄。重復(fù)以上步驟,直到找到對當(dāng)前事務(wù)可見的版本。
creator_trx_id字段:ReadView中的屬性,記錄創(chuàng)建這條記錄/最后一次修改該記錄的事務(wù)ID。
如果某個版本記錄經(jīng)過以上步驟判斷確定其對當(dāng)前事務(wù)可見,則查詢結(jié)果返回此版本記錄;否則讀取下一個版本記錄繼續(xù)按照上述步驟進行判斷,直到版本鏈的尾結(jié)點。如果遍歷完版本鏈沒有找到對當(dāng)前事務(wù)可見的版本,則查詢結(jié)果為空。
Read Committed下的MVCC
假設(shè)在Read Committed隔離級別下,有如下事務(wù)在執(zhí)行,事務(wù)id為10:
BEGIN; // 開啟Transaction 10
UPDATE book SET stock = 200 WHERE id = 2;
UPDATE book SET stock = 300 WHERE id = 2;
此時該事務(wù)尚未提交,id為2的記錄版本鏈如下圖所示:

然后開啟一個事務(wù)對id為2的記錄進行查詢:
BEGIN;
SELECT * FROM book WHERE id = 2; // 此時Transaction 10 未提交
當(dāng)執(zhí)行SELECT語句時會生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE為[10, 11),creator_trx_id為0(因為事務(wù)中當(dāng)執(zhí)行寫操作時才會分配一個單獨的事務(wù)id,否則事務(wù)id為0)。按照之前所述ReadView的工作原理,查詢到的id=2的book,stock值應(yīng)為100。
然后將事務(wù)id為10的事務(wù)提交:
BEGIN; // 開啟Transaction 10
UPDATE book SET stock = 200 WHERE id = 2;
UPDATE book SET stock = 300 WHERE id = 2;
COMMIT;
同時開啟執(zhí)行另一事務(wù)id為11的事務(wù),但不提交:
BEGIN; // 開啟Transaction 11
UPDATE book SET stock = 400 WHERE id = 2;
此時id為2的記錄版本鏈如下圖所示: 
然后回到剛才的查詢事務(wù)中再次查詢id為2的記錄:
BEGIN;
SELECT * FROM book WHERE id = 2; // 此時Transaction 10 未提交
SELECT * FROM book WHERE id = 2; // 此時Transaction 10 已提交
當(dāng)?shù)诙螆?zhí)行SELECT語句時會再次生成一個ReadView,該ReadView中的ACTIVE_TRX_ID_RANGE為[11, 12),當(dāng)前事務(wù)IDcreator_trx_id依然為0。按照ReadView的工作原理進行分析,查詢到的id=2的book,stock值為300。
從上述分析可以發(fā)現(xiàn),因為每次執(zhí)行查詢語句都會生成新的ReadView,所以在Read Committed隔離級別下的事務(wù)讀取到的是查詢時刻表中已提交事務(wù)修改之后的數(shù)據(jù)。
Repeatable Read下的MVCC
Repeatable Read隔離級別下的事務(wù)只會在第一次執(zhí)行查詢時生成ReadView,該事務(wù)中后續(xù)的查詢操作都不會生成新的ReadView,因此Repeatable Read隔離級別下,一個事務(wù)中多次執(zhí)行同樣的查詢,其結(jié)果都是一樣的,這樣就實現(xiàn)了可重復(fù)讀。
補充
redo/undo log和binlog的區(qū)別:
層次不同。redo/undo是innodb引擎層維護的,而binlog是mysql server層維護的,跟采用何種引擎沒有關(guān)系,記錄的是所有引擎的更新操作的日志記錄。
記錄內(nèi)容不同。redo/undo記錄的是每個頁/每個數(shù)據(jù)的修改情況,屬于物理日志+邏輯日志結(jié)合的方式(redo log是物理日志,undo log 是邏輯日志);binlog是邏輯日志,其記錄是對應(yīng)的SQL語句。
記錄時機不同。redo/undo在事務(wù)執(zhí)行過程中會不斷的寫入,而binlog是在事務(wù)最終提交前寫入的。binlog什么時候刷新到磁盤跟參數(shù)
sync_binlog相關(guān)。
當(dāng)前讀
SELECT xxx LOCK IN SHARE MODE 語句為當(dāng)前讀,加S鎖和元數(shù)據(jù)讀鎖,直到事務(wù)結(jié)束。
SELECT xxx FOR UPDATE 語句為當(dāng)前讀,加X鎖和元數(shù)據(jù)讀鎖,直到事務(wù)結(jié)束
這種讀取方式讀取的是記錄的當(dāng)前最新版本,稱為當(dāng)前讀。另外對于INSERT、DELETE、UPDATE操作,也需要先讀取記錄,獲取記錄的X鎖,這個過程也是一個當(dāng)前讀。由于需要對記錄進行加鎖,會阻塞其他事務(wù)的寫操作,因此也叫加鎖讀或阻塞讀。
在MySQL的Repeatable Read隔離級別下,當(dāng)前讀使用臨鍵鎖解決幻讀問題。
快照讀
在Read Committed和Repeatable Read隔離級別下,普通的SELECT查詢(不包括 select ... lock in share mode, select ... for update)都是讀取MVCC版本鏈中的一個版本,相當(dāng)于讀取一個快照,因此稱為快照讀。這種讀取方式不會加鎖,因此讀操作時非阻塞的,也叫非阻塞讀。
在標(biāo)準(zhǔn)的Repeatable Read隔離級別下讀操作會加S鎖,直到事務(wù)結(jié)束,因此可以阻止其他事務(wù)的寫操作;但在MySQL的Repeatable Read隔離級別下讀操作沒有加鎖,不會阻止其他事務(wù)對相同記錄的寫操作。
注意:快照讀的時間點是以第一個select來確認(rèn)的。所以即使事務(wù)開始后先update再select,那么select獲取的數(shù)據(jù)則是對應(yīng)update動作之后生成的ReadView。
在MySQL的Repeatable Read隔離級別下,快照讀使用MVCC解決幻讀問題。
