分散式資料庫除了 Data Sharding 關鍵技術外,跨 Server Transaction 實作 ACID 功能也很重要,然而當資料分散在不同資料庫後,Transaction 如何在不同 DB 之間確保 Atomicity 就是一個挑戰了。
首先是資料不一致問題,如果 DB A 成功 Commit 但是 DB B Commit 失敗,此時 DB A 資料要 Rollback,但如果有其他 Transaction 已經更新了 DB A 的資料,那 Rollback 就會導致其他 Transaction 更新的資料也被 Rollback,另外在某些業務情境,直接 Rollback 是會出問題的,例如轉帳 system,DB A 加值 A 用戶成功,DB B 扣款 B 用戶失敗,此時要 Rollback A 用戶的加值,但如果用戶 A 把錢花掉了,直接 Rollback 就會有問題。
而 Two-Phase Commit 是實作分散式 Transaction Atomicity 的核心技術之一,由於 DB Transaction 有提供 Commit 指令,Commit 後才讓異動資料可被其他 Transaction 修改和讀取,且 Commit 指令本身很單純最不可能出錯。
因此 Two-Phase Commit 利用 Commit 指令特性,確保所有資料庫都執行完 SQL 後,在一起執行 Commit,盡可能降低我先 Commit 後,別人執行 SQL 出錯,我無法 Rollback 的問題。
(作者產圖)
Two-Phase Commit 下的轉帳,兩個 SQL 都成功後在一起 Commit。
畢竟兩個 DB 連線屬於不同網路環境,如果 B 資料庫網路環境差,導致送 Commit 失敗,Transaction Idle 太久就會 Rollback,一樣有資料不一致的風險。
雖然將執行順序調換,變成先扣款再加值,可保證 Rollback 時不出錯,但這樣 A & B 用戶同時互相轉帳會產生 DeadLock。
為解決上面問題,有了 XA Transaction 協議,每個 XA 會有一個 ID 用來追蹤 Commit 狀態,且在 Commit 前要先執行 Prepare 指令確保 Commit 能被執行,例如會驗證 Client 到 DB 網路暢通以及 Redo Log 有空間能寫入資料等,所有 DB Transaction 都執行完 Prepare 後就能一起執行 Commit。
若剛好某個 Client 網路出問題 Commit 無法送出,XA 執行紀錄會儲存在 DB 中,可透過 XA Recover 指令列出所有執行過 Prepare 的 XA ID 並重新 Commit。
(作者產圖)
使用 XA 一定要有 XA Recover 排程定期檢查,避免有遺失的 Transaction Idle 太久被 Rollback。
而 XA 最大缺點是效能,XA 分散式 Transaction 要等多個 DB Prepare 完才能 Commit,雖然 Prepare 執行不複雜且理想上很快,但如果有 DB 網路環境不好導致速度慢,就會拖累整個 Transaction 影響到其他 DB 無法 Commit,此外若節點變多,執行 Prepare 的數量變多時間也會被拉長,擴展性不佳。
此外 XA Recover 排程如果出問題一樣會有資料不一致風險。
TiDB 是 MySQL Compatible 的分散式資料庫,它支援 MySQL 語法跟 Protocol,但 Storage Engine 是用 RocksDB Key-Value 資料庫儲存,並有 Data Sharding 和分散式 Transaction 功能。
TiDB 的分散式 Transaction 功能是基於 Percolator (Google 2010 設計的分散式 Transaction) 概念實作的。
Percolator 將分散式 Transaction 分為一個 Primary DB 跟多個 Secondary DB,執行 Write (Update, Insert or Delete) 指令時,會在指令中標注誰是 Primary DB,一旦所有 SQL 指令執行完,只對 Primary DB 執行 Commit ,其他 Secondary DB 的 Transaction 會用異步方式完成 Commit。
如此一來 Percolator 的分散式 Transaction 就不需要多個 Prepare 以及 Commit 指令,也不用擔心有 Idle Transaction 會被 Rollback。
Secondary DB 執行 Write 指令時,會先儲存異動資料到 DB,並紀錄該異動屬於哪個 Primary DB,當其他 Transaction 讀到這筆資料時,會先檢查該紀錄 Commit 狀態,若是未 Commit 狀態,會去詢問 Primary DB 該 Transaction 要 Commit 還是 Rollback,已此來決定這筆修改要 Commit or Rollback。
同時 Percolator 也採用 MVCC,會紀錄每一筆異動資料和版本號 (Timestamp),當一筆資料累積了多筆異動紀錄,可以用版本號 (Timestamp) 來決定最後 Commit 的資料內容。
TiDB 使用 RocksDB Key-Value 資料庫,一筆資料會有三個類型的 Key:
假設有一個 accounts table primary key 為 name 並有 Bob 跟 Alice 兩筆紀錄:
Data Key 會儲存:
Write Key 會儲存:
此時,執行一個 Transaction Bob 轉 100 元給 Alice,Primary DB 為 Bob 的資料庫:
Data Key 儲存:
Transaction 還沒 Commit 不會異動 Write Key :
Transaction 執行中,Lock Key 會有內容:
此時其他 Transaction 查詢 Bob & Alice 餘額,會去檢查 Write Key 內容確認版本,因此餘額都還是 1000,若有其他 Transaction 執行 UPDATE or SELECT FOR UPDATE 會因為 Lock Key 有值被卡住。
執行 Primary DB Commit 後:
Write Key 會異動:
Lock 會只剩 Alice 的資訊:
其他 Transaction 讀取 Alice 資料,會發現 Lock Key 有值,透過 Primary Key 找到儲存 Bob 的資料庫並確認已 Commit,修改 Alice 資料:
Write Key 變成:
Lock 會清空,讀取出 Alice 的餘額為 1100。
Percolator 將 Commit 區分為 Primary & Secondary 可避免分散式 Transaction Commit 時執行多次 Prepare & Commit,效能好擴展性佳,但讀取要透過 Lock 內容向 Primary DB 檢查該異動是否可 Commit,若 Primary DB 當時網路環境不好或處於不可用狀態,讀取就會卡住,因此 TiDB Cluster 中的每個 DB 都會用 Raft 共識機制建立多個 Follower 確保可用性。