MySQL 事務篇:ACID 原則、事務隔離級別及事務機制原理剖析

引言

   衆所周知,MySQL數據庫的核心功能就是存儲數據,通常是整個業務系統中最重要的一層,可謂是整個系統的 “大本營”,因此只要MySQL存在些許隱患問題,對於整個系統而言都是致命的。那此刻不妨思考一個問題:

MySQL在接受外部數據寫入時,有沒有可能會發生問題呢?

有人也許會笑着回答:“那怎麼可能啊,MySQL在寫入數據時怎麼會存在問題呢”。

   的確,MySQL本身在寫入數據時並不會有問題,就算部署MySQL的機器斷電 / 宕機,其內部也有一套健全的機制確保數據不丟失。但往往風險並不來自於表象,雖然MySQL寫入數據沒問題,但結合業務來看就會有一個很大的隱患,此話怎講吶?先看案例:

-- 從庫存表中扣減商品數量
UPDATE `zz_inventory` SET ......;

-- 向訂單表、訂單詳情表中插入訂單記錄
INSERT INTO `zz_order` VALUES(....);
INSERT INTO `zz_order_info` VALUES(....);

-- 向物流表中插入相應的物流信息
INSERT INTO `zz_logistics` VALUES(....);

上述的僞SQL中,描述的是一個經典下單業務,先扣庫存數量、再增加訂單記錄、再插入物流信息,按照正常的邏輯來看,上面的SQL也沒有問題。但是請仔細想想!實際的項目中,這三組SQL是會由客戶端(Java線程)一條條發過來的,假設執行到「增加訂單記錄」時,Java程序那邊拋出了異常,會出現什麼問題呢?

乍一想似乎沒問題,但仔細一想:Java 線程執行時出現異常會導致線程執行中斷。

因爲Java線程中斷了,所以線程不會再向數據庫發送「增加訂單詳情記錄、插入物流信息」的SQL,此刻再來想想這個場景,由於增加訂單詳情和物流信息的SQL都未發送過來,因此必然也不會執行,但此時庫存已經扣了,用戶錢也付了,但卻沒有訂單和物流信息,這引發的後果估計老闆都能殺個程序員祭天了......

其實上面列舉的這個案例,在數據庫中被稱之爲事務問題,接下來一起聊一聊。

一、事務的 ACID 原則

   什麼是事務呢?事務通常是由一個或一組SQL組成的,組成一個事務的SQL一般都是一個業務操作,例如前面聊到的下單:「扣庫存數量、增加訂單詳情記錄、插入物流信息」,這一組SQL就可以組成一個事務。

而數據庫的事務一般也要求滿足ACID原則,ACID是關係型數據庫實現事務機制時必須要遵守的原則。

ACID主要涵蓋四條原則,即:

那這四條原則分別是什麼意思呢?接下來一起聊一聊。

1.1、Atomicity 原子性

   原子性這個概念,在之前《併發編程系列 - JMM 內存模型》時曾初次提到過,而在MySQL中原子性的含義也大致相同,指組成一個事務的一組SQL要麼全部執行成功,要麼全部執行失敗,事務中的一組SQL會被看成一個不可分割的整體,當成一個操作看待。

好比事務A①、②、③SQL組成,那這一個事務中的三條SQL必須全部執行成功,只要其中任意一條執行失敗,例如執行時出現異常了,此時就會導致事務A中的所有操作全部失敗。

1.2、Consistency 一致性

   一致性也比較好理解,也就是不管事務發生的前後,MySQL中原本的數據變化都是一致的,也就是DB中的數據只允許從一個一致性狀態變化爲另一個一致性狀態。這句話似乎聽起來有些繞,不太好理解對嘛?簡單解釋一下就是:一個事務中的所有操作,要麼一起改變數據庫中的數據,要麼都不改變,對於其他事務而言,數據的變化是一致的,上栗子:

假設此時有一個事務A,這個事務隸屬於一個下單操作,由「⓵扣庫存數量、⓶增加訂單詳情記錄、⓷插入物流信息」三這條SQL操作組成。

一致性的含義是指:在這個事務執行前,數據庫中的數據是處於一致性狀態的,而SQL執行完成之後事務提交,數據庫中的數據依舊處於一個 “一致性” 狀態,也就是庫存數量 + 訂單數量永遠是等於最初的庫存總數的,比如原本的總庫存是10000個,此時庫存剩餘8888個,那也就代表着必須要有1112條訂單數據纔行。

