Spring事務專題(三)事務的基本概念,Mysql事務處理原理

程式設計師DMZ 發表於 2020-08-01

前言

本專題大綱:

專欄大綱

我重新整理了大綱,思考了很久,決定單獨將MySQL的事務實現原理跟Spring中的事務示例分為兩篇文章,因為二者畢竟沒有什麼實際關係,實際上如果你對MySQL的事務原理不感興趣也可以直接跳過本文,等待接下來兩篇應用及原始碼分析,不過我覺得知識的學習應該慢慢行成一個體系,為了建立一個完善的體系應該要對資料庫本身事務的實現有一定認知才行。

本文為Spring事務專題第三篇,在前兩篇文章中我們已經對Spring中的資料訪問有了一定的瞭解,那麼從本文開始我們正式接觸事務,在分析Spring中事務的實現之前我們應該要對事務本身有一定的瞭解,同時也要對資料庫層面的事務如何實現有一定了解。話不多說,我們開始正文

本文大綱:

MYSQL事務大綱

初識事務

為什麼需要事務?

這裡又要掏出那個爛大街的銀行轉賬案例了,以A、B兩個賬戶的轉賬為例,假設現在要從A賬戶向B賬戶中轉入1000員,當進行轉賬時,需要先從銀行賬戶A中取出錢,然後再存入銀行賬戶B中,SQL樣本如下:

// 第一步:A賬戶餘額減少減少1000  
update balance set money = money -500 where name= ‘A’;
// 第二步:B賬戶餘額增加1000  
update balance set money = money +500 where name= ‘B’;

如果在完成了第1步的時候突然當機了,A的錢減少了而B的錢沒有增加,那A豈不是白白丟了1000元,這時候就需要用到我們的事務了,開啟事務後SQL樣本如下:

// 第一步:開始事務
start transaction;
// 第二步:A賬戶餘額減少減少1000  
update balance set money = money -500 where name= ‘A’;
// 第三步:B賬戶餘額增加1000  
update balance set money = money +500 where name= ‘B’;
// 第四步:提交事務
commit;

什麼是事務

事務(Transaction)是訪問和更新資料庫的程式執行單元;事務中可能包含一個或多個sql語句,這些語句要麼都執行成功,要麼全部執行失敗。

事務的四大特性(ACID)

  • 原子性(Atomicity,或稱不可分割性)

一個事務必須被視為一個不可分割的最小工作單元,整個事務中所有的操作要麼全部提交成功,要麼全部失敗回滾,對於一個事務來說,不可能只執行其中的一部分操作,這就是事務的原子性

  • 一致性(Consistency)

資料庫總是從一個一致性的狀態轉換到另外一個一致性的狀態,在事務開始之前和之後,資料庫的完整性約束沒有被破壞。在前面的例子中,事務結束前後A、B賬戶總額始終保持不變

  • 隔離性(Isolation)

隔離性是指,事務內部的操作與其他事務是隔離的,併發執行的各個事務之間不能互相干擾。嚴格的隔離性,對應了事務隔離級別中的Serializable (可序列化),但實際應用中出於效能方面的考慮很少會使用可序列化。

  • 永續性(Durability)

永續性是指事務一旦提交,它對資料庫的改變就應該是永久性的。接下來的其他操作或故障不應該對其有任何影響。

事務的隔離級別

在前文中我們介紹了隔離性,但實際上隔離性比想象的要複雜的多。在SQL標準中定義了四種隔離級別,每一種隔離級別都規定了一個事務所做的修改,哪些在事務內和事務間是可見的,哪些是不可見的,較低階別的隔離通常可以執行跟高的併發,系統的開銷也更低

未提交讀(READ UNCOMMITTED)

在這個隔離級別下,事務的修改即使沒有提交,對其他事務也是可見的。事務可以讀取未提交的資料,這也被稱之為髒讀。這個級別會帶來很多問題,從效能上來說,READ UNCOMMITTED不會比其他的級別好太多,但是卻會帶來很多問題,除非真的有非常必要的理由,在實際應用中一般很少使用。

