如何優雅地實現多資料庫的發件箱模式

葉東富發表於2022-07-04

發件箱模式簡介

一個微服務可能需要執行“存資料庫”和“傳送事件”兩個步驟。例如釋出一篇文章後,需要更新作者的發文統計資訊。業務上要求兩個操作同時失敗,或者同時成功,而不能出現一個成功一個失敗。假如最終文章釋出了,更新發文統計失敗了,就會導致資料不一致。

發件箱模式是解決這個問題的最常用模式,其原理為:

  1. 本地業務作為一個事務執行,在提交事務之前,將事件寫入到訊息表;提交事務時,會同時提交業務,以及事件
  2. 通過輪詢訊息表或者監聽binlog方式,將事件發給訊息佇列

    • 輪詢方式:每隔1s或者0.2s取出訊息表中事件,發給訊息佇列,然後刪除事件
    • 監聽binlog方式:通過Debezium等資料庫工具,監聽資料庫的binlog,獲取事件,傳送給訊息佇列
  3. 編寫消費者,處理事件

由於1中,業務和事件的提交是在同一個事務,保證了兩者會同時提交。
在步驟2,3中,都是不會失敗的操作,如果中間發生當機事件等,都會重試,並最終成功。

對於前述的發文後提交統計資訊場景,上述方案保證了統計資訊被最終更新,資料會達到最終一致

多資料庫的問題

在當今流行的微服務架構下,通常一個微服務會採用一個單獨的資料庫。當多個服務需要使用發件箱模式時,那麼傳統的發件箱架構就比較難以維護。

  • 採用輪詢方式獲取事件:需要在輪詢任務中,編寫多個資料庫的輪詢任務
  • 採用監聽binlog獲取事件:需要監聽多個資料庫的binlog

上述兩種獲取事件的方式,在面對數量較多的資料庫,可維護性差。而且該架構的彈性並不好,假如資料庫多,而時間產生的事件少,也會導致該架構的負載高,浪費資源。最理想的架構負載是,只跟傳送的事件數量相關,跟其他因素無關。

解決方案

開源分散式事務框架 https://github.com/dtm-labs/dtm 裡面的二階段訊息,可以很好的處理這個問題。下面是一個跨行轉賬業務的使用示例:

msg := dtmcli.NewMsg(DtmServer, gid).
    Add(busi.Busi+"/TransIn", &TransReq{Amount: 30})
err := msg.DoAndSubmitDB(busi.Busi+"/QueryPreparedB", db, func(tx *sql.Tx) error {
    return busi.SagaAdjustBalance(tx, busi.TransOutUID, -req.Amount, "SUCCESS")
})

這部分程式碼中

  • 首先生成一個DTM的msg全域性事務,傳遞dtm的伺服器地址和全域性事務id
  • 給msg新增一個分支業務邏輯,這裡的業務邏輯為餘額轉入操作TransIn,然後帶上這個服務需要傳遞的資料,金額30元
  • 然後呼叫msg的DoAndSubmitDB,這個函式保證業務成功執行和msg全域性事務提交,要麼同時成功,要麼同時失敗

    1. 第一個引數為回查URL,詳細含義稍後說
    2. 第二個引數為sql.DB,是業務訪問的資料庫物件
    3. 第三個引數是業務函式,我們這個例子中的業務是給A扣減30元餘額

成功流程

DoAndSubmitDB是如何保證業務成功執行與msg提交的原子性的呢?請看如下的時序圖:
image.png

一般情況下,時序圖中的5個步驟會正常完成,整個業務按照預期進行,全域性事務完成。這裡面有個新的內容需要解釋一下,就是msg的提交是按照兩個階段發起的,第一階段呼叫Prepare,第二階段呼叫Commit,DTM收到Prepare呼叫後,不會呼叫分支事務,而是等待後續的Submit。只有收到了Submit,開始分支呼叫,最終完成全域性事務。

異常情況