這也就是前面說的:“事務發生的前後,MySQL中原本的數據變化都是一致的”,這句話的含義,不可能庫存減了,但訂單沒有增加,這樣就會導致數據庫整體數據出現不一致。

如果出現庫存減了,但訂單沒有增加的情況,就代表着事務執行過程中出現了異常,此時MySQL就會利用事務回滾機制,將之前減的庫存再加回去,確保數據的一致性。

但來思考一個問題,如果事務執行過程中,剛減完庫存後,MySQL所在的服務器斷電了咋整?似乎無法利用事務回滾機制去確保數據一致性了撒?對於這點大可不必擔心,因爲MySQL宕機重啓後,會通過分析日誌的方式恢復數據,確保一致性(對於這點稍後再細聊)。

1.3、Isolation 獨立性 / 隔離性

   簡單理解原子性和一致性後,再來看看ACID中的隔離性,在有些地方也稱之爲獨立性,意思就是指多個事務之間都是獨立的,相當於每個事務都被裝在一個箱子中,每個箱子之間都是隔開的,相互之間並不影響,同樣上個栗子:

假設數據庫的庫存表中,庫存數量剩餘8888個,此時有A、B兩個併發事務,這兩個事務都是相同的下單操作,由「⓵扣庫存數量、增⓶加訂單詳情記錄、⓷插入物流信息」三這條SQL操作組成。

此時A、B兩個事務一起執行,同一時刻執行減庫存的SQL,因此這裏是併發執行的,那兩個事務之間是否會互相影響,導致扣的是同一個庫存呢?答案是不會,ACID原則中的隔離性保障了併發事務的順序執行,一個未完成事務不會影響另外一個未完成事務。

隔離性在底層是如何實現的呢?基於MySQL的鎖機制和MVCC機制做到的(後續《MySQL 事務與鎖原理篇》再詳細去講)。

1.4、Durability 持久性

   相較於之前的原子性、一致性、隔離性來說,持久性是ACID原則中最容易理解的一條,持久性是指一個事務一旦被提交,它會保持永久性,所更改的數據都會被寫入到磁盤做持久化處理,就算MySQL宕機也不會影響數據改變,因爲宕機後也可以通過日誌恢復數據。

也就相當於你許下一個諾言之後,那你無論遇到什麼情況都會保證做到,就算遇到山水洪災、地球毀滅、宇宙爆炸..... 任何情況也好,你都會保證完成你的諾言爲止。

二、MySQL 的事務機制綜述

   剛剛說到的ACID原則是數據庫事務的四個特性,也可以理解爲實現事務的基礎理論,那接下來一起看看MySQL所提供的事務機制。在MySQL默認情況下,一條SQL會被視爲一個單獨的事務,同時也無需咱們手動提交,因爲默認是開啓事務自動提交機制的,如若你想要將多條SQL組成一個事務執行,那需要顯式的通過一些事務指令來實現。

2.1、手動管理事務

MySQL中,提供了一系列事務相關的命令,如下:

當需要使用事務時,可以先通過start transaction命令開啓一個事務,如下:

-- 開啓一個事務
start transaction;

-- 第一條SQL語句
-- 第二條SQL語句
-- 第三條SQL語句

-- 提交或回滾事務
commit || rollback;

對於上述MySQL手動開啓事務的方式,相信大家都不陌生,但大家有一點應該會存在些許疑惑:事務是基於當前數據庫連接而言的,而不是基於表,一個事務可以由操作不同表的多條SQL組成,這句話什麼意思呢?看下圖:

數據庫連接

上面畫出了兩個數據庫連接,假設連接A中開啓了一個事務,那後續過來的所有SQL都會被加入到一個事務中,也就是圖中連接A,後面的SQL②、SQL③、SQL④、SQL⑤這四條都會被加入到一個事務中,只要在未曾收到commit/rollback命令之前,這個連接來的所有SQL都會加入到同一個事務中,因此對於這點要牢記,開啓事務後一定要做提交或回滾處理。

不過在連接A中開啓事務,是不會影響連接B的,這也是我說的:事務是基於當前數據庫連接的,每個連接之間的事務是具備隔離性的,比如上個真實栗子~

此時先打開兩個cmd命令行,然後用命令連接MySQL,或者也可以用Navicat、SQLyog等數據庫可視化工具,新建兩個查詢,如下:

兩個查詢

這裏插個小偏門知識:當你在Navicat、SQLyog這類可視化工具中,新建一個查詢時,本質上它就是給你建立了一個數據庫連接,每一個新查詢都是一個新的連接。

