TiDB 最佳實踐系列(三)樂觀鎖事務

作者:Shirly

TiDB 最佳實踐系列是面向廣大 TiDB 用戶的系列教程,旨在深入淺出介紹 TiDB 的架構與原理,幫助用戶在生產環境中最大限度發揮 TiDB 的優勢。我們將分享一系列典型場景下的最佳實踐路徑,便於大家快速上手,迅速定位並解決問題。

在前兩篇的文章中,我們分別介紹了 TiDB 高併發寫入常見熱點問題及規避方法PD 調度策略最佳實踐,本文我們將深入淺出介紹 TiDB 樂觀事務原理,並給出多種場景下的最佳實踐,希望大家能夠從中收益。同時,也歡迎大家給我們提供相關的優化建議,參與到我們的優化工作中來。

建議大家在閱讀之前先了解 TiDB 的整體架構Percollator 事務模型。另外,本文重點關注原理及最佳實踐路徑,具體的 TiDB 事務語句大家可以在 官方文檔 中查閱。

TiDB 事務定義

TiDB 使用 Percolator 事務模型,實現了分佈式事務(建議未讀過該論文的同學先瀏覽一下 論文 中事務部分內容)。

說到事務,不得不先拋出事務的基本概念。通常我們用 ACID 來定義事務(ACID 概念定義)。下面我們簡單說一下 TiDB 是怎麼實現 ACID 的:

  • A(原子性):基於單實例的原子性來實現分佈式事務的原子性,和 Percolator 論文一樣,TiDB 通過使用 Primary Key 所在 region 的原子性來保證。
  • C(一致性):本身 TiDB 在寫入數據之前,會對數據的一致性進行校驗,校驗通過纔會寫入內存並返回成功。
  • I(隔離性):隔離性主要用於處理併發場景,TiDB 目前只支持一種隔離級別 Repeatable Read,即在事務內可重複讀。
  • D(持久性):事務一旦提交成功,數據全部持久化到 TiKV, 此時即使 TiDB 服務器宕機也不會出現數據丟失。

截止本文發稿時,TiDB 一共提供了兩種事務模式:樂觀事務和悲觀事務。那麼樂觀事務和悲觀事務有什麼區別呢?最本質的區別就是什麼時候檢測衝突:

  • 悲觀事務:顧名思義,比較悲觀,對於每一條 SQL 都會檢測衝突。
  • 樂觀事務:只有在事務最終提交 commit 時纔會檢測衝突。

下面我們將着重介紹樂觀事務在 TiDB 中的實現。另外,想要了解 TiDB 悲觀事務更多細節的同學,可以先閱讀本文,思考一下在 TiDB 中如何實現悲觀事務,我們後續也會提供《悲觀鎖事務最佳實踐》給大家參考。

樂觀事務原理

有了 Percolator 基礎後,下面我們來介紹 TiDB 樂觀鎖事務處理流程。

TiDB 在處理一個事務時,處理流程如下:

  1. 客戶端 begin 了一個事務。

    a. TiDB 從 PD 獲取一個全局唯一遞增的版本號作爲當前事務的開始版本號,這裏我們定義爲該事務的 start_ts

  2. 客戶端發起讀請求。

    a. TiDB 從 PD 獲取數據路由信息,數據具體存在哪個 TiKV 上。

    b. TiDB 向 TiKV 獲取 start_ts 版本下對應的數據信息。

  3. 客戶端發起寫請求。

    a. TiDB 對寫入數據進行校驗,如數據類型是否正確、是否符合唯一索引約束等,確保新寫入數據事務符合一致性約束,將檢查通過的數據存放在內存裏

  4. 客戶端發起 commit。
  5. TiDB 開始兩階段提交將事務原子地提交,數據真正落盤。

    a. TiDB 從當前要寫入的數據中選擇一個 Key 作爲當前事務的 Primary Key。

    b. TiDB 從 PD 獲取所有數據的寫入路由信息,並將所有的 Key 按照所有的路由進行分類。

    c. TiDB 併發向所有涉及的 TiKV 發起 prewrite 請求,TiKV 收到 prewrite 數據後,檢查數據版本信息是否存在衝突、過期,符合條件給數據加鎖。

    d. TiDB 收到所有的 prewrite 成功。

    e. TiDB 向 PD 獲取第二個全局唯一遞增版本,作爲本次事務的 commit_ts

    f. TiDB 向 Primary Key 所在 TiKV 發起第二階段提交 commit 操作,TiKV 收到 commit 操作後,檢查數據合法性,清理 prewrite 階段留下的鎖。

    g. TiDB 收到 f 成功信息。

  6. TiDB 向客戶端返回事務提交成功。
  7. TiDB 異步清理本次事務遺留的鎖信息。