提交讀(REDA COMMITED)

大多數資料系統的預設隔離級別都是REDA COMMITED(MySql不是),REDA COMMITED滿足前面提到的隔離性的簡單定義:一個事務開始時,只能看到已經提交的事務所做的修改。換句話說,一個事物從開始直到提交前,所做的修改對其他事務不可見。這個級別有時候也叫做不可重複讀,因為執行兩次相同的查詢可能會得到不同的結果。

可重複讀(REPEATABLE READ)

REPEATABLE READ解決了髒讀以及不可重複度的問題。該級別保證了同一個事務多次讀取同樣記錄的結果是一致的。但是理論上,可重複度還是無法解決另外一個幻讀的問題。所謂幻讀,指的是當某個事務在讀取某個範圍內的記錄時,另外一個事務又在該範圍內插入了新的記錄,當之前的事務再次讀取該範圍的記錄時,就會產生幻行。

不可重複讀跟幻讀的區別在於,前者是資料發生了變化,後者是資料的行數發生了變化

可序列化(SERIALIZABLE)

SERIALIZABLE是最高的隔離級別,它通過強制事務序列執行,避免前面說的幻讀。簡單來說SERIALIZABLE會在讀取的每一行資料上都加鎖,所以可能會導致大量的超時和鎖爭用的問題。實際應用中也很少使用這個隔離級別,只有在非常需要確保資料一致性而且可以接受沒有併發的情況下,才考慮此級別。

儲存點

我們可以在事務執行的過程中定義儲存點,在回滾時直接指定回滾到指定的儲存點而不是事務開始之初,有點像我們玩遊戲的時候可以存檔而不是每次都要重新再來

定義儲存點的語法如下:

SAVEPOINT 儲存點名稱;

當我們想回滾到某個儲存點時,可以使用下邊這個語句(下邊語句中的單詞WORKSAVEPOINT是可有可無的):

ROLLBACK [WORK] TO [SAVEPOINT] 儲存點名稱;

MySQL中的事務跟原理

MySQL中的事務

  1. MySQL中不是所有的儲存引擎都支援事務,例如MyISAM就不支援事務,實際上支援事務的只有InnoDBNDB Cluster本文關於事務的分析都是基於InnoDB

  2. MySQL預設採用的是自動提交的方式,也就是說如果不是顯示的開始一個事務,則系統會自動向資料庫提交結果。在當前連線中,還可以通過設定AUTOCONNIT變數來啟用或者禁用自動提交模式。

  • 開啟自動提交功能
SET AUTOCOMMIT = 1;

MySQL中預設情況下的自動提交功能是已經開啟的。

  • 關閉自動提交功能。
SET AUTOCOMMIT = 0;

關閉自動提交功能後,只用當執行COMMIT命令後,MySQL才將資料表中的資料提交到資料庫中。如果執行ROLLBACK命令,資料將會被回滾。如果不提交事務,而終止MySQL會話,資料庫將會自動執行回滾操作。

  1. MySQL的預設隔離級別是可重複讀(REPEATABLE READ)

事務的實現原理