然後開始在兩個查詢中編寫對應的SQL命令,先在查詢窗口中開啓一個事務:

-- 先查詢一次表數據
SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 1111      | 男       | 8888     | 2022-09-17 23:48:29 |
+---------+-----------+----------+----------+---------------------+

-- 開啓事務
start transaction;

-- 修改 ID=4 的姓名爲:黑熊
update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;

-- 刪除 ID=1 的行數據
delete from `zz_users` where `user_id` = 1;

-- 再次查詢一次數據
SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 黑熊      | 男       | 8888     | 2022-09-17 23:48:29 |
+---------+-----------+----------+----------+---------------------+

觀察上面的結果,對比開啓事務前後的的表數據查詢,在事務中分別修改、刪除一條數據後,再次查詢表數據時會觀察到表數據已經變化,此時再去查詢窗口中查詢表數據:

SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 1111      | 男       | 8888     | 2022-09-17 23:48:29 |
+---------+-----------+----------+----------+---------------------+

在查詢窗口中,也就相當於在第二個連接中查詢數據時,會發現第一個連接(窗口)改變的數據並未影響到第二個連接,啥原因呢?這是因爲窗口中還未提交事務,所以第一個連接改變的數據不會影響第二個連接。

其實具體的原因是由於MySQL事務的隔離機制造成的,但對於這點後續再去分析。

此時在查詢窗口中,輸入rollback命令,讓當前事務回滾:

-- 回滾當前連接中的事務
rollback;
-- 再次查詢表數據
SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 1111      | 男       | 8888     | 2022-09-17 23:48:29 |
+---------+-----------+----------+----------+---------------------+

結果很明顯,當事務回滾後,之前所做的數據更改操作全部都會撤銷,恢復到事務開啓前的表數據。當然,如果不手動開啓事務,執行下述這條SQL會發生什麼情況呢?

update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;

會直接修改表數據,並且其他連接可見,因爲MySQL默認將一條SQL視爲單個事務,同時默認開啓自動提交事務,也就是上面這條SQL執行完了之後就會自動提交。

-- 查看 自動提交事務 是否開啓
SHOW VARIABLES LIKE 'autocommit';

-- 關閉或開啓自動提交
SET autocommit = 0|1|ON|OFF;

上述的[0/ON]是相同的意思,表示開啓自動提交,[1/OFF]則表示關閉自動提交。

2.2、事務回滾點

   在上面簡單闡述了事務的基本使用,但假設目前有一個事務,由很多條SQL組成,但是我想讓其中一部分執行成功後,就算後續SQL執行失敗也照樣提交,這樣可以做到嗎?從前面的理論上來看,一個事務要麼全部執行成功,要麼全部執行失敗,似乎做不到啊,但實際上是可以做到的,這裏需要利用事務的回滾點機制。

在某些SQL執行成功後,但後續的操作有可能成功也有可能失敗,但不管成功亦或失敗,你都想讓前面已經成功的操作生效時,此時就可在當前成功的位置設置一個回滾點。當後續操作執行失敗時,就會回滾到該位置,而不是回滾整個事務中的所有操作,這個機制則稱之爲事務回滾點。

MySQL中提供了兩個關於事務回滾點的命令:

以前面的案例來演示效果,如下:

-- 先查詢一次用戶表
SELECT * FROM `zz_users`;
-- 開啓事務
start transaction;
-- 修改 ID=4 的姓名爲:黑熊
update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;
-- 添加一個事務回滾點:update_name
savepoint update_name;
-- 刪除 ID=1 的行數據
delete from `zz_users` where `user_id` = 1;
-- 回滾到 update_name 這個事務點
rollback to update_name;
-- 再次查詢一次數據
SELECT * FROM `zz_users`;
-- 提交事務
COMMIT;

上述代碼中開啓了一個事務,事務中總共修改和刪除兩條SQL組成,然後在修改語句後面添加了一個事務回滾點update_name,在刪除語句後回滾到了前面添加的回滾點。

但要注意:回滾到事務點後不代表着事務結束了,只是事務內發生了一次回滾,如果要結束當前這個事務,還依舊需要通過commit|rollback;命令處理。

其實藉助事務回滾點,可以很好的實現失敗重試,比如對事務中的每個SQL添加一個回滾點,當執行一條SQL時失敗了,就回滾到上一條SQL的事務點,接着再次執行失敗的SQL,反覆執行到所有SQL成功爲止,最後再提交整個事務。

