Undo Log
Undo Log 是為了實現事務的原子性,主要記錄的是一個操作的反操作的內容。
-
事務的原子性(Atomicity)
一個事務(transaction)中的所有操作,要么全部完成,要么全部不完成,不會結束在中間某個環節。
事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。 -
事務的持久性(Durability)
事務處理結束后,對數據的修改就是永久的,即便系統故障也不會丟失。 -
用Undo Log實現原子性和持久化的事務的簡化過程
假設有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.將事務標記為已提交的狀態
這整個過程中,有可能出現異常情況。在目前的系統中,我們認為異常情況有兩種:
一正常的邏輯已經無法繼續下去,但是程序本身還是可以正常運行的,可以依靠程序本身的異常處理邏輯來處理這部分異常。
第二種異常比較嚴重,程序本身已經無法正常工作了,比如系統突然斷電。
為了便於敘述我們將前者稱為邏輯異常,后者稱為宕機異常。
- 如果在A到F的過程中出現了邏輯異常,數據庫會將此次的事務表示為失敗的狀態,因為G並沒有執行,所以數據還是原樣不動,符合一致性。
- 如果在G到H的過程中出現了邏輯異常,數據庫會將此次的事務表示為失敗的狀態,當發現G已執行后,會執行F中保存的undolog,將數據恢復。
- 如果在A到E的過程中出現了宕機異常,數據庫重啟后會發現這個事務處於初始狀態,但是沒看到undolog,說明G肯定沒執行,數據是一致的,可以放心地直接將事務標記為失敗。
- 如果在F到H的過程中出現了宕機異常,數據庫重啟后會發現這個事務處於初始狀態,然后一看,undolog是存在的,這個時候就尷尬了,G到底執行成功了沒有呢?,如果未執行成功,則數據現在就是一致的,直接將事務標記為失敗就好,如果執行成功了,則需要執行undolog將數據恢復成一致的狀態,這可如何是好?
這里需要引入一個redolog,就是將之前的更新A和更新B的操作也記錄下來,只要這個redolog的執行可以保證冪等性,之前苦惱的問題就解決了,也不需要猜測這個G是否真正的執行成功了,只需要將redolog重新執行一遍即可。然后就可以放心地執行undolog,將數據恢復一致性后再將事務標記成失敗的狀態。
冪等是一個很好的詞語,我們在設計自己的系統時候,可以很輕松地通過請求流水號等參數將冪等實現。但是對於數據庫來說,因為對性能的要求比較高,所以冪等有可能不成立。(這一點我不確定,我猜測的,支持冪等最好)
回到上文中的順序,其實F和G這兩步驟,在每一個的事務執行的過程中,都需要強行地寫兩次磁盤。這樣會導致大量的磁盤IO,因此性能很低。
綜合這兩點來說,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 崩潰恢復過程
分布式事務
回到剛才的問題,之所以做了那么多的擴展,是因為遇到了前面說的那個問題,無法確定將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賬戶的錢恰好在這一刻完全花完了,這種情況,只好交給人工處理。
測試
1、代碼中的test目錄下,模擬了各種情況下出現的非宕機異常,驗證了結果的有效性。單測中使用了h2數據庫,測試前必須先將其啟動。
2、對於宕機異常,寫了一個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的狀態如下

小細節
1、這里是先執行了do,然后再保存undo,其實因為兩者屬於同一個數據庫事務,先后順序其實不那么重要。但是在邏輯異常需要回滾的時候,我們還是希望能夠直接從內存里拿出undolog,然后進行undo操作的,這樣可以減少一次數據庫查找的開銷,因為這個原因,所以將undosave放在了后面,寫代碼的時候比較不容易弄混。
2、小技巧,對於所有的有可能存在並發的對數據的操作,所有的讀操作都是不正確的,直接使用CAS的寫操作,以寫代查,才是正確的做法。
3、判斷一個事務是否是因為宕機停留在初始狀態是通過超時來判斷的,所以執行中的distributeJob需要select for update。
4、正常的服務都是無狀態擴容的,雖然我們的rollback支持並發和冪等,但是為了避免過度競爭影響效率,rollback操作還是需要制定一台的執行。
局限
聽起來很美妙,實現了分布式事務!遺憾的是,這套代碼並沒有在生產環境上使用,理由有如下:
1、真實情況下,一個交易並不僅僅是只有賬戶的變動,還有其他服務的調用、跨服務的rpc,而不僅僅是數據庫。關於這一點,下一篇我會寫寫跨服務的分布式事務。
2、大多數交易出現的費宕機異常都是網絡的IO異常,這種情況下完全可以通過重試解決,直接rollback的方式過於悲觀,而且增加上游接入難度。
3、“分布式事務”本身的局限性,這里只對量變比較好使,對於質變這種方式,這種rollback是不是正確的呢?況且即使是做CAS也無法解決ABA的問題。
最佳使用場景,電商購物車多件商品搶購模型。因為都是量變,而且直接在失敗時迅速回歸庫存正好適用於此場景。
參考文檔
MySQL數據庫InnoDB存儲引擎Log漫游(1)
MySQL · 引擎特性 · InnoDB undo log 漫游
MySQL · 引擎特性 · InnoDB redo log漫游