我們要探究MySQL中事務的實現原理,實際上就是要弄明天它的ACID特性是如何實現的,在這裡有必要先說明的是,ACID中的一致性是事務的最終目標,前面提到的原子性、永續性和隔離性,都是為了保證資料庫狀態的一致性。所以我們要分析的就是MySQL的原子性、永續性和隔離性的實現原理,在分析事務的實現原理之前我們需要補充一些InnoDB的相關知識

  1. InnoDB是一個將表中的資料儲存到磁碟上的儲存引擎,所以即使關機後重啟我們的資料還是存在的。而真正處理資料的過程是發生在記憶體中的所以需要把磁碟中的資料載入到記憶體中,如果是處理寫入或修改請求的話,還需要把記憶體中的內容重新整理到磁碟上。而我們知道讀寫磁碟的速度非常慢,和記憶體讀寫差了幾個數量級,所以當我們想從表中獲取某些記錄時,InnoDB儲存引擎需要一條一條的把記錄從磁碟上讀出來麼?不,那樣會慢死,InnoDB採取的方式是:將資料劃分為若干個頁,以頁作為磁碟和記憶體之間互動的基本單位,InnoDB中頁的大小一般為 16 KB。也就是在一般情況下,一次最少從磁碟中讀取16KB的內容到記憶體中,一次最少把記憶體中的16KB內容重新整理到磁碟中。

  2. 我們還需要對MySQL中的日誌有一定了解。MySQL的日誌有很多種,如二進位制日誌(bin log)、錯誤日誌、查詢日誌、慢查詢日誌等,此外InnoDB儲存引擎還提供了兩種事務日誌:redo log(重做日誌)和undo log(回滾日誌)。其中redo log用於保證事務永續性;undo log則是事務原子性和隔離性實現的基礎。

  3. InnoDB作為MySQL的儲存引擎,資料是存放在磁碟中的,但如果每次讀寫資料都需要磁碟IO,效率會很低。為此,InnoDB提供了快取(Buffer Pool),Buffer Pool中包含了磁碟中部分資料頁的對映,作為訪問資料庫的緩衝:當從資料庫讀取資料時,會首先從Buffer Pool中讀取,如果Buffer Pool中沒有,則從磁碟讀取後放入Buffer Pool;當向資料庫寫入資料時,會首先寫入Buffer Pool,Buffer Pool中修改的資料會定期重新整理到磁碟中(這一過程稱為刷髒)。

  4. InnoDB儲存引擎檔案主要可以分為兩類,表空間檔案及重做日誌檔案(redo log file),表空間檔案又可以細分為兩類,共享表空間跟獨立表空間。undo log位於共享表空間中的undo段中,每個表空間都被劃分成了若干個頁面,凡是頁面的讀寫都在buffer pool中進行,這意味著undo log也需要先寫入到buffer pool,所以undo log的生成也需要持久化,也就是說undo log的生成需要記錄對應的redo log。(注意:不是所有的undo log的生成都會產生對應的redo log,對於操作臨時表生成的undo log並不會生成對應的undo log,因為修改臨時表而產生的undo日誌只需要在系統執行過程中有效,如果系統奔潰了,那麼在重啟時也不需要恢復這些undo日誌所在的頁面,所以在寫針對臨時表的Undo頁面時,並不需要記錄相應的redo日誌。)

永續性實現原理

​ 通過前面的補充知識我們知道InnoDB引入了Buffer Pool來優化讀寫的效能,但是雖然Buffer Pool優化了效能,但同時也帶來了新的問題:如果MySQL當機,而此時Buffer Pool中修改的資料還沒有重新整理到磁碟,就會導致資料的丟失,事務的永續性無法保證

​ 基於此,redo log就誕生了,redo log是物理日誌,記錄的是資料庫中資料庫中物理頁的情況,redo log包括兩部分:一是記憶體中的日誌緩衝(redo log buffer),該部分日誌是易失性的;二是磁碟上的重做日誌檔案(redo log file),該部分日誌是持久的。在概念上,innodb通過force log at commit機制實現事務的永續性,即在事務提交的時候,必須先將該事務的所有事務日誌寫入到磁碟上的redo log file和undo log file中進行持久化。

​ 看到這裡可能有的小夥伴又會有疑問了,既然redo log也需要在事務提交時將日誌寫入磁碟,為什麼它比直接將Buffer Pool中修改的資料寫入磁碟(即刷髒)要快呢?主要有以下兩方面的原因:

(1)刷髒是隨機IO,因為每次修改的資料位置隨機,但寫redo log是追加操作,屬於順序IO。

(2)刷髒是以資料頁(Page)為單位的,MySQL預設頁大小是16KB,一個Page上一個小修改都要整頁寫入;而redo log中只包含真正需要寫入的部分,無效IO大大減少。

