天天看點

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

一、事務

事務性資料庫實作的是

  • 支援未完成的資料修改復原的機制,對應“原子性”
  • 力所能及的資料合法性檢查,對應“一緻性”
  • 保證資料并發的修改的規則,對應“隔離性”
  • 使用基于持久化存儲(磁盤、SSD)的方式對資料進行存儲,對應“持久性”

1. ACID的概念(原子性、一緻性、隔離性、持久性):

1.1Atomicity 原子性

很令人郁悶的是,這個詞表達意思與正常語境下不太一樣。對于一門支援并發的程式設計語言(比如Java,C++),原子性是指一組指令被執行時,不受其他指令的幹擾。比如我們可以說“CAS是原子的;給一個整型變量指派是原子的”等等。但是在ACID語境下,“不受幹擾”這層意思其實是後邊“隔離性“說的事情。在ACID語境下,原子性是指一組對資料庫的改變,要麼最終成功執行完成,要不就全部復原。這就要求資料庫系統要實作某種復原的機制,比如redo/undo log)。是以,也許這裡用術語”revertability“可能更适合。與事務性資料庫相比,一些NoSQL的資料庫也聲稱支援原子性,但是意義不同。比如Redis事務的原子性的意思可能更接近于“一組指令被執行時,不受其他指令的幹擾”,而不是“可以復原”。

1.2Consistency 一緻性

這個術語的用詞也頗為怪異。一般來講,我們習慣用“一緻性”來描述資料在某些條件下可以變成一樣的。例如,在描述CPU工作方式時可以說需要主記憶體在CPU Core1裡的緩存和CPU Core2裡的緩存是“一緻的“;或者,一個分布式資料系統中,A節點從B節點複制資料,A的資料要和B的資料"嚴格一緻"或者“最終一緻”。而ACID下的一緻性指的是,在事務完成前後,資料都是要在業務意義上是”正确的“,是以也許術語”correctness“更适合這裡的意思。但如果這樣定義的話,資料庫的位置就很尴尬了,因為保證業務是否正确是要業務代碼來最終保證的,資料庫能做的非常有限。目前資料庫裡實作的限制檢查,比如唯一限制、外鍵限制、一些enum測檢查、一些資料類型/長度/有效數字的檢查等等,對于簡單的場景還可以使用。對于複雜的業務限制檢查,很難或者不可能實作。有一類資料正确性問題正是由于下面隔離性的使用不當而帶來的。 真實複雜業務的資料正确性維護一般用正确的業務代碼 + 合法性job來定時執行 + 資料庫自身的簡單合法性防護一起實作

1.3Isolation 隔離性

是指一組對資料庫的并發修改互相不影響。這個概念表面上看來并不是能說得通,因為如果并發修改的是互不相幹的資料,那麼自然隔離性可以得到滿足;如果并發修改的是相關聯的,或者就是同一份資料,就必然會互相影響。那麼,此時可以做的就是區分哪個修改優先級更加高。而高優先級的修改應該覆寫掉低優先級的修改。但是,現實往往更複雜,因為并發的修改并不一定能夠講明白先來後到的(要不怎麼叫并發呢),此時誰應該生效無法很好的定義。另外一種情況是“先讀取,再基于讀取結果對資料進行修改”這樣業務邏輯。比如,先找到可用的庫存,有則扣減,沒有則提示缺貨;再比如先讀取目前的計數值,再往上加1。這時保證隔離性的主要問題不在于隔離本身,而在于如果将讀取作為對資料修改的前提條件,之後在對資料進行修改的一刹那,讀取時的前提條件還是否滿足。畢竟讀取和寫入是兩個分開的指令,而在這兩個指令中間可能夾雜其他事務對資料的修改。保持隔離性的一個簡單做法是保證對關聯資料的修改串行化,對應事務性資料庫的“Serializable”隔離級别。保證串行化的一種方案是鎖,通過鎖定可以徹底避免競争條件。但是大家都能明白加鎖對資料庫并發的性能負面影響很大,是以就衍生出了幾種弱一些的隔離性保證——READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ。此外MVCC能夠解決一部分鎖帶來的問題。這些内容在下文中會詳細的講解。

1.4.Duration 持久性

是指對資料的修改,一旦完成,該結果就應當永遠不丢失。這是這4個術語中我唯一覺得表面上和實際上意思差不多的一條。在現實當中,一般通過持久性儲存設備(比如磁盤/SSD)寫入并重新整理來保證資料的持久性。如果覺得一個節點不靠譜,可以增加多個副本(replica)一起來保證持久;如果覺得這樣還不夠靠譜,可以在不同的地理位置的另一個資料中心做備份。實際上絕對的持久性是不存在的,因為整個存儲層面有很多不确定因素,比如檔案系統本身fsync指令實作有bug,磁盤的固件有bug,供電出現問題造成資料錯亂,異步的資料複制沒有生效等等。是以在現實當中的資料庫,隻能在目前成本和技術限制的限制下,盡量維持一定程度的持久性。

二、隔離級别和并發控制

四種隔離級别

  • Read Uncommitted
  • Read Committed
  • Repeatable Read
  • Serializable

定義這4種隔離級别時,制定者主要圍繞着基于鎖的并發控制來說的。但是後來出現了MVCC,之後主流資料庫都開始支援MVCC。有的資料庫采用比較純粹的MVCC實作,比如PostgreSQL;有的則是混雜的,比如MySQL InnoDB。這就會造成資料庫的實作和标準的描述有很多出入。

1. 最不嚴格的隔離級别

理論上,最不嚴格的隔離級别應該是不隔離。

不隔離很容易了解,不同的事務可以對同一資料并發的随便改:A事務改了一半的結果B能看到;B改了一半的結果A也能看到;如果A和B反複修改同一個資料,那麼彼此的修改可以覆寫。資料系統在沒有做隔離防護時,就一定會是這個樣子。這樣也就無所謂事務了。

這裡資料通路沖突可以分為兩種:

  1. Dirty Read,髒讀。即一個事務的沒送出之前的修改被另外一個事務可以看到。
  2. Dirty Write,髒寫。即一個事務的沒送出之前的修改可以被另外一個事務的修改覆寫掉。

其實髒讀在某些場景下還是可以接受的,比如完全不需要“讀取-計算-修改”邏輯的事務。這種事務完全不在意别的事物是怎麼修改的,就是自己改自己的;要不就是業務上“就算是基于一個錯誤的前提進行了資料修改,結果也可以接受”的場景。

而髒寫是無法被接受的,因為他會讓事務原子性無法實作。試想以下A和B兩個事務的行為(假設x的值一開始是0):

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

