【云圖說】第235期 DDS讀寫兩步走 帶您領略只讀節點的風采
820
2025-04-01
前段時間開發時,正好遇到了2個進程同時更新一行記錄時引發的bug,雖然問題最終解決了,但自己對背后的運行邏輯仍舊一頭霧水。事后嘗試簡單翻了下各種博客資料,還有《高性能mysql》那本書時,發現大部分是將一堆八股文概念堆砌在一起,很少完整串聯過這堆概念。
于是我重新完整學習了這些概念和底層原理, 通過一個轉賬問題的場景,將這些概念全部關聯起來。
將下面這些數據庫的概念單獨拿出來時,相信很多人都有了解或者記憶過,但是將這些概念全部串聯在一起時,可能就會很混亂。
我這里舉個例子:
排他鎖、共享鎖
行鎖、表鎖、意向鎖、間隙鎖、next-key鎖
悲觀鎖、樂觀鎖
兩階段鎖協議
LCBB鎖并發控制協議、MVCC多版本控制協議
臟讀、不可重復讀、幻讀
RU\RC\RR\SE隔離級別
然后自己問自己一個問題:
這一堆鎖的關聯關系究竟是什么?
各隔離級別究竟是怎么用各種鎖+MVCC來解決事務讀問題的?
首先,我們完全不考慮數據庫引擎、隔離級別設置之類的,就當作你用一個超簡陋的兒科級別數據庫來存放和更新數據。
假設你的商城服務正好在同時執行如下的2種事情
張三給窮光蛋李四轉賬100元。
李四嘗試下單購買100元的衣服
李四在最開始余額只有0元錢。
注意因為是同時執行,在沒有做任何保護的情況下,就可能會出現下圖這樣的情況
可以看到李四明明沒有錢,卻扣費了,變成了很奇怪的-100元。
Q: 那這個有問題的讀過程叫什么?
A: 這個過程就叫做臟讀。 即更新回退的時,另一個事務讀到了臟數據,判斷失誤,導致做了錯誤的處理。
根本原因是2個事務都是先查后扣,卻沒有提前保護的形式
Q: 在不修改數據庫隔離級別的情況下, 我們可以如何用sql語句手動解決這個臟讀?
A: 那很顯然就是加鎖對事務過程做提前保護, 不讓B去判斷和扣費。
sql語句里有個 ”for update“ 語法, 會手動鎖住李四那一行,在調用commit后釋放
具體見下面綠色的標注部分:
Q: 剛才看到”鎖住李四這一行“, 那么這個就叫行級鎖。
什么情況下會變成鎖住整個表?
A:
name ='李四’這句話, 如果name是索引列的話,就會加行鎖
如果不是索引列, 就會變成表鎖。
換言之, 行鎖的本質是在索引節點上加鎖
如果無法在索引節點上加鎖,那就會直接變成整張表的鎖,代價就會很大。
另外表鎖也可以單獨用lock table的語法手動加鎖
Q: 如果一個事務A申請了行鎖,鎖住某一行, 另一個事務B申請了表鎖,那B會被阻塞嗎?
A:
B事務既然申請表鎖,說明可能會用到A中的每一行。
B申請的流程可以是下面這樣:
判斷表是否已被其他事務用表鎖鎖表
判斷表中的每一行是否已被行鎖鎖住。
但2這一步也太耗時了。
因此A申請行鎖前,會優先申請一個意向鎖,再申請行鎖。
然后B申請時,第2步改成判斷意向鎖即可,有意向鎖就阻塞。
簡單點說, 意向鎖就是行鎖操作用來阻塞表鎖用的。 但行鎖和行鎖之間不會互相阻塞,除非行有沖突。
剛才看到的for update會限制其他并行事務的所有讀寫操作,而且是2個事務上都加了”for update“。
那么這個鎖就叫做”排他鎖“, 屬于非常強勢的鎖, 相當于其他讀寫操作馬上全部攔住了。
這里使用排他鎖來解決臟讀的原因是因為后面有查詢余額+扣余額的代碼,寫這段代碼的人必須做提前保護,以避免自己讀到一個可能被修改的數據,導致判斷和修改失誤。
和排他鎖對應的是“共享鎖”,也就是熟知的讀寫鎖。
可以讓多個事務同時讀,但是不允許修改 。
手動加共享鎖的方式:把for update改成 lock in share mode即可
Q: 那么什么時候使用共享鎖比排他鎖要好呢?
A:
可以看下面的例子:
可以看到沒有查自身+更新自身的操作, 僅僅是查+更新其他表,表之間也互不關聯,對余額的實時性也不是要求太高。
如果都加排他鎖,各種select操作就會很慢。
但如果不加共享鎖, T6這邊刪除時,就可能產生冗余數據,所以還是得加鎖。
Q: 那我加的共享鎖(S鎖)和排他鎖(X)什么時候釋放呢?是每次執行完update馬上釋放嗎?
A:
這里就涉及了“兩階段鎖”協議。
加鎖階段:在該階段可以進行加鎖操作。在對任何數據進行讀操作之前要申請并獲得S鎖(共享鎖,其它事務可以繼續加共享鎖,但不能加排它鎖),在進行寫操作之前要申請并獲得X鎖(排它鎖,其它事務不能再獲得任何鎖)。加鎖不成功,則事務進入等待狀態,直到加鎖成功才繼續執行。
解鎖階段:當事務釋放了一個封鎖以后,事務進入解鎖階段,在該階段只能進行解鎖操作不能再進行加鎖操作。
說人話, 就是在事務中需要加鎖時再加鎖, 直到commit完一次性解鎖。
為什么要兩階段鎖,看到的一句話是
若并發執行的所有事務均遵守兩段鎖協議,則對這些事務的任何并發調度策略都是可串行化的。
Q: 兩階段鎖協議可以避免死鎖嗎?
A:
不能避免,但是可以通過死鎖檢測算法進行事務解除。
重新回到張三李四轉賬+下單的場景上來。
for update這種鎖,其實也是一種“悲觀鎖” ,加鎖解鎖比較耗時, 默認經常發生競爭。
但如果我的轉賬和下單過程要求非常快,每次只有幾毫秒,那加悲觀鎖成本就太大了
這時候就可以手動使用樂觀鎖, 需要你自己在余額表里增加version列,增加后如下所示:
這樣就不需要特地加鎖了,每次循環判斷即可,前提是沖突發生概率比較低,阻塞時間比較短。
剛才一個小小的臟讀,就已經解決了下面3個問題
排他鎖和共享鎖的區別:前者是拒絕所有讀寫 , 后者是允許并發讀拒絕寫
行鎖和表鎖的區別: 前者是對單行加鎖 , 后者是對整表加鎖, 區別是 是否涉及索引
悲觀鎖和樂觀鎖的區別: 前者主動用數據庫自帶的鎖, 后者自己添加version版本號
外加一個兩階段鎖協議
繼續回到臟讀問題, 前面我們學習的所有概念,都是和數據庫自身隔離級別無關,使用數據庫的鎖語法或者version版本號來避免。
但數據庫發展這么強大,怎么可能需要我們頻繁自己寫這種復雜邏輯,于是數據庫誕生了隔離級別設置。
前面會發生臟讀的隔離級別, 叫做RU(read uncommited)
即RU級別時, 我可以在別的事務沒完全commit好時就讀到數據。
Q: 先來個小問題,RU級別沒有任何鎖,對嗎?
A:
錯誤, RU級別做update等增刪改操作時,仍然會默認在事務更新操作中增加排他鎖,避免update沖突。
切記臟讀的發生原因,是查詢+更新+回滾時沒加鎖導致其他查詢操作出現失誤判斷。
即查詢這塊可能讀到沒提交的數據,導致錯誤,而不是更新的并發問題。
Q: 當我們的數據庫被設置成RC級別(Read commited)時, 可以解決臟讀, 那么背后是怎么解決的呢?
A:
業界有兩種方式
LBCC基于鎖的并發控制(Lock-Based Concurrency Control))
MVCC基于多版本的并發控制協議(Multi-Version Concurrency Control)
LBCC其實就是類似前面手動用悲觀鎖的方式, 事務操作中查詢時默認試圖加鎖,因此就可能被update的排他鎖阻塞住,避免了臟讀。
但代價就是效率很低。很多場景下,select的次數是遠大于update的。
所以InnoDb 基于樂觀鎖的概念, 想了一個MVCC,自己在事務的背后實現了一套類似樂觀鎖的機制來處理這種情況。 確保了盡可能不在讀操作上加鎖, 排他鎖只對更新操作生效。
Q: MVCC究竟是怎么做的呢?
A:
簡單來說,就是默認給每個數據行加了一個版本號列TRX_ID和回滾版本鏈ROLL_BT,具體可以看《高性能mysql》書里的這段描述:
簡而言之
查的時候,只查當前事務之前的記錄,或者回滾版本比當前大的已刪記錄。
增的時候,加新版本的記錄
刪的時候,把老記錄標記上回滾版本
改的時候,本質上是加新記錄, 同時把老記錄標上回滾版本
Q: MVCC機制下, 什么是快照讀,什么是當前讀?
A:
快照讀:對于select讀操作,統一默認不加鎖,使用歷史版本數據。
當前讀:對于insert、update、delete操作,仍然需要加X鎖,因為涉及了數據變更,必須使用最新數據進行修改
Q: 那么回到剛才的臟讀問題, MVCC究竟是怎么在讀不加鎖的情況下, 解決臟讀的?
A:
首先,每次select都不用任何鎖, 每次都是快照讀,不會阻塞,因此會變成下面這樣:
總結這個圖,就是
每次讀時,會生成一個readView,用來記錄當前還沒提交的事務版本號。
根據自己事務的版本號version,去尋找小于自己當前版本且不在readView集合中的記錄。
這樣的話就保證了讀的數據必須是已經完成提交的,是不是很簡單?
Q: 如果事務B中不做余額判斷,支持直接賒賬+扣費, 那是不是會導致先扣費,然后回滾成0這樣的情況?
A:
不會。
上面提過, MVCC中更新操作都是“當前讀”,仍然需要加X鎖, 且因為涉及了數據變更,必須使用最新數據版本進行修改
換言之, update等操作, 還是會加鎖,且用最新版本更新,避免了臟更新的問題,如下:
Q: 上面這個過程有什么隱患
A:
如果1個事務中連續讀2次余額,可能有“不可重復讀”的風險,即前后讀的數據發生了不一致
如下所示
因此RC隔離級別無法解決 “不可重復讀的問題”
Q: RR(可重復讀,Repeat Read)的隔離級別又是怎么解決上面這個問題的?
A:
本質上就是readView生成時的區別
上面RC不可重復讀的圖中可以看到,每次讀時,都取了最新的readView。 這可能導致事務A提交后, 事務B觀察到的readView集合發生了變化。
因此RR機制改變了readView的生成方式, 每次讀時只使用事務B最開始拿到的那個readView,這樣永遠就只取老的數據了。
Q: 那讀問題中的幻讀又是什么?
A:
剛才的”不可重復讀“,是一個事務中查詢2次結果,發現值對不上。
而”幻讀“,是指一個事務中查詢2批結果,發現這2批數量對不上,就好象發生了幻覺。
就像下圖所示展示:
Q: RR隔離級別中的MVCC機制可以解決上面的問題嗎?
A:
可以解決。
通過查詢的快照讀,能夠保證只查詢到同一批數據。
Q: 那如果像下面這樣, 事務A連續做兩次更新呢,單純靠MVCC能避免更新操作的幻讀么?
A:
如果只依靠MVCC,那就無法避免了, 因為update操作是”當前讀“,每次取最新版本做更新, 這會導致update中的讀操作出現幻讀,前后更新的記錄數量不一樣了。
Q: 那數據庫怎么處理這種2次updete中間做insert的幻讀情況呢?
A:
之前有了解到, update過程仍然會加鎖,
RR級別會啟用一個叫”間隙鎖“(Gap鎖)的玩意,專門來防這樣情況。
即調用 update xxx where name ='李四’時, 不僅僅在李四的行上加鎖, 更會在中間所有行的間隙、左右邊界的兩邊,加上一個gap間隙鎖,就像下面這個圖一樣:
可以看到,訂單D的插入過程被update過程的間隙鎖攔住了,于是無法插入,置到事務結束才會釋放。
因此事務中兩次update之間的幻讀是可以避免的,也能。
Q: 那行鎖、間隙鎖、next-key鎖是什么區別?
A:
行鎖就是單個行(單個索引節點)加鎖
間隙鎖就是在行(索引節點之間)加鎖
next-key就是“行鎖+間隙鎖”,一起使用。
Q: 如果name這個字段不是索引,而是普通字段,那間隙鎖會怎么加?
A:
那就會給整個表的所有間隙都加上鎖!
因為數據庫無法確認到底是哪個范圍,所以干脆全加上。
這就會導致整表鎖住,性能很差。
Q: 那是不是只要name是索引,就不會給整個表全加間隙鎖了?
A:
不對, 如果where條件寫的有問題,不符合最左匹配原則,那也會導致索引失效, 以至于給整個表加鎖。
Q: 剛才看到說RR可以解決2次select之間的幻讀, 也能解決2次update之間的幻讀, 那為什么很多資料里,仍然說RR不能解決幻讀?
A:
這個問題我也是翻了好多資料, 終于找到了一個合理的解釋。
看下面這個場景:
發現什么區別沒, 事務B的insert操作,發生在了事務A的update之前。因此事務B的insert操作沒有被間隙鎖阻塞。
而update用的是當前讀, 于是更新的數量和 最初select的數量匹配不上了。
Mysql官方給出的幻讀解釋是:只要在一個事務中,第二次select多出了row就算幻讀,所以這個場景下,算出現幻讀了。
這也就是下面這個圖的來源:
Q: 那串行化serializable隔離級別,為什么就能避免幻讀了?
A:
Se級別時,會從MVCC并發控制退化為基于鎖的并發控制(LCBB)。
不區別快照讀和當前讀
所有的讀操作都是當前讀,讀加讀鎖(S鎖),寫加寫鎖(X鎖)。在該隔離級別下,讀寫沖突,因此并發性能急劇下降,在MySQL/InnoDB中不建議使用。
這就是我們文章最開頭手動加鎖的那個過程了。
EI企業智能 MySQL 可信智能計算服務 TICS 數據庫 智能數據
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。