當然,這個只是理論上的假設,實際業務中不要這麼幹~

2.3、MySQL 事務的隔離機制

   OK~,在前面做的小測試中,咱們會發現不同的數據庫連接中,一個連接的事務並不會影響其他連接,當時也稍微的提過一嘴:這是基於事務隔離機制實現的,那接下來重點聊一聊MySQL的事務隔離機制。其實在MySQL中,事務隔離機制分爲了四個級別:

上述四個級別,越靠後併發控制度越高,也就是在多線程併發操作的情況下,出現問題的幾率越小,但對應的也性能越差,MySQL的事務隔離級別,默認爲第三級別:Repeatable read可重複讀,但如若想要真正理解這幾個隔離級別,得先明白幾個因爲併發操作造成的問題。

2.3.1、髒讀、幻讀、不可重複讀問題

數據庫的髒讀問題

首先來看看髒讀,髒讀的意思是指一個事務讀到了其他事務還未提交的數據,也就是當前事務讀到的數據,由於還未提交,因此有可能會回滾,如下:

髒讀

比如上圖中,DB連接①/ 事務A正在執行下單業務,目前扣減庫存、增加訂單兩條SQL已經完成了,恰巧此時DB連接②/ 事務B跑過來讀取了一下庫存剩餘數量,就將事務A已經扣減之後的庫存數量讀回去了。但好巧不巧,事務A在添加物流信息時,執行異常導致事務A全部回滾,也就是原本扣的庫存又會增加回去。

在個案例中,事務A先扣減了庫存,然後事務回滾時又加了回去,但連接②已經將扣減後的庫存數量讀回去操作了,這個過程就被稱爲數據庫髒讀問題。這個問題很嚴重,會導致整個業務系統出現問題,數據最終錯亂。

數據庫的不可重複讀問題

再來看看不可重複讀問題,不可重複讀問題是指在一個事務中,多次讀取同一數據,先後讀取到的數據不一致,如下:

不可重複讀問題

你沒看錯,就是對前面那張圖稍微做了一點改造,事務A執行下單業務時,因爲添加物流信息的時候出錯了,導致整個事務回滾,事務回滾完成後,事務A就結束了。但事務B卻並未結束,在事務B中,在事務A執行時讀取了一次剩餘庫存,然後在事務回滾後又讀取了一次剩餘庫存,仔細想想:B事務第一次讀到的剩餘庫存是扣減之後的,第二次讀到的剩餘庫存則是扣減之前的(因爲A事務回滾又加回去了)。

在上述這個案例中,同一個事務中讀取同一數據,結果卻並不一致,也就說明了該數據存在不可重複讀問題,這樣說似乎有些繞,那再結合可重複讀來一起理解:
可重複讀的意思是:在同一事務中,不管讀取多少次,讀到的數據都是相同的。

結合上述可重複讀的定義,再去理解不可重複讀問題會容易很多,重點是理解可重複、不可重複這個詞義,爲了更形象化一點,舉個生活中的案例:

一張衛生紙,我先拿去擦了一下桌子上的污水漬,然後又放回了原位,當我想上廁所再次拿起時,它已經無法使用了,這就代表着一張衛生紙是不可重複使用的。

一個大鐵錘,我先拿去敲一下鬆掉的桌腿,然後放回了原位,當我又想敲一下牆上的釘子再次拿起時,這個大鐵錘是沒有發生任何變化的,可以再次用來敲釘子,這就代表大鐵錘是可以重複使用的。

相信結合這兩個栗子,更能讓你明白可重複與不可重複的概念定義。

數據庫的幻讀問題

對於幻讀的解釋在網上也有很多資料,但大部分資料是這樣描述幻讀問題的:

幻讀:指同一個事務內多次查詢返回的結果集不一樣。比如同一個事務A,在第一次查詢表的數據行數時,發現表中有n條行記錄,但是第二次以同等條件查詢時,卻發現有n+1條記錄,這就好像產生了幻覺。

這個說法實際上並不嚴謹,第一次讀和第二次讀同一數據,結果集並不相同,這其實屬於一個不可重複讀的問題,而並非幻讀問題。那接下來舉例說明一下什麼叫做真正的幻讀問題,先上圖:

幻讀問題

做過電商業務的小夥伴都清楚,一般用戶購買商品後付的錢會先凍結在平臺上,然後由平臺在固定的時間內結算用戶款,例如七天一結算、半月一結算等方式,在結算業務中通常都會涉及到覈銷處理,也就是將所有爲「已簽收狀態」的訂單改爲「已覈銷狀態」。