事務B将事務A的修改覆寫了,将x改為2,然後復原。但是事務A卻commit了。此時x應該是多少呢?從事務A的角度可能應該是1,但是從事務B的角度應該是0。這種情況無論如何都不能自洽。是以,任何支援事務的資料庫都有一個基本原則:不論隔離級别是什麼,髒寫都是不允許的!! 如何避免髒寫呢?答案很簡單——使用鎖。實際上,一般資料庫都會使用排他鎖來标記要修改的資料(update,delete,select … for update)。鎖的存在可以保證——寫要block寫。這個規則永遠生效。 在MySQL InnoDB中,這種鎖被稱為“X鎖”。它的特性是,隻要有一個事務擷取了一條資料的X鎖,其他事物如果也想擷取這個鎖,就必須等待,直到第一個事務送出/復原後釋放鎖,或者等到逾時自動復原。例如上面的例子的第二行事務B嘗試進行set x = 2就會被排他鎖卡住。有很多教程會提示一些資料庫的某些隔離級别是使用MVCC而不是鎖的方案來實作的,說的好像用了MVCC就完全不需要鎖。這是非常容易引起誤解的。事務資料庫對于寫操作永遠需要鎖來避免髒寫,即使是基于MVCC的資料庫。所謂某個隔離級别使用MVCC不需要鎖,僅僅是指在讀取的時候是否需要鎖。

是以,最不嚴格的隔離級别的隔離級别是允許髒讀,但不允許髒寫。這種隔離級别被稱為Read Uncommitted。這種隔離級别一般不建議使用。事務應該是資料被修改的最小機關,而Read Uncommitted喪失了“事務”這個詞本身存在的意義。雖然該隔離級别可以帶來一些性能上的優勢,但因為其容易造成資料由于并發操作帶來的問題,是以應該用在不不太在意資料正确性的場景。但如果你的業務需要性能上的優勢,就說明請求量很高,而請求量很高的業務一般很重要,不太可能不需要資料正确性。如果通路請求量不高,直接用預設的隔離級别(有的資料庫是Read Committed,有的是Repeatable Read)就是了,沒有必要去折騰資料庫配置。 如果是高性能的簡單資料操作(比如根據資料ID直接修改資料行),用Read Uncommitted是可行的。但既然這樣,為什麼不用一個正兒八經的NoSQL資料庫(比如RocksDB),進而得到高的多寫入性能?

Read Uncommitted在SQL92裡被定義為最低級别的隔離。但在PostgreSQL中,Read Uncommitted壓根就沒有實作,設定Read Uncommitted等價于設定Read Committed。

**簡單來說,對于事務性資料庫,Read Uncommitted是雞肋一般的存在。諸位可以無視之。**O(∩_∩)O哈哈哈~

2. Read Committed和Repeatable Read

把這倆種隔離級别放一起說是因為它們的基本原則是一樣的:讀不block讀和寫,寫不block讀。隻不過是發生了并行讀寫的隔離效果不太一樣。此外,它們兩個對OLTP業務代碼的編寫的影響差不多——它們都無法解決“寫前提困境“。在深入讨論之前,這裡先複述一遍這倆隔離級别的意思。

Read Committed是指一個事務能看到另外一個事務對一條資料記錄已經送出的修改。例如下面的操作序列(假設變量x的值一開始是0):

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

這裡可以看到事務A對x的兩次讀取,因為發生在事務B對x修改的前後,得到了不同的結果。事務A可以看到事務B已經送出的修改。

Repeatable Read是指一個事務一旦開始,反複讀取一條資料記錄,都會得到相同的結果。或者說,假如有兩個事務A和B,A在B之前開始,那麼B對資料的修改對于事務A總是不可見的。例如下面的操作序列(假設變量x的值一開始是0):

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

Repeatable Read的直覺感覺仿佛是給事務做一個整個資料庫做了一個快照,是以很多時候這種隔離級别又被稱為Snapshot Isolation。"快照"的功能在一些場景下非常重要,如:

  1. 資料備份。例如資料庫S從資料庫M中複制資料,但是同時M資料庫又被持續修改。S需要拿到一個M的資料快照,但是又不能真的把M給停了。
  2. 資料合法性檢查。例如有兩張資料表,一張記錄了當時的交易總額,另外一張表記錄了每個交易的金額。那麼在讀取資料時,如果沒有快照的存在,交易金額的總和可能與當時的交易總額對不上,因為随着檢查事務的進行,新的交易記錄資料會被送出。這些新的送出會被檢查事務看到。

在基于MVCC的資料庫中,一般認為隻實作了Read Committed和Repeatable Read兩隔離級别。PostgreSQL在9.1以前,Serializable和Repeatable Read是一樣的(PostgreSQL 9.1以後的Serializable增強了資料依賴性的檢查)。

此外值得一提的是幻讀的問題。在SQL92标準中提到了Repeatable Read中是可以出現幻讀的——即一個事務盡管不能讀取到後續其他事務對現有資料的修改,但是能夠讀取到插入的新資料。但是,基于MVCC的實作,Repeatable Read可以完全避免幻讀(這豈不是更好)。無論MySQL還是PostgreSQL在Repeatable Read隔離級别都不會出現幻讀。

3. MVCC

MVCC是"Multi-Version Concurrency Control"的縮寫。名字看上去很吓唬人,有點不明覺厲,但是可以這樣簡單了解——對資料庫的任何修改的送出都不會直接覆寫之前的資料,而是産生一個新的版本與老版本共存,使得讀取時可以完全不加鎖。這個版本一般用進行資料操作的事務ID(單調遞增)來定義。MVCC大緻可以這麼實作: 每個資料記錄攜帶兩個額外的資料created_by_txn_id和deleted_by_txn_id。

  1. 當一個資料被insert時,created_by_txn_id記錄下插入該資料的事務ID,deleted_by_txn_id留白。
  2. 當一個資料被delete時,該資料的deleted_by_txn_id記錄執行該删除的事務ID。
  3. 當一個資料被update時,原有資料的deleted_by_txn_id記錄執行該更新的事務ID,并且新增一條新的資料記錄,其created_by_txn_id記錄下更新該資料的事務ID

在另一個事務進行讀取時,由隔離級别來控制到底取哪個版本。同時,在讀取過程中,完全不加鎖(除非用SELECT … FOR UPDATE強行加鎖)。這樣可以極大降低資料讀取時因為沖突被Block的機會。

