微服務架構下的數據一致性

一、分佈式事務保證一致性

1.1 兩階段提交

在分佈式系統中,每個節點雖然可以知曉自己的操作時成功或者失敗,卻無法知道其他節點的操作的成功或失敗。當一個事務跨越多個節點時,爲了保持事務的ACID特性,需要引入一個作爲協調者的組件來統一掌控所有節點(稱作參與者)的操作結果並最終指示這些節點是否要把操作結果進行真正的提交(比如將更新後的數據寫入磁盤等等)。因此,二階段提交的算法思路可以概括爲:參與者將操作成敗通知協調者,再由協調者根據所有參與者的反饋情報決定各參與者是否要提交操作還是中止操作。

所謂的二階段提交(Two Phase Commit)是指:

第一階段:準備階段(投票階段)

第二階段:提交階段(執行階段)

這裏寫圖片描述

缺點:

1、同步阻塞問題。執行過程中,所有參與節點都是事務阻塞型的。當參與者佔有公共資源時,其他第三方節點訪問公共資源不得不處於阻塞狀態。

2、單點故障。由於協調者的重要性,一旦協調者發生故障。參與者會一直阻塞下去。尤其在第二階段,協調者發生故障,那麼所有的參與者還都處於鎖定事務資源的狀態中,而無法繼續完成事務操作。(如果是協調者掛掉,可以重新選舉一個協調者,但是無法解決因爲協調者宕機導致的參與者處於阻塞狀態的問題)。

3、數據不一致。在二階段提交的階段二中,當協調者向參與者發送commit請求之後,發生了局部網絡異常或者在發送commit請求過程中協調者發生了故障,這回導致只有一部分參與者接受到了commit請求。而在這部分參與者接到commit請求之後就會執行commit操作。但是其他部分未接到commit請求的機器則無法執行事務提交。於是整個分佈式系統便出現了數據不一致性的現象。

4、二階段無法解決的問題:協調者再發出commit消息之後宕機,而唯一接收到這條消息的參與者同時也宕機了。那麼即使協調者通過選舉協議產生了新的協調者,這條事務的狀態也是不確定的,沒人知道事務是否被已經提交。


1.2 傳統分佈式事務不是微服務中一致性的最佳選擇

依據CAP理論,必須在可用性(availability)和一致性(consistency)之間做出選擇。如果選擇提供一致性需要付出在滿足一致性之前阻塞其他併發訪問的代價。這可能持續一個不確定的時間,尤其是在系統已經表現出高延遲時或者網絡故障導致失去連接時。

依據目前的成功經驗,可用性一般是更好的選擇,但是在服務和數據庫之間維護數據一致性是非常根本的需求,微服務架構中選擇滿足最終一致性。

當然選擇了最終一致性,就要保證到最終的這段時間要在用戶可接受的範圍之內。


二、微服務架構實現最終一致性的三種模式

2.1 可靠事件模式

可靠事件模式屬於事件驅動架構,當某件重要事情發生時,例如更新一個業務實體,微服務會向消息代理髮佈一個事件。消息代理會向訂閱事件的微服務推送事件,當訂閱這些事件的微服務接收此事件時,就可以完成自己的業務,也可能會引發更多的事件發佈。

2.1.1 例子

(1)如訂單服務創建一個待支付的訂單,發佈一個“創建訂單”的事件。

這裏寫圖片描述

(2)支付服務消費“創建訂單”事件,支付完成後發佈一個“支付完成”事件。

這裏寫圖片描述

(3)訂單服務消費“支付完成”事件,訂單狀態更新爲待出庫。


2.1.2 難點

這個過程可能導致出現不一致的地方在於:

  • 某個微服務在更新了業務實體後發佈事件卻失敗;
  • 雖然微服務發佈事件成功,但是消息代理未能正確推送事件到訂閱的微服務;
  • 接受事件的微服務重複消費了事件。

可靠事件模式在於保證可靠事件投遞避免重複消費

避免重複消費要求服務實現冪等性,如支付服務不能因爲重複收到事件而多次支付。


2.1.3 異常捕獲和回滾

舉個例子,Bob向Smith轉賬100塊,那我們到底是先發送消息,還是先執行扣款操作?

爲了實現可靠事件投遞,我們可以採用異常捕獲和回滾。即先執行業務操作,再發送消息,如果消息發送失敗,則回滾業務操作。

缺點:

(1)在網絡不穩定的情況下,Producer發送的消息可能已經被消息中間件成功接收,但是返回超時了,這時Producer回滾了本地事務,這就會造成Bob並沒有減少100塊,但Smith增加了100塊。

(2)在投遞完成後到數據庫commit操作之間如果微服務宕機也將造成數據庫操作因爲連接異常關閉而被回滾。最終結果還是事件被投遞,數據庫卻被回滾。