此時假設連接①/ 事務A正在執行「半月結算」這個工作,那首先會讀取訂單表中所有狀態爲「已簽收」的訂單,並將其更改爲「已覈銷」狀態,然後將用戶款打給商家。

但此時恰巧,某個用戶的訂單正好到了自動確認收貨的時間,因此在事務A剛剛改完表中訂單的狀態時,事務B又向表中插入了一條「已簽收狀態」的訂單並提交了,當事務A完成打款後,再次查詢訂單表,結果會發現表中還有一條「已簽收狀態」的訂單數據未結算,這就好像產生了幻覺一樣,這纔是真正的幻讀問題。

當然,這樣講似乎還不是那麼令人理解,再舉個更通俗易懂的栗子,假設此時平臺要升級,用戶表中的性別字段,原本是以「男、女」的形式保存數據,現在平臺升級後要求改爲「0、1」代替。
因此事務A開始更改表中所有數據的性別字段,當負責執行事務A的線程正在更改最後一條表數據時,此時事務B來了,正好向用戶表中插入了一條「性別 = 男」的數據並提交了,然後事務A改完原本的最後一條數據後,當再次去查詢用戶表時,結果會發現表中依舊還存在一條「性別 = 男」的數據,似乎又跟產生了幻覺一樣。

經過上述這兩個案例,大家應該能夠理解真正的幻讀問題,發生幻讀問題的原因是在於:另外一個事務在第一個事務要處理的目標數據範圍之內新增了數據,然後先於第一個事務提交造成的問題。

數據庫髒寫問題

其實除開三個讀的問題外,還有有一個叫做髒寫的問題,也就是多個事務一起操作同一條數據,例如兩個事務同時向表中添加一條ID=88的數據,此時就會造成數據覆蓋,或者主鍵衝突的問題,這個問題也被稱之爲更新丟失問題。

2.3.2、事務的四大隔離級別

在上面連續講了髒讀、不可重複讀以及幻讀三個問題,那這些問題該怎麼解決呢?其實四個事務隔離級別,解決的實際問題就是這三個,因此一起來看看各級別分別解決了什麼問題:

前面提到過,MySQL默認是處於第三級別的,可以通過如下命令查看目前數據庫的隔離級別:

-- 查詢方式①
SELECT @@tx_isolation;
-- 查詢方式②
show variables like '%tx_isolation%';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+

其實數據庫不同的事務隔離級別,是基於不同類型、不同粒度的鎖實現的,因此想要真正搞懂隔離機制,還需要弄明白MySQL的鎖機制,事務與鎖機制二者之間本身就是相輔相成的關係,鎖就是爲了解決併發事務的一些問題而存在的,但對於鎖的內容在後續的《MySQL 鎖篇》再細聊,這裏就簡單概述一下。

這裏先說明一點,事務是基於數據庫連接的,數據庫連接在《MySQL 架構篇》中曾說過:數據庫連接本身會有一條工作線程來維護,也就是說事務的執行本質上就是工作線程在執行,因此所謂的併發事務也就是指多條線程併發執行。

多線程其實是咱們的老朋友了,在之前的《併發編程系列》中,幾乎將多線程的底褲都翻出來了,因此結合多線程角度來看,髒讀、不可重複讀、幻讀這一系列問題,本質上就是一些線程安全問題,因此需要通過鎖來解決,而根據鎖的粒度、類型,又分出了不同的事務隔離級別。

讀未提交級別

這種隔離級別是基於「寫互斥鎖」實現的,當一個事務開始寫某一個數據時,另外一個事務也來操作同一個數據,此時爲了防止出現問題則需要先獲取鎖資源,只有獲取到鎖的事務,才允許對數據進行寫操作,同時獲取到鎖的事務具備排他性 / 互斥性,也就是其他線程無法再操作這個數據。

但雖然這個級別中,寫同一數據時會互斥,但讀操作卻並不是互斥的,也就是當一個事務在寫某個數據時,就算沒有提交事務,其他事務來讀取該數據時,也可以讀到未提交的數據,因此就會導致髒讀、不可重複讀、幻讀一系列問題出現。

但是由於在這個隔離級別中加了「寫互斥鎖」,因此不會存在多個事務同時操作同一數據的情況,因此這個級別中解決了前面說到的髒寫問題。

讀已提交級別