優缺點分析

從上面這個過程可以看到, TiDB 事務存在以下優點:

  • 簡單,好理解。
  • 基於單實例事務實現了跨節點事務。
  • 去中心化的鎖管理。

缺點如下:

  • 兩階段提交,網絡交互多。
  • 需要一箇中心化的版本管理服務。
  • 事務在 commit 之前,數據寫在內存裏,數據過大內存就會暴漲。

基於以上缺點的分析,我們有了一些實踐建議,將在下文詳細介紹。

事務大小

1. 小事務

爲了降低網絡交互對於小事務的影響,我們建議小事務打包來做。如在 auto commit 模式下,下面每條語句成爲了一個事務:

# original version with auto_commit
UPDATE my_table SET a='new_value' WHERE id = 1; 
UPDATE my_table SET a='newer_value' WHERE id = 2;
UPDATE my_table SET a='newest_value' WHERE id = 3;

以上每一條語句,都需要經過兩階段提交,網絡交互就直接 *3, 如果我們能夠打包成一個事務提交,性能上會有一個顯著的提升,如下:

# improved version
START TRANSACTION;
UPDATE my_table SET a='new_value' WHERE id = 1; 
UPDATE my_table SET a='newer_value' WHERE id = 2;
UPDATE my_table SET a='newest_value' WHERE id = 3;
COMMIT;

同理,對於 insert 語句也建議打包成事務來處理。

2. 大事務

既然小事務有問題,我們的事務是不是越大越好呢?

我們回過頭來分析兩階段提交的過程,聰明如你,很容易就可以發現,當事務過大時,會有以下問題:

  • 客戶端 commit 之前寫入數據都在內存裏面,TiDB 內存暴漲,一不小心就會 OOM。
  • 第一階段寫入與其他事務出現衝突的概率就會指數級上升,事務之間相互阻塞影響。
  • 事務的提交完成會變得很長很長 ~~~

爲了解決這個問題,我們對事務的大小做了一些限制:

  • 單個事務包含的 SQL 語句不超過 5000 條(默認)
  • 每個鍵值對不超過 6MB
  • 鍵值對的總數不超過 300,000
  • 鍵值對的總大小不超過 100MB

因此,對於 TiDB 樂觀事務而言,事務太大或者太小,都會出現性能上的問題。我們建議每 100~500 行寫入一個事務,可以達到一個比較優的性能。

事務衝突

事務的衝突,主要指事務併發執行時,對相同的 Key 有讀寫操作,主要分兩種:

  • 讀寫衝突:存在併發的事務,部分事務對相同的 Key 讀,部分事務對相同的 Key 進行寫。
  • 寫寫衝突:存在併發的事務,同時對相同的 Key 進行寫入。

在 TiDB 的樂觀鎖機制中,因爲是在客戶端對事務 commit 時,纔會觸發兩階段提交,檢測是否存在寫寫衝突。所以,在樂觀鎖中,存在寫寫衝突時,很容易在事務提交時暴露,因而更容易被用戶感知。

默認衝突行爲