2.1.4 本地事件表

在基於異常捕獲和回滾的可靠事件方案中,本地事件表這樣改進:

  • 在發佈事件之前要先在本地事件表中添加一條記錄,
  • 如果事件發佈成功立即刪除記錄,
  • 事件恢復服務定時從事件表中恢復未發佈成功的事件,重新發布,重新發布成功才刪除記錄的事件。

缺點:

額外的事件數據庫操作也會給數據庫帶來額外的壓力,可能成爲瓶頸。


2.1.5 事務消息

下面以阿里巴巴的RocketMQ中間件爲例,分析下其設計和實現思路。

第一階段Producer發送Prepared消息時,RocketMQ也不會對外發送消息;

第二階段:執行本地事務;在業務回滾時,通過實時事件向事件系統取消事件;

第三階段,Producer向RocketMQ發送確認消息,這時RocketMQ纔會對訂閱者發送消息。如果確認消息發送失敗,RocketMQ會定期掃描消息集羣中的事物消息,這時候發現了Prepared消息,它會向消息發送者確認,Bob的錢到底是減了還是沒減呢?如果減了是回滾還是繼續發送確認消息呢?如下圖:

這裏寫圖片描述

我們繼續分析分析異常情況。如果Prepared消息發送失敗,則本地事務就不會執行;即使Prepared消息超時但是RocketMQ收到了Prepared消息,MQ也不會對外發送消息,而是調用Producer的反查接口確認這個消息的當前狀態。

總結:據筆者的瞭解,各大知名的電商平臺和互聯網公司,幾乎都是採用類似的設計思路來實現“最終一致性”的。這種方式適合的業務場景廣泛,而且比較可靠。不過這種方式技術實現的難度比較大。目前主流的開源MQ(ActiveMQ、RabbitMQ、Kafka)均未實現對事務消息的支持,所以需二次開發或者新造輪子。比較遺憾的是,RocketMQ事務消息部分的代碼也並未開源,需要自己去實現。

做過支付寶交易接口的同學都知道,我們一般會在支付寶的回調頁面和接口裏,解密參數,然後調用系統中更新交易狀態相關的服務,將訂單更新爲付款成功。同時,只有當我們回調頁面中輸出了success字樣或者標識業務處理成功相應狀態碼時,支付寶纔會停止回調請求。否則,支付寶會每間隔一段時間後,再向客戶方發起回調請求,直到輸出成功標識爲止。


2.1.6 冪等性

爲保證冪等性一個簡單的做法是在事件中添加時間戳,微服務記錄每類型的事件最後處理的時間戳,如果收到的事件的時間戳早於我們記錄的,丟棄該事件。

如果事件不是在同一個服務器上發出的,那麼服務器之間的時間同步是個難題,更穩妥的做法是使用一個全局遞增序列號替換時間戳。

對於本身不具有冪等性的操作,主要思想是爲每條事件存儲執行結果,當收到一條事件時我們需要根據事件的id查詢該事件是否已經執行過,如果執行過直接返回上一次的執行結果,否則調度執行事件。


2.2 補償模式

爲了描述方便,這裏先定義兩個概念:

  • 業務異常:業務邏輯產生錯誤的情況,比如賬戶餘額不足、商品庫存不足等。
  • 技術異常:非業務邏輯產生的異常,如網絡連接異常、網絡超時等。

補償模式使用一個額外的協調服務來協調各個需要保證一致性的微服務,協調服務按順序調用各個微服務,如果某個微服務調用異常(包括業務異常和技術異常)就取消之前所有已經調用成功的微服務。

補償模式建議僅用於不能避免出現業務異常的情況,如果有可能應該優化業務模式,以避免要求補償事務。如賬戶餘額不足的業務異常可通過預先凍結金額的方式避免,商品庫存不足可要求商家準備額外的庫存等。我們通過一個實例來說明補償模式,一家旅行公司提供預訂行程的業務,可以通過公司的網站提前預訂飛機票、火車票、酒店等。


2.2.1 例子

假設一位客戶規劃的行程是,(1)上海-北京6月19日9點的某某航班,(2)某某酒店住宿3晚,(3)北京-上海6月22日17點火車。在客戶提交行程後,旅行公司的預訂行程業務按順序串行的調用航班預訂服務、酒店預訂服務、火車預訂服務。最後的火車預訂服務成功後整個預訂業務纔算完成。

這裏寫圖片描述

如果火車票預訂服務沒有調用成功,那麼之前預訂的航班、酒店都得取消。取消之前預訂的酒店、航班即爲補償過程。

這裏寫圖片描述

2.2.2 實現原理