這裡我以文章開頭的例子進行說明redo log為何能保證永續性:

// 第一步:開始事務
start transaction;
// 第二步:A賬戶餘額減少減少1000  
update balance set money = money -500 where name= ‘A’;
// 第三步:B賬戶餘額增加1000  
update balance set money = money +500 where name= ‘B’;
// 第四步:提交事務
commit;

redo

這裡需要對redo log的刷盤補充一點內容:

MySQL支援使用者自定義在commit時如何將log buffer中的日誌刷log file中。這種控制通過變數 innodb_flush_log_at_trx_commit 的值來決定。該變數有3種值:0、1、2,預設為1。但注意,這個變數只是控制commit動作是否重新整理log buffer到磁碟。

  • 當設定為1的時候,事務每次提交都會將log buffer中的日誌寫入os buffer並呼叫fsync()函式刷到log file on disk中。這種方式即使系統崩潰也不會丟失任何資料,但是因為每次提交都寫入磁碟,IO的效能較差。
  • 當設定為0的時候,事務提交時不會將log buffer中日誌寫入到os buffer(核心緩衝區),而是每秒寫入os buffer並呼叫fsync()寫入到log file on disk中。也就是說設定為0時是(大約)每秒重新整理寫入到磁碟中的,當系統崩潰,會丟失1秒鐘的資料。
  • 當設定為2的時候,每次提交都僅寫入到os buffer,然後是每秒呼叫fsync()將os buffer中的日誌寫入到log file on disk。

可以看到設定為0或者2時,都有可能丟失1s的資料

原子性實現原理

前面提到了,所謂原子性就是指整個事務是一個不可分隔的整體,組成事務的一組SQL要麼全部成功,要麼全部失敗,要達到這個目的就意味著當某一個SQL執行失敗時,我們要能夠撤銷掉其它SQL的執行結果,在MySQL中這是依賴undo log(回滾日誌)來實現。

undo log屬於邏輯日誌前面提到的redo log屬於物理日誌,記錄的是資料頁的情況),我們可以這麼認為,當delete一條記錄時,undo log中會記錄一條對應的insert記錄,反之亦然,當update一條記錄時,它記錄一條對應相反的update記錄。

但執行發生異常時,會根據undo log中的記錄進行回滾。undo log主要分為兩種

  1. insert undo log
  2. update undo log

insert undo log是指在insert 操作中產生的undo log,因為insert操作的記錄,只對事務本身可見,對其他事務不可見。故該undo log可以在事務提交後直接刪除,不需要進行purge操作。

而update undo log記錄的是對delete 和update操作產生的undo log,該undo log可能需要提供MVCC機制,因此不能再事務提交時就進行刪除。提交時放入undo log連結串列,等待purge執行緒進行最後的刪除。

補充:purge執行緒兩個主要作用是:清理undo頁和清除page裡面帶有Delete_Bit標識的資料行。在InnoDB中,事務中的Delete操作實際上並不是真正的刪除掉資料行,而是一種Delete Mark操作,在記錄上標識Delete_Bit,而不刪除記錄。是一種"假刪除",只是做了個標記,真正的刪除工作需要後臺purge執行緒去完成。

這裡我們就來看看insert undo log的結構,如下:

insert undo

在上圖中,undo type記錄的是undo log的型別,對於insert undo log,該值始終為11(TRX_UNDO_INSERT_REC),undo no在一個事務中是從0開始遞增的,也就是說只要事務沒提交,每生成一條undo日誌,那麼該條日誌的undo no就增1。table id記錄undo log所對應的表物件。如果記錄中的主鍵只包含一個列,那麼在型別為TRX_UNDO_INSERT_RECundo日誌中只需要把該列佔用的儲存空間大小和真實值記錄下來,如果記錄中的主鍵包含多個列(複合主鍵),那麼每個列佔用的儲存空間大小和對應的真實值都需要記錄下來(圖中的len就代表列佔用的儲存空間大小,value就代表列的真實值),在回滾時只需要根據主鍵找到對應的列然後刪除即可。end of record記錄了下一條undo log在頁面中開始的地址,start of record記錄了本條undo log在頁面中開始的地址。

