TiDB 新特性漫談:悲觀事務

作者:黃東旭

關注 TiDB 的朋友大概會注意到,TiDB 在 3.0 中引入了一個實驗性的新功能:悲觀事務模型。這個功能也是千呼萬喚始出來的一個功能。

大家知道,發展到今天,TiDB 不僅僅在互聯網行業廣泛使用,更在一些傳統金融行業開花結果,而悲觀事務是在多數金融場景不可或缺的一個特性。另外事務作爲一個關係型數據庫的核心功能,任何在事務模型上的改進都會影響無數的應用,而且在一個分佈式系統上如何漂亮的實現悲觀事務模型,是一個很有挑戰的工作,所以今天我們就來聊聊這塊“硬骨頭”。

ACID 和分佈式事務?

在聊事務之前,先簡單科普一下 ACID 事務,下面是從 Wikipedia 摘抄的 ACID 的定義:

  • Atomicity(原子性):一個事務(transaction)中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被 回滾)(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
  • Consistency(一致性):在事務開始之前和事務結束以後,數據庫的完整性沒有被破壞。
  • Isolation(隔離性):數據庫允許多個併發事務同時對其數據進行讀寫和修改的能力,隔離性可以防止多個事務併發執行時由於交叉執行而導致數據的不一致。
  • Durability(持久性):事務處理結束後,對數據的修改就是永久的,即便系統故障也不會丟失。

舉個直觀的例子,就是銀行轉賬,要麼成功,要麼失敗,在任何情況下別出現這邊扣了錢那邊沒加上的情況。

所謂分佈式事務,簡單來說就是在一個分佈式數據庫上實現和傳統數據庫一樣的 ACID 事務功能。

什麼是樂觀?什麼是悲觀?一個小例子

很多人介紹樂觀事務和悲觀事務的時候會扯一大堆數據庫教科書的名詞搞得很專業的樣子,其實這個概念並不複雜, 甚至可以說非常好理解。我這裏用一個生活中的小例子介紹一下。

想象一下你馬上出發要去一家餐廳吃飯,但是你去之前不確定會不會滿桌,你又不想排號。這時的你會有兩個選擇,如果你是個樂觀的人,內心戲可能會是「管他的,去了再說,大不了沒座就回來」。反之,如果你是一個悲觀的人,可能會先打個電話預約一下,先確認下肯定有座,同時交點定金讓餐廳預留好這個座位,這樣就可以直接去了。

上面這個例子很直觀的對應了兩種事務模型的行爲,樂觀事務模型就是直接提交,遇到衝突就回滾,悲觀事務模型就是在真正提交事務前,先嚐試對需要修改的資源上鎖,只有在確保事務一定能夠執行成功後,纔開始提交。

理解了上面的例子後,樂觀事務和悲觀事務的優劣就很好理解了。對於樂觀事務模型來說,比較適合衝突率不高的場景,因爲直接提交(“直接去餐廳”)大概率會成功(“餐廳有座”),衝突(“餐廳無座”)的是小概率事件,但是一旦遇到事務衝突,回滾(回來)的代價會比較大。悲觀事務的好處是對於衝突率高的場景,提前上鎖(“打電話交定金預約”)的代價小於事後回滾的代價,而且還能以比較低的代價解決多個併發事務互相沖突、導致誰也成功不了的場景。

TiDB 的事務模型 - Percolator

在 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_LockB_LockC_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 3.0 中的悲觀事務實現

在討論實現之前,我們先聊聊幾個重要的設計目標:

  1. 兼容性,最大程度上的兼容 MySQL 的悲觀事務的行爲,使用戶業務改造的成本最小。
  2. 靈活性,支持 Session 級別甚至事務級別的悲觀/樂觀行爲變更,所以需要考慮樂觀事務和悲觀事務共存的情況。
  3. 高性能,死鎖檢測和維護鎖的代價不能太高。
  4. 高可用 + 可擴展性,系統中不存在單點故障(single point of failure),並且可擴展。

TiDB 實現悲觀事務的方式很聰明而且優雅,我們仔細思考了 Percolator 的模型發現,其實我們只要將在客戶端調用 Commit 時候進行兩階段提交這個行爲稍微改造一下,將第一階段上鎖和等鎖提前到在事務中執行 DML 的過程中不就可以了嗎,就像這樣:

TiDB 的悲觀鎖實現的原理確實如此,在一個事務執行 DML (UPDATE/DELETE) 的過程中,TiDB 不僅會將需要修改的行在本地緩存,同時還會對這些行直接上悲觀鎖,這裏的悲觀鎖的格式和樂觀事務中的鎖幾乎一致,但是鎖的內容是空的,只是一個佔位符,待到 Commit 的時候,直接將這些悲觀鎖改寫成標準的 Percolator 模型的鎖,後續流程和原來保持一致即可,唯一的改動是:

對於讀請求,遇到這類悲觀鎖的時候,不用像樂觀事務那樣等待解鎖,可以直接返回最新的數據即可(至於爲什麼,讀者可以仔細想想)。

至於寫請求,遇到悲觀鎖時,只需要和原本一樣,正常的等鎖就好。

這個方案很大程度上兼容了原有的事務實現,擴展性、高可用和靈活性都有保證(基本複用原來的 Percolator 自然沒有問題)。

但是引入悲觀鎖和可交互式事務,就可能引入另外一個問題:死鎖。這個問題其實在樂觀事務模型下是不存在的,因爲已知所有需要加鎖的行,所以可以按照順序加鎖,就自然避免了死鎖(實際 TiKV 的實現裏,樂觀鎖不是順序加的鎖,是併發加的鎖,只是鎖超時時間很短,死鎖也可以很快重試)。但是悲觀事務的上鎖順序是不確定的,因爲是可交互事務,舉個例子:

  • 事務 1 操作順序:UPDATE A,UPDATE B
  • 事務 2 操作順序:UPDATE B,UPDATE A

這倆事務如果併發執行,就可能會出現死鎖的情況。

所以爲了避免死鎖,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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章