在這個隔離級別中,對於寫操作同樣會使用「寫互斥鎖」,也就是兩個事務操作同一數據時,會出現排他性,而對於讀操作則使用了一種名爲MVCC多版本併發控制的技術處理,也就是有事務中的SQL需要讀取當前事務正在操作的數據時,MVCC機制不會讓另一個事務讀取正在修改的數據,而是讀取上一次提交的數據(也就是讀原本的老數據)。

也就是在這個隔離級別中,基於同一條數據而言,對於寫操作會具備排他性,對於讀操作則只能讀已提交事務的數據,不會讀取正在操作但還未提交的事務數據,爲了理解還是簡單的說一下其過程,同樣有兩個事務A、B

事務A的主要工作是負責更新ID=1的這條數據,事務B中則是讀取ID=1的這條數據。 此時當A正在更新數據但還未提交時,事務B開始讀取數據,此時MVCC機制則會基於表數據的快照創建一個ReadView,然後讀取原本表中上一次提交的老數據。然後等事務A提交之後,事務B再次讀取數據,此時MVCC機制又會創建一個新的ReadView,然後讀取到最新的已提交的數據,此時事務B中兩次讀到的數據並不一致,因此出現了不可重複讀問題。

當然,對於MVCC機制以及鎖機制這裏暫時先不展開敘述,後續會開單章講解。

可重複讀級別

在這個隔離級別中,主要就是解決上一個級別中遺留的不可重複讀問題,但MySQL依舊是利用MVCC機制來解決這個問題的,只不過在這個級別的MVCC機制會稍微有些不同。在讀已提交級別中,一個事務中每次查詢數據時,都會創建一個新的ReadView,然後讀取最近已提交的事務數據,因此就會造成不可重複讀的問題。

而在可重複讀級別中,則不會每次查詢時都創建新的ReadView,而是在一個事務中,只有第一次執行查詢會創建一個ReadView,在這個事務的生命週期內,所有的查詢都會從這一個ReadView中讀取數據,從而確保了一個事務中多次讀取相同數據是一致的,也就是解決了不可重複讀問題。

雖然在這個隔離級別中,解決了不可重複讀問題,但依舊存在幻讀問題,也就是事務A在對錶中多行數據進行修改,比如前面的舉例,將性別「男、女」改爲「0、1」,此時事務B又插入了一條性別爲男的數據,當事務A提交後,再次查詢表時,會發現表中依舊存在一條性別爲男的數據。

序列化 / 串行化級別

這個隔離級別是最高的級別,處於該隔離級別的MySQL絕不會產生任何問題,因爲從它的名字上就可以得知:序列化意思是將所有的事務按序排隊後串行化處理,也就是操作同一張表的事務只能一個一個執行,事務在執行前需要先獲取表級別的鎖資源,拿到鎖資源的事務才能執行,其餘事務則陷入阻塞,等待當前事務釋放鎖。

但這種隔離級別會導致數據庫的性能直線下降,畢竟相當於一張表上只能允許單條線程執行了,雖然安全等級最高,可以解決髒寫、髒讀、不可重複讀、幻讀等一系列問題,但也是代價最高的,一般線上很少使用。

這種隔離級別解決問題的思想很簡單,之前我們分析過,產生一系列問題的根本原因在於:多事務 / 多線程併發執行導致的,那在這個隔離級別中,直接將多線程化爲了單線程,自然也就從根源上避免了問題產生。

是不是非常 “銀杏花”,雖然我解決不了問題,但我可以直接解決製造問題的人。

略微提一嘴:其實在RR級別中也可以解決幻讀問題,就是使用臨鍵鎖(間隙鎖 + 行鎖)這種方式來加鎖,但具體的還是放在《MySQL 鎖篇》詳細闡述。

2.3.3、事務隔離機制的命令

簡單認識MySQL事務隔離機制後,接着來看看一些關於事務隔離機制的命令:

-- 方式①:查詢當前數據庫的隔離級別
SELECT @@tx_isolation;
-- 方式②:查詢當前數據庫的隔離級別
show variables like '%tx_isolation%';

-- 設置隔離級別爲RU級別(當前連接生效)
set transaction isolation level read uncommitted;
-- 設置隔離級別爲RC級別(全局生效)
set global transaction isolation level read committed;
-- 設置隔離級別爲RR級別(當前連接生效)
-- 這裏和上述的那條命令作用相同,是第二種設置的方式
set tx_isolation = 'repeatable-read';
-- 設置隔離級別爲最高的serializable級別(全局生效)
set global.tx_isolation = 'serializable';