對undo log有一定了解後,我們再回頭看看文章開頭的例子,分析下為什麼undo log能保證原子性

// 第一步:開始事務
start transaction;
// 第二步:A賬戶餘額減少減少1000  
update balance set money = money -500 where name= ‘A’;
// 第三步:B賬戶餘額增加1000  
update balance set money = money +500 where name= ‘B’;
// 第四步:提交事務
commit;

undo redo

考慮到排版,這裡我只畫了一條語句的流程圖,第二條也是一樣的,每次更新或者插入前,先記錄undo,再修改記憶體中資料,再記錄redo。

隔離性實現原理

我們知道,一個事務中的讀操作是不會影響到另外一個事務的,所以在討論隔離性我們主要分為兩種情況

  1. 一個事務中的寫操作,對另外一個事務中寫操作的影響
  2. 一個事務中的寫操作,對另外一個事務中讀操作的影響

寫操作之間的隔離是通過鎖來實現的,MySQL中的鎖機制要詳細來講是很複雜的,要講明白整個鎖需要從索引開始介紹,限於筆者能力及文章篇幅,本文只對MySQL中的鎖機制做一個簡單的介紹

MySQL中的鎖機制(InnoDB)

讀鎖跟寫鎖
  1. 讀鎖又稱為共享鎖`,簡稱S鎖,顧名思義,共享鎖就是多個事務對於同一資料可以共享一把鎖,都能訪問到資料,但是隻能讀不能修改。

  2. 寫鎖又稱為排他鎖`,簡稱X鎖,顧名思義,排他鎖就是不能與其他所並存,如一個事務獲取了一個資料行的排他鎖,其他事務就不能再獲取該行的其他鎖,包括共享鎖和排他鎖,但是獲取排他鎖的事務是可以對資料就行讀取和修改。

行鎖跟表鎖
  1. 表鎖在運算元據時會鎖定整張表,併發效能較差;

  2. 行鎖則只鎖定需要操作的資料,併發效能好。

  3. 但是由於加鎖本身需要消耗資源(獲得鎖、檢查鎖、釋放鎖等都需要消耗資源),因此在鎖定資料較多情況下使用表鎖可以節省大量資源。MySQL中不同的儲存引擎支援的鎖是不一樣的,例如MyIsam只支援表鎖,而InnoDB同時支援表鎖和行鎖,且出於效能考慮,絕大多數情況下使用的都是行鎖。

意向鎖
  1. 意向鎖分為兩種,意向讀鎖(IS)跟意向寫鎖(IX)

  2. 意向鎖是表級別的鎖

  3. 為什麼需要意向鎖呢?思考一個問題:如果我們想對某個表加一個表鎖,那麼在加鎖之前我們需要去檢查表中的每一行記錄是否已經被單獨加了行鎖,這樣的話豈不是意味著我們需要去遍歷表中所有的記錄依次進行檢查,遍歷是不可能的,這輩子都不可能遍歷的,基於效率的考慮,我們可以在每次給行記錄加鎖時先給當前表加一個意向鎖,如果我們要對行加讀鎖(S)的話,那麼就先給表加一個意向讀鎖(IS),如果要對行加寫鎖(X)的話,那麼先給表加一個意向寫鎖(IX),這樣當我們需要給整個表加鎖的時候就可以通過先判斷表上是否已經存在了意向鎖來決定是否可以上鎖了,避免遍歷,提高了效率。

  4. 意向鎖跟普通的讀鎖寫鎖間的相容性如下:

IS IX S X
IS 相容 相容 相容 不相容
IX 相容 相容 不相容 不相容
S 相容 不相容 相容 不相容
X 不相容 不相容 不相容 不相容