開發者還可以將整個行爲鏈加密,這樣只有該行爲鏈的接收者才能夠操控這個行爲鏈。當一個行爲完成後,會將完成的信息記錄到一個集合(比如說,是一個隊列)中,之後可以通過這個集合訪問到對應的行爲。當一個行爲失敗的實收,行爲將本地清理完畢,然後將消息發送給該集合,從而路由到之前執行成功的行爲,然後回滾所有的事務。

程序會生成一個典型的集合用來訪問對應的行爲鏈中的行爲,會創建3個獨立的進程,每一個進程都會負責一個指定的任務。分別是租車,預訂酒店以及預訂機票三個獨立的任務。

static ActivityHost[] processes;

static void Main(string[] args)
{
    var routingSlip = new RoutingSlip(new WorkItem[]
    {
        new WorkItem<ReserveCarActivity>(new WorkItemArguments),
        new WorkItem<ReserveHotelActivity>(new WorkItemArguments),
        new WorkItem<ReserveFlightActivity>(new WorkItemArguments)
    });

    // imagine these being completely separate processes with queues between them
    processes = new ActivityHost[]
    {
        new ActivityHost<ReserveCarActivity>(Send),
        new ActivityHost<ReserveHotelActivity>(Send),
        new ActivityHost<ReserveFlightActivity>(Send)
    };

    // hand off to the first address
    Send(routingSlip.ProgressUri, routingSlip);
}

static void Send(Uri uri, RoutingSlip routingSlip)
{
    // this is effectively the network dispatch
    foreach (var process in processes)
    {
        if (process.AcceptMessage(uri, routingSlip))
        {
            break;
        }
    }
}

其中的AcitivityHost就是對外部服務的一個抽象,RoutingSlip是對前面說的集合的抽象。
下面是具體的一個服務的簡化實現,ReserveHotelActivity以及ReserveFlightActivity的實現就不在此處列出了,下面是ReserveCarActivity的實現。其中主要包括的幾個方法:

DoWork以及Compensate方法是Activity抽象出來的用來執行實際操作以及回滾的補償方法。
WorkItemQueueAddress以及CompensationQueueAddress都是用來索引到對應服務的。參考如下代碼:

class ReserveCarActivity : Activity
{
    static Random rnd = new Random(2);

    public override WorkLog DoWork(WorkItem workItem)
    {
        Console.WriteLine("Reserving car");
        var car = workItem.Arguments["vehicleType"];
        var reservationId = rnd.Next(100000);
        Console.WriteLine("Reserved car {0}", reservationId);
        return new WorkLog(this, new WorkResult
        {
            { "reservationId", reservationId }
        });
    }

    public override bool Compensate(WorkLog item, RoutingSlip routingSlip)
    {
        var reservationId = item.Result["reservationId"];
        Console.WriteLine("Cancelled car {0}", reservationId);
        return true;
    }

    public override Uri WorkItemQueueAddress
    {
        get { return new Uri("sb://./carReservations"); }
    }

    public override Uri CompensationQueueAddress
    {
        get { return new Uri("sb://./carCancellactions"); }
    }
}

RoutingSlip是對成功行爲集合的抽象,用來索引到對應的服務,包含了兩個隊列,一個是完成的任務,一個是等待執行的任務。RoutingSlip主要用來控制連接多個行爲。如果成功就會將任務向前執行,如果失敗就會向後執行 。RoutingSlip使用隊列來向前執行,使用棧來向後執行。

class RoutingSlip
{
    readonly Stack<WorkLog> completedWorkLogs = new Stack<WorkLog>();
    readonly Queue<WorkItem> nextWorkItem = new Queue<WorkItem>();

    public RoutingSlip()
    {
    }

    public RoutingSlip(IEnumerable<WorkItem> workItems)
    {
        foreach (var workItem in workItems)
        {
            this.nextWorkItem.Enqueue(workItem);
        }
    }

    public bool IsCompleted
    {
        get { return this.nextWorkItem.Count == 0; }
    }

    public bool IsInProgress
    {
        get { return this.completedWorkLogs.Count > 0; }
    }

    public bool ProcessNext()
    {
        if (this.IsCompleted)
        {
            throw new InvalidOperationException();
        }

        var currentItem = this.nextWorkItem.Dequeue();
        var activity = (Activity) Activator.CreateInstance(
            currentItem.ActivityType);

        try
        {
            var result = activity.DoWork(currentItem);
            if (result != null)
            {
                this.completedWorkLogs.Push(result);
                return true;
            }
        }
        catch (Exception e)
        {
            Console.WriteLine("Exception {0}", e.Message);
        }
        return false;
    }

    public Uri ProgressUri
    {
        get
        {
            if (IsCompleted)
            {
                return null;
            }
            else
            {
                return
                    ((Activity)Activator.CreateInstance(this.nextWorkItem.Peek().ActivityType)).
                        WorkItemQueueAddress;
            }
        }
    }

