分散式事務 | 使用 dotnetcore/CAP 的本地訊息表模式

「聖傑」發表於2023-01-30

本地訊息表模式

本地訊息表模式,其作為柔性事務的一種,核心是將一個分散式事務拆分為多個本地事務,事務之間透過事件訊息銜接,事件訊息和上個事務共用一個本地事務儲存到本地訊息表,再透過定時任務輪詢本地訊息表進行訊息投遞,下游業務訂閱訊息進行消費,本質上是依靠訊息的重試機制達到最終一致性。其示意圖如下所示,主要分為以下三步:

  1. 本地業務資料和釋出的事件訊息共享同一個本地事務,進行資料落庫,其中事件訊息持久化到單獨的事件發件箱表中。
  2. 單獨的程式或執行緒不斷查詢發件箱表中未釋出的事件訊息。
  3. 將未釋出的事件訊息釋出到訊息代理,然後將訊息的狀態更新為已釋出。

dotnetcore/CAP 簡介

在《.NET 微服務:適用於容器化 .NET 應用程式的體系結構》電子書中,提及瞭如何設計兼具原子性和彈性的事件匯流排,其中提出了三種思路:使用完整的事件溯源模式,使用事務日誌挖掘,使用發件箱模式(The outbox pattern)。其中事件溯源模式實現相對複雜,事務日誌挖掘侷限於特定型別資料庫,而發件箱模式則是一種相對平衡的實現方式,其基於事務資料庫表和簡化的事件溯源模式。發件箱模式的示意圖如下所示:

從上圖可以看出,其實現原理與上面提及的本地訊息表模式十分相似,我們可以理解其也是本地訊息表模式的一種實現。作者Savorboard也正是受該電子書啟發,實現了.NET版本的本地訊息表模式,並命名為dotnetcore/CAP,其架構如下圖所示。其同時也兼具EventBus的功能,其支援主流訊息代理,如RabbitMQ、Redis、Kafka和Pulsar,同時支援多種持久化儲存方式進行訊息儲存,包括MySQL、PostgreSQL、SQL Server和MongoDB。因此基於dotnetcore/CAP,.NET 開發者也可以快速實現微服務間的非同步通訊和解決分散式事務問題。

基於dotnetcore/CAP 實現分散式事務

那具體如何使用dotnetcore/CAP來解決分散式事務問題呢,基於本地訊息表加補償模式實現。dotnetcore/CAP的補償模式比較巧妙,其基於釋出事件的方法簽名中提供了一個回撥引數。釋出方法的事件簽名為:PublishAsync<T>(string name, T? contentObj, string? callbackName=null),第一個引數是事件名稱,第二個引數為事件資料包,第三個引數用來指定於接收事件消費結果的回撥地址(事件),但是否觸發回撥,取決於事件訂閱方是否定義返回引數,若有則觸發。如果基於CAP實現下單流程,則其流程如下所示:

接下來就來建立解決方案來實現以上下單流程示例。依次建立以下專案,訂單服務、庫存服務和支付服務均依賴共享類庫專案,其中共享類庫新增DotNetCore.CapDotNetCore.Cap.MySqlDotNetCore.Cap.RabbitMQNuGet包。

專案 專案名 專案型別
訂單服務 CapDemo.OrderService ASP.NET Core Web API
庫存服務 CapDemo.InventoryService Worker Service
支付服務 CapDemo.PaymentService Worker Service
共享類庫 CapDemo.Shared Class Library

訂單服務

訂單服務首先需要暴露WebApi用於訂單的建立,為了方便資料的持久化,首先新增Pomelo.EntityFrameworkCore.MySqlNuget包,然後建立OrderDbContext

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using CapDemo.OrderService.Domains;

namespace CapDemo.OrderService.Data
{
    public class OrderDbContext : DbContext
    {
        public OrderDbContext (DbContextOptions<OrderDbContext> options)
            : base(options) {}

        public DbSet<CapDemo.OrderService.Domains.Order> Order { get; set; } = default!;
    }
}

然後建立OrdersController並新增PostOrder方法如下所示:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CapDemo.OrderService.Data;
using CapDemo.OrderService.Domains;
using DotNetCore.CAP;
using CapDemo.Shared;
using CapDemo.Shared.Models;

