基於補償的數據庫分佈式事務實踐

Undo Log

Undo Log 是爲了實現事務的原子性,主要記錄的是一個操做的反操做的內容。php

  • 事務的原子性(Atomicity)
    一個事務(transaction)中的全部操做,要麼所有完成,要麼所有不完成,不會結束在中間某個環節。
    事務在執行過程當中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務歷來沒有執行過同樣。
  • 事務的持久性(Durability)
    事務處理結束後,對數據的修改就是永久的,即使系統故障也不會丟失。mysql

  • 用Undo Log實現原子性和持久化的事務的簡化過程git

    假設有A、B兩個數據,初始值分別爲1和2。如今須要執行一個事務,將A的值改成3且將B的值改成4。  
    
      A.事務開始.   
      B.記錄A=1到undo log的內存buffer.   
      C.在內存中修改A=3.   
      D.記錄B=2到undo log的內存buffer.   
      E.在內存中修改B=4.   
      F.將undo log的buffer寫到磁盤。  
      G.將內存中修改後的數據寫到磁盤。  
      H.將事務標記爲已提交的狀態

這整個過程當中,有可能出現異常狀況。在目前的系統中,咱們認爲異常狀況有兩種:
一正常的邏輯已經沒法繼續下去,可是程序自己仍是能夠正常運行的,能夠依靠程序自己的異常處理邏輯來處理這部分異常。
第二種異常比較嚴重,程序自己已經沒法正常工做了,好比系統忽然斷電。
爲了便於敘述咱們將前者稱爲邏輯異常,後者稱爲宕機異常。github

  • 若是在A到F的過程當中出現了邏輯異常,數據庫會將這次的事務表示爲失敗的狀態,由於G並無執行,因此數據仍是原樣不動,符合一致性。
  • 若是在G到H的過程當中出現了邏輯異常,數據庫會將這次的事務表示爲失敗的狀態,當發現G已執行後,會執行F中保存的undolog,將數據恢復。
  • 若是在A到E的過程當中出現了宕機異常,數據庫重啓後會發現這個事務處於初始狀態,可是沒看到undolog,說明G確定沒執行,數據是一致的,能夠放心地直接將事務標記爲失敗。
  • 若是在F到H的過程當中出現了宕機異常,數據庫重啓後會發現這個事務處於初始狀態,而後一看,undolog是存在的,這個時候就尷尬了,G到底執行成功了沒有呢?,若是未執行成功,則數據如今就是一致的,直接將事務標記爲失敗就好,若是執行成功了,則須要執行undolog將數據恢復成一致的狀態,這可如何是好?

這裏須要引入一個redolog,就是將以前的更新A和更新B的操做也記錄下來,只要這個redolog的執行能夠保證冪等性,以前苦惱的問題就解決了,也不須要猜想這個G是否真正的執行成功了,只須要將redolog從新執行一遍便可。而後就能夠放心地執行undolog,將數據恢復一致性後再將事務標記成失敗的狀態。
冪等是一個很好的詞語,咱們在設計本身的系統時候,能夠很輕鬆地經過請求流水號等參數將冪等實現。可是對於數據庫來講,由於對性能的要求比較高,因此冪等有可能不成立。(這一點我不肯定,我猜想的,支持冪等最好)sql

回到上文中的順序,其實F和G這兩步驟,在每個的事務執行的過程當中,都須要強行地寫兩次磁盤。這樣會致使大量的磁盤IO,所以性能很低。shell

綜合這兩點來講,redolog是避免不掉的,並且既然已經有redolog了,是否就能夠再也不須要將數據實時寫到磁盤這一步,大不了奔潰的時候,直接使用redolog將數據恢復。數據庫

A.事務開始.
    B.記錄A=1到undo log的內存buffer.
    C.內存中修改A=3.
    D.記錄A=3到redo log的內存buffer.
    E.記錄B=2到undo log的內存buffer.
    F.內存中修改B=4.
    G.記錄B=4到redo log的內存buffer.
    H.將undo log的buffer寫到磁盤。
    I.將redo log的內存buffer寫入磁盤。
    J.事務提交