注:IS(意向讀鎖/意向共享鎖), IX(意向寫鎖/意向排他鎖), S(讀鎖/共享鎖),X(寫鎖/排他鎖)

從上圖中可以看出,意向鎖之間都是相容的,這是因為意向鎖的作用僅僅是來快速判斷是否可以直接上表鎖。


接下來介紹的這幾種鎖都屬於行鎖,為了更好的理解這幾種鎖,我們先建立一個表

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(10) NOT NULL,
  PRIMARY KEY (`id`),
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

其中id為主鍵,沒有建其餘的索引,插入如下資料

INSERT INTO `test`.`user`(`id`, `name`) VALUES (1, 'a張大膽');
INSERT INTO `test`.`user`(`id`, `name`) VALUES (3, 'b王翠花');
INSERT INTO `test`.`user`(`id`, `name`) VALUES (6, 'c範統');
INSERT INTO `test`.`user`(`id`, `name`) VALUES (8, 'd朱逸群');
INSERT INTO `test`.`user`(`id`, `name`) VALUES (15, 'e董格求');
Record Lock(記錄鎖)
  1. 鎖定單條記錄
  2. 也分為S鎖跟X鎖

如果我們對id為3的記錄新增一個行鎖,對應如下(圖中每一列代表資料庫中的一行記錄):

行鎖

Gap Lock(間隙鎖)
  1. 鎖定一個範圍,但是不包含記錄本身
  2. 間隙鎖的主要作用在於防止幻讀的發生,雖然也有S鎖跟X鎖的區分,但是它們的作用都是相同的,而且如果你對一條記錄加了間隙鎖(不論是共享間隙鎖還是獨佔間隙鎖),並不會限制其他事務對這條記錄加記錄鎖或者繼續加間隙鎖,再強調一遍,間隙鎖的作用僅僅是為了防止幻讀的發生。

假設我們要對id為6的記錄新增間隙鎖,那麼此時鎖定的區域如下所示

其中虛線框代表的是要鎖定的間隙,其實就是當前需要加間隙鎖的記錄跟上一條記錄之間的範圍,但是間隙鎖不會鎖定當前記錄,如圖所示,id=6的記錄並沒有被加鎖。(圖中虛線框表鎖間隙,沒有插入真實的記錄)

間隙鎖

Next-Key Lock(Gap Lock+Record Lock)

假設我們要對id為6的記錄新增Next-Key Lock,那麼此時鎖定的區域如下所示

next key lock

跟間隙鎖最大的區別在於,Next-Key Lock除了鎖定間隙之外還要鎖定當前記錄

通過鎖實現了寫、寫操作之間的隔離性,實際上我們也可以通過加鎖來實現讀、寫之間的隔離性,但是這樣帶來一個問題,讀、寫需要序列執行這樣會大大降低效率,所以MySQL中實現讀寫之間的隔離性是通過MVCC+鎖來實現的,對於讀採用快照都,對於寫使用加鎖!

MVCC(多版本併發控制)

版本鏈

在介紹MVCC之前我們需要對MySQL中的行記錄格式有一定了解,其實除了我們在資料庫中定義的列之外,每一行中還包含了幾個隱藏列,分別是

  • row_id:行記錄的唯一標誌
  • transaction_id:事務ID
  • roll_pointer:回滾指標

row_id是行記錄的唯一標誌,這一列不是必須的。

MySQL會優先使用使用者自定義主鍵作為主鍵,如果使用者沒有定義主鍵,則選取一個Unique鍵作為主鍵,如果表中連Unique鍵都沒有定義的話,則InnoDB會為表預設新增一個名為row_id的隱藏列作為主鍵。也就是說只有在表中既沒有定義主鍵,也沒有申明唯一索引的情況MySQL才會新增這個隱藏列。