    public Uri CompensationUri
    {
        get
        {
            if (!IsInProgress)
            {
                return null;
            }
            else
            {
                return ((Activity) Activator.CreateInstance(
                    this.completedWorkLogs.Peek().ActivityType)).
                        CompensationQueueAddress;
            }
        }
    }

    public bool UndoLast()
    {
        if (!this.IsInProgress)
        {
            throw new InvalidOperationException();
        }

        var currentItem = this.completedWorkLogs.Pop();
        var activity = (Activity) Activator.CreateInstance(
            currentItem.ActivityType);

        try
        {
            return activity.Compensate(currentItem, this);
        }
        catch (Exception e)
        {
            Console.WriteLine("Exception {0}", e.Message);
            throw;
        }
    }
}

ActivityHost會調用RoutingSlip上面的ProcessNext方法來解析下一個行爲並正向調用DoWork()或者反向調用Compensate()方法。

abstract class ActivityHost
{
    Action<Uri, RoutingSlip> send;

    public ActivityHost(Action<Uri, RoutingSlip> send)
    {
        this.send = send;
    }

    public void ProcessForwardMessage(RoutingSlip routingSlip)
    {
        if (!routingSlip.IsCompleted)
        {
            // if the current step is successful, proceed
            // otherwise go to the Unwind path
            if (routingSlip.ProcessNext())
            {
                // recursion stands for passing context via message
                // the routing slip can be fully serialized and passed
                // between systems.
                this.send(routingSlip.ProgressUri, routingSlip);
            }
            else
            {
                // pass message to unwind message route
                this.send(routingSlip.CompensationUri, routingSlip);
            }
        }
    }

    public void ProcessBackwardMessage(RoutingSlip routingSlip)
    {
        if (routingSlip.IsInProgress)
        {
            // UndoLast can put new work on the routing slip
            // and return false to go back on the forward
            // path
            if (routingSlip.UndoLast())
            {
                // recursion stands for passing context via message
                // the routing slip can be fully serialized and passed
                // between systems
                this.send(routingSlip.CompensationUri, routingSlip);
            }
            else
            {
                this.send(routingSlip.ProgressUri, routingSlip);
            }
        }
    }

    public abstract bool AcceptMessage(Uri uri, RoutingSlip routingSlip);
}

2.3 TCC模式(Try-Confirm-Cancel)

一個完整的TCC業務由一個主業務服務和若干個從業務服務組成,主業務服務發起並完成整個業務活動,TCC模式要求從服務提供三個接口:Try、Confirm、Cancel。

(1) Try:完成所有業務檢查

預留必須業務資源

(2) Confirm:真正執行業務

不作任何業務檢查

只使用Try階段預留的業務資源

Confirm操作滿足冪等性

(3)Cancel:

釋放Try階段預留的業務資源

Cancel操作滿足冪等性

整個TCC業務分成兩個階段完成。

這裏寫圖片描述

第一階段:主業務服務分別調用所有從業務的try操作,並在活動管理器中登記所有從業務服務。當所有從業務服務的try操作都調用成功或者某個從業務服務的try操作失敗,進入第二階段。

第二階段:活動管理器根據第一階段的執行結果來執行confirm或cancel操作。如果第一階段所有try操作都成功,則活動管理器調用所有從業務活動的confirm操作。否則調用所有從業務服務的cancel操作。

需要注意的是第二階段confirm或cancel操作本身也是滿足最終一致性的過程,在調用confirm或cancel的時候也可能因爲某種原因(比如網絡)導致調用失敗,所以需要活動管理支持重試的能力,同時這也就要求confirm和cancel操作具有冪等性。

2.3.1 TCC和2PC的區別

  • TCC是更上層的抽象,Try階段凍結資源(業務層面,不一定涉及本地事務),Comfirm/Cancel階段執行事務或者取消;
  • 2PC是在Prepare階段就已經開啓了各個服務的本地事務,所以性能很低。

總的來說,TCC實際上把數據庫層的二階段提交上提到了應用層來實現,對於數據庫來說是一階段提交,規避了數據庫層的2PC性能低下問題。

TCC事務的缺點,主要就一個:TCC的Try、Confirm和Cancel操作功能需要業務提供,開發成本高


三、對賬是最後的終極防線

如果有些業務由於瞬時的網絡故障或調用超時等問題,通過上文所講的3種模式一般都能得到很好的解決。但是在當今雲計算環境下,很多服務是依賴於外部系統的可用性情況,在一些重要的業務場景下還需要週期性的對賬來保證真實的一致性。比如支付系統和銀行之間每天日終是都會有對賬過程。

發佈了94 篇原創文章 · 獲贊 43 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章