那麼那些多出來的無用資料怎麼被最終被清理呢?支援MVCC的資料庫一般會有一個背景任務來定時清理那些肯定沒用的資料。隻要一個資料記錄的deleted_by_txn_id不為空,并且比目前還沒結束的事務ID中最小的一個還要小,該資料記錄就可以被清理掉。在PostgreSQL中,這個背景任務叫做“VACUUM”程序;而在MySQL InnoDB中,叫做“purge“。

在PostgreSQL的實作中,MVCC産生的所有版本的節點都生成存儲資料表的B+樹的節點。新的節點和老的節點并存,隻是上邊的标記不同。這個實作的好處是選擇讀取哪個版本非常友善,可以和B+樹的搜尋算法合并到一起,還能兼顧SSI檢測(下文會提到)。壞處是清理廢棄資料相對麻煩。

MySQL采用Undo Log的實作。這種實作下,用于存儲資料表的B+樹節點總是隻保留最新的資料,而老版本的資料被放在Undo Log裡,并且以指針的形式關聯起來,形成一個連結清單。這樣,在查找老的版本時,需要按連結清單順序查找,直到找到created_by_txn_id <= 目前事務ID的最新那條記錄即可。這種實作的優缺點和PostgreSQL正相反,查詢時會在B+樹查找後多引入一個連結清單查詢;但是清理廢棄資料時會更簡單,隻要把Undo Log找到一個合适的位置一刀切了即可。 有了MVCC,Read Committed和Repeatable Read就的實作就很直覺了:

  1. 對于Read Committed,每次讀取時,總是取最新的,被送出的那個版本的資料記錄。
  2. 對于Repeatable Read,每次讀取時,總是取created_by_txn_id小于等于目前事務ID的那些資料記錄。在這個範圍内,如果某一資料多個版本都存在,則取最新的。

有趣的是,隔離級别可以是一個Session級别的配置。即每一個Session可以在運作時選擇自己希望使用什麼隔離級别,也可以随時修改(隻要目前沒有尚未結束的事務)。每個Session的隔離級别和其他Session是什麼隔離級别完全無關。Session隻要根據自己的隔離級别,選擇用MVCC提供的合适的版本即可。

MySQL InnoDB、PostgreSQL、Oracle (從版本4開始)、MS SQL Server(從版本2005開始)都實作了MVCC。值得一提的是MySQL InnoDB盡管一開始就實作了MVCC,但是之前很多人還在使用MyISAM存儲引擎,而MyISAM根本就不支援事務,更不要提MVCC。直到MySQL 5.5.5,InnoDB才成為MySQL預設的搜尋引擎。是以如果有想用MySQL做隔離級别實驗的讀者最好先弄清自己用的MySQL版本和存儲引擎。

4. 寫前提困境

盡管在MVCC的加持下Read Committed和Repeatable Read都可以得到很好的實作。但是對于某些業務代碼來講,在目前事務中看到/看不到其他的事務已經送出的修改的意義不是很大。這種業務代碼一般是這樣的:

  1. 先讀取一段現有的資料
  2. 在這個資料的基礎上做邏輯判斷或者計算;
  3. 将計算的結果寫回資料庫。 這樣第三步的寫入就會依賴第一步的讀取。但是在1和3之間,不管業務代碼離得有多近,都無法避免其他事務的并發修改。換句話說,步驟1的資料正确是步驟3能夠在業務上正确的前提。

在Repeatable Read下是解決不了這個問題的,因為在步驟3時,目前事務根本就無法看到另外一個事務對資料的修改。這個問題一般被稱為Lost Updates。看這個例子,假設用Repeatable Read隔離級别實作一個計數器:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

這時,事務A結束後,就會丢掉一次counter的計數。

而Read Commited盡管能夠看到其他事務已經送出的修改,問題在于,在Read Commited,你必須重複寫一句select語句才能拿到。而不管你反複讀取多少次,不管這個select離得與下面的update有多近,理論上都無法避免丢失其他事務的修改。

  • 簡單來說,這個問題就是在修改的事務在送出時,無法確定這個修改的前提是否還可靠。這種問題我稱之為寫前提困境。 解決這類問題有3種辦法:
  1. 資料庫支援某種代碼塊,這個代碼塊的執行是排他的;
  2. 加悲觀鎖,把期望依賴的資料獨占,在修改完成前不允許其他并發修改發生;
  3. 加樂觀鎖,在事務送出的一刹那(注意是commit時,不是修改時),檢查修改的依賴是不是沒有被修改; 在事務性資料庫中,第一種被稱為Actual Serial Execution,第二種是加鎖(手工或者自動);第三種被稱為Serializable Snapshot Isolation, SSI。

5. Actual Serial Execution

Actual Serial Execution是一種執行的效果,即一段代碼在資料庫伺服器端執行時不會受到其他并發控制的幹擾。但要達成這個效果并不簡單。

最簡單的實作方案是讓整個資料庫隻能單線程跑。這樣什麼并發隔離保護的機制都可以不要,所有的資料不會有任何并發修改的問題。一些NoSQL的存儲,如Redis、VoltDB都是這麼實作的。但是他們這麼實作是有原因的,因為他們都是基于記憶體的存儲,其資料操作的延遲相對于網絡IO幾乎可以忽略不計。是以即使是單線程,配合nonblocking IO,他們的并發性能也可以非常高。但這個假設對事務性資料庫并不成立,因為事務性資料庫要操作磁盤/SSD。即便是SSD的寫入速度,也會在數量級上低于記憶體。是以事務性資料庫如果強行改成單線程,就會極大的損害并發性能。

此外,單線程存儲因為隻能使用單線程,是以一個執行個體隻能使用一個CPU核心。在多核心機器上就會浪費資源。是以往往要單機啟用多個執行個體。而一旦啟用多個執行個體就意味着要提前對資料進行Partition,配置設定給多個執行個體。但是Partition會造成單Partion查詢友善,跨越多個Partition的查詢麻煩的問題。是以簡單的說,這條路子可以走,但是有比較大的局限性,比較适合為特定業務做定制存儲。

另外一種辦法是用存儲過程将業務邏輯包起來丢給資料庫執行。但這樣做其實不現實,因為存儲過程本身并不具備原子性和隔離性。為了讓存儲過程中執行是排他的,依然需要在存儲過程裡聲明一個事務。如果必要,可以聲明目前的事務隔離級别為Serializable以避免寫前提困境。例如Mysql InnoDB可以這樣寫。

sql複制代碼CREATE PROCEDURE foo_proc(  
IN param_1 INT,  
IN param_2 VARCHAR(100)  
)  
BEGIN
  SET TRANSACTION ISOLATION_LEVEL SERIALIZABLE; -- 可以設定下一個事務用Serializable隔離級别
  START TRANSACTION;
  -- 一系列操作代碼……
  COMMIT;
