MySQL事務淺析|由淺入深
很多人都在講事務,事務是個啥,我感覺我沒開事物也沒什么事情啊,學事務有必要嗎?

今天照舊,本文在一開始將講解一些入門適合理解的知識,在后面逐層加深,如果對事務有了解,希望知道細節,可以在下面的目錄跳一下
文章目錄
- MySQL事務淺析|由淺入深
- 事務是個啥?
- For Example1
- 例子2 臟寫
- 例子3 臟讀
- 例子4 不可重復讀
- 例子5 幻讀
- 并發編程帶來的資料庫隱患
- 通過對事務的分析,得到了四個特點 ACID
- MySQL如何保證事務完好
- 持久性的保證
- 原子性的保證
- 隔離性的保證|MVCC
- MVCC
- 沒錯,有個東西叫Undo日志
- 那么這四級隔離都是如何具體實作的?
- 什么是ReadView?
- 修改ReadView就能獲得不同的隔離策略:
- 防止臟寫 | MySQL讀寫鎖
- 一致性讀與鎖定讀
- 共享鎖與獨占鎖
- 表鎖
- MySQL的鎖
- MySQL對自增id的保護
不會吧不會吧,不開事務你們系統還好嗎?
事務是個啥?
相信在做的各位,大部分都是為了吃飯、或者為了遠大崇高的理想而奮斗(摸魚
那么,我們用錢,,,買回來的手辦來舉例,相信各位的體驗將更加深刻,


For Example1
現有以下場景,你在bilibili(無意打擾,勿殺)中花費了1w元,買下了自己的老婆(不是
注意這個細節:買下,而不是買回
那么分析這個流程,其中有四個模塊

有的杠精就要跳出來,我付款不是一個流程嗎?為啥是個扣款加收到?
這個問題建議你復習一下什么是微信錢包嗷,以及正視b站和tx不是一家公司的問題,
好的,凡是總是怕萬一嘛,快遞還可能被炸毀嘞,
如果你在扣款成功后,啪,很快啊,微信或者bilibili的服務器掛了,
你重新審視這張簡單無比的圖片,發現一個嚴重的問題:
如果沒有其他手段保證的話,bilibili沒收到錢,老婆沒了,
那么請問這個問題,他嚴重嗎,答案肯定是嚴重的,
同時上述的問題是事務持久性的體現,對資料的修改是永久的,即使故障也不會丟失,
下面我將繼續舉出幾個例子,建議看一下,別覺得簡單就跳了,對后面的理解有很大的幫助,
例子2 臟寫
又是你,作為當代光榮程式員,你完成了leader的任務,寫下了五百行代碼,踏上了回家與老婆鼓掌的出租車
但是很不幸,有個憨批張三,
他把你剛剛交上去的代碼改成了一個字符畫豬(* ̄(oo) ̄),
你的五百行代碼不翼而飛,還被換成了豬,
那么請問,如何生吃張三更快人心?

一個事務修改了另一個 未提交的事務 修改過的資料,稱為臟寫
例子3 臟讀
恭喜你,你終于買了1w元的老婆!(高興的拍起肚皮
但是,生活再次對你下手,你買回來的,是個半成品,沒上色啊喂!

一個事物讀取到了 另一個未提交的事務修改過的資料,稱為臟讀
例子4 不可重復讀
一番波折,你終于帶回了自己的老婆(不是,女朋友大大別打我
你看看她的樣子
select * from home where id =1;

很不巧,領居家的熊孩子來家里玩,

鄰居走了以后,你又看了看她的樣子
select * from home where id =1;
發現老婆,她,變成了這樣

一個事務內兩次讀到的資料不一樣,稱為不可重復讀,
換句話說,一個事務提交的修改,會影響其他未完成的事務內的資料,
又或者說,如果一個事務只能讀到另一個已經提交的事務修改過的資料,并且其他事務每對該資料進行一次修改并提交后,該事務都能查詢得到最新值,
例子5 幻讀
你今個又來看自己的老婆
select * from home where id =1;
ans: 初音(韶華)
覺得她很棒♂,于是你放心的去了一趟扯碩,
回來你又來看老婆
select * from home where id =1;
ans: 初音,2233
但是發生了奇怪的事情,老婆多了一個?!
這不是好事情嗎,滑稽?
但是《正 直》的你不這么認為,于是給2233擺正,微笑入睡
讀到了其他事務 增加的記錄,稱為幻讀,(可能這就是幻術吧
并發編程帶來的資料庫隱患
我們使用SQL時,看起來似乎永遠都是單執行緒操作,而實際上資料庫幾乎是并發的一個高峰點,有無數的執行緒同時在這里進行操作,如果對資料的讀寫不能加以限制,那么你將再次痛失老婆(并不
大佬們把資料庫的隱患,歸結到了四種,臟寫,臟讀,不可重復讀與幻讀
這四個概念在上文給大家講了例子,下面簡要總結一下
- 臟寫:一個事物寫了另一個未提交事物修改的資料,
- 臟讀:一個事物讀到了另一個未提交事物所修改的資料,
- 不可重復讀:由于其他事務的操作,一個事務內兩次讀某條資料的結果不同,
- 幻讀:由于其他事務的操作,一個事務第二次查詢到了多的資料,
同時把我們的一組邏輯操作稱為事務(比如去銀行取錢,買手辦等等
通過對事務的分析,得到了四個特點 ACID
- Atomicity(原子性):一個事務(transaction)中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節,事務在執行程序中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣,即,事務不可分割、不可約簡,
- Consistency(一致性):在事務開始之前和事務結束以后,資料庫的完整性沒有被破壞,
- Isolation(隔離性):資料庫允許多個并發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務并發執行時由于交叉執行而導致資料的不一致,
- Durability(持久性):事務處理結束后,對資料的修改就是永久的,即便系統故障也不會丟失,
以上來自維基百科,下面是人話版本,
- Atomicity(原子性):這個簡單,你一組操作肯定是要綁在一起的嘛,沒什么說的,要死一起死,
- Consistency(一致性):真實世界中,資料是有格式的,比如小數位啊,沒有負數啊等等,一致性就是為了保證資料處理前后,都符合特定的要求(主鍵,外鍵,其他約束等等),
- Isolation(隔離性):學過JUC的老鐵應該懂,當多個執行緒一起操作一個臨界區的時候,沒限制多半要出事,隔離性是為了保證多個事務在執行的時候,能像單執行緒一樣順利,
- Durability(持久性):事務處理結束后,對資料的修改就是永久的,即便系統故障也不會丟失,(沒什么說的,懂的都懂)
MySQL如何保證事務完好
一致性我們就不多說了,用觸發器,約束這些是很基礎的資料庫操作,
我們的主要關注點放在持久性、原子性和隔離性上,
持久性的保證
持久性:對資料的修改是永久的,即使故障也不會丟失,
MySQL里面有個叫redo的玩意兒
MySQL執行每條更新資料的操作,都會產生一個redo日志(即時的),然后再依次執行redo日志,
為啥?不直接把結果io到庫里,非要搞個redo,裝杯嗎這是?
這里大家需要考慮一個問題:
直接io結果會涉及隨機讀寫,使用redo日志是順序讀寫
MySQL中以典型的innodb為例,使用的是聚簇索引(不懂的可以去搜索一下

圖源:從根上理解MySQL
這里的索引部分是一頁,下面的資料又是不同的頁,如果一個操作將更新多個資料,可能將涉及大量的隨機讀寫,我們不可能等這么長時間完成(等一半就暴斃了怎么辦)所以使用了redo日志,后面慢慢寫redo日志就行了嘛,這里的redo日志可能大家沒聽過,不知道binlog這個名字大家熟悉嗎,哈哈哈
資料的修改操作將記錄在binlog,然后將有一個執行緒慢慢寫binlog,(修改會暫時保存在快取中)
原子性的保證
原子性:同生共死,要么都成功,要么都失敗,那么我們需要關注的是,執行到一半,后悔了,我要恢復,怎么辦?
MySQL的方式是使用undo日志
undo日志的原理其實不難,為了知道每一步我們都干了點啥,我們每修改一次資料,就做一次記錄(順序讀寫,很快的),要是有問題,就用這些記錄把資料恢復了就行,
下面我們從設計undo日志的角度出發,來理解undo日志
為了記錄一個資料的修改,同時達到順序讀寫的效果,鏈表可能是我們理想的資料結構

日志肯定要分開存盤,不然回滾還要篩選找到日志,很麻煩,那我們直接把undo日志掛在記錄上應該就可以了,

現在我們還有一個因素要考慮:并發,多事務執行,
對于并發問題:我們必須意識到:不能允許多個事務同時修改一個數,這屬于臟寫,所以我們將使用鎖來保證,
那么我們已經使用鎖來保證當前資料只能被一個事務修改,我們下面需要考慮的是修改資料也有很多種類的,洗掉,增加,修改,怎么處理?
加一個標志位標識種類,不同種類有不同的內容,完美
綜合我們可以設計出這樣的結構
因為mysql為了提高讀取速度,在存盤,讀取資料時以頁面(16kb)為單位,
同時頁面有多種型別,undo日志和資料頁就是兩種不同的型別頁,


圖片來源:掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》
當我們發生情況,要回滾的時候,按照這個日志回滾回去就行了,
但是由于MySQL中,delete行為的不同:
delete并不會洗掉資料,而是在當前事務內,標記這個點被洗掉了(行里面有一個Header標志位),然后在事務結束后放入垃圾鏈表,垃圾鏈表可以快速的被回收利用或在未來釋放空間,
如下圖所示
下面是一個正常的表,左邊是正常的記錄,右邊是該表的垃圾鏈表,記錄洗掉的空間,

事務收到請求,把標志位delete_mask置為1

在事務結束后,將該節點加入垃圾鏈表

標志這一步驟看似多此一舉,實際上保證了MVCC,標志記錄是很快的,當其他事務讀取到時,可以感知到該條記錄是否被洗掉,而不是等到結束才感知到,
同時,這些操作在事務內是單向的,日志也是使用鏈表記錄,這樣就構成了版本鏈

關于具體的存盤方式啊,回滾段啊就不再贅述了,這部分的內容不是很容易理解
還是很推薦這本書MySQL 是怎樣運行的:從根兒上理解 MySQL,也有紙質書,
隔離性的保證|MVCC
說到隔離性,必須要提一下大家小學三年級就知道的四個隔離級別
一般是大寫的,為了大家英文看的舒服,寫成小寫容易認出來單詞
- Read Uncommitted 讀未提交
- Read Committed 讀提交
- Repeatable Read 可重復讀
- Serializable 可串行化
Read Uncommitted 讀未提交,有的地方叫未提交讀,本人覺得讀未提交更符合他描述的情況,因為他描述的是可以讀到未提交的資訊,
這四種級別的隔離程度逐級增加,解決了臟寫,臟讀,不可重復讀,幻讀的問題,
其中臟寫是非常恐怖的,所以MySQL默認是必須解決臟寫的
既然寫到了隔離級別,我們就把他講完,然后說一下臟寫的解決,
| 隔離級別 | 臟寫 | 臟讀 | 不可重復讀 | 幻讀 |
|---|---|---|---|---|
READ UNCOMMITTED | Not Possible | Possible | Possible | Possible |
READ COMMITTED | Not Possible | Not Possible | Possible | Possible |
REPEATABLE READ | Not Possible | Not Possible | Not Possible | Possible |
SERIALIZABLE | Not Possible | Not Possible | Not Possible | Not Possible |
MVCC
首先,預備一下,在MySQL中,一個表除了我們設定的幾個欄位,還存在一些隱含欄位
這里強調一下,Innodb是支持事務的,MyISAM是不支持事務的,
所以MVCC是跟innodb關聯的,
MySQL默認是使用COMPACT行格式的,當然無論什么格式,都會存在隱含列以及Header標志
我們用COMPACT行格式來舉例講解

暫時不關心奇奇怪怪的設定了,之前所提到的delete_mask就在記錄頭中
記錄的真實資料部分包含了三個隱含列
的資料以外,MySQL會為每個記錄默認的添加一些列(也稱為隱藏列),具體的列如下:
| 列名 | 是否必須 | 占用空間 | 描述 |
|---|---|---|---|
row_id | 否 | 6位元組 | 行ID,唯一標識一條記錄 |
transaction_id | 是 | 6位元組 | 事務ID |
roll_pointer | 是 | 7位元組 | 回滾指標 |
實際上這幾個列的真正名稱其實是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,我們為了美觀才寫成了row_id、transaction_id和roll_pointer,
其中,row_id 是為了在沒有指定主鍵,且沒有唯一列時作為該條資料主鍵的(主鍵的必要的,否則無法區分資料),若指定了主鍵,他就是空的,
roll_pointer 就是上文我們所提到的記錄指向undo日志的指標,
transaction_id 是最后一個修改該行的事務id,
事務id:為了區分不同的事務,事務也要有個類似主鍵的東西嘛,就是id號,
MySQL內部會維護一個遞增的id號,
MVCC聽起來很高大上,實際上并不復雜,大家先不要關注什么MySQL的具體實作,下面先簡單說一下MVCC是啥,怎么用的,
首先,我們需要明確我們的需求:
不同的事務能看到不同的資料(寫是不允許混用的,用上鎖保證了)
為了達到這個目的,我們可能需要這樣做:給每個執行緒一個快取空間(類似JUC,Java并發中的JMM)
那么問題來了:資料庫很可能是一個系統中并發量最高的地方,大張旗鼓的新開空間,可能造成困難,
如果可以利用一下之前的東西?
沒錯,有個東西叫Undo日志
undo日志里面包含了修改資料的內容對吧,而且為了回滾,都會記錄在哪里,而且還保證了查詢速度,
那么很簡單,既然每個事務都會記錄這個undo日志,我在查詢的時候,只需要看看最后占用這個資料的事務id,是不是比自己小?
事務id是遞增的
如果比自己小,說明這個資料是安全的,可以讀取,否則就在undo日志里面找個合適的出來,
如下圖:
依次從大到小,找到合適的版本就行了(trx_id)

MVCC的基礎原理就是這樣,不是很復雜吧,
但是,我們都知道,SQL是規定了幾個隔離級別的,觀察上面的實作,我們發現這個方法是第三級:Repeatable Read 可重復讀:
在一個事務內,查詢時總跟自己的事務id進行比較
那么這四級隔離都是如何具體實作的?
首先要明確的是,mysql的事務id肯定不是記錄自己的id這么簡單
MySQL是如何記錄當前活躍的事務的?
答:使用ReadView
什么是ReadView?
我們還是按照軟體設計的正常思路:需求-》設計
需求是什么?
我們需要在一個合適的時間點,查看自己的事務id與當前所有的事務;因為事務可能很多,最好給一個快速判斷是否為過去事務的方法,
實作:
我們設計一個獨特的記錄結構賦予每一個事務
首先需要記錄當前所有的事務
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,
最后,記錄當前的事務id
-
creator_trx_id:表示生成該ReadView的事務的事務id,小貼士: 我們前邊說過,只有在對表中的記錄做改動時(執行INSERT、DELETE、UPDATE這些陳述句時)才會為事務分配事務id,否則在一個只讀事務中的事務id值都默認為0,
我們設定每次的查詢都根據ReadView來獲取結果(這是為了解耦,大家可以思考一下,如果只跟ReadView互動,將會很大程度解耦)
被訪問的記錄上有header記錄了最后修改的事務id(trx_id)
在訪問某條記錄時,按照下邊的步驟判斷記錄的某個版本是否可見:
- 如果被訪問版本的
trx_id屬性值與ReadView中的creator_trx_id值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問, - 如果被訪問版本的
trx_id屬性值小于ReadView中的min_trx_id值,表明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問, - 如果被訪問版本的
trx_id屬性值大于或等于ReadView中的max_trx_id值,表明生成該版本的事務在當前事務生成ReadView后才開啟,所以該版本不可以被當前事務訪問, - 如果被訪問版本的
trx_id屬性值在ReadView的min_trx_id和max_trx_id之間,那就需要判斷一下trx_id屬性值是不是在m_ids串列中,如果在,說明創建ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問;如果不在,說明創建ReadView時生成該版本的事務已經被提交,該版本可以被訪問,

有點蒙圈?
Read View其實實質是當前的事務id
為了快速的判斷,給出了記錄與范圍
試想,目標記錄有個事務id 100,
能訪問的條件:
- 記錄的事務id是不在當前活躍的事務id中
- 或記錄的事務id已經不活躍且小于當前事務id,
如果命中了,就不能訪問,為了加速,我們直接獲取最大與最小的范圍,在范圍之外我們可以迅速判斷,
修改ReadView就能獲得不同的隔離策略:
- Read uncommitted:直接讀取最新的記錄
- Read committed: **每次查詢都生成一個新的ReadView,**這樣,我們的權限范圍就始終是最新的,就能查到最新的資料
- Repeatable read: 在第一次讀取資料時生成一個ReadView,之后使用這個id,這就保證了我們每次讀取,狀態都是相同的,
- Serializable:使用鎖保證一個事務訪問,
防止臟寫 | MySQL讀寫鎖
Java程式中,為了保證臨界區的安全,我們經常會提到一個概念,叫鎖,
我們可以這樣理解,東陽市大學有個廁所,經常出現一個人進了一個有人的廁所的尷尬情況,
為了保證男孩子和女孩子們的安全,出現了一個叫信號量的東西,
廁所的門口放了一張卡,只有擁有這張卡,才能進入,使用完后要及時歸還,下個人才能使用,
這就是鎖,不難吧,
回到MySQL的世界,我們的資料在并發狀態一般就這幾種情況:(AA BB AB|BA)
- 多個事務一起讀
- 多個事務一起寫
- 一個事務寫,另一個事務讀
其中存在寫的地方需要我們上鎖來保護資料(懂的人已經發現了這是讀寫鎖)
一致性讀與鎖定讀
MySQL讀取的資料有兩種方式:一致性讀與鎖定讀,他們分別對應著無鎖、與顯式上鎖
一致性讀(Consistent Reads)
一致性讀并不會對表中的任何記錄做加鎖操作,其他事務可以自由的對表中的記錄做改動,一致性讀使用的方式是MVCC,
鎖定讀
鎖定讀是類似Java日常開發中對臨界區的保護,必須先上鎖才能訪問資料,
MySQL為了保證多個事務能夠一起讀的同時,寫操作還能鎖死資料,設計出了兩把鎖
共享鎖與獨占鎖
- 讀操作請求一個共享鎖
- 寫操作請求一個獨占鎖
當有獨占鎖出現后,共享鎖也將被鎖定
若有共享鎖存在,需要等待解鎖才能申請獨占鎖
我們可以從打掃廁所的角度來理解:(抱歉今個有點重口味
當清潔工打掃時,會等里面的同學出來,然后在門口放上警示(獨占鎖
當無人打掃時,xdm可以一起幸♂福的沖沖沖
這就是共享鎖與讀寫鎖,實質上并不難,他們的原理還是信號量

但是啊,這個時候東陽市大學又整了新操作,他們要修整宿舍樓,修整宿舍樓的時候,也是要限制滴
上述情況中,宿舍樓對應了一張表,廁所對應了一行記錄
于是MySQL就出現了不同粒度的鎖:表鎖
表鎖
MySQL中也有修改表的陳述句,這些陳述句修改的級別是表而不是上文的一行資料,于是表鎖出現了
給表加的鎖也可以分為共享鎖(共享鎖)和獨占鎖(獨占鎖):
-
給表加
共享鎖:如果一個事務給表加了
共享鎖,那么:- 別的事務可以繼續獲得該表的
共享鎖 - 別的事務可以繼續獲得該表中的某些記錄的
共享鎖 - 別的事務不可以繼續獲得該表的
獨占鎖 - 別的事務不可以繼續獲得該表中的某些記錄的
獨占鎖
- 別的事務可以繼續獲得該表的
-
給表加
獨占鎖:如果一個事務給表加了
獨占鎖(意味著該事務要獨占這個表),那么:- 別的事務不可以繼續獲得該表的
共享鎖 - 別的事務不可以繼續獲得該表中的某些記錄的
共享鎖 - 別的事務不可以繼續獲得該表的
獨占鎖 - 別的事務不可以繼續獲得該表中的某些記錄的
獨占鎖
- 別的事務不可以繼續獲得該表的
綜合來講與行級鎖幾乎一致,只是層級高了,
但是這其中又有些不一樣的地方:表鎖需要考慮這個表里面有沒有行鎖,表的上鎖需要等待行鎖
但是一個庫可能有幾百萬行,不可能一個一個查吧
為了提高表鎖的效率,我們提出一種意向鎖,使用意向鎖來包裹一次原來的行級鎖,以此表示是否有正在進行的鎖任務,
當我們準備獲取鎖的時候,先用意向鎖上鎖,此時其他的事務發現這個表被上了意向鎖,表示這個表已經鎖定了,需要等待一下,
用鎖來表示當前是否有人訪問,免去了遍歷查詢的痛哭,好!
當然,既然是包裹一層,自然意向鎖也有兩種,共享與獨占,
| 兼容性 | 獨占 | 意向獨占 | 共享 | 意向共享 |
|---|---|---|---|---|
獨占 | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
意向獨占 | 不兼容 | 兼容 | 不兼容 | 兼容 |
共享 | 不兼容 | 不兼容 | 兼容 | 兼容 |
意向共享 | 不兼容 | 兼容 | 兼容 | 兼容 |
MySQL的鎖
MySQL中MyISAM、MEMORY、MERGE這些存盤引擎只支持表級鎖,只有Innodb才支持事務、行鎖
MySQL對自增id的保護
自增關鍵字AUTO_INCREMENT也會使用鎖來保證唯一與遞增
他主要有兩種實作方式:重量級別的表鎖與輕量級的鎖,他們對應了目標數量是否確實的兩種情況
- 如果目標的數量不確定,那就有必要先把整個表鎖起來,然后再慢慢增加
- 如果目標數量是確定,我們直接用輕量級的鎖快速賦值,
寫到這里,已經7k字了,豬豬男孩決定不再繼續深入了,關于鎖的具體實作,大家可以看看
掘金小冊《MySQL 是怎樣運行的:從根兒上理解 MySQL》
覺得有用,還請一鍵三連,送弟弟上岸,

轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/258778.html
標籤:其他
