做者:黃東旭node
關注 TiDB 的朋友大概會注意到,TiDB 在 3.0 中引入了一個實驗性的新功能:悲觀事務模型。這個功能也是千呼萬喚始出來的一個功能。mysql
你們知道,發展到今天,TiDB 不只僅在互聯網行業普遍使用,更在一些傳統金融行業開花結果,而悲觀事務是在多數金融場景不可或缺的一個特性。另外事務做爲一個關係型數據庫的核心功能,任何在事務模型上的改進都會影響無數的應用,並且在一個分佈式系統上如何漂亮的實現悲觀事務模型,是一個頗有挑戰的工做,因此今天咱們就來聊聊這塊「硬骨頭」。算法
在聊事務以前,先簡單科普一下 ACID 事務,下面是從 Wikipedia 摘抄的 ACID 的定義:sql
舉個直觀的例子,就是銀行轉帳,要麼成功,要麼失敗,在任何狀況下別出現這邊扣了錢那邊沒加上的狀況。數據庫
所謂分佈式事務,簡單來講就是在一個分佈式數據庫上實現和傳統數據庫同樣的 ACID 事務功能。緩存
不少人介紹樂觀事務和悲觀事務的時候會扯一大堆數據庫教科書的名詞搞得很專業的樣子,其實這個概念並不複雜, 甚至能夠說很是好理解。我這裏用一個生活中的小例子介紹一下。網絡
想象一下你立刻出發要去一家餐廳吃飯,可是你去以前不肯定會不會滿桌,你又不想排號。這時的你會有兩個選擇,若是你是個樂觀的人,心裏戲可能會是「管他的,去了再說,大不了沒座就回來」。反之,若是你是一個悲觀的人,可能會先打個電話預定一下,先確認下確定有座,同時交點定金讓餐廳預留好這個座位,這樣就能夠直接去了。併發
上面這個例子很直觀的對應了兩種事務模型的行爲,樂觀事務模型就是直接提交,遇到衝突就回滾,悲觀事務模型就是在真正提交事務前,先嚐試對須要修改的資源上鎖,只有在確保事務必定可以執行成功後,纔開始提交。 異步
理解了上面的例子後,樂觀事務和悲觀事務的優劣就很好理解了。對於樂觀事務模型來講,比較適合衝突率不高的場景,由於直接提交(「直接去餐廳」)大機率會成功(「餐廳有座」),衝突(「餐廳無座」)的是小几率事件,可是一旦遇到事務衝突,回滾(回來)的代價會比較大。悲觀事務的好處是對於衝突率高的場景,提早上鎖(「打電話交定金預定」)的代價小於過後回滾的代價,並且還能以比較低的代價解決多個併發事務互相沖突、致使誰也成功不了的場景。分佈式
在 TiDB 中分佈式事務實現一直使用的是 Percolator 的模型。在聊咱們的悲觀事務實現以前,咱們先簡單介紹下 Percolator。
Percolator 是 Google 在 OSDI 2010 的一篇 論文 中提出的在一個分佈式 KV 系統上構建分佈式事務的模型,其本質上仍是一個標準的 2PC(2 Phase Commit),2PC 是一個經典的分佈式事務的算法。網上介紹兩階段提交的文章不少,這裏就不展開了。可是 2PC 通常來講最大的問題是事務管理器(Transaction Manager)。在分佈式的場景下,有可能會出現第一階段後某個參與者與協調者的鏈接中斷,此時這個參與者並不清楚這個事務到底最終是提交了仍是被回滾了,由於理論上來講,協調者在第一階段結束後,若是確認收到全部參與者都已經將數據落盤,那麼便可標註這個事務提交成功。而後進入第二階段,可是第二階段若是某參與者沒有收到 COMMIT 消息,那麼在這個參與者復活之後,它須要到一個地方去確認本地這個事務後來到底有沒有成功被提交,此時就須要事務管理器的介入。
聰明的朋友在這裏可能就看到問題,這個事務管理器在整個系統中是個單點,即便參與者,協調者均可以擴展,可是事務管理器須要原子的維護事務的提交和回滾狀態。
Percolator 的模型本質上改進的就是這個問題。下面簡單介紹一下 Percolator 模型的寫事務流程:
其實要說沒有單點也是不許確的,Percolator 的模型內有一個單點 TSO(Timestamp Oracle)用於分配單調遞增的時間戳。可是在 TiDB 的實現中,TSO 做爲 PD leader 的一部分,由於 PD 原生支持高可用,因此天然有高可用的能力。
每當事務開始,協調者(在 TiDB 內部的 tikv-client 充當這個角色)會從 PD leader 上獲取一個 timestamp,而後使用這個 ts 做爲標記這個事務的惟一 id。標準的 Percolator 模型採用的是樂觀事務模型,在提交以前,會收集全部參與修改的行(key-value pairs),從裏面隨機選一行,做爲這個事務的 Primary row,剩下的行自動做爲 secondary rows,這裏注意,primary 是隨機的,具體是哪行徹底不重要,primary 的惟一意義就是負責標記這個事務的完成狀態。
在選出 Primary row 後, 開始走正常的兩階段提交,第一階段是上鎖+寫入新的版本,所謂的上鎖,其實就是寫一個 lock key, 舉個例子,好比一個事務操做 A、B、C,3 行。在數據庫中的原始 Layout 以下:
假設咱們這個事務要 Update (A, B, C, Version 4),第一階段,咱們選出的 Primary row 是 A,那麼第一階段後,數據庫的 Layout 會變成:
上面這個只是一個釋義圖,實際在 TiKV 咱們作了一些優化,可是原理上是相通的。上圖中標紅色的是在第一階段中在數據庫中新寫入的數據,能夠注意到,A_Lock
、B_Lock
、C_Lock
這幾個就是所謂的鎖,你們看到 B 和 C 的鎖的內容其實就是存儲了這個事務的 Primary lock 是誰。在 2PC 的第二階段,標誌事務是否提交成功的關鍵就是對 Primary lock 的處理,若是提交 Primary row 完成(寫入新版本的提交記錄+清除 Primary lock),那麼表示這個事務完成,反之就是失敗,對於 Secondary rows 的清理不須要關心,能夠異步作(爲何不須要關心這個問題,留給讀者思考)。
理解了 Percolator 的模型後,你們就知道實際上,Percolator 是採用了一種化整爲零的思路,將集中化的事務狀態信息分散在每一行的數據中(每一個事務的 Primary row 裏),對於未決的狀況,只須要經過 lock 的信息,順藤摸瓜找到 Primary row 上就能肯定這個事務的狀態。
對於不少普通的互聯網場景,雖然併發量和數據量都很大,可是衝突率其實並不高。舉個簡單的例子,好比電商的或者社交網絡,刨除掉一些比較極端的 case 例如「秒殺」或者「大V」,訪問模式基本能夠認爲仍是比較隨機的,並且在互聯網公司中不少這些極端高衝突率的場景都不會直接在數據庫層面處理,大多經過異步隊列或者緩存在來解決,這裏不作過多展開。
可是對於一些傳統金融場景,因爲種種緣由,會有一些高衝突率可是又須要保證嚴格的事務性的業務場景。舉個簡單的例子:發工資,對於一個用人單位來講,發工資的過程其實就是從企業帳戶給多個員工的我的帳戶轉帳的過程,通常來講都是批量操做,在一個大的轉帳事務中可能涉及到成千上萬的更新,想象一下若是這個大事務執行的這段時間內,某個我的帳戶發生了消費(變動),若是這個大事務是樂觀事務模型,提交的時候確定要回滾,涉及上萬個我的帳戶發生消費是大機率事件,若是不作任何處理,最壞的狀況是這個大事務永遠沒辦法執行,一直在重試和回滾(飢餓)。
另一個更重要的理由是,有些業務場景,悲觀事務模型寫起來要更加簡單。此話怎講?
由於 TiDB 支持 MySQL 協議,在 MySQL 中是支持可交互事務的,例如一段程序這麼寫(僞代碼):
mysql.SetAutoCommit(False); txn = mysql.Begin(); affected_rows = txn.Execute(「UPDATE t SET v = v + 1 WHERE k = 100」); if affected_rows > 0 { A(); } else { B(); } txn.Commit();
你們注意下,第四行那個判斷語句是直接經過上面的 UPDATE 語句返回的 affected_rows
來決定究竟是執行 A 路徑仍是 B 路徑,可是聰明的朋友確定看出問題了,在一個樂觀事務模型的數據庫上,在 COMMIT 執行以前,實際上是並不知道最終 affected_rows
究竟是多少的,因此這裏的值是沒有意義的,程序有可能進入錯誤的處理流程。這個問題在只有樂觀事務支持的數據庫上幾乎是無解的,須要在業務側重試。
這裏的問題的本質是 MySQL 的協議支持可交互事務,可是 MySQL 並無原生的樂觀事務支持(MySQL InnoDB 的行鎖能夠認爲是悲觀鎖),因此原生的 MySQL 在執行上面這條 UPDATE 的時候會先上鎖,確認本身的 Update 可以完成纔會繼續,因此返回的 affected_rows
是正確的。可是對於 TiDB 來講,TiDB 是一個分佈式系統,若是要實現幾乎和單機的 MySQL 同樣的悲觀鎖行爲(就像咱們在 3.0 中乾的那樣),仍是比較有挑戰的,好比須要引入一些新的機制來管理分佈式鎖,因此呢,咱們選擇先按照論文實現了樂觀事務模型,直到 3.0 中咱們才動手實現了悲觀事務。下面咱們看看這個「魔法」背後的實現吧。
在討論實現以前,咱們先聊聊幾個重要的設計目標:
TiDB 實現悲觀事務的方式很聰明並且優雅,咱們仔細思考了 Percolator 的模型發現,其實咱們只要將在客戶端調用 Commit 時候進行兩階段提交這個行爲稍微改造一下,將第一階段上鎖和等鎖提早到在事務中執行 DML 的過程當中不就能夠了嗎,就像這樣:
TiDB 的悲觀鎖實現的原理確實如此,在一個事務執行 DML (UPDATE/DELETE) 的過程當中,TiDB 不只會將須要修改的行在本地緩存,同時還會對這些行直接上悲觀鎖,這裏的悲觀鎖的格式和樂觀事務中的鎖幾乎一致,可是鎖的內容是空的,只是一個佔位符,待到 Commit 的時候,直接將這些悲觀鎖改寫成標準的 Percolator 模型的鎖,後續流程和原來保持一致便可,惟一的改動是:
對於讀請求,遇到這類悲觀鎖的時候,不用像樂觀事務那樣等待解鎖,能夠直接返回最新的數據便可(至於爲何,讀者能夠仔細想一想)。
至於寫請求,遇到悲觀鎖時,只須要和本來同樣,正常的等鎖就好。
這個方案很大程度上兼容了原有的事務實現,擴展性、高可用和靈活性都有保證(基本複用原來的 Percolator 天然沒有問題)。
可是引入悲觀鎖和可交互式事務,就可能引入另一個問題:死鎖。這個問題其實在樂觀事務模型下是不存在的,由於已知全部須要加鎖的行,因此能夠按照順序加鎖,就天然避免了死鎖(實際 TiKV 的實現裏,樂觀鎖不是順序加的鎖,是併發加的鎖,只是鎖超時時間很短,死鎖也能夠很快重試)。可是悲觀事務的上鎖順序是不肯定的,由於是可交互事務,舉個例子:
這倆事務若是併發執行,就可能會出現死鎖的狀況。
因此爲了不死鎖,TiDB 須要引入一個死鎖檢測機制,並且這個死鎖檢測的性能還必須好。其實死鎖檢測算法也比較簡單,只要保證正在進行的悲觀事務之間的依賴關係中不能出現環便可。
例如剛纔那個例子,事務 1 對 A 上了鎖後,若是另一個事務 2 對 A 進行等待,那麼就會產生一個依賴關係:事務 2 依賴事務 1,若是此時事務 1 打算去等待 B(假設此時事務 2 已經持有了 B 的鎖), 那麼死鎖檢測模塊就會發現一個循環依賴,而後停止(或者重試)這個事務就行了,由於這個事務並無實際的 prewrite + 提交,因此這個代價是比較小的。
<center>TiDB 悲觀鎖的死鎖檢測</center>
在具體的實現中,TiKV 會動態選舉出一個 TiKV node 負責死鎖檢測(實際上,咱們就是直接使用 Region1 所在的 TiKV node),在這個 TiKV node 上會開闢一塊內存的記錄和檢測正在執行的這些事務的依賴關係。在悲觀事務在等鎖的時候,第一步會通過這個死鎖檢測模塊,因此這部分可能會多引入一次 RPC 進行死鎖檢測,實際實現時死鎖檢測是異步的,不會增長延遲(回想一下交給飯店的定金 :P)。由於是純內存的,因此性能仍是很不錯的,咱們簡單的對死鎖檢測模塊進行了 benchmark,結果以下:
基本能達到 300k+ QPS 的吞吐,這個吞吐已經可以適應絕大多數的併發事務場景了。另外還有一些優化,例如,顯然的悲觀事務等待的第一個鎖不會致使死鎖,不會發送請求給 Deadlock Detector 之類的,其實在實際的測試中, 悲觀事務模型帶來的 overhead 其實並不高。另外一方面,因爲 TiKV 自己支持 Region 的高可用,因此必定能保證 Region 1 會存在,間接解決了死鎖檢測服務的高可用問題。
關於悲觀鎖還須要考慮長事務超時的問題,這部分比較簡單,就不展開了。
在 TiDB 3.0 的配置文件中有一欄:
將這個 enable
設置成 true
便可,目前默認是關閉的。
第二步,在實際使用的時候,咱們引入了兩個語法:
BEGIN PESSIMISTIC
BEGIN /*!90000 PESSIMISTIC */
用這兩種 BEGIN 開始的事務,都會進入悲觀事務模式,就這麼簡單。
悲觀事務模型是對於金融場景很是重要的一個特性,並且對於目標是兼容 MySQL 語義的 TiDB 來講,這個特性也是提高兼容性的重要一環,但願你們可以喜歡,Enjoy it!
原文閱讀:https://pingcap.com/blog-cn/pessimistic-transaction-the-new-features-of-tidb/