END;  
           

這種做法其實等價于下面要講的Serializable隔離級别。

順便提一下,存儲過程存在一系列使用上的問題,如不好開發,不好調試,不好版本管理,一旦寫錯可能會拖垮整個資料庫伺服器等。是以真的用起來先要仔細權衡是否值得使用它。

最後還有一個招數是用單SQL語句的事務。比如上面的計數器可以這樣寫:

ini複制代碼UPDATE counter_tbl SET counter = counter + 1 where id = xxx;
           

這樣寫的确能保證排他性執行,因為這條語句自身可以成為一個事務,并且因為是UPDATE語句,是以必然會搶占X鎖。鎖的存在可以確定不會出現寫前提困境。但這麼做的前提是有辦法把一個業務邏輯用一句修改類SQL表達。一個計數器的邏輯可以,但是複雜一些的業務就不行;或者在文法層面可行,但是寫出來幾百行的SQL,根本無法調試和維護。

6. 加鎖和基于鎖的Serializable

通過加鎖可以有效的排除所有可能的競争問題。在MySQL InnoDB中,Serializable隔離級别是依靠MVCC + 加更多的鎖。以下摘自MySQL的文檔

SERIALIZABLE This level is like REPEATABLE READ, but InnoDB implicitly converts all plain SELECT statements to SELECT ... LOCK IN SHARE MODE if autocommit is disabled.

簡單來講就是所有的讀取要加上共享鎖。與MySQL不同,在PostgreSQL中,Serializable使用SSI實作,見下文的SSI的介紹。 資料庫中經典的加鎖的過程被稱為兩階段加鎖 (2 Phases Locking, 2PL)。注意這個詞和兩階段送出(2PC)很像,但他們是截然不同的兩個意思。所謂兩階段加鎖是指:

  • 加鎖階段:在事務過程中,根據不同的SQL指令加鎖。
  • 釋放鎖階段:鎖定直到這個事務被送出或者復原(包括等待逾時造成復原)時釋放。 基于鎖的Serializable的實作準則是:讀要block寫,寫也要block讀,讀不block讀。

還是考慮上面計數器的例子,在MySQL InnoDB Serializable隔離級别下的過程。

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

上面的例子解釋了counter這條記錄上的鎖如何避免了counter的競争問題。

實際上MySQL的Serializable除了鎖記錄,還會鎖記錄的間隙,避免意外的插入。這種鎖概念上被稱為區間鎖(Range Lock)。MySQL InnoDB中的叫法是Gap Lock和Next-key Lock。

上文中有提到基于MVCC的Repeatable Read可以避免幻讀。在基于鎖的Serializable中做的更強硬,它會直接鎖定以避免插入。

考慮下面的例子:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

在MySQL中,不同的隔離級别内部實作使用不同的MVCC讀取政策+不同種類的鎖來完成。這就好像你春節在飯館吃飯,得點套餐一樣。那麼如果這些隔離級别沒有一個滿足心意,或者希望根據業務邏輯實作一些定制優化,是不是可以不可以DIY一下呢?是可以的:

  • SELECT ... LOCK IN SHARE MODE -- 嘗試将查詢符合條件的記錄加上共享鎖,如果鎖已經被占了就等待
  • SELECT ... FOR UPDATE -- 嘗試将符合查詢條件的記錄加上與等價UPDATE語句一樣的鎖,包括排他鎖和區間鎖

這些語句可以無視目前的隔離級别,完全按照你的心意來加鎖。舉個例子,假設有一個user資料表有id和name兩列。id=3的name資料初始為"tom"。事務A和B都使用隔離級别Repeatable Read。

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

在MySQL中SELECT … FOR UPDATE會打破目前的Repeatable Read隔離級别,拿到另外一個事務送出的最新的資料;同樣的行為在PostgreSQL執行的結果不同,PostgreSQL會檢測出并發的資料修改而直接復原事務。

基于鎖的Serializable隔離級别,或者手工加鎖,是可以根除任何并發沖突的,但是這是有代價的——大大的增加了鎖的數量,同時也就增加了等待鎖的時間以及死鎖的機會。下面我們再看看SSI。

7. SSI和基于SSI實作的Serializable

相對于悲觀鎖的方案,相對應的樂觀鎖的方式就是SSI——Serialized Snopshot Isolation。它的大緻意思是:本質上,整個事務還是Snapshot Isolation,但事務在進行過程中,除了對資料進行操作外,還要對整個事務的“寫前提”——所有修改操作的依賴資料做追蹤。當事務被commit時,目前事務會檢查這個“寫前提”是否被其他事務修改過,如果是,則復原掉目前事務。PostgreSQL的Serializable基于SSI實作。

例如下面的是一個SSI隔離級别的例子。

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

那麼問題在于如何偵測到這個修改已經發生了?基本的辦法有:

  • 在一個事務進行送出時,對于所有修改的資料,檢視MVCC中是否已經有其他的版本已經送出了但是本事務因為snapshot機制沒有讀取到
  • 事務進行時,标記自身所有讀取過的記錄(就好像加共享鎖,但是并不真的鎖定什麼)。另外一個事務如果送出了一個寫操作,則反查這個寫操作影響到的資料有哪些被讀取中,并且讀取他們的事務還沒送出 SSI并非絕對優于鎖的方案。悲觀鎖方案和樂觀鎖方案的權衡點在于,沖突是否足夠多。如果沖突太多,SSI會造成大量的資源浪費(做了很多計算,但是就是commit不了);如果沖突不是很多,加鎖方案帶來鎖等待和死鎖的負面效果更顯著。是以在選取方案之前,先對業務造成的并發修改沖突量有一個估計。

三、鎖的具體内容

在mysql中的鎖看起來是很複雜的,因為有一大堆的東西和名詞:排它鎖,共享鎖,表鎖,頁鎖,間隙鎖,意向排它鎖,意向共享鎖,行鎖,讀鎖,寫鎖,樂觀鎖,悲觀鎖,死鎖。這些名詞有的部落格又直接寫鎖的英文的簡寫--->X鎖,S鎖,IS鎖,IX鎖,MMVC...

鎖的相關知識又跟存儲引擎,索引,事務的隔離級别都是關聯的....

這就給初學資料庫鎖的人帶來不少的麻煩~于是我下面就簡單整理一下資料庫鎖的知識點,希望大家看完會有所幫助。

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

1. MySQL鎖分類(按粒度)