transaction_id代表的是事務的ID。當一個事務對某個表執行了增、刪、改操作,那麼InnoDB儲存引擎就會給它分配一個獨一無二的事務id,分配方式如下:

  • 對於只讀事務來說,只有在它第一次對某個使用者建立的臨時表執行增、刪、改操作時才會為這個事務分配一個事務id,否則的話是不分配事務id的。

  • 對於讀寫事務來說,只有在它第一次對某個表(包括使用者建立的臨時表)執行增、刪、改操作時才會為這個事務分配一個事務id,否則的話也是不分配事務id的。

    有的時候雖然我們開啟了一個讀寫事務,但是在這個事務中全是查詢語句,並沒有執行增、刪、改的語句,那也就意味著這個事務並不會被分配一個事務id

roll_pointer表示回滾指標,指向該記錄對應的undo log。前文已經提到過了,undo log記錄了對應記錄在修改前的狀態,通過roll_pointer我們就可以找到對應的undo log,然後根據undo log進行回滾。

在之前介紹undo log的時候我們只介紹了insert undo log的資料格式,實際上除了insert undo log還有update undo log,而update undo log中也包含roll_pointertransaction_idupdate undo log中的roll_pointer指標其實就是儲存的被更新的記錄中的roll_pointer指標

除了這些隱藏列以外,實際上每條記錄的記錄頭資訊中還會儲存一個標誌位,標誌該記錄是否刪除。

我們以實際的例子來說明上面三個隱藏列的作用,還是以之前的表為例,現在對其執行如下SQL:

# 開啟事務
START TRANSACTION;
# 插入一條資料
INSERT INTO `test`.`user`(`id`, `name`) VALUES (16, 'e杜子騰');
# 更新插入的資料
UPDATE `test`.`user` SET name = "史珍香" WHERE id = 16;
# 刪除資料
DELETE from  `test`.`user` WHERE id = 16;

我們通過畫圖來看看上面這段SQL在執行的過程中都做了什麼

SQL執行流程圖

從上圖中我們可以看到,每對記錄進行一次增、刪、改時,都會生成一條對應的undo log,並且被修改後的記錄中的roll pointer指標指向了這條undo log,同時如果不是新增操作,那麼生成的undo log中也會儲存一個roll pointer,其值是從被修改的資料中複製過來了,在我們上邊的例子中update undo log的roll pointer就複製了insert進去的資料中的roll pointer指標的值。

另外我們會發現,根據當前記錄中的roll pointer指標,我們可以找到一個有undo log組成的連結串列,這個undo log連結串列其實就是這條記錄的版本鏈

ReadView(快照)

對於使用READ UNCOMMITTED隔離級別的事務來說,由於可以讀到未提交事務修改過的記錄,所以直接讀取記錄的最新版本就好了;

對於使用SERIALIZABLE隔離級別的事務來說,MySQL規定使用加鎖的方式來訪問記錄;

對於使用READ COMMITTEDREPEATABLE READ隔離級別的事務來說,都必須保證讀到已經提交了的事務修改過的記錄,也就是說假如另一個事務已經修改了記錄但是尚未提交,是不能直接讀取最新版本的記錄的,核心問題就是:需要判斷一下版本鏈中的哪個版本是當前事務可見的

為了解決這個問題,MySQL提出了一個ReadView(快照)的概念,在Select操作前會為當前事務生成一個快照,然後根據快照中記錄的資訊來判斷當前記錄是否對事務是可見的,如果不可見那麼沿著版本鏈繼續往上找,直至找到一個可見的記錄。

ReadView(快照)中包含了下面幾個關鍵屬性:

  • m_ids:表示在生成ReadView時當前系統中活躍的讀寫事務的事務id列表。

  • min_trx_id:表示在生成ReadView時當前系統中活躍的讀寫事務中最小的事務id,也就是m_ids中的最小值。

  • max_trx_id:表示生成ReadView時系統中應該分配給下一個事務的id值。

    小貼士: 注意max_trx_id並不是m_ids中的最大值,事務id是遞增分配的。比方說現在有id為1,2,3這三個事務,之後id為3的事務提交了。那麼一個新的讀事務在生成ReadView時,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。

  • creator_trx_id:表示生成該ReadView的事務的事務id

    小貼士: 我們前邊說過,只有在對錶中的記錄做改動時(執行INSERT、DELETE、UPDATE這些語句時)才會為事務分配事務id,否則在一個只讀事務中的事務id值都預設為0。