上述實際上一眼就能看懂,唯一要注意的在於:如果想要讓設置的隔離級別在全局生效,一定要記得加上global關鍵字,否則生效範圍是當前會話,也就是針對於當前數據庫連接有效,在其他連接中依舊是原本的隔離級別。

三、MySQL 的事務實現原理

   到這裏爲止,一些MySQL事務相關的概念和基礎就已經講明白了,現在重點來聊一聊MySQL事務究竟是怎麼實現的呢?先把結論拋出來:**MySQL的事務機制是基於日誌實現的 **。爲什麼是基於日誌實現的呢?一起來展開聊一聊。

3.1、正常 SQL 的事務機制

   在前面聊到過的一點:**MySQL默認開啓事務的自動提交,並且將一條SQL視爲一個事務 **。那MySQL在何種情況下會將事務自動提交呢?什麼情況下又會自動回滾呢?想要弄明白這個問題,首先得回顧一下之前講過的《SQL 執行篇 - 寫入 SQL 的執行流程》,在講寫入類型SQL的執行流程時,曾講過一點:任意一條寫SQL的執行都會記錄三個日誌:undo-log、redo-log、bin-log

在寫SQL執行記錄的三個日誌中,bin-log暫且不需要關心,這個跟事務機制沒關係,重點是undo-log、redo-log這兩個日誌,其中最重要的是redo-log這個日誌。

redo-log是一種WAL(Write-ahead logging)預寫式日誌,在數據發生更改之前會先記錄日誌,也就是在SQL執行前會先記錄一條prepare狀態的日誌,然後再執行數據的寫操作。

但要注意:MySQL是基於磁盤的,但磁盤的寫入速度相較內存而言會較慢,因此MySQL-InnoDB引擎中不會直接將數據寫入到磁盤文件中,而是會先寫到BufferPool緩衝區中,當SQL被成功寫入到緩衝區後,緊接着會將redo-log日誌中相應的記錄改爲commit狀態,然後再由MySQL刷盤機制去做具體的落盤操作。

因爲默認情況下,一條SQL會被當成一個事務,數據寫入到緩衝區後,就代表執行成功,因此會自動修改日誌記錄爲commit狀態,後續則會由MySQL的後臺線程執行刷盤動作。

舉個僞邏輯的例子,例如下述這條插入SQL的執行過程大致如下:

-- 先記錄一條狀態爲 prepare 的日誌
-- 然後執行SQL,在緩衝區中更改對應的數據
INSERT INTO `zz_users` VALUES(5,"黑竹","男","9999","2022-09-24 23:48:29");
-- 寫入緩衝區成功後,將日誌記錄改爲 commit狀態
-- 返回 [Affected rows: 1],MySQL後臺線程執行刷盤動作

一條SQL語句組成的事務,其執行過程是不是很容易理解~,接着來看看手動開啓事務的實現。

3.2、多條 SQL 的事務機制

先把前面的案例搬下來,如下:

-- 開啓事務
start transaction;
-- 修改 ID=4 的姓名爲:黑熊(原本user_name = 1111)
update `zz_users` set `user_name` = "黑熊" where `user_id` = 4;
-- 刪除 ID=1 的行數據
delete from `zz_users` where `user_id` = 1;
-- 提交事務
COMMIT;

比如這段SQL代碼執行的過程又是啥樣的呢?一起來瞧一瞧:

①當MySQL執行時,碰到start transaction;的命令時,會將後續所有寫操作全部先關閉自動提交機制,也就是後續的所有寫操作,不管有沒有成功都不會將日誌記錄修改爲commit狀態。

②先在redo-log中爲第一條SQL語句,記錄一條prepare狀態的日誌,然後再生成對應的撤銷日誌並記錄到undo-log中,然後執行SQL,將要寫入的數據先更新到緩衝區。

③再對第二條SQL語句做相同處理,如果有更多條SQL則逐條依次做相同處理..... ,這裏簡單的說一下撤銷日誌長啥樣,大致如下:

-- 第一條修改SQL的撤銷日誌(將修改的姓名字段從 黑熊 改回 1111)
update `zz_users` set `user_name` = "1111" where `user_id` = 4;
-- 第二條刪除SQL的撤銷日誌(將刪除的行數據再次插入)
INSERT INTO `zz_users` VALUES(1,"熊貓","女","6666","2022-08-14 15:22:01");