namespace CapDemo.OrderService.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class OrdersController : ControllerBase
    {
        private readonly OrderDbContext _context;
        private readonly ICapPublisher _capPublisher;
        private readonly ILogger<OrdersController> _logger;

        public OrdersController(OrderDbContext context, ICapPublisher capPublisher,ILogger<OrdersController> logger)
        {
            _context = context;
            _capPublisher = capPublisher;
            _logger = logger;
        }
        [HttpPost]
        public async Task<ActionResult<Order>> PostOrder(CreateOrderDto orderDto)
        {
            var shoppingItems =
                orderDto.ShoppingCartItems.Select(item => new ShoppingCartItem(item.SkuId, item.Price, item.Qty));
            var order = new Order(orderDto.CustomerId).NewOrder(shoppingItems.ToArray());
            
            using (var trans = _context.Database.BeginTransaction(_capPublisher, autoCommit: false))
            {
                _context.Order.Add(order);

                var deduceDto = new DeduceInventoryDto()
                {
                    OrderId = order.OrderId,
                    DeduceStockItems = order.OrderItems.Select(
                        item => new DeduceStockItem(item.SkuId, item.Qty, item.Price)).ToList()
                };
                await _capPublisher.PublishAsync(TopicConsts.DeduceInventoryCommand,deduceDto,
                    callbackName: TopicConsts.CancelOrderCommand);
                await _context.SaveChangesAsync();
                await trans.CommitAsync();
            }
                
            _logger.LogInformation($"Order [{order.OrderId}] created successfully!");

            return CreatedAtAction("GetOrder", new { id = order.OrderId }, order);
        }
    }
}

從程式碼中可以看出,在訂單持久化和事件釋出之前先行使用事務包裹:using (var trans = _context.Database.BeginTransaction(_capPublisher, autoCommit: false)) {},以確保訂單和事件的持久化共享同一個事務,這一步是使用CAP的重中之重。訂單服務透過注入了ICapPublisher服務,並透過PublishAsync方法釋出扣減庫存事件,並指定了callbackName: TopicConsts.CancelOrderCommand
訂單服務還需要訂閱取消訂單和訂單支付結果的事件,進行訂單狀態的更新,新增OrderConsumers如下所示,其中透過實現ICapSubscribe介面來顯式標記為消費者,然後定義方法並在方法體上透過[CapSubscribe]特性指定訂閱的事件名稱來完成事件的消費。

using CapDemo.OrderService.Data;
using CapDemo.Shared;
using DotNetCore.CAP;

namespace CapDemo.OrderService.Consumers;

public class OrderConsumers:ICapSubscribe
{
    private readonly OrderDbContext _orderDbContext;
    private readonly ILogger<OrderConsumers> _logger;

    public OrderConsumers(OrderDbContext orderDbContext,ILogger<OrderConsumers> logger)
    {
        _orderDbContext = orderDbContext;
        _logger = logger;
    }
    [CapSubscribe(TopicConsts.CancelOrderCommand)]
    public async Task CancelOrder(string orderId)
    {
        if(string.IsNullOrEmpty(orderId)) return;
        var order = await _orderDbContext.Order.FindAsync(orderId);
        order?.CancelOrder();
        _logger.LogWarning($"Order [{orderId}] has been canceled!");
        await _orderDbContext.SaveChangesAsync();
    }

    [CapSubscribe(TopicConsts.PayOrderSucceedTopic)]
    public async  Task MarkToPaid(string orderId)
    {
        var order = await _orderDbContext.Order.FindAsync(orderId);
        
        order?.UpdateToPaid();

        await _orderDbContext.SaveChangesAsync();
    }
}

最後修改Program.cs新增CAP服務和消費者的註冊。

using CapDemo.OrderService.Consumers;
using CapDemo.OrderService.Data;
using Microsoft.EntityFrameworkCore;
using DotNetCore.CAP;

var builder = WebApplication.CreateBuilder(args);

// 註冊 DbContext
var connectionStr = builder.Configuration.GetConnectionString("Default");
builder.Services.AddDbContext<OrderDbContext>(options =>
    options.UseMySql(connectionStr ?? throw new InvalidOperationException("Connection string 'OrderDbContext' not found."), ServerVersion.AutoDetect(connectionStr)));
// 註冊CAP
builder.Services.AddCap(x =>
{
    x.UseEntityFramework<OrderDbContext>();
    x.UseRabbitMQ("localhost");
});
// 註冊消費者
builder.Services.AddTransient<OrderConsumers>();

庫存服務

庫存服務在整個下單流程的職責主要是庫存的扣減和返還,新增InventoryConsumer來消費庫存扣減和返還事件即可。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using CapDemo.Shared;
using CapDemo.Shared.Models;
using DotNetCore.CAP;

namespace CapDemo.InventoryService.Consumers
{
    public class InventoryConsumer : ICapSubscribe
    {
        private readonly ILogger<InventoryConsumer> _logger;
        private readonly ICapPublisher _capPublisher;

        public InventoryConsumer(ILogger<InventoryConsumer> logger, ICapPublisher capPublisher)
        {
            _logger = logger;
            _capPublisher = capPublisher;
        }

        [CapSubscribe(TopicConsts.DeduceInventoryCommand)]
        public async Task DeduceInventory(DeduceInventoryDto deduceStockDto)
        {
            // 省略扣減庫存邏輯,直接成功
            _logger.LogInformation($"Inventory has been deducted for order [{deduceStockDto.OrderId}]!");
            var amount = deduceStockDto.DeduceStockItems.Sum(t => t.Price * t.Qty);
            await _capPublisher.PublishAsync(TopicConsts.PayOrderCommand, new PayDto(deduceStockDto.OrderId, amount),
                callbackName: TopicConsts.ReturnInventoryTopic);
        }