雖然數據不須要時寫磁盤了,可是undolg和redolog仍是須要寫,看起來並無什麼改觀?
可是有個不同的是,數據庫的數據是結構化存儲的,存儲位置早就肯定了,並且大多數是更新請求。
可是redolog和undolog都是新的內容,對他們來講,保存就是新增文件。
再聯想到kafka爲何寫文件效率那麼高,磁盤的順序寫操做實際上是很是快的,並不比內存滿多少。
並且既然想要實現順序寫,就乾脆把undolog也做爲redolog的內容的一部分進行保存。網絡

順序寫,就代表了這個過程當中,redolog多是邏輯無關的,不少分別屬於不一樣事務的redolog會被一塊兒寫到磁盤上,當系統在出現宕機異常時,會找到數據保存的那個checkpoint,而後開始執行以後的redolog,將數據恢復。
若是執行了還沒有被標記爲成功的事務,或者執行了已經被標記爲Rollback的事務,這時候會去找到他們的undolog,執行undolog後,將數據恢復到一致的狀態。併發

詳細的流程這裏就再也不講了,涉及到mvcc的更加複雜,我也還沒有徹底弄清楚,能夠參考如下文章:
InnoDB recovery詳細流程
MySQL · 引擎特性 · InnoDB 崩潰恢復過程mvc

分佈式事務

回到剛纔的問題,之因此作了那麼多的擴展,是由於遇到了前面說的那個問題,沒法肯定將undo log的buffer寫到磁盤執行成功後,將內存中修改後的數據寫到磁盤是否執行成功,若是解決了這個問題,也就沒redolog啥事了。

咱們在執行分佈於不一樣的兩個數據庫的操做時,數據庫的事務已經沒法使用了,可是對於單個數據庫來講了,數據庫的事務仍是頗有效的,並且對於咱們應用層的框架來講,也不會那麼糾結於性能,畢竟網絡IO會佔大多數。
想到這裏,咱們可使用數據庫的事務來保證數據A操做的do操做和undolog的保存在同一個事務中!

由於咱們主要的業務是作支付,那麼咱們就將A轉帳給B這個場景來進行討論

如圖所示,distributeJob表明了一個完整的轉帳場景,A轉帳10元給B,其中A和B的帳戶存儲在不一樣的數據庫中,執行的過程是先經過transferOut從A的帳戶中扣除10元,而後再執行transferIn給B增長10元。

undoOutSave,代表將transferOut的undolog保存起來,方便在須要rollback時將transferOut的影響撤銷,在這裏其實就是將錢加回來,即給A的帳戶增長10元。同理對於transferIn的undolog的保存也就是undoInSave。
其中綠色的框框將兩個操做框起來,是代表這兩個操做是位於同一個數據庫事務中。

當執行過程當中出現異常時,會將以前全部已完成的操做回滾,恢復到初始狀態,一個比較通用的總體的流程以下

實現 talk is cheap,code is here

由於咱們是使用thrift框架來作服務的,整個過程使用攔截器來實現各類邏輯,最外層代碼看起來以下

@DistributeJob
public boolean transfer(Context context, String fromId, String toId, long amount) throws TException {
  try {
    transferOut(context, fromId, toId, amount);
    transferIn(context, fromId, toId, amount);
  } catch (Exception e) {
    throw new TException(e);
  }
  return true;
}


@DoJob
@GetConnection
public boolean transferOut(Context context, @SharedKey("userId") String fromId, String toId,
                           long amount) throws Exception {
  userDao.updateBalanceById(context.getConnection(), fromId, -amount, 0L);
  undoTransferOut(context, null, fromId, toId, amount);
  return true;
}

@UndoJob
@GetConnection
public boolean undoTransferOut(Context context, com.xiaojing.distributed.model.UndoJob undoJob,
                               @SharedKey("userId") String fromId, String toId, long amount)
  throws Exception {
  userDao.updateBalanceById(context.getConnection(), fromId, amount, Long.MIN_VALUE);
  return true;
}

看不懂也不要緊,下面有詳細的流程圖。

紅色的線代表,全部被拋出的邏輯異常都會觸發在線的回滾。這裏叫邏輯異常也不全,也有多是網絡異常致使的IO異常,這裏咱們換個說法,將這些異常統稱爲,非宕機異常。