在分散式系統中,各類的當機和網路異常都是需要考慮的,下面我們來看看可能發生的問題:

首先我們要達到的最重要目標是業務成功執行和msg事務是原子操作,那麼假如前面時序圖中,當Prepare訊息傳送成功之後,Submit訊息傳送成功之前,出現異常當機會如何?這個時候dtm會檢測到該事務超時,會進行回查。對於開發人員來說,該回查很簡單,只需要貼上如下程式碼即可:

    app.GET(BusiAPI+"/QueryPreparedB", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
        return MustBarrierFromGin(c).QueryPrepared(dbGet())
    }))

如果您使用的不是go框架gin,那麼您需要根據您的框架做一些小修改,但是該程式碼是通用的,適合您的每個業務。

回查的主要原理主要是通過訊息表,但是dtm的回查經過仔細的論證,能夠處理以下情況:

  • 回查時,本地事務未開始
  • 回查時,本地事務還在進行中
  • 回查時,本地事務已回滾
  • 回查時,本地事務已提交

詳細的回查原理有些複雜,已申請了專利,這裡不做詳細介紹,詳情可以參考https://dtm.pub/practice/msg.html

多資料庫支援

該方案下,如果您需要處理多資料庫,運維層面,只需要給相應的庫建立好訊息表;程式碼層面,只需要在回查的地方,傳入不同的資料庫連線即可。

對比於原有的輪詢表,以及監聽binlog方案,運維成本大大降低。該架構的負載僅僅與事件數量相關,跟資料庫數量等其他因素無關,具備了更好的彈性。

更多儲存引擎的支援

dtm的二階段訊息,不僅提供了資料庫的支援DoAndSubmitDB,還提供了NoSQL的支援

Mongo支援

下面這段程式碼,可以保證Mongo下的業務和訊息兩者同時提交

err := msg.DoAndSubmit(busi.Busi+"/RedisQueryPrepared", func(bb *dtmcli.BranchBarrier) error {
    return bb.MongoCall(MongoGet(), func(sc mongo.SessionContext) error {
        return SagaMongoAdjustBalance(sc, sc.Client(), TransOutUID, -reqFrom(c).Amount, reqFrom(c).TransOutResult)
    })
})

Redis支援

下面這段程式碼,可以保證Redis下的業務和訊息兩者同時提交

err := msg.DoAndSubmit(busi.Busi+"/RedisQueryPrepared", func(bb *dtmcli.BranchBarrier) error {
    return bb.RedisCheckAdjustAmount(busi.RedisGet(), busi.GetRedisAccountKey(busi.TransOutUID), -30, 86400)
})

dtm的回查方案可以很容易的擴充套件到其他各種各樣的支援事務的儲存引擎

方案特點

二階段訊息下具備以下特點:

  • 優雅的支援了多資料庫
  • 不僅支援SQL資料庫,還支援了Mongo,Redis等NoSQL
  • 程式碼簡短,比通常的發件箱模式程式碼量大幅減少
  • 整個架構和開發過程不涉及訊息佇列,只涉及api,更容易上手
  • 負載僅僅與訊息量有關,與涉及的資料庫數量無關

對比RocketMQ事務訊息

回查的這種形式最早是在RocketMQ的事務訊息中提出的,但是作者全網查詢了回查的例子,以及各種案例,都未找到能夠把各種異常情況都處理好的回查方案。已找到的方案中,都未能夠正確處理”本地事務還在進行中“的這種情況,都會存在極端情況導致資料不一致,詳情參考https://dtm.pub/practice/msg.html

另外dtm的二階段訊息,不需要引入佇列,或者也可以結合其他的訊息佇列使用,因此使用範圍更廣

小結

本文介紹的dtm二階段訊息,更好的支援多資料庫的情況。該架構方案,具備諸多優點,可以完美的替代發件箱模式,給開發者帶來更簡單易用的架構。

歡迎訪問https://github.com/dtm-labs/dtm 並star支援我們

相關文章