因爲我們本文着重將樂觀鎖的最佳實踐,那麼我們這邊來分析一下樂觀事務下,TiDB 的行爲。

默認配置下,以下併發事務存在衝突時,結果如下:

在這個 case 中,現象分析如下:

  • 如上圖,事務 A 在時間點 t1 開始事務,事務 B 在事務 t1 之後的 t2 開始。
  • 事務 A、事務 B 會同時去更新同一行數據。
  • 時間點 t4 時,事務 A 想要更新 id = 1 的這一行數據,雖然此時這行數據在 t3 這個時間點被事務 B 已經更新了,但是因爲 TiDB 樂觀事務只有在事務 commit 時才檢測衝突,所以時間點 t4 的執行成功了。
  • 時間點 t5,事務 B 成功提交,數據落盤。
  • 時間點 t6,事務 A 嘗試提交,檢測衝突時發現 t1 之後有新的數據寫入,返回衝突,事務 A 提交失敗,提示客戶端進行重試。

根據樂觀鎖的定義,這樣做完全符合邏輯。

重試機制

我們知道了樂觀鎖下事務的默認行爲,可以知道在衝突比較大的時候,Commit 很容易出現失敗。然而,TiDB 的大部分用戶,都是來自於 MySQL;而 MySQL 內部使用的是悲觀鎖。對應到這個 case,就是事務 A 在 t4 更新時就會報失敗,客戶端就會根據需求去重試。

換言之,MySQL 的衝突檢測在 SQL 執行過程中執行,所以 commit 時很難出現異常。而 TiDB 使用樂觀鎖機製造成的兩邊行爲不一致,則需要客戶端修改大量的代碼。 爲了解決廣大 MySQL 用戶的這個問題,TiDB 提供了內部默認重試機制,這裏,也就是當事務 A commit 發現衝突時,TiDB 內部重新回放帶寫入的 SQL。爲此 TiDB 提供了以下參數,

如何設置以上參數呢?推薦兩種方式設置:

  1. session 級別設置:

    set @@tidb_disable_txn_auto_retry = 0;
    set @@tidb_retry_limit = 10;
  2. 全局設置:

    set @@global.tidb_disable_txn_auto_retry = 0;
    set @@global.tidb_retry_limit = 10;

萬能重試

那麼重試是不是萬能的呢?這要從重試的原理出發,重試的步驟:

  1. 重新獲取 start_ts
  2. 對帶寫入的 SQL 進行重放。
  3. 兩階段提交。

細心如你可能會發現,我們這邊只對寫入的 SQL 進行回放,並沒有提及讀取 SQL。這個行爲看似很合理,但是這個會引發其他問題:

  1. start_ts 發生了變更,當前這個事務中,讀到的數據與事務真正開始的那個時間發生了變化,寫入的版本也是同理變成了重試時獲取的 start_ts 而不是事務一開始時的那個。
  2. 如果當前事務中存在更新依賴於讀到的數據,結果變得不可控。

打開了重試後,我們來看下面的例子:

我們來詳細分析以下這個 case:

  • 如圖,在 session B 在 t2 開始事務 2,t5 提交成功。session A 的事務 1 在事務 2 之前開始,在事務 n2 提交完成後提交。
  • 事務 1、事務 2 會同時去更新同一行數據。
  • session A 提交事務 1 時,發現衝突,tidb 內部重試事務 1。

    • 重試時,重新取得新的 start_tst8’
    • 回放更新語句 update tidb set name='pd' where id =1 and status=1

      i. 發現當前版本 t8’ 下並不存在符合條件的語句,不需要更新。

      ii. 沒有數據更新,返回上層成功。

  • tidb 認爲事務 1 重試成功,返回客戶端成功。
  • session A 認爲事務執行成功,查詢結果,在不存在其他更新的情況下,發現數據與預想的不一致。