        [CapSubscribe(TopicConsts.ReturnInventoryTopic)]
        public void ReturnInventory(PayResult payResult)
        {  
        	// 若支付失敗,則執行庫存返還併發布取消訂單命令
            if (!payResult.IsSucceed)
            {
            	// 省略返還庫存邏輯
                _logger.LogWarning($"Inventory has been returned for order [{payResult.OrderId}]");
                _capPublisher.PublishAsync(TopicConsts.CancelOrderCommand, payResult.OrderId);
            }
        }
    }
}

以上的庫存扣減實現中省略了扣減庫存邏輯,直接模擬成功扣減,也就無需觸發回撥,那就可以透過將方法簽名定義為public async Task DeduceInventory(DeduceInventoryDto deduceStockDto),這樣就不會觸發訂單服務釋出扣減庫存事件時指定的回撥。庫存扣減成功隨即釋出支付訂單的命令,由於不涉及其他資料持久化,因此無需手動開啟事務。釋出支付訂單命令時指定了callbackName: TopicConsts.ReturnInventoryTopic,其將根據訂單支付結果也就是ReturnInventory(PayResult payResult)中指定的入參決定是否返還庫存。
最後同樣需要在Program.cs中注入CAP服務和消費者:

using CapDemo.InventoryService;
using CapDemo.InventoryService.Consumers;

IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        var connStr = context.Configuration.GetConnectionString("Default");
        services.AddCap(x =>
        {
            x.UseMySql(connStr);
            x.UseRabbitMQ("localhost");
        });

        services.AddTransient<InventoryConsumer>();
    })
    .Build();

await host.RunAsync();

支付服務

對於下單流程的支付用例來說,要麼成功要麼失敗,並不需要像以上兩個服務一樣定義補償邏輯,因此僅需要訂閱支付訂單命令即可,定義PaymentConsumers如下所示,因為庫存服務釋出支付訂單命令時指定的回撥依賴支付結果,因此該方法必須指定與回撥匹配的返回引數型別,也就是PayResult

using CapDemo.Shared;
using CapDemo.Shared.Models;
using DotNetCore.CAP;

namespace CapDemo.PaymentService.Consumers;

public class PaymentConsumers:ICapSubscribe
{
    private readonly ICapPublisher _capPublisher;
    private readonly ILogger<PaymentConsumers> _logger;

    public PaymentConsumers(ICapPublisher capPublisher,ILogger<PaymentConsumers> logger)
    {
        _capPublisher = capPublisher;
        _logger = logger;
    }
    [CapSubscribe(TopicConsts.PayOrderCommand)]
    public async Task<PayResult> Pay(PayDto payDto)
    {
        bool isSucceed = false;
        if (payDto.Amount % 2 == 0)
        {
            isSucceed = true;
            _logger.LogInformation($"Order [{payDto.OrderId}] paid successfully!");
            await _capPublisher.PublishAsync(TopicConsts.PayOrderSucceedTopic, payDto.OrderId);
        }
        else
        {
            isSucceed = false;
            _logger.LogWarning($"Order [{payDto.OrderId}] payment failed!");
        }

        return new PayResult(payDto.OrderId, isSucceed);
    }
}

最後同樣需要在Program.cs中注入CAP服務和消費者:

using CapDemo.PaymentService;
using CapDemo.PaymentService.Consumers;

IHost host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((context, services) =>
    {
        var connStr = context.Configuration.GetConnectionString("Default");
        services.AddCap(x =>
        {
            x.UseMySql(connStr);
            x.UseRabbitMQ("localhost");
        });
        services.AddTransient<PaymentConsumers>();
    })
    .Build();

await host.RunAsync();

執行結果

使用docker啟動MySQL和RabbitMQ,然後再啟動三個服務,並在訂單服務的Swagger中發起訂單建立請求,如下圖所示:

最終執行結果如下圖所示:

開啟RabbitMQ後臺,可以看見CAP為每個服務建立了一個唯一佇列接收訊息,並透過建立的名為cap.default.router的Exchange根據事件名稱作為RoutingKey進行訊息路由。

其中透過dotnetcore/CAP釋出的訊息結構如下圖所示,該圖是訂單服務釋出的扣減庫存的訊息。

開啟MySQL,可以發現dotnetcore/CAP 根據配置的連線字串,分別為各個服務建立了cap.publishedcap.received訊息表,如下圖所示:

小結

透過以上示例,可以發現dotnetcore/CAP無疑是一個出色的事件匯流排,簡單易用且能確保事件的有效送達。同時基於dotnetcore/CAP的本地訊息表模式和補償模式,也可以有效的實現分散式事務。但相較而言,補償僅限於直接上下游服務之間,不能鏈式反向補償,控制邏輯比較分散,屬於協同式事務,各個服務需要訂閱自己關注的事件並實現,適用於小中型專案,對於大型專案而言尤其需要注意事件的流轉,以避免陷入事件漩渦。

相關文章