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
主要涵蓋四條原則,即:
-
•
A/Atomicity
:原子性 -
•
C/Consistency
:一致性 -
•
I/Isolation
:獨立性 / 隔離性 -
•
D/Durability
:持久性
那這四條原則分別是什麼意思呢?接下來一起聊一聊。
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 | begin | begin work
:開啓一個事務 -
•
commit
:提交一個事務 -
•
rollback
:回滾一個事務
當需要使用事務時,可以先通過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
中提供了兩個關於事務回滾點的命令:
-
•
savepoint point_name
:添加一個事務回滾點 -
•
rollback to point_name
:回滾到指定的事務回滾點
以前面的案例來演示效果,如下:
-- 先查詢一次用戶表
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
中,事務隔離機制分爲了四個級別:
-
• ①
Read uncommitted/RU
:讀未提交 -
• ②
Read committed/RC
:讀已提交 -
• ③
Repeatable read/RR
:可重複讀 -
• ④
Serializable
:序列化 / 串行化
上述四個級別,越靠後併發控制度越高,也就是在多線程併發操作的情況下,出現問題的幾率越小,但對應的也性能越差,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
。
-
•
undo-log
:主要記錄SQL
的撤銷日誌,比如目前是insert
語句,就記錄一條delete
日誌。 -
•
redo-log
:記錄當前SQL
歸屬事務的狀態,以及記錄修改內容和修改頁的位置。 -
•
bin-log
:記錄每條SQL
操作日誌,只要是用於數據的主從複製與數據恢復 / 備份。
在寫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
提交事務的命令,則先將當前事務中,所有的SQL
的redo-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
的四大特性:原子性、一致性、隔離性、持久性。
-
• 原子性要求事務中所有操作要麼全部成功,要麼全部失敗,這點是基於
undo-log
來實現的,因爲在該日誌中會生成相應的反SQL
,執行失敗時會利用該日誌來回滾所有寫入操作。 -
• 持久性要求的是所有
SQL
寫入的數據都必須能落入磁盤存儲,確保數據不會丟失,這點則是基於redo-log
實現的,具體的實現過程在前面事務恢復機制講過。 -
• 隔離性的要求是一個事務不會受到另一個事務的影響,對於這點則是通過鎖機制和
MVCC
機制實現的,只不過MySQL
屏蔽了加鎖和MVCC
的細節,具體的會在後續章節中細聊。 -
• 一致性要求數據庫的整體數據變化,只能從一個一致性狀態變爲另一個一致性狀態,其實前面的原子性、持久性、隔離性都是爲了確保這點而存在的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/S-9B_5JoSG6YlBLulL5VGg