當生成快照後,會通過下面這個流程來判斷該記錄對當前事務是否可見

MVCC

  1. 從上圖中我們可以看到,在根據當前資料庫中執行中的讀寫事務id,會去生成一個ReadView。
  2. 然後根據要讀取的資料記錄中的事務id(方便區別,記為r_trx_id)跟ReadView中儲存的幾個屬性做如下判斷
  • 如果被訪問版本的r_trx_id屬性值與ReadView中的creator_trx_id值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的r_trx_id屬性值小於ReadView中的min_trx_id值,表明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的r_trx_id屬性值大於或等於ReadView中的max_trx_id值,表明生成該版本的事務在當前事務生成ReadView後才開啟,所以該版本不可以被當前事務訪問。
  • 如果被訪問版本的r_trx_id屬性值在ReadViewmin_trx_idmax_trx_id之間,那就需要判斷一下r_trx_id屬性值是不是在m_ids列表中,如果在,說明建立ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問;如果不在,說明建立ReadView時生成該版本的事務已經被提交,該版本可以被訪問。
  • 如果某個版本的資料對當前事務不可見的話,那就順著版本鏈找到下一個版本的資料,繼續按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最後一個版本。如果最後一個版本也不可見的話,那麼就意味著該條記錄對該事務完全不可見,查詢結果就不包含該記錄。

實際上,提交讀跟可重複讀在實現上最大的差異就在於

  1. 提交讀每次select都會生成一個快照
  2. 可重複讀只有在第一次會生成一個快照

總結

本文主要介紹了事務的基本概念跟MySQL中事務的實現原理。下篇文章開始我們就要真正的進入Spring的事務學習啦!鋪墊了這麼久,終於開始主菜了......

在前面的大綱裡也能看到,會分為上下兩篇,第一篇講應用以及在使用過程中會碰到的問題,第二篇我們就深入原始碼分析Spring中的事務機制的實現原理!

如果本文對你由幫助的話,記得點個贊吧!也歡迎關注我的公眾號,微信搜尋:程式設計師DMZ,或者掃描下方二維碼,跟著我一起認認真真學Java,踏踏實實做一個coder。

公眾號

我叫DMZ,一個在學習路上匍匐前行的小菜鳥!

參考

書籍:掘金小冊《MySQL 是怎樣執行的:從根兒上理解 MySQL》:https://juejin.im/book/6844733769996304392

書籍:《MySQL技術內幕:InnoDB儲存引擎》:關注公眾號,程式設計師DMZ,後臺回覆InnoDB即可領取

書籍:《高效能MySQL》:關注公眾號,程式設計師DMZ,後臺回覆MySQL即可領取

文章:《深入學習MySQL事務:ACID特性的實現原理》:https://www.cnblogs.com/kismetv/p/10331633.html

文章:《詳細分析MySQL事務日誌(redo log和undo log)》:https://www.cnblogs.com/f-ck-need-u/p/9010872.html

文章:《Mysql事務實現原理》:https://www.lagou.com/lgeduarticle/82740.html

文章:《面試官:你說熟悉MySQL事務,那來談談事務的實現原理吧!》:https://mp.weixin.qq.com/s/jrfZr3YzE_E0l3KjWAz1aQ

文章:《InnoDB 事務分析-Undo Log》:http://leviathan.vip/2019/02/14/InnoDB的事務分析-Undo-Log/

文章:《InnoDB 的 Redo Log 分析》:http://leviathan.vip/2018/12/15/InnoDB的Redo-Log分析/

文章:《MySQL redo & undo log-這一篇就夠了》:https://www.jianshu.com/p/336e4995b9b8