為什么要寫這篇筆記?
TiDB自3.0.8版本開始默認使用悲觀事務模型(只限新建集群,從之前的版本升級上來的默認還是使用樂觀事務模式)。
事務模型影響着數據庫高並發場景下的寫入性能並且關系到數據的完整性,如果不了解其中的差異那么在面對事務沖突引發的問題時就會比較盲目。
很多新人(包括我在內)在學習TiDB的最初階段對於TiDB的事務模型不甚了解,官方文檔的解釋雖精辟但並不很人性化,這篇筆記從最初的悲觀和樂觀模式的概念出發來探究樂觀模式與悲觀模式的差異,以及優劣。
為了解決什么問題?
精通TiDB的事務模型可以幫助了解日常生產遇到的寫沖突異常,並可以幫助決定是使用悲觀鎖還是樂觀鎖模式。
筆記正文:
TiDB 4.0 新特性前瞻(二)白話“悲觀鎖” | PingCAP
Percolator 和 TiDB 事務算法 | PingCAP
完整的官網文檔見上述鏈接,一些FAQ和具體細節就不復制粘貼了,本文主要通過一些描述來幫助理解這兩種事務模型。
一、廣義概念的悲觀鎖和樂觀鎖
樂觀鎖:並發場景下,數據庫認為並發會話之間並不會互相影響,因此在事務的內存讀寫階段不對數據加鎖,只在事務提交時進行寫沖突檢測(例如數據版本是否過期,被刪除等等)。
悲觀鎖:並發場景下,數據庫認為並發會話之間的數據寫入是有沖突的,因此在事務做更改的初始階段就會對要修改的數據加鎖,直到事務結束。
樂觀鎖與悲觀鎖模式的主要差異在於:到底是事務提交階段加鎖來檢測事務沖突?還是在事務開始階段就直接對數據加鎖來阻塞其他寫操作?
可以看到,樂觀鎖模式下,只有在事務提交階段才會檢測寫入沖突,而悲觀模式全程加鎖,所以相對而言樂觀模式的並發量會高於悲觀鎖模式,但是其數據一致性性卻比悲觀鎖差,並且事務失敗時需要依賴應用層進行重試。
主流的關系型數據庫例如oracle、sqlserver、mysql等,大多采用悲觀事務模型以保證高並發場景下事務的一致性。
但是這里要強調一下,主流關系型數據庫在事務模型的處理上多采用悲觀事務模型以保證數據一致性,但並不是所有場景都使用悲觀模型,例如mysql的commit和鎖獲取階段都是樂觀事務模型,只有在針對數據的獲取方面采用悲觀模型,而且在RDBMS系統中也鮮少會有產品在所有場景中都采用悲觀模式,因為代價太大,且誰都不能確保自己的網絡、硬件等永遠不出問題。
此外這里推幾篇鏈接幫助理解樂觀/悲觀鎖模式:
pessimistic locking vs optimistic locking - Ask TOM (oracle.com)
sql server - Optimistic vs. Pessimistic locking - Stack Overflow
Concurrency Control - SQL Server | Microsoft Docs
二、TiDB的樂觀事務模型
這里可以直接用一幅圖來描述TiDB的樂觀事務模型:
略微解析一下上述的流程,這幅圖需要按從左往右,從上往下順序來看,其主要步驟為(詳細解釋參考官網鏈接):
1.客戶端開啟事務后,tidb向pd獲取一個tso作為事務的start_ts, 理解為start timestamp即可,以下同理。
2.tidb從pd獲取讀取數據的路由(既數據存在哪些tikv節點上),然后從tikv讀取數據,讀取小於此start_ms的最新數據版本。
3.數據在tidb的內存中完成修改。
4.客戶端發起commit操作,接下來就是TiDB的兩階段提交流程:
5.第一階段(prewrite階段,也可以叫做加鎖階段):
。tidb將要寫入的數據按照key分類,然后從pd獲取數據的寫入路由(既數據應該寫入到哪些tikv節點)。
。tidb並發的向所有涉及的tikv發起請求,tikv收到請求后檢查對應記錄是否過期或者存在版本沖突,正常的話會加鎖。
。tidb收到所有prewrite成功的響應,至此第一階段完成。
6.第二階段(正式提交階段)
。tidb向pd獲取一個tso作為commit_ts
。tidb向tikv發起第二階段的提交請求,tikv進行數據寫入,然后清理第一階段的鎖。
。tidb收到兩階段提交成功的信息,客戶端收到tidb反饋的事務成功的信息。
7.最后tidb異步的清理本次事務遺留的鎖信息。
詳細的2PC commit加鎖比上述步驟描述的要復雜些,涉及到primary row的選擇和secondries的異步提交以及行存儲中某些字段的變更,可以參考上述鏈接了解。
樂觀事務的優點和缺陷:
樂觀事務的優點在於無需在事務執行階段加鎖,減少了鎖獲取的消耗,這樣可以略微增加並發的性能。但前提是並發之間不會互相影響。
樂觀事務最大的缺陷在於出現寫入沖突時,只有一個會話可以成功,其他的都只能失敗,套用Tom Kyte的一句話就是:
“我花了那么多時間來更新數據,結果到提交的時候你告訴我說:對不起你更改的數據已被其他會話變更,請重新開始???”
這就是傳統RDBMS事務中使用悲觀事務模型的原因,因為可以避免此類寫沖突問題,且實際上有很多方法來極大減小悲觀鎖模型下的獲取鎖的消耗。
樂觀事務下的重試機制:
從上邊的描述我們知道,樂觀鎖模型下會出現寫失敗,全部依賴程序解決有點不現實,所以tidb內部增加了重試機制。
重試就相當於重新執行了事務,這樣破壞了原本的事務一致性,可能產生更新丟失,不過一般情況下高並發時的更新丟失不會對業務造成什么影響,更新實際並未丟失,只是先后提交的問題。
但:慎重開啟樂觀事務重試,如不能確保事務重試對業務的影響,那么使用悲觀事務,或關閉樂觀事務重試,至少這樣程序側會返回錯誤。
以下兩個參數控制重試的行為:
# 設置是否禁用自動重試,默認為 “on”,即不重試。
tidb_disable_txn_auto_retry = OFF
# 控制重試次數,默認為 “10”。只有自動重試啟用時該參數才會生效。
# 當 “tidb_retry_limit= 0” 時,也會禁用自動重試。
tidb_retry_limit = 10
# 上述兩個參數可以session或者global設置,上述參數對悲觀事務不生效,因為悲觀事務有自己的一套不會影響事務完整性的重試體系。
三、TiDB的悲觀事務模型
TiDB如何開啟悲觀事務模式:(需要提前配置tidb:pessimistic-txn.enable=true以及tikv:pessimistic-txn.enabled=true)
SET GLOBAL tidb_txn_mode = 'pessimistic';
# 或者執行以下 SQL 語句顯式地開啟悲觀事務:
BEGIN PESSIMISTIC;
在了解了樂觀事務之后,我們再理解悲觀事務就很簡單了,樂觀事務不是在提交階段才加鎖嗎,悲觀事務就是事務的起始階段就加鎖,
以上邊樂觀事務的圖為例,加鎖就發生在get data from TiKV or cache with start_ts階段,如果事務中包含select語句(不帶for update的)那么會在執行首個DML語句時加鎖。
另外悲觀模式下由於阻塞獲取鎖失敗也有次數限制,默認256次,可以通過pessimistic-txn.max-retry-limit修改。
TiDB的悲觀事務模型依然基於Percolator,可以看做是基於傳統Percolator的改進,改進點在於將加鎖的階段從2PC prewrite階段提前到了事務執行階段,只是悲觀鎖相比樂觀鎖而言只有一個占位符(除此之外鎖的位置和鎖格式幾乎一模一樣),等2PC prewrite時會直接將悲觀鎖改寫為標准的Percolator樂觀鎖。
Percolator模型的每條記錄都有L(lock)列和W(write)列,前者用於記錄事務上鎖信息,后者用於記錄各個已提交行版本的commitTs信息。
悲觀事務下可以解決樂觀事務模型下寫入沖突的問題嗎?
想啥呢......當然不可以,悲觀事務模式下同一時刻依然只能有一個會話執行成功,但是其他會話並不是直接失敗,而是被阻塞直到可以獲取鎖。這種模式更接近innodb的悲觀鎖模式。可以避免程序進行寫沖突處理,或者避免事務重試時造成的更新丟失。
Pipelined加鎖流程(默認關閉的):
悲觀事務模型下事務加鎖需要向tikv寫入數據,經過raft提交並apply之后才會返回,這樣開銷比較大,所以TiDB通過pipelined機制降低加鎖消耗:
當數據滿足加鎖要求時,TiKV 立刻通知 TiDB 執行后面的請求,並異步寫入悲觀鎖,從而降低大部分延遲,顯著提升悲觀事務的性能。但當 TiKV 出現網絡隔離或者節點宕機時,悲觀鎖異步寫入有可能失敗,從而產生以下影響:
無法阻塞修改相同數據的其他事務。如果業務邏輯依賴加鎖或等鎖機制,業務邏輯的正確性將受到影響。
有較低概率導致事務提交失敗,但不會影響事務正確性。
如果業務邏輯依賴加鎖或等鎖機制,或者即使在集群異常情況下也要盡可能保證事務提交的成功率,應關閉 pipelined 加鎖功能。
四、為什么我們應該使用樂觀/悲觀事務模型?
回到最核心最迫切的問題上,我們的TiDB應該使用哪種事務模型?悲觀or樂觀?
官方的回答如下:
樂觀事務模型下,將修改沖突視為事務提交的一部分。因此並發事務不常修改同一行時,可以跳過獲取行鎖的過程進而提升性能。但是並發事務頻繁修改同一行(沖突)時,樂觀事務的性能可能低於悲觀事務。
啟用樂觀事務前,請確保應用程序可正確處理 commit
語句可能返回的錯誤。如果不確定應用程序將會如何處理,建議改為使用悲觀事務。
我的回答如下:
用悲觀事務模型就好了(重要:4.0以下請繼續使用樂觀模型),不必了一丁點虛無縹緲的性能提升采用樂觀事務模型,而且悲觀事務模型下使用mysql driver訪問tidb的BUG更少,行為更加貼近訪問mysql本身。從我個人的觀測看來,悲觀事務下集群性能相比樂觀事務模型並無明顯下降。
此外如果業務本身並不要求事務的強一致性,且追求極限的讀寫性能,那么可以繼續使用樂觀事務模型並開啟重試。
五、該使用什么事務隔離級別?
在TiDB4.0.0-beta版本之前,無論是樂觀還是悲觀事務模式都只能使用RR事務隔離級別(snapshot隔離級別),在4.0.0之后tidb引入了RC事務隔離級別。
關於TiDB事務隔離級別的官方解釋及其與MySQL的差異,參考:TiDB 事務隔離級別 | PingCAP Docs
RR隔離級別下tidb和mysql的區別是tidb的當前事務發現自己的欲鎖定數據被更新后會失敗回滾,而MySQL會更新成功,但MySQL更新成功並不是指更新生效,只是返回更新成功,實際上更新並未被寫入磁盤。
RC隔離級別下tidb的行為模式與mysql、oracle等更加相似,所以官方建議悲觀事務模型下采用RC事務隔離級別,並關閉事務重試功能。
六、關於讀寫阻塞
官網關於TiDB讀寫阻塞的相關信息比較少,參考上述鏈接。在一些博客和線下的meetup中我們也可以抓取到一些信息,但是因為版本更迭較快文檔較少還是無法一窺全貌。
如果不看上述鏈接,TiDB現有官方文檔就會潛移默化的傳遞給你一個寫不會阻塞讀的信息,因為tidb采用MVCC機制並且事務執行階段不加鎖。但實質上某些情況下寫是會阻塞讀操作的。
樂觀模型:
當事務進行到2PC commit階段時會對當前行版本加鎖,其他更新相同行的事務會由於沖突進行事務回滾,之后進行重試(如果重試開啟的話);select語句在需要讀取相應行記錄時會被阻塞,因為無法確定被鎖定的行commit_ts是否小於讀事務start_ts,為確保一致性讀,只能等待至超時或者鎖釋放。
悲觀模型:
事務執行階段,悲觀鎖不會阻塞讀事務,因為還沒到prewrite階段;當事務進行到2PC commit階段時,悲觀鎖會轉為樂觀鎖,此時讀事務依然可能被阻塞並重試,原因同上。
MVCC:
當讀事務讀取數據時相應記錄上已經沒有鎖,那么比較行記錄W列中的commit_ts,找到小於start_ts的最新行版本進行讀取。
TiDB4.0的大事務尤其需要解決這種寫阻塞讀的問題,因為大事務時出現讀寫阻塞的幾率會極大增加,因此TiDB 4.0做出了相應改進:
簡單來說就是悲觀事務進入2pc commit的prewrite階段時,TiDB4.0的事務會在鎖中保留一個minCommitTs(4.0之前這個ts其實就是真正的commit_ts)。當讀事務讀到鎖時如果根據鎖的狀態發現寫事務還處於執行階段那么直接用讀事務的start_ts更新這個minCommitTs,然后直接到各個歷史行版本里找對應的滿足一致性讀的行版本即可,這樣讀事務的start_ms永遠<=寫事務的commit_ts。這樣依據minCommitTs進行讀取的結果就是讀事務遇到鎖之后不會等待也不會讀到寫事務提交的值。缺陷在於會強制推進寫事務的commit_ts,導致寫事務記載的提交時間被延后,但這不算是個大問題,因為同一時刻只有一個寫事務能獲取鎖,大家一起延后唄。
而當讀事務讀到鎖時如果根據鎖的狀態發現寫事務處於commit階段,那么就像上邊說的:讀事務不知道寫事務什么時候提交,所以會產生等待,直到讀事務提交才可以判斷被鎖定的記錄是否是自己需要的,如果直接讀取數據可能產生幻讀。
因此在TiDB4.0版本之后,我們可以說由於開啟了大事務支持所以悲觀鎖不再會阻塞讀(但是高並發單點更新依然會觸發讀被阻塞)。