一樣的,如前文所說,還有一種異常叫作宕機異常,在解決這些異常時,沒有在線回滾了,只能在服務重啓後,經過掃表的方式來進行離線的回滾。離線回滾無非就是線找到未完成的事務,而後將其的undolog找出來,而後執行undolog便可。

如前文所說的,undolog執行的冪等仍是很重要的,在這裏咱們是經過將undolog置爲rollbacked和執行undolog的內容放在同一個事務中來保證,undolog只會執行一次的。

在出現異常的時候,回滾的流程圖以下。

圖中的rollback fail,極少數狀況下會發生,好比B帳戶註銷了,或者是B帳戶的錢剛好在這一刻徹底花完了,這種狀況,只好交給人工處理。

測試

一、代碼中的test目錄下,模擬了各類狀況下出現的非宕機異常,驗證告終果的有效性。單測中使用了h2數據庫,測試前必須先將其啓動。
二、對於宕機異常,寫了一個shell腳本,每1s關閉服務一次,client不斷去調用server執行轉帳操做。當中止兩個腳本後,正常啓動服務,最後check金額,知足一致性。執行方法以下:

mvn -Dmaven.test.skip=true clean package
nohup sh transfer_server.sh daemon &
nohup sh transfer_server.sh kill &
nohup sh transfer_client.sh start &
kill -9 (daemon/kill/client) 將服務端和客戶端都中止
sh scheduler_rollback.sh start

執行前,須要將配置文件中的數據庫換成本身的地址,並執行一下init.sql中的初始化語句,還有把腳本里的路徑換成你本身的。
在正常服務中,scheduler_rollback不須要做爲一個單獨的服務進行啓動,他只是main server的一個線程

在執行rollback以前,查看數據庫中的數據,明顯能夠看出數據是不一致的

rollback服務啓動一段時間後,查看數據庫中帳戶1和2的總金額,發現是一致的。咱們這裏使用的是10s調度一次,經過測試數據觀察,真正的回滾耗費實踐是在ms級別的。

rollback執行過程當中的數據distribute_job和undo_job的狀態以下

小細節

一、這裏是先執行了do,而後再保存undo,其實由於二者屬於同一個數據庫事務,前後順序其實不那麼重要。可是在邏輯異常須要回滾的時候,咱們仍是但願可以直接從內存裏拿出undolog,而後進行undo操做的,這樣能夠減小一次數據庫查找的開銷,由於這個緣由,因此將undosave放在了後面,寫代碼的時候比較不容易弄混。
二、小技巧,對於全部的有可能存在併發的對數據的操做,全部的讀操做都是不正確的,直接使用CAS的寫操做,以寫代查,纔是正確的作法。
三、判斷一個事務是不是由於宕機停留在初始狀態是經過超時來判斷的,因此執行中的distributeJob須要select for update。
四、正常的服務都是無狀態擴容的,雖然咱們的rollback支持併發和冪等,可是爲了不過分競爭影響效率,rollback操做仍是須要制定一臺的執行。

侷限

聽起來很美妙,實現了分佈式事務!遺憾的是,這套代碼並無在生產環境上使用,理由有以下:

一、真實狀況下,一個交易並不只僅是隻有帳戶的變更,還有其餘服務的調用、跨服務的rpc,而不只僅是數據庫。關於這一點,下一篇我會寫寫跨服務的分佈式事務。
二、大多數交易出現的費宕機異常都是網絡的IO異常,這種狀況下徹底能夠經過重試解決,直接rollback的方式過於悲觀,並且增長上游接入難度。
三、「分佈式事務」自己的侷限性,這裏只對量變比較好使,對於質變這種方式,這種rollback是否是正確的呢?何況即便是作CAS也沒法解決ABA的問題。

最佳使用場景,電商購物車多件商品搶購模型。由於都是量變,並且直接在失敗時迅速回歸庫存正好適用於此場景。

參考文檔

MySQL數據庫InnoDB存儲引擎Log漫遊(1)
MySQL · 引擎特性 · InnoDB undo log 漫遊
MySQL · 引擎特性 · InnoDB redo log漫遊

相關文章
相關標籤/搜索