相對于其他的資料庫而言,MySQL的鎖機制比較簡單,最顯著的特點就是不同的存儲引擎支援不同的鎖機制。根據不同的存儲引擎,MySQL中鎖的特性可以大緻歸納如下:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

1.1 表鎖:

  1. 開銷小,加鎖快;
  2. 不會出現死鎖;
  3. 鎖定力度大,發生鎖沖突機率高,并發度最低

1.2 行鎖:

  1. 開銷大,加鎖慢;
  2. 會出現死鎖;
  3. 鎖定粒度小,發生鎖沖突的機率低,并發度高

1.3 頁鎖:

  1. 開銷和加鎖速度介于表鎖和行鎖之間;
  2. 會出現死鎖;
  3. 鎖定粒度介于表鎖和行鎖之間,并發度一般

從上述的特點可見,很難籠統的說哪種鎖最好,隻能根據具體應用的特點來說哪種鎖更加合适。僅僅從鎖的角度來說的話:

  1. 表鎖更适用于以查詢為主,隻有少量按索引條件更新資料的應用;
  2. 行鎖更适用于有大量按索引條件并發更新少量不同資料,同時又有并發查詢的應用。(PS:由于BDB已經被InnoDB所取代,我們隻讨論MyISAM表鎖和InnoDB行鎖的問題)

不同的存儲引擎支援的鎖粒度是不一樣的:

  1. InnoDB行鎖和表鎖都支援!
  2. MyISAM隻支援表鎖!
  3. InnoDB隻有通過索引條件檢索資料才使用行級鎖,否則,InnoDB将使用表鎖

也就是說,

  • InnoDB的行鎖是基于索引的!
  • InnoDB的行鎖是基于索引的!
  • InnoDB的行鎖是基于索引的!

敲黑闆劃重點,重要的事情說三遍!!!

2. 表鎖

表鎖下分為兩種模式:

  • 表讀鎖(Table Read Lock)
  • 表寫鎖(Table Write Lock)

從下圖可以清晰看到,在表讀鎖和表寫鎖的環境下:

  • 讀讀不阻塞
  • 讀寫阻塞
  • 寫寫阻塞
  1. 讀讀不阻塞:目前使用者在讀資料,其他的使用者也在讀資料,不會加鎖
  2. 讀寫阻塞:目前使用者在讀資料,其他的使用者不能修改目前使用者讀的資料,會加鎖
  3. 寫寫阻塞:目前使用者在修改資料,其他的使用者不能修改目前使用者正在修改的資料,會加鎖
再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

從上面已經看到了:讀鎖和寫鎖是互斥的,讀寫操作是串行。

如果某個程序想要擷取讀鎖,同時另外一個程序想要擷取寫鎖。在mysql裡邊,寫鎖是優先于讀鎖的!

寫鎖和讀鎖優先級的問題是可以通過參數調節的:max_write_lock_count和low-priority-updates

值得注意的是:

MyISAM可以支援查詢和插入操作的并發進行。 可以通過系統變量concurrent_insert來指定哪種模式,在MyISAM中它預設是:如果MyISAM表中沒有空洞(即表的中間沒有被删除的行),MyISAM允許在一個程序讀表的同時,另一個程序從表尾插入記錄。 但是InnoDB存儲引擎是不支援的!

2.1 查詢表級鎖争用情況

可以通過檢查table_locks_waited和table_locks_immediate狀态變量來分析系統上的表鎖定争奪:

mysql> show status like 'table%';

Variable_name Value
Table_locks_immediate 2979
Table_locks_waited

2 rows in set (0.00 sec))

如果Table_locks_waited的值比較高,則說明存在着較嚴重的表級鎖争用情況。

  • 對MyISAM表的讀操作,不會阻塞其他使用者對同一表的讀請求,但會阻塞對同一表的寫請求;
  • 對MyISAM表的寫操作,則會阻塞其他使用者對同一表的讀和寫操作;
  • MyISAM表的讀操作與寫操作之間,以及寫操作之間是串行的!
  • 當一個線程獲得對一個表的寫鎖後,隻有持有鎖的線程可以對表進行更新操作。其他線程的讀、寫操作都會等待,直到鎖被釋放為止。

2.2 如何加表鎖

MyISAM在執行查詢語句(SELECT)前,會自動給涉及的所有表加讀鎖,在執行更新操作(UPDATE、DELETE、INSERT等)前,會自動給涉及的表加寫鎖,這個過程并不需要使用者幹預,是以,使用者一般不需要直接用LOCK TABLE指令給MyISAM表顯式加鎖。顯式加鎖基本上都是為了友善而已,并非必須如此。

給MyISAM表顯示加鎖,一般是為了在一定程度模拟事務操作,實作對某一時間點多個表的一緻性讀取。例如,有一個訂單表orders,其中記錄有各訂單的總金額total,同時還有一個訂單明細表order_detail,其中記錄有各訂單每一産品的金額小計 subtotal,假設我們需要檢查這兩個表的金額合計是否相符,可能就需要執行如下兩條SQL:

sql複制代碼Select sum(total) from orders;
Select sum(subtotal) from order_detail;
           

這時,如果不先給兩個表加鎖,就可能産生錯誤的結果,因為第一條語句執行過程中,order_detail表可能已經發生了改變。是以,正确的方法應該是:

sql複制代碼Lock tables orders read local, order_detail read local;
Select sum(total) from orders;
Select sum(subtotal) from order_detail;
Unlock tables;
           

要特别說明以下兩點内容。

  • 上面的例子在LOCK TABLES時加了“local”選項,其作用就是在滿足MyISAM表并發插入條件的情況下,允許其他使用者在表尾并發插入記錄,有關MyISAM表的并發插入問題,在後面的章節中還會進一步介紹。
  • 在用LOCK TABLES給表顯式加表鎖時,必須同時取得所有涉及到表的鎖,并且MySQL不支援鎖更新。也就是說,在執行LOCK TABLES後,隻能通路顯式加鎖的這些表,不能通路未加鎖的表;同時,如果加的是讀鎖,那麼隻能執行查詢操作,而不能執行更新操作。其實,在自動加鎖的情況下也基本如此,MyISAM總是一次獲得SQL語句所需要的全部鎖。這也正是MyISAM表不會出現死鎖(Deadlock Free)的原因。
  • 注意,當使用LOCK TABLES時,不僅需要一次鎖定用到的所有表,而且,同一個表在SQL語句中出現多少次,就要通過與SQL語句中相同的别名鎖定多少次,否則也會出錯!
  • (1)對actor表獲得讀鎖:
  • mysql> lock table actor read;
  • Query OK, 0 rows affected (0.00 sec)
  • (2)但是通過别名通路會提示錯誤:
  • mysql> select a.first_name,a.last_name,b.first_name,b.last_name from actor a,actor b where a.first_name = b.first_name and a.first_name = 'Lisa' and a.last_name = 'Tom' and a.last_name <> b.last_name;
  • ERROR 1100 (HY000): Table 'a' was not locked with LOCK TABLES
  • (3)需要對别名分别鎖定:
  • mysql> lock table actor as a read,actor as b read;
  • Query OK, 0 rows affected (0.00 sec)
  • (4)按照别名的查詢可以正确執行:
  • mysql> select a.first\_name,a.last\_name,b.first\_name,b.last\_name from actor a,actor b where a.first\_name = b.first\_name and a.first\_name = 'Lisa' and a.last\_name = 'Tom' and a.last\_name <> b.last\_name;
  • first_namelast_namefirst_namelast_nameLisaTomLISAMONROE
  • 1 row in set (0.00 sec)

3. 行鎖

上邊簡單講解了表鎖的相關知識,我們使用Mysql一般是使用InnoDB存儲引擎的。InnoDB和MyISAM有兩個本質的差別:

  • InnoDB支援行鎖
  • InnoDB支援事務

從上面也說了:我們是很少手動加表鎖的。表鎖對我們程式員來說幾乎是透明的,即使InnoDB不走索引,加的表鎖也是自動的!

我們應該更加關注行鎖的内容,因為InnoDB一大特性就是支援行鎖!

InnoDB實作了 讀鎖(共享鎖、S鎖)和 寫鎖(排他鎖、X鎖)兩種類型的行鎖。

X鎖,S鎖,讀鎖,寫鎖,共享鎖,排它鎖其實總共就兩個鎖,隻不過它們有多個名字罷了~

3.1 讀鎖(共享鎖、S鎖)

允許一個事務去讀一行,阻止其他事務獲得相同資料集的寫鎖。

讀鎖是共享的,多個客戶可以同時讀取同一個資源,但不允許其他客戶修改。

3.2 寫鎖(排他鎖、X鎖)

允許獲得寫鎖的事務更新資料,阻止其他事務取得相同資料集的讀鎖和寫鎖。

寫鎖是排他的,寫鎖會阻塞其他的寫鎖和讀鎖。

4. 意向鎖(Intention Locks)

為了允許行鎖和表鎖共存,實作多粒度鎖機制,InnoDB還有兩種内部使用的意向鎖(Intention Locks),意向鎖也是資料庫隐式幫我們做了,不需要程式員操心!

這兩種意向鎖都是表鎖:

4.1 意向讀鎖(意向共享鎖、IS鎖)

事務打算給資料行加行讀鎖,事務在給一個資料行加讀鎖前必須先取得該表的意向讀鎖。

4.2 意向寫鎖(意向排他鎖、IX鎖)

事務打算給資料行加行寫鎖,事務在給一個資料行加寫鎖前必須先取得該表的意向寫鎖。

5. MVCC和事務的隔離級别

資料庫事務有不同的隔離級别,不同的隔離級别對鎖的使用是不同的,鎖的應用最終導緻不同僚務的隔離級别

MVCC(Multi-Version Concurrency Control)多版本并發控制,可以簡單地認為:MVCC就是行級鎖的一個變種(更新版)。

事務的隔離級别就是通過鎖的機制來實作,隻不過隐藏了加鎖細節 在表鎖中我們讀寫是阻塞的,基于提升并發性能的考慮,MVCC一般讀寫是不阻塞的(是以說MVCC很多情況下避免了加鎖的操作)

MVCC實作的讀寫不阻塞正如其名:多版本并發控制--->通過一定機制生成一個資料請求時間點的一緻性資料快照(Snapshot),并用這個快照來提供一定級别(語句級或事務級)的一緻性讀取。從使用者的角度來看,好像是資料庫可以提供同一資料的多個版本。

5.1 快照有兩個級别

  1. 語句級:針對于Read committed隔離級别
  2. 事務級别:針對于Repeatable read隔離級别

5.2 事務的隔離級别

我們在初學的時候已經知道,事務的隔離級别有4種:

  1. Read uncommitted 會出現髒讀,不可重複讀,幻讀
  2. Read committed 會出現不可重複讀,幻讀
  3. Repeatable read 會出現幻讀(但在Mysql實作的Repeatable read配合gap鎖不會出現幻讀!)
  4. Serializable 串行,避免以上的情況!

5.2.1 Read uncommitted 會出現-->髒讀:一個事務讀取到另外一個事務未送出的資料

例子:A向B轉賬,A執行了轉賬語句,但A還沒有送出事務,B讀取資料,發現自己賬戶錢變多了!B跟A說,我已經收到錢了。A復原事務【rollback】,等B再檢視賬戶的錢時,發現錢并沒有多。 出現髒讀的原因是因為在讀的時候沒有加讀鎖,導緻可以讀取出還沒釋放鎖的記錄。

Read uncommitted過程:

css複制代碼事務A讀取記錄(沒有加任何的鎖)
事務B修改記錄(此時加了寫鎖,并且還沒有commit-->也就沒有釋放掉寫鎖)
事務A再次讀取記錄(此時因為事務A在讀取時沒有加任何鎖,是以可以讀取到事務B還沒送出的(沒釋放掉寫鎖)的記錄
           

Read committed避免髒讀的做法其實很簡單:

在讀取的時候生成一個版本号,直到事務其他commit被修改了之後,才會有新的版本号

Read committed過程:

css複制代碼事務A讀取了記錄(生成版本号)
事務B修改了記錄(此時加了寫鎖)
事務A再讀取的時候,是依據最新的版本号來讀取的(當事務B執行commit了之後,會生成一個新的版本号),如果事務B還沒有commit,那事務A讀取的還是之前版本号的資料。
           

5.2.2 Read committed 會出現-->不可重複讀:一個事務讀取到另外一個事務已經送出的資料,也就是說一個事務可以看到其他事務所做的修改

注:A查詢資料庫得到資料,B去修改資料庫的資料,導緻A多次查詢資料庫的結果都不一樣【危害:A每次查詢的結果都是受B的影響的,那麼A查詢出來的資訊就沒有意思了】

上面也說了,Read committed是語句級别的快照!每次讀取的都是目前最新的版本!

5.2.3 Repeatable read避免不可重複讀是事務級别的快照!每次讀取的都是目前事務的版本,即使被修改了,也隻會讀取目前事務版本的資料。

呃...如果還是不太清楚,我們來看看InnoDB的MVCC是怎麼樣的吧(摘抄《高性能MySQL》)

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦
再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

至于虛讀(幻讀):是指在一個事務内讀取到了别的事務插入的資料,導緻前後讀取不一緻。

注:和不可重複讀類似,但虛讀(幻讀)會讀到其他事務的插入的資料,導緻前後讀取不一緻 MySQL的Repeatable read隔離級别加上GAP間隙鎖已經處理了幻讀了。

6. 樂觀鎖和悲觀鎖

無論是Read committed還是Repeatable read隔離級别,都是為了解決讀寫沖突的問題。

單純在Repeatable read隔離級别下我們來考慮一個問題:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

此時,使用者李四的操作就丢失掉了:

丢失更新:一個事務的更新覆寫了其它事務的更新結果。 (ps:暫時沒有想到比較好的例子來說明更新丢失的問題,雖然上面的例子也是更新丢失,但一定程度上是可接受的..不知道有沒有人能想到不可接受的更新丢失例子呢...)

解決的方法:

使用Serializable隔離級别,事務是串行執行的!

  • 樂觀鎖是一種思想,具體實作是,表中有一個版本字段,第一次讀的時候,擷取到這個字段。處理完業務邏輯開始更新的時候,需要再次檢視該字段的值是否和第一次的一樣。如果一樣更新,反之拒絕。之是以叫樂觀,因為這個模式沒有從資料庫加鎖,等到更新的時候再判斷是否可以更新。
  • 悲觀鎖是資料庫層面加鎖,都會阻塞去等待鎖。

6.1 悲觀鎖

是以,按照上面的例子。我們使用悲觀鎖的話其實很簡單(手動加行鎖就行了):

sql複制代碼select * from xxxx for update
           

在select 語句後邊加了 for update相當于加了排它鎖(寫鎖),加了寫鎖以後,其他的事務就不能對它修改了!需要等待目前事務修改完之後才可以修改.

也就是說,如果張三使用select ... for update,李四就無法對該條記錄修改了~

6.2 樂觀鎖

樂觀鎖不是資料庫層面上的鎖,是需要自己手動去加的鎖。一般我們添加一個版本字段來實作:

具體過程是這樣的:

張三 select * from table --->會查詢出記錄出來,同時會有一個version字段

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

李四select * from table --->會查詢出記錄出來,同時會有一個version字段

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

李四對這條記錄做修改:update A set Name=lisi,version=version+1 where ID=#{id} and version=#{version},判斷之前查詢到的version與現在的資料的version進行比較,同時會更新version字段

此時資料庫記錄如下:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

張三也對這條記錄修改: update A set Name=lisi,version=version+1 where ID=#{id} and version=#{version},但失敗了!因為目前資料庫中的版本跟查詢出來的版本不一緻!

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

7. 間隙鎖 GAP

當我們用範圍條件檢索資料而不是相等條件檢索資料,并請求共享或排他鎖時,InnoDB會給符合範圍條件的已有資料記錄的索引項加鎖;對于鍵值在條件範圍内但并不存在的記錄,叫做“間隙(GAP)”。InnoDB也會對這個“間隙”加鎖,這種鎖機制就是所謂的間隙鎖。

值得注意的是:間隙鎖隻會在Repeatable read隔離級别下使用~

例子:假如emp表中隻有101條記錄,其empid的值分别是1,2,...,100,101

Select * from emp where empid > 100 for update; 上面是一個範圍查詢,InnoDB不僅會對符合條件的empid值為101的記錄加鎖,也會對empid大于101(這些記錄并不存在)的“間隙”加鎖。

InnoDB使用間隙鎖的目的有兩個:

  1. 為了防止幻讀(上面也說了,Repeatable read隔離級别下再通過GAP鎖即可避免了幻讀)滿足恢複和複制的需要
  2. MySQL的恢複機制要求:在一個事務未送出前,其他并發事務不能插入滿足其鎖定條件的任何記錄,也就是不允許出現幻讀

8. 死鎖

并發的問題就少不了死鎖,在MySQL中同樣會存在死鎖的問題。

但一般來說MySQL通過復原幫我們解決了不少死鎖的問題了,但死鎖是無法完全避免的,可以通過以下的經驗參考,來盡可能少遇到死鎖:

  1. 以固定的順序通路表和行。比如對兩個job批量更新的情形,簡單方法是對id清單先排序,後執行,這樣就避免了交叉等待鎖的情形;将兩個事務的sql順序調整為一緻,也能避免死鎖。
  2. 大事務拆小。大事務更傾向于死鎖,如果業務允許,将大事務拆小。
  3. 在同一個事務中,盡可能做到一次鎖定所需要的所有資源,減少死鎖機率。
  4. 降低隔離級别。如果業務允許,将隔離級别調低也是較好的選擇,比如将隔離級别從RR調整為RC,可以避免掉很多因為gap鎖造成的死鎖。
  5. 為表添加合理的索引。可以看到如果不走索引将會為表的每一行記錄添加上鎖,死鎖的機率大大增大。

9. 總結

上面說了一大堆關于MySQL資料庫鎖的東西,現在來簡單總結一下。

表鎖其實我們程式員是很少關心它的:

  • 在MyISAM存儲引擎中,當執行SQL語句的時候是自動加的。
  • 在InnoDB存儲引擎中,如果沒有使用索引,表鎖也是自動加的。
  • 現在我們大多數使用MySQL都是使用InnoDB,InnoDB支援行鎖:
  1. 共享鎖--讀鎖--S鎖
  2. 排它鎖--寫鎖--X鎖

在預設的情況下,select是不加任何行鎖的~事務可以通過以下語句顯示給記錄集加共享鎖或排他鎖。

  1. 讀鎖、共享鎖(S):SELECT * FROM table\_name WHERE ... LOCK IN SHARE MODE
  2. 寫鎖、排他鎖(X):SELECT * FROM table\_name WHERE ... FOR UPDATE

InnoDB基于行鎖還實作了MVCC多版本并發控制,MVCC在隔離級别下的Read committed和Repeatable read下工作。MVCC能夠實作讀寫不阻塞!

InnoDB實作的Repeatable read隔離級别配合GAP間隙鎖已經避免了幻讀!

  1. 樂觀鎖其實是一種思想,正如其名:認為不會鎖定的情況下去更新資料,如果發現不對勁,才不更新(復原)。在資料庫中往往添加一個version字段來實作。
  2. 悲觀鎖用的就是資料庫的行鎖,認為資料庫會發生并發沖突,直接上來就把資料鎖住,其他事務不能修改,直至送出了目前事務。

四、MySQL和PostgreSQL對比

通篇看下來你會發現MySQL和PostgreSQL對于并發控制的路子不太一樣。這裡稍微總結一下:

再過60分鐘你就能了解mysql中的事務、隔離級别和鎖啦

在MySQL中,很多開發者傾向于自己在預設隔離級别之外手工加鎖。而PostgreSQL則建議盡量避免直接加鎖,因為其Repeatable Read和Serializable的實作已經相當完善,開發者沒必要自找麻煩。

五、日常開發

1. 大部分時候不要操這份心

因為很多網際網路業務其實不需要特别在意并發時的正确性。比如一個社交網站大家發部落格。一個使用者自己管理自己的部落格,幾乎不太可能出并發性的問題。考慮如下的schema:

sql複制代碼create table blog (
    blog_id int primary key auto_increment,
    author_id int not null,
    title varchar(40) not null,
    content longtext not null default ''
    created_at datetime default current_timestamp,
    updated_at datetime default current_timestamp on update current_timestam,
);
           

如果發生了并發的問題,就意味着使用者同時在多個裝置上編輯自己的部落格,并幾乎同時點選“儲存”。這在現實中發生的幾率非常少。即便發生了,資料會以稍後commit的事務為準。在業務上這種級别的正确性完全是可以接受的。本質上,這種場景天然的讓資料發生了partition(本例子中author_id可以作為partition key),不同的事務隻是修改自己那份partition而已,不會互相影響。

是以此時隻需要使用資料庫預設的隔離級别就可以了。基于MVCC的實作基本上免除了大部分的鎖等待問題。

2. 為特定的業務場景做優化

那麼什麼時候會遇到并發修改問題?比如:

有全局資料需要增減。例如的庫存數量/墊資額度要扣減。此時應該選擇Serializable隔離級别或者手工SELECT … FOR UPDATE加鎖。但是要特别留意,因為這樣做會增加死鎖等待/并發修改造成事務失敗的問題發生的幾率,是以盡量保證事務的粒度盡可能的小。避免一個巨大的事務長時間執行。

  • 20~30年前對資料改動的業務邏輯的主流實作方法是,将全部業務邏輯實作在一個巨大的存儲過程裡,然後配合适當的隔離級别。現在的基于Web/App的業務流程早已轉變成以互動為核心,是以業務邏輯會被拆解到若幹個細小的事務裡。其中一些業務流程(如第三方支付)不可避免的必須從事務中分離出來,做成分布式的事務。

需要讀取大量資料。例如業務清算時需要讀取一段時間所有的交易記錄和資金流水。這種場景不屬于OLTP,應該選擇Repeatable Read隔離級别得到一個“快照”,并标記事務為隻讀SET TRANSACTION READ ONLY。這樣會讓資料庫對事務的執行做優化,盡量避免沖突的發生。

海量資料插入到OLTP資料庫。比如交易系統把每天使用者的資産和收益計算後更新到OLTP資料庫讓使用者通路。此時應該實作一個“業務事務”的概念。即不要依靠資料庫的業務,而是依靠一個标記。當一個使用者的資料正在更新時,應該避免使用者看到部分被更新的資料。隻有當資料全部更新完了,最後更新一下标記,讓資料對使用者可見。同時,對資料的更新應該拆解一個個小的事務,避免一個巨大的事務一次性完成更新。

簡單的資料讀取-更新場景。比如計數器。可以用單行UPDATE SQL的方式實作。前文中有提到。

特種業務的并發修改。比如共享文檔編輯。這種情況下,無法隻依靠資料庫的并發控制,還必須引入業務級别的沖突檢測,自動和手動的Resolve Conflict流程。這類問題找一個專題可以專門讨論一下。

3. 避免糾結于Repeatable Read和Read Committed的差別

上文已經提到了,這兩個隔離級别都無法解決“寫前提困境”。是以除非你是做資料庫并發控制的開發者,或者是作為興趣研究,不要去糾結于這些細節,也不要根據這些細節來有針對性的編寫業務代碼。業務的發展是完全不可控的,也許今天Read Committed可以,下次稍微改動一點點代碼就必須要Repeatable Read。在正常開發中,因為一點點細節的改動就要造成整個并發控制方法發生更改,是需要盡量避免的情況。

4. 對并發沖突或者死鎖嘗試進行重試

在基于鎖的實作中,可能會出現鎖等待逾時復原;而在基于SSI的實作中,事務送出時可能會檢測到并發修改,進而強行復原事務。無論哪一種,都需要重試。需要編寫代碼來處理這種重試,并且需要根據業務需求确定重試的驅動者是誰——到底是後端代碼,前端代碼還是使用者需要介入重試。

5. 對于MySQL考慮樂觀鎖

因為MySQL的隔離級别不支援SSI,是以可以考慮手工實作樂觀鎖。即自己在資料表裡增加一個version列,并且在更新資料總是将修改之前的version放在UPDATE語句的where條件裡。關于手工實作樂觀鎖的文章有很多,有些工具比如RoR可以半自動的産生樂觀鎖代碼,這裡就不贅述了。

但是很可惜,樂觀鎖的實作是有前提的,即修改的資料和修改的前提是同一份資料。如果這個前提不滿足就無法實作。比如這個例子: 一組值班的客服可以打卡下班,但是要保證至少有5個客服在崗。假設有一個客服工作狀态的表。

sql複制代碼create table supportor_work_status (
    supportor_id int primary key
    active bit not null default b'0'
);
           

需要始終這種表裡active為1的行數必須高于5個。那麼可能的代碼為

ini複制代碼int activeCnt = db.query("select count(*) as cnt from supportor_work_status where active = 1");
if (activeCnt > 5) {
    db.execute("update supportor_work_status set active = 0 where supportor_id = ?, selfId);
}
           

該事務在Repeatable Read下有機會造成少于5個人在崗。 此時,可以考慮“物化沖突”(Materialize Conflict)。即把這個沖突點變成一行資料,讓資料庫可以根據這行資料檢測到沖突。比如上面的例子中,可以增加一個表supportor_active_count記錄目前的在崗人數。并且對這條記錄增加樂觀鎖即可。

6. 注意監控資料庫的事務執行情況

一般監控都能做到監控資料庫的CPU、磁盤、IO等資源的占用情況。除此之外,應當注意對事務的執行時間和數量做監控。資料庫系統本身一般并不限制事務的執行時間(但是限制事務等待一個鎖的時間)。一個執行數小時甚至數天的事務極大機率是有問題的,會帶來如死鎖增加,MVCC垃圾得不到清理等問題。

繼續閱讀