這裏我們可以看到,對於重試事務,如果本身事務中更新語句需要依賴查詢結果時,因爲重試時會重新取版本號作爲 start_ts,因而無法保證事務原本的 ReadRepeatable 隔離型,結果與預測可能出現不一致。

綜上所述,如果存在依賴查詢結果來更新 SQL 語句的事務,建議不要打開 TiDB 樂觀鎖的重試機制。

衝突預檢

從上文我們可以知道,檢測底層數據是否存在寫寫衝突是一個很重的操作,因爲要讀取到數據進行檢測,這個操作在 prewrite 時 TiKV 中具體執行。爲了優化這一塊性能,TiDB 集羣會在內存裏面進行一次衝突預檢測。

TiDB 作爲一個分佈式系統,我們在內存中的衝突檢測主要在兩個模塊進行:

  • TiDB 層,如果在 TiDB 實例本身發現存在寫寫衝突,那麼第一個寫入發出去後,後面的寫入就已經能清楚地知道自己衝突了,沒必要再往下層 TiKV 發送請求去檢測衝突。
  • TiKV 層,主要發生在 prewrite 階段。因爲 TiDB 集羣是一個分佈式系統,TiDB 實例本身無狀態,實例之間無法感知到彼此的存在,也就無法確認自己的寫入與別的 TiDB 實例是否存在衝突,所以會在 TiKV 這一層檢測具體的數據是否有衝突。

其中 TiDB 層的衝突檢測可以關閉,配置項可以啓用:

txn-local-latches:事務內存鎖相關配置,當本地事務衝突比較多時建議開啓。

  • enable

    • 開啓
    • 默認值:false
  • capacity

    • Hash 對應的 slot 數,會自動向上調整爲 2 的指數倍。每個 slot 佔 32 Bytes 內存。當寫入數據的範圍比較廣時(如導數據),設置過小會導致變慢,性能下降。
    • 默認值:1024000

細心的朋友可能又注意到,這邊有個 capacity 的配置,它的設置主要會影響到衝突判斷的正確性。在實現衝突檢測時,我們不可能把所有的 Key 都存到內存裏,佔空間太大,得不償失。所以,真正存下來的是每個 Key 的 hash 值,有 hash 算法就有碰撞也就是誤判的概率,這裏我們通過 capacity 來控制 hash 取模的值:

  • capacity 值越小,佔用內存小,誤判概率越大。
  • capacity 值越大,佔用內存大,誤判概率越小。

在真實使用時,如果業務場景能夠預判斷寫入不存在衝突,如導入數據操作,建議關閉。

相應地,TiKV 內存中的衝突檢測也有一套類似的東西。不同的是,TiKV 的檢測會更嚴格,不允許關閉,只提供了一個 hash 取模值的配置項:

  • scheduler-concurrency

    • scheduler 內置一個內存鎖機制,防止同時對一個 Key 進行操作。每個 Key hash 到不同的槽。
    • 默認值:2048000

此外,TiKV 提供了監控查看具體消耗在 latch 等待的時間:

如果發現這個 wait duration 特別高,說明耗在等待鎖的請求上比較久,如果不存在底層寫入慢問題的話,基本上可以判斷這段時間內衝突比較多。

總結

綜上所述,Percolator 樂觀事務實現原理簡單,但是缺點諸多,爲了優化這些缺陷帶來的性能上和功能上的開銷,我們做了諸多努力。但是誰也不敢自信滿滿地說:這一塊的性能已經達到了極致。

時至今日,我們還在持續努力將這一塊做得更好更遠,希望能讓更多使用 TiDB 的小夥伴能從中受益。與此同時,我們也非常期待大家在使用過程中的反饋,如果大家對 TiDB 事務有更多優化建議,歡迎聯繫我 [email protected] 。您看似不經意的一個舉動,都有可能使更多飽受折磨的互聯網同學們從中享受到分佈式事務的樂趣。

原文閱讀https://pingcap.com/blog-cn/best-practice-optimistic-transaction/

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