④直到碰到了rollback、commit命令時,再對前面的所有寫SQL做相應處理:

如果是commit提交事務的命令,則先將當前事務中,所有的SQLredo-log日誌改爲commit狀態,然後由MySQL後臺線程做刷盤,將緩衝區中的數據落入磁盤存儲。

如果是rollback回滾事務的命令,則在undo-log日誌中找到對應的撤銷SQL執行,將緩衝區內更新過的數據全部還原,由於緩衝區的數據被還原了,因此後臺線程在刷盤時,依舊不會改變磁盤文件中存儲的數據。

OK~,其實事務機制的底層實現也並不麻煩,稍微一推導、一思考就能想明白的道理。

當然,大家有興趣的再去推導一下:事務撤銷點是怎麼實現的呢?其實也並不難的,略加思考即可以得到答案。

3.3、事務的恢復機制

   現在再來思考一個問題,有沒有這麼一種可能呢?也就是當SQL執行時,數據還沒被刷寫到磁盤中,結果數據庫宕機了,那數據是不是就丟了啊?畢竟本地磁盤中的數據,在MySQL重啓後依舊存在,但緩衝區中還未被刷到磁盤的數據呢?因爲緩衝區位於內存中,所以裏面的數據重啓是不會存在的撒?

對於這個問題呢實際上並不需要擔心,因爲前面聊到過redo-log是一種預寫式日誌,會先記錄日誌再去更新緩衝區中的數據,所以就算緩衝區的數據未被刷寫到磁盤,在MySQL重啓時,依舊可以通過redo-log日誌重新恢復未落盤的數據,從而確保數據的持久化特性。

當然,有人或許又會問:那如果在記錄redo-log日誌時,MySQL芭比 Q 了咋整?如果遇到了這個問題呢,首先得恭喜你,你的運氣屬於很棒,能碰到這個問題的幾率足夠你買彩票中五百萬了~

玩笑歸玩笑,現在迴歸話題本身,這個問題總不能讓它存在是不?畢竟有這個問題對於系統而言也是個隱患啊,但仔細一思考,其實這個問題不必多慮,爲啥?推導一下。

首先看看前面的那種情況:數據被更新到緩衝區但沒刷盤,然後MySQL宕機了,MySQL會通過日誌恢復數據。這裏要注意的是:數據被更新到緩衝區代表着SQL執行成功了,此時客戶端會收到MySQL返回的寫入成功提示,只是沒有落盤而言,所以MySQL重啓後只需要再次落盤即可。

但如果在記錄日誌的時候MySQL宕機了,這代表着SQL都沒執行成功,SQL沒執行成功的話,MySQL也不會向客戶端返回任何信息,因爲MySQL一直沒返回執行結果,因此會導致客戶端連接超時,而一般客戶端都會有超時補償機制的,比如會超時後重試,如果MySQL做了熱備 / 災備,這個重試的時間足夠MySQL重啓完成了,因此用戶的操作依舊不會丟失(對於超時補償機制,在各大數據庫連接池中是有實現的)。

但如若又有小夥伴糾結:我MySQL也沒做熱備 / 災備這類的方案吶,此時咋整呢?

如果是這樣的情況,那就只能自認倒黴了,畢竟MySQL掛了一直不重啓,不僅僅當前的SQL會丟失,後續平臺上所有的用戶操作都會無響應,這屬於系統崩潰級別的災難了,因此只能靠完善系統架構來解決。

四、MySQL 事務篇總結

   一點點看到這裏,《MySQL 事務篇》也就接近了尾聲,在本篇中對事務機制一點點去引出,慢慢的到事務機制的概述、併發事務的問題、事務的隔離級別、事務的實現原理等諸多方面進行了全面剖析,但大家應該也略微有些不盡興,畢竟對於隔離級別的具體實現並未講到,這是由於MySQL事務與鎖機制之間有着千絲萬縷的關係,所以在《MySQL 鎖篇》中會再次詳細講到事務隔離機制的。

當然,由於目前是分佈式 / 微服務架構橫行的時代,所以也引出了新的問題,即分佈式事務問題,這個問題又需要通過全新的事務機制去處理了,對於這點再講完《MySQL 分庫分表》後,會再單開一章《分佈式事務篇》去詳細闡述,這裏頭的學問很大~

再次結合undo-log、redo-log日誌來看待ACID的四大特性:原子性、一致性、隔離性、持久性。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/S-9B_5JoSG6YlBLulL5VGg