一、背景
熟悉MySQL數據庫的朋友們都知道,查詢數據常見模式有三種:
1. select ... :快照讀,不加鎖
2. select ... in share mode:當前讀,加讀鎖
3. select ... for update:當前讀,加寫鎖
從技術層面理解三種方式的應用場景其實並不困難,下面我們先快速復習一下這三種讀取模式的在技術層面上的區別。
注:為了簡化問題的描述,下面所有結論均是針對MySQL數據庫InnoDB儲存引擎RR隔離級別的。
1.1 select ...
讀取當前事務開始時結果集的快照版本,快照版本也可以理解為歷史版本。
因為只需讀取一個歷史版本,而歷史不會被修改,故歷史版本本身就是一個不可變版本,所以本讀取模式對讀取前后的資源處理相對簡單:
1. 讀取行為發生之前,如果有其他尚未提交的事務已經修改了結果集,本讀取模式不會等待這些事務結束,自然也讀取不到這些修改。
2. 讀取行為發生之后,當前事務提交之前,本讀取模式也不會阻止其他事務修改數據,產生更新版本的結果集。
1.2 select ... in share mode
讀取結果集的最新版本,同時防止其他事務產生更新的數據版本。
由於數據的最新版本是不斷變化的,所以本讀取模式需要強制阻斷最新版本的變化,保證自己讀取到的是所有人都一致認可的名副其實的最新版本。
本讀取模式在讀取前后對資源處理如下:
1. 讀取行為發生之前,獲取讀鎖。這意味着如果有其他尚未提交的事務已經修改了結果集,本讀取模式會等待這些事務結束,以確保自己稍后可以讀取到這些事務對結果集的修改。
2. 讀取行為發生之后,當前事務提交之前,本讀取模式會阻塞其他事務對結果集的修改。
3. 當前事務提交后,釋放讀鎖。這意味着所有之前被阻塞的事務可恢復繼續執行。
1.3 select ... for update
本讀取模式擁有select ... in share mode的一切功能,同時它還額外具備阻止其他事務讀取最新版本的能力。
本讀取模式在讀取前后對資源的處理如下:
1. 讀取行為發生之前,獲取寫鎖。這意味着如果有其他尚未提交的事務已經修改了結果集,本讀取模式會等待這些事務結束,以確保自己稍后可以讀取到這些事務對結果集的修改。
2. 讀取行為發生之后,當前事務提交之前,本讀取模式會阻塞其他事務對結果集的修改,也會阻塞其他事務對結果集最新版本的讀取(注:其他事務仍可以讀取快照版本)。
3. 當前事務提交后,釋放寫鎖。這意味着所有之前被阻塞的事務可恢復繼續執行。
三種讀取模式在技術層面的區別到此就復習完了,可是我們在實際業務編程過程中,讀取數據庫中的記錄到底什么時候要加讀鎖,什么時候要加寫鎖呢?
讀取快照版本的歷史數據和讀取最新版本的數據映射到業務層面是怎樣的一種業務邏輯需求?難道每寫一處數據庫查詢代碼,都要從技術層面去細細思考不同讀取模式其讀取行為發生之前、之后對資源的處理是否符合業務需求嗎?這樣編程也太辛苦啦。
帶着上述疑問,本文將嘗試從每種讀取模式的技術性功能出發,將不同模式下的技術功能差異轉換為業務需求差異,從而總結出不同功能的應用場景,最終產出少數的操作性強的場景判定規則,用於快速回答不同業務場景下查詢數據庫是否應該加讀鎖或寫鎖這一問題。
不過在討論數據庫加鎖的應用場景之前,我們先弄清楚一個問題,應用層可以加鎖,數據庫也可以加鎖,他們之間的功能似乎有一點重疊,那么什么情況下需要使用數據庫鎖而不是應用層鎖呢?
二、應用層加鎖 vs 數據庫加鎖
應用層加鎖,指的是在同一個進程內,通過同步代碼塊(臨界區)、信號量、Lock鎖對象等編程組件,實現並發資源的有序訪問。
理論上來說,數據庫加鎖需要解決的問題,通過應用層鎖都能解決。
但是應用層加鎖最大的局限在於其作用范圍是單進程內。在分布式集群系統盛行的今天,絕大部分模塊都有可能會啟動多個進程實例,以實現負載均衡功能。如果兩個進程並發訪問數據庫,通過進程內的應用層鎖,是無法將跨進程的多個處理流程協調成有序執行的。
同時我們也應該認識到,數據庫鎖是稀缺資源,因為儲存着狀態的數據庫難以橫向擴展,幾乎是整個系統的最終瓶頸。而無狀態的計算處理模塊可以輕松的彈性伸縮,一個性能不夠啟動兩個,兩個不夠啟動三個。。。
所以,我們可以得出如下結論:
結論1:只會在單進程內形成的資源爭用,進程內部應優先使用應用層鎖自己解決,而不應該將其轉嫁給數據庫鎖(雖然很多時候用巧妙地使用數據庫鎖可能編程更加方便)。數據庫鎖應主要用於解決多進程間並發處理數據庫中的數據時可能形成的混亂。
下面我們討論的數據庫加鎖應用場景,其間提及的多個事務,均是指的這些事務在不同進程中開啟的情況。
三、技術功能差異到業務需求差異的轉換
2.1 select ... for update vs select ... in share mode
select ... for update相對於select ... in share mode而言,對讀取到的結果集的最新版本具有更強的獨占性。select ... in share mode只是阻塞其他事務對結果集產生更新版本,而select .. for update還會阻塞其他事務對結果集最新版本的讀取。
業務層面在什么情況下需要阻塞其他事務對結果集最新版本的讀取呢?
不想讓別人也可以讀取到最新版本,往往是因為自己想在最新版本上進行修改,同時擔心其他人也和自己一樣。因為大家在修改數據時,總是希望自己的修改與數據的最新版本(而不是歷史版本)合並后存入數據庫中,所以大家在修改數據前,都會嘗試獲取數據的最新版本,基於最新版本進行修改。如果每個人都可以同時獲取到數據的最新版本並在最新版本上加入自己的修改,最后大家一起提交數據,必然會出現一個人的修改覆蓋了其他人修改的情況,這就是經典的“更新丟失”問題。如下圖所示:
其實這個問題還可以反過來問,什么情況下不必阻塞其他事務對結果集的讀取呢?
試想如果無論你阻不阻塞讀取,其他事務讀取到的結果集都是一樣的,你又何必阻塞它呢?如果你不修改讀取出的結果集,那么別人早讀晚讀又有什么區別?
丟失更新問題場景有一種特殊情況需要特別注意:當你嘗試讀取一條不存在的記錄,確認其確實不存在后,插入該記錄(常見的帶查重的插入操作)。此場景等價於你讀取了某個范圍的結果集,然后要更新此結果集,如果不加寫鎖,判重邏輯可能會失效。
通過上面的思考,我們可以得出如下結論:
結論2:如果讀取出的某個范圍的結果集自己不需要修改它,是肯定不需要使用select ... for update的。
結論3:如果讀取出的某個范圍的結果集自己需要修改它,此時需要使用select ... for update。
2.2 select ... in share mode vs select ...
select ... in share mode相對於select ... 而言,主要新增了兩點約束:
1. 讀取數據之前,等待修改了這些數據的事務提交。
2. 讀取數據之后,防止其他事務修改這些數據。
我們先用業務層面的語言將上述兩點約束合並簡述為:希望讀取到所有人都一致認可的最新版本的數據(即沒有其他人還正在修改這些數據)並鎖定它。
那么什么樣的業務場景下,我們需要達到這樣的效果呢?
我能想到的有如下兩個典型的場景:
例1. 基於更新時間戳增量處理數據
當此次讀取並處理了時間點A之前的數據,下次就不會再讀取並處理這個范圍內的數據了,這就是增量處理的要求。如果讀取之前有人已經修改這個范圍內的數據,只是事務尚未提交(由於修改行為發生在時間點A之前,所以這些數據的更新時間戳也在時間點A之前),但讀取之后這些修改提交了,會出現什么問題呢?
如果采用的是普通的select ... 意味着雖然讀取並處理了時間點A之前的數據,但是在讀取之后這個范圍內又出現了新的數據。這就會漏掉部分尚未處理的數據。如下圖所示:
如果采用的是select ... in share mode,則會等待待查詢時間范圍內的修改均提交后,再處理這個范圍內的數據,就可以避免漏處理問題。
本例中出現的問題隱含了一個前提條件,那就是新的數據提交時,新增數據的一方並沒有主動通知我們進行處理,而是由我們基於時間戳掃描新增數據。相當於業務邏輯的完整性由我們單方面保證,而另外一方並不願意為此事效勞。這種情況在基於更新時間戳增量處理數據的場景中是很常見的,因為通常我們的處理程序是作為第三方,基於時間戳掃描增量數據是為了盡量保證原數據表上應用系統無需修改,即減少侵入性。
(注:基於更新時間戳處理新增數據時,設置安全讀取時延是更加常用的解決方式。即每次讀取的時間點設置為當前時間X分鍾前,X分鍾大於系統中事物持續的最大時間,以保證抽取時間點之前的所有修改都已提交。但是這種方式會降低數據處理的實時性。)
那么,假設修改數據的每一方都願意通力配合,竭盡全力地保證數據的一致性和業務邏輯的完整性時,就不會出問題了么?請看下面這個例子。
例2. 更新關聯關系
比如,比如有Books和Students兩張表,一張BooksToStudents的多對多關聯表。新增Book需要讓每個Studuent都有這個Book。新增Student需要讓所有Book都屬於該Student。無論何時,對數據一致性的要求是:所有Student都擁有所有的Book。
如果兩個人A和B,同時開啟事務,一人新增BookA,一人新增StuduentB,大家各自嚴格按照數據一致性要求去維護BooksToStudents關聯表。
如果不使用select ... in share mode而是使用select ... ,由於每個事務都無法讀取到對方的尚未提交的新增實體,A不知道有StudentB,所以A的BookA不會屬於StudentB;B不知道有BookA,所以B的StudentB下不會有BookA。最終兩個事務提交后,結果就是StudentB沒有擁有BookA。如下圖所示:
A和B都有機會建立起StudentB下擁有BookA這一關聯記錄,但是這份關聯記錄的建立只在A添加BookA時,以及B添加StudentB時處理,如果這兩個時刻均讀取不到需要的記錄,這份關聯記錄的建立將永遠不會再被觸發。
但是,如果使用select ... in share mode,當A讀取Students表時,發現沒有StudentB后,B也無法再往Students表中添加StudentB,直至A的事務提交。屆時,B再讀取Books表時,也能發現A提交的BookA,進而正確新增StudentB下擁有BookA這一關聯記錄。
本例雖以多對多關聯關系為例,其實在一對多、多對一關聯關系中也可能存在類似問題。原理都大同小異,只不過一對多、多對一的關聯關系通常直接儲存在關聯實體的某一列中,而不是儲存在獨立的關聯關系表中。
例1呈現出來的場景可以總結為:
結論4:當數據一致性和業務邏輯完整性只能由自己單方面保證時,且自己利用了數據的某種單調性增量處理數據時,需使用select ... in share mode查詢更新數據。
例2呈現出來的場景可以總結為:
結論5:當有關聯關系的兩個實體可能同時新增時,一方因新增實體修改關聯關系,需使用select ... in share mode查詢另一方數據進行關聯關系的更新。
2.3 select ... 快照讀有那么危險嗎?
看了上面的介紹,大家可能恨不得所有查詢都使用最嚴格的select ... for update,這樣至少不會錯。但是作為最常見的普通select語句,真的有那么危險嗎?
快照讀意味着讀取歷史數據,其實把時間放長遠了看,基本上絕大部分數據后續都有更新的可能。所以即便是使用最嚴格的select ... for update讀取模式,讀到的數據也終究抵不過時間的流逝,淪為歷史數據。用戶更多關注的並不是某份數據有多新,而是某份數據不要太過時,快照讀讀取的歷史數據通常也就是最近幾十毫秒到幾秒前的歷史版本,完全能夠滿足用戶的查看需求。
當讀取數據是為了后台嚴格的邏輯控制判定時,我們會擔心讀取過程中出現的更新版本的數據會錯過本次事務中的處理邏輯,但是這個擔心一般來說也是多余的,因為別人產生新版本的數據時,必然也會觸發一系列的處理來保證數據的一致性和業務邏輯的完整性,不必在自己的事務中過於操心別人的事情。
四、總結
我們的原則通常是,優先使用鎖范圍小的查詢模式,以盡量提升數據庫的並發性能。即先選select ... ,不行再用select ... in share mode,再不行再提升為select ... for update。而結論2告訴我們何時無需用select ... for update,在此原則下,我們需要搞清楚的是何時需要用select ... for update,所以這個結論可以忽略。
我們的日常開發中,大部分情況下不需要自己單方面保證數據的一致性和業務邏輯的完整性,所有數據的修改方都可以通力合作。所以結論4可以暫時忽略。
綜上,日常開發過程中,我們需記住:
1. 只會在單進程內形成的資源爭用,進程內部應優先使用應用層鎖自己解決,而不應該將其轉嫁給數據庫鎖。數據庫鎖應主要用於解決多進程間並發處理數據庫中的數據時可能形成的混亂。
2. 優先使用select ...
3. 當有關聯關系的兩個實體可能同時新增時,一方因新增實體修改關聯關系,需使用select ... in share mode查詢另一方數據進行關聯關系的更新。
4. 如果讀取出來的結果集需要修改后再提交,需使用select ... for update讀取結果集。
如果你不幸需要與第三方系統(或難以修改的遺留系統)以數據庫的方式進行集成時,需再多記住一點:
5. 當數據一致性和業務邏輯完整性只能由自己單方面保證時,且自己利用了數據的某種單調性增量處理數據時,需使用select ... in share mode查詢更新數據。
如果還有其他漏掉的場景規則,歡迎大家補充。