這是數據庫事務分享的第二篇,上一篇講解數據庫事務併發會產生的問題,這篇會詳細講數據庫如何避免這些問題,也就是如何實現隔離,主要是講兩種主流技術方案——MVCC與鎖,理解了MVCC與鎖,就能夠觸類旁通地看各類數據庫併發控制方案,並理解每種實現能解決的問題以及須要開發者本身注意的併發問題,以更好支撐業務開發。數據庫
先回顧一下上一篇討論過的,若是沒有隔離或者隔離級別不足,會帶來的問題:編程
可見,全部問題本質上都是由寫形成的,根源都是數據的改變。 讀是不改變數據的,所以不管多少讀併發,都不會出現衝突,若是全部的事務都只由讀組成,那麼不管如何調度它們,它們都是可串行化的,由於它們的執行結果,都與某個串行執行的結果相同,可是寫會形成數據的改變,稍有不慎,這個併發調度的結果就會與串行調度的結果不符合。安全
在進行下面的討論下,先定義好咱們描述事務的模型: 咱們用 R 表示讀(read),用 W 表示寫(Write),在操做後跟數字,表明哪一個事務在進行操做,在數字後跟括號,表明操做哪一個元素 用 R1(A) 表示事務1讀元素A,用 R2(A)表示事務2讀A數據結構
看一下寫操做如何形成併發調度與串行執行的結果不符合: 併發
事務1讀A的值,而且在此基礎上增長50並回寫A,可是在回寫以前,事務2將A修改成了200,這兩個事務按照此調度執行後,A的最終值爲150,不符合任何串行調度的結果。 若是串行調度爲 事務1 => 事務2,那麼A最終應該是200 若是串行調度爲 事務2 => 事務1,那麼A最終應該是250 因而可知,不一樣事務間,讀-寫、寫-寫都是衝突的,不加控制的寫操做,會致使併發調度不可串行化。(本節以MySQL InnoDB爲基本模型)高併發
實現可串行化的基石是控制衝突,強行保證衝突操做的串行化,那麼應該遵循如下原則:post
讀的時候不能寫,寫的時候不能讀也不能寫,可是讀的時候能夠讀,由於讀不衝突,因而數據庫須要兩種鎖:編碼
這兩種鎖和讀、寫是什麼關係呢? 讀寫都會加鎖,可是讀-讀能夠併發,寫則須要與任何操做排隊,因此:設計
經過讀寫操做加鎖,實現了讀寫、寫寫的排隊,可是靠簡單加鎖保證的排隊,但排隊粒度過小,僅僅是操做與操做之間的排隊,不足以解決上面圖中的不可串行化問題,由於若是事務1讀A後立刻釋放讀鎖,則事務2能夠立刻獲取到A的寫鎖,改變A的值,仍是會出現上面的不可串行化問題,所以事務須要保證更大粒度的排隊——若是一個記錄被某個事務讀取或者寫入,則直到這個事務提交,才能被別的事務修改
, 嚴格兩階段加鎖(Strict Two-Phase Locking) 由此誕生。3d
首先提一句什麼是兩階段加鎖協議(2PL),它規定事務的加鎖與解鎖分爲2個獨立階段,加鎖階段只能加鎖不能解鎖,一旦開始解鎖,則進入解鎖階段,不能再加鎖。 嚴格兩階段加鎖(S2PL)在2PL的基礎上規定事務的解鎖階段只能是執行commit或者rollback後,所以S2PL保證了一個事務曾經讀取或寫入的記錄,在此事務commit或rollback前都不會被釋放鎖,所以不能被其餘記錄加鎖,不會形成記錄的改變,由此實現了可串行化。
InnoDB中不止支持行級鎖,還支持表級鎖,爲了兼容多粒度的鎖,設計了一種特殊的鎖——意向鎖(Intention Lock),它自己不具有鎖的功能,只承擔「指示」功能。 若是要加表級鎖,則必須保證行級鎖已徹底釋放,整張表都沒有任何鎖時,才能爲表加上表鎖。那麼問題來了,怎麼判斷是否整張表的每一條記錄都已經釋放鎖? 若是經過遍歷每條記錄的加鎖狀態,未免效率過低,所以須要意向鎖,它只是一個指示牌,告訴數據庫,在此粒度之下有沒有被加鎖,被加了什麼鎖。就像停車場會在門口立一個牌子指示「車位已滿」仍是「內有空餘」,不須要開車進去一個個車位檢查,提升了效率。 InnoDB若是要對一條記錄進行加鎖,它須要先向表加上意向鎖,而後才能對記錄加普通鎖,獲取意向鎖失敗,則不能繼續向下獲取鎖。
意向鎖之間是徹底兼容的,很好理解,由於意向鎖只表明事務想向下獲取鎖,具體是哪條記錄不肯定,所以意向鎖是徹底兼容的,即便表上已經被其餘事務加了某種意向鎖,事務仍是可以成功爲表加意向鎖。通常咱們不會在事務中加表鎖,表鎖效率過低,咱們加的通常是行級鎖,行級鎖是加在某條特定的記錄上,咱們稱之爲記錄鎖。 這一節的內容主要是對多粒度加鎖有個概念,現實中不多用表鎖。 上面說的共享鎖、排它鎖是按照鎖兼容性定義,表鎖、記錄鎖(Record Lock)則是按加鎖範圍定義,根據加鎖範圍不一樣,還有其餘N種鎖,下面會提到一些。
考慮一個例子: 事務1執行「SELECT name FROM students WHERE age = 18」返回結果爲「張三」,而事務2立刻插入一行記錄「INSERT INTO students VALUES("李四",18)」並提交,事務1再次執行相同的SELECT語句,發現結果變爲了「張三」+「李四」,這就是幻讀,同一個事務進行的兩次相同條件的讀取,卻讀取到了以前沒有讀到的記錄
。 有了記錄鎖雖然能夠實現對已存在記錄進行併發控制,也就是對於更新、刪除操做,不再會有併發問題,可是沒法對插入作併發控制,由於插入操做是對不存在的記錄,而還不存在的記錄,咱們沒法爲其加記錄鎖,所以可能會產生幻讀現象。 爲了解決這個問題,出現了間隙鎖,間隙鎖也是加在某一條記錄上,但是它並不鎖住記錄自己,它只鎖住這條記錄與它的上一條記錄之間的間隙,防止插入
。 以下圖所示,若是一張表有主鍵爲一、二、5的三條記錄,若是5被加上間隙鎖,只會鎖住開區間(2,5)間隙,而不會鎖住5這條記錄自己。
不少時候須要鎖住多個間隙以及記錄自己,好比執行「SELECT name FROM students WHERE id >= 1」,須要鎖住(1,3)、(3,5)、(六、7)以及一、三、五、7四條記錄自己:
間隙鎖和記錄鎖是兩種鎖結構,所以不能合併,若是爲3個間隙分別加間隙鎖,4條記錄分別加記錄鎖,則會產生7條鎖記錄,很佔用內存,所以MySQL有一種鎖稱爲Next-Key Lock,若是在小紅的記錄上面加Next-Key Lock,則會鎖住(1,3]這個前開後閉的區間,也就是鎖住了記錄自己+記錄以前的間隙,能夠發現,Next-Key Lock其實就是Gap Lock + Record Lock
。此時鎖結構就能夠簡化成爲ID爲1的記錄加上記錄鎖+後面連續的3個Next-Key Lock,因爲Next-Key Lock類型相同而且連續,能夠將它們放入同一個鎖記錄,最後只有ID爲1的記錄鎖+1個Next-Key Lock。 Next-Key Lock並無什麼特別之處,只是對Record Lock + Gap Lock的一種簡化。
方案:事務寫記錄必須獲取排它鎖
原理:事務寫記錄以前獲取它的排它鎖,同時因爲嚴格兩階段加鎖,在事務提交前都不會釋放鎖,所以徹底避免了髒寫。
方案:事務寫記錄必須獲取排它鎖
原理:當記錄被加上排它鎖後,是不容許再被加任何鎖的,所以任何事務都沒法讀到其餘事務寫入還未提交的數據。
方案:事務讀記錄必須加鎖(S或X鎖都可)
原理:因爲事務在讀記錄時已經爲記錄上鎖,所以其餘事務沒法再爲這條記錄上排它鎖,所以根本沒法修改這條記錄,也不會出現不可重複讀。
方案:間隙鎖
原理:間隙鎖阻塞了插入,所以也不會出現幻讀問題。
讀誤差須要再稍微解釋下,仍是用上一篇提到的例子:好比X、Y兩個帳戶餘額都爲50,他們總和爲100,事務A讀X餘額爲50,而後事務B從X轉帳50到Y而後提交,事務A在B提交後讀Y發現餘額爲100,那麼它們總和變成了150,此時事務A讀到的數據違反業務一致性,爲讀誤差。 能夠發現,讀誤差是因爲業務一致性是由多條記錄的總狀態保證的,在事務A開啓並讀取了其中某一部分記錄後,事務B對A尚未讀到的記錄進行了修改而且B提交了,此時數據庫已經進入了新的一致狀態,可是A在B提交後再去讀那部分記錄,讀到了B修改後的數據,雖然此時數據庫事實上依舊處於一致狀態,可是A卻發現多條記錄的總狀態不符合業務一致性,產生讀誤差
。 讀誤差的本質是由於事務A有一部分是陳舊數據,另外一部分是新數據,總狀態不一致。
方案:讀數據必須獲取鎖,寫數據必須加排它鎖
原理:因爲事務在讀記錄時已經加上了鎖,那麼任何事務都不能再獲取排它鎖,也就不能更新這條已經被讀過的數據,那麼對於事務天然不可能存在「陳舊數據」一說,任何被讀到的數據,在它提交前都不可能被修改,所以讀到的都是最新數據。
上一篇有詳細講到寫誤差,這裏就很少說,它與讀誤差本質相同,都是由於讀到的某一部分數據成爲了陳舊數據,寫誤差使用陳舊數據做爲寫前提,所以做出了錯誤判斷,寫入了業務不一致的結果,所以解決寫誤差須要解決陳舊數據問題。
方案:讀數據必須獲取鎖,寫數據必須加排它鎖
原理:它與寫誤差的解決原理徹底相同,都是由於加鎖強制避免了事務讀取過的數據被修改,防止了陳舊數據的出現。
丟失更新也在上一篇中有講到,大概就是事務A先讀X,對X進行計算後再寫X,可是在寫X以前,已經被事務B修改了X的值並提交了,而A不知道,將它認爲正確的X值寫入,覆蓋了事務B的值,此爲丟失更新。 丟失更新的本質也是基於陳舊數據作出修改決策,只不過陳舊記錄與被修改記錄爲同一條記錄,這是和寫誤差的惟一區別。
方案:讀數據必須獲取鎖,寫數據必須加排它鎖
原理:它與避免讀、寫誤差徹底相同的原理,避免記錄成爲陳舊記錄。
可見,InnoDB中的可串行化隔離級別,基於鎖,避免了全部併發問題,是最安全的事務隔離級別,可是在業務開發中並非每一個併發問題咱們均可能遇到,因爲業務的獨特性,可能只會面臨某一些併發問題或者能夠用其餘方式去規避這些併發問題帶來的業務損害,而爲了不全部的併發問題去使用鎖,明顯是個收益很低的選擇,有時能夠容許某些併發問題,減小鎖的使用,提升併發效率,下面會講到的MVCC就是個很好的替代品。
可串行化雖然保證了事務的絕對安全,可是併發度很低,不少操做都須要排隊進行,爲了提升效率,SQL標準在隔離級別上進行了妥協,由此有了可重複讀、讀提交的隔離級別,它們都容許部分併發問題,這裏先講可重複讀隔離級別。 SQL標準中,可重複讀僅僅須要徹底避免髒寫、髒讀、不可重複讀三種異常,此時若是再用加鎖實現,讀-寫排隊未免效率過低,因而MVCC誕生了。 MVCC全稱Multiple Version Concurrency Control,也就是多版本併發控制,重點在多版本,簡單來講,它爲每一個事務生成了一個快照,保證每一個事務只能讀到本身的快照數據,不論其餘事務如何更新一條記錄,這個事務所讀到的數據都不會產生變化,也就是說,會爲一條記錄保留多個版本,多個事務讀到的版本不一樣,MVCC代替了讀鎖,實現了讀-寫不阻塞
。 MVCC的意義只是替代讀鎖,寫依舊是加鎖的,這樣避免了髒寫,下面先講一下MVCC的實現思路,認識MVCC如何避免併發問題,最後討論MVCC在併發中的侷限性。
在MVCC中,每條記錄都有多個版本,串成了一個版本鏈,也就是說,記錄被UPDATE時並非In Place Update,而是將記錄複製而後修改存一份到版本鏈,被DELET時,也不是立刻從文件刪除,而是將記錄標記爲被刪除,它也是版本鏈的一環。 在InnoDB中每條記錄中都有2個隱藏列,1個是trx_id,一個是roll_pointer。
MVCC中最常聽到的概念就是快照,其實快照只是最終結果,而不是實現方式,快照 = 版本鏈 + Read View。 MVCC並非將表中全部的記錄都爲這個事務凍結了一份快照,而是在事務執行第一條語句時時生成了一個叫作Read View的數據結構,注意,Read View是事務執行語句時纔會生成的,僅僅執行start transaction是不會生成Read View的
。 Read View保存着如下信息:
Read View結合版本鏈使用,當事務讀取某條記錄時,會根據此事務的Read View判斷此記錄的哪一個版本是這個事務可見的:
有了版本鏈和Read View,即便其餘事務修改了記錄,先生成Read View的事務也不會讀到,只要Read View不改變,每次讀到的版本必定相同。MySQL中可重複讀和讀提交級別都基於MVCC,區別只是生成Read View的時機不一樣,可重複讀級別是在事務執行第一個SQL時生成Read View,而讀提交級別是在事務每執行一條SQL時都會從新生成Read View
。
MVCC取代了讀鎖的位置,它不阻塞寫入雖然有提升效率的優點,可是同時也沒法防止全部併發問題。
事務是沒法讀到Read View生成後別的事務產生的記錄版本,所以能夠在不加間隙鎖的狀況下也不會讀到別的事務的插入,那MVCC能避免幻讀嗎? 先說結論:MVCC不能夠避免幻讀。 致使這個問題的根本緣由是:InnoDB將Update、Insert、Delete都視爲特殊操做,特殊操做對記錄進行的是當前讀(Current Read),也就是會讀取最新的記錄,也就是說Read View只對SELECT語句起做用。 若是users表中有id爲一、二、3共3條記錄,事務A先讀,事務B插入一條記錄並提交,事務A更新被插入的記錄是能夠成功的,由於UPDATE是進行當前讀,更新時能夠讀到id爲4的記錄存在,所以能夠成功更新,事務A成功更新id爲4的記錄後,將在id爲4的記錄版本鏈上新增一條事務A的版本,所以事務A再次SELECT,就能夠名正言順地讀到這條記錄,符合Read View規則,但產生了幻讀。
若是要避免幻讀,可使用MVCC+間隙鎖的方式。因爲MVCC中讀-寫互不阻塞,所以事務讀取的快照可能已通過期,讀到的可能已經成爲陳舊數據,所以可能出現Read Skew與Write Skew。
仍是因爲讀-寫不阻塞的特性: R1(A) => R2(A) => W2(A) => W1(A) 事務1讀出的A值已通過期,可是它不知道,仍是根據舊的A值去更新A,最後覆蓋了事務2的寫入。 在Postgrel中,Repeatable Read級別就已經避免了丟失更新,由於它使用MVCC+樂觀鎖,若是事務1去寫入A,存儲引擎檢測到A值已經在事務1開啓後被別的事務修改過,則會報錯,阻止事務1的寫入。單純的MVCC並不能防止丟失更新,須要配合其餘機制。
在進行業務開發時應該先了解項目使用的數據庫的事務隔離級別以及其原理、表現,而後根據事務實現原理去思考更好的編碼方式。
這種狀況你們必定很熟悉了:
所以建議在不一樣的業務中, 儘可能統一操做相同記錄語句的順序。鎖都是加在索引上的(這裏最好先理解一下B+Tree索引),因此一條SQL若是涉及多個索引,會爲每一個索引加鎖,好比有一張users表(id,user_name,password),主鍵爲id,在user_name上有一個惟一索引(Unique Index),如下語句:
UPDATE users SET user_name = 'j.huang@aftership.com' WHERE id = 1;
這條語句中涉及到了id與user_name兩個索引,InnoDB是索引組織表,主鍵是聚簇索引,所以記錄是存在主鍵聚簇索引結構中的,那麼這條SQL的加鎖順序爲:
此時若是另外一條事務執行以下語句:
UPDATE users SET password = '123' WHERE user_name = 'j.huang@aftership.com';
則可能產生死鎖。 緣由你們能夠先思考一下。 這條語句的加鎖順序是:
他們都會對同一個主鍵索引加鎖和同一個二級索引,可是加鎖順序不一樣,所以可能形成死鎖,這種狀況很難避免,MySQL中能夠經過SHOW ENGINE INNODB STATUS
查看InnoDB的死鎖檢測狀況。
其實不少業務場景並不須要事務,好比說領取優惠券,並不須要開啓一個Serializable級別的事務去SELECT優惠券剩餘數量,判斷是否有餘量,再UPDATE領取優惠券,徹底能夠一條語句解決:
UPDATE coupons SET balance = balance - 1 WHERE id = 1 and balance >= 1;
語句返回後判斷更新行數,若是更新行數爲1,則表明領取成功,更新行數爲0,表明沒有符合條件的記錄,領取失敗。 (注意:這裏只考慮領取優惠券的場景,若是業務還須要將優惠券寫入users表等其餘一系列操做,就須要根據業務需求放入事務)
首先應該理解將SELECT放入事務的意義是什麼?
若是不是以上兩個緣由,則SELECT是沒有必要放入事務的,好比下單一件產品,若是隻是SELECT它的product_name去寫入orders表,這種非強一致要求的數據,沒有必要放入事務,由於product_name即便被改變了,寫入order的product_name是1秒前的舊數據,也是能夠接受的。
不少開發者誤覺得將SELECT放入事務,將結果做爲判斷條件或者寫入條件是安全的,其實根據隔離級別不一樣,是不必定的,舉個例子:
將這兩條語句放入事務也不必定是安全的,這取決於事務的實現,若是是InnoDB的Repeatable Read級別,那麼這個事務是不安全的,由於SELECT讀到的是快照,在UPDATE以前,其餘事務可能就已經修改了user的等級信息,他可能已經不知足3倍積分條件,而此時再去UPDATE user_scores表,這個事務是個業務不安全的事務。 所以,要先了解事務,再去使用,不然容易用錯。