基于CAP组件实现补偿事务与消息幂等性

2022-09-19 17:57:35 浏览数 (1)

1 补偿事务和幂等性

在微服务架构下,我们会采用异步通信来对各个微服务进行解耦,从而我们会用到消息中间件来传递各个消息。 

补偿事务

某些情况下,消费者需要返回值以告诉发布者执行结果,以便于发布者实施一些动作,通常情况下这属于补偿范围

例如,在一个电商程序中,订单初始状态为 pending,当商品数量成功扣除时将状态标记为 succeeded ,否则为 failed。

那么,这样看来实现逻辑应该是:当订单微服务提交订单,并发布了一个已下单的消息至下游微服务比如库存微服务,当库存微服务扣减库存后,无论扣减成功与否,都发送一个回调给订单微服务告知扣减状态。

如果我们自己来实现,可能需要较多的工作量,我们可以借助CAP组件来实现,它提供的callback功能可以很方便的做到这一点

幂等性

所谓幂等性,就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

在采用了消息中间件的分布式系统中,存在3中可能:

  • Exactly Once(*) (仅有一次)
  • At Most Once (最多一次)
  • At Least Once (最少一次)

带 * 号的也就是Exactly Once在实际场景中,很难达到

我们都知道,在CAP组件中,采用了数据库表(准确来说是临时存储),也许可以做到At Most Once,但是并没有提供严格保证消息不丢失的相关功能或配置。因此,CAP采用的交付保证是At Least Once,它并没有实现幂等。

其实,目前业界大多数基于事件驱动的框架都是要求用户自己来保证幂等性的,比如ENode,RocketMQ等。

综述,CAP组件可以帮助实现一些比较不严格的幂等,但是严格的幂等无法做到。这就需要我们自己来处理,通常有两种方式:

(1)以自然的方式处理幂等消息

比如数据库提供的 INSERT ON DUPLICATE KEY UPDATE 或者是才去类型的程序判断行为。

(2)显示处理幂等消息

这种方式更为常见,在消息传递过程中传递ID,然后由单独的消息跟踪器来处理。比如,我们可以借助Redis来实现这个消息跟踪器,下面的示例就是基于Redis来显示处理幂等的。

2 基于CAP组件的示例代码

这里我们以刚刚提到的电商服务为例,订单服务负责下单,库存服务负责扣减库存,二者通过Kafka进行消息传递,通过MongoDB进行持久化数据,CAP作为事件总线。

案例结构图

订单下单时会将将初始化状态为Pending的订单数据存入MongoDB,然后发送一个订单已下达的消息至事件总线,下游系统库存服务订阅这个消息并消费,也就是扣减库存。库存扣减成功后,订单服务根据扣减状态将订单状态改为Succeeded或Failed。

编写订单服务

创建一个ASP.NET 5/6 WebAPI项目,引入以下Package:

代码语言:javascript复制
PM>Install-Package AutoMapper
PM>Install-Package AutoMapper.Extensions.Microsoft.DependencyInjection
PM>Install-Package DotNetCore.CAP
PM>Install-Package DotNetCore.CAP.Kafka
PM>Install-Package DotNetCore.CAP.MongoDB

编写一个Controller用于接收下单请求:

代码语言:javascript复制
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderRepository _orderRepository;
    private readonly IMapper _mapper;
    private readonly ICapPublisher _eventPublisher;

    public OrdersController(IOrderRepository orderRepository, IMapper mapper, ICapPublisher eventPublisher)
    {
        _orderRepository = orderRepository;
        _mapper = mapper;
        _eventPublisher = eventPublisher;
    }

    [HttpGet]
    public async Task<ActionResult<IList<OrderVO>>> GetAllOrders()
    {
        var orders = await _orderRepository.GetAllOrders();
        return Ok(_mapper.Map<IList<OrderVO>>(orders));
    }

    [HttpGet("id")]
    public async Task<ActionResult<OrderVO>> GetOrder(string id)
    {
        var order = await _orderRepository.GetOrder(id);
        if (order == null)
            return NotFound();

        return Ok(_mapper.Map<OrderVO>(order));
    }

    [HttpPost]
    public async Task<ActionResult<OrderVO>> CreateOrder(OrderDTO orderDTO)
    {
        var order = _mapper.Map<Order>(orderDTO);
        // 01.生成订单初始数据
        order.OrderId = SnowflakeGenerator.Instance().GetId().ToString();
        order.CreatedDate = DateTime.Now;
        order.Status = OrderStatus.Pending;
        // 02.订单数据存入MongoDB
        await _orderRepository.CreateOrder(order);
        // 03.发布订单已生成事件消息
        await _eventPublisher.PublishAsync(
            name: EventNameConstants.TOPIC_ORDER_SUBMITTED,
            contentObj: new EventData<NewOrderSubmittedEvent>(new NewOrderSubmittedEvent(order.OrderId, order.ProductId, order.Quantity)),
            callbackName: EventNameConstants.TOPIC_STOCK_DEDUCTED
            );

        return CreatedAtAction(nameof(GetOrder), new { id = order.OrderId }, _mapper.Map<OrderVO>(order));
    }
}

这里使用了CAP提供的callback机制实现订单状态的修改。其原理就是新建了一个Consumer用于接收库存微服务的新Topic订阅消费。其中,Topic名字定义在了一个常量中。

代码语言:javascript复制
public class ProductStockDeductedEventService : IProductStockDeductedEventService, ICapSubscribe
{
    private readonly IOrderRepository _orderRepository;

    public ProductStockDeductedEventService(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    [CapSubscribe(name: EventNameConstants.TOPIC_STOCK_DEDUCTED, Group = EventNameConstants.GROUP_STOCK_DEDUCTED)]
    public async Task MarkOrderStatus(EventData<ProductStockDeductedEvent> eventData)
    {
        if (eventData == null || eventData.MessageBody == null)
            return;

        var order = await _orderRepository.GetOrder(eventData.MessageBody.OrderId);
        if (order == null)
            return;

        if (eventData.MessageBody.IsSuccess)
        {
            order.Status = OrderStatus.Succeed;
            // Todo: 一些额外的逻辑
        }
        else
        {
            order.Status = OrderStatus.Failed;
            // Todo: 一些额外的逻辑
        }

        await _orderRepository.UpdateOrder(order);
    }
}

这里回调的消费逻辑很简单,就是根据库存扣减的结果更新订单的状态。

编写库存服务

创建一个ASP.NET 5/6 WebAPI项目,引入以下Package:

代码语言:javascript复制
PM>Install-Package AutoMapper
PM>Install-Package AutoMapper.Extensions.Microsoft.DependencyInjection
PM>Install-Package DotNetCore.CAP
PM>Install-Package DotNetCore.CAP.Kafka
PM>Install-Package DotNetCore.CAP.MongoDB

编写一个Controller用于接收库存查询请求:

代码语言:javascript复制
public class StocksController : ControllerBase
{
    private readonly IStockRepository _stockRepository;
    private readonly IMapper _mapper;
    private readonly ICapPublisher _eventPublisher;

    public StocksController(IStockRepository stockRepository, IMapper mapper, ICapPublisher eventPublisher)
    {
        _stockRepository = stockRepository;
        _mapper = mapper;
        _eventPublisher = eventPublisher;
    }

    [HttpGet]
    public async Task<ActionResult<IList<StockVO>>> GetAllStocks()
    {
        var stocks = await _stockRepository.GetAllStocks();
        return Ok(_mapper.Map<IList<StockVO>>(stocks));
    }

    [HttpGet("id")]
    public async Task<ActionResult<StockVO>> GetStock(string id)
    {
        var stock = await _stockRepository.GetStock(id);
        if (stock == null)
            return NotFound();

        return Ok(_mapper.Map<StockVO>(stock));
    }

    [HttpPost]
    public async Task<ActionResult<StockVO>> CreateStock(StockDTO stockDTO)
    {
        var stock = _mapper.Map<Stock>(stockDTO);
        stock.CreatedDate = DateTime.Now;
        stock.UpdatedDate = stock.CreatedDate;
        await _stockRepository.CreateStock(stock);

        return CreatedAtAction(nameof(GetStock), new { id = stock.ProductId }, _mapper.Map<StockVO>(stock));
    }
}

编写一个Consumer用于消费订单下达事件的消息:

代码语言:javascript复制
public class NewOrderSubmittedEventService : INewOrderSubmittedEventService, ICapSubscribe
{
    private readonly IStockRepository _stockRepository;
    private readonly IMsgTracker _msgTracker;

    public NewOrderSubmittedEventService(IStockRepository stockRepository, IMsgTracker msgTracker)
    {
        _stockRepository = stockRepository;
        _msgTracker = msgTracker;
    }

    [CapSubscribe(name: EventNameConstants.TOPIC_ORDER_SUBMITTED, Group = EventNameConstants.GROUP_ORDER_SUBMITTED)]
    public async Task<EventData<ProductStockDeductedEvent>> DeductProductStock(EventData<NewOrderSubmittedEvent> eventData)
    {
        // 幂等性保障
        if(await _msgTracker.HasProcessed(eventData.Id))
            return null;

        // 产品Id合法性校验
        var productStock = await _stockRepository.GetStock(eventData.MessageBody.ProductId);
        if (productStock == null)
            return null;

        // 核心扣减逻辑
        EventData<ProductStockDeductedEvent> result;
        if (productStock.StockQuantity - eventData.MessageBody.Quantity >= 0)
        {
            // 扣减产品实际库存
            productStock.StockQuantity -= eventData.MessageBody.Quantity;
            // 提交至数据库
            await _stockRepository.UpdateStock(productStock);
            result = new EventData<ProductStockDeductedEvent>(new ProductStockDeductedEvent(eventData.MessageBody.OrderId, true));
        }
        else
        {
            // Todo: 一些额外的逻辑
            result = new EventData<ProductStockDeductedEvent>(new ProductStockDeductedEvent(eventData.MessageBody.OrderId, false, "扣减库存失败"));
        }

        // 幂等性保障
        await _msgTracker.MarkAsProcessed(eventData.Id);
        return result;
    }
}

在消费逻辑中,会经历幂等性校验、合法性校验、扣减逻辑 和 添加消费记录。最终,会再次发送一个订单扣减完成事件,供订单服务将其作为回调进行消费,也就是更新订单状态。

自定义MsgTracker

在上面的示例代码中,我们自定义了一个MsgTracker消息跟踪器,它是基于Redis实现的,示例代码如下:

代码语言:javascript复制
public class RedisMsgTracker : IMsgTracker
{
    private const string KEY_PREFIX = "msgtracker:"; // 默认Key前缀
    private const int DEFAULT_CACHE_TIME = 60 * 60 * 24 * 3; // 默认缓存时间为3天,单位为秒
    private readonly IRedisCacheClient _redisCacheClient;

    public RedisMsgTracker(IRedisCacheClient redisCacheClient)
    {
        _redisCacheClient = redisCacheClient ?? throw new ArgumentNullException("RedisClient未初始化");
    }

    public async Task<bool> HasProcessed(string msgId)
    {
        var msgRecord = await _redisCacheClient.GetAsync<MsgTrackLog>($"{KEY_PREFIX}{msgId}");
        if (msgRecord == null)
            return false;

        return true;
    }

    public async Task MarkAsProcessed(string msgId)
    {
        var msgRecord = new MsgTrackLog(msgId);
        await _redisCacheClient.SetAsync($"{KEY_PREFIX}{msgId}", msgRecord, DEFAULT_CACHE_TIME);
    }
}

在示例代码中,约定了所有服务发送的消息都是EventData类,它接受一个泛型,定义如下:

代码语言:javascript复制
public class EventData<T> where T : class
{
    public string Id { get; set; }

    public T MessageBody { get; set; }

    public DateTime CreatedDate { get; set; }

    public EventData(T messageBody)
{
        MessageBody = messageBody;
        CreatedDate = DateTime.Now;
        Id = SnowflakeGenerator.Instance().GetId().ToString();
    }
}

其中,它自带了一个由雪花算法生成的消息Id用于传递过程中的唯一性,这个Id也被MsgTracker用于幂等性校验。

测试验证

首先,在库存服务里面先查一下各个商品的库存:

可以看到商品Id为1003的库存有5个。

其次,在订单服务里面新建一个订单请求,买5个Id为1003的商品:

代码语言:javascript复制
{
  "userId": "1002",
  "productId": "1003",
  "quantity": 5
}

提交成功后,查看库存状态:

然后再查看订单状态:

如果这时再下单Id=1003的商品,订单状态变为-1即Failed:

3 CAP与本地事务的集成

在上面的示例代码中,如果订单提交MongoDB成功,但是在发布消息的时候失败了,那么下单逻辑就应该是失败的。这时,我们希望这两个操作可以在一个事务里边进行原子性保障,CAP提供了与本地事务的集成机制,在本地消息表与业务逻辑数据存储为同一个存储类型介质下(如本文例子的MongoDB)可以做到事务的集成。

例如,我们将数据持久化和消息发布/消费重构在一个Service类中进行封装,Controller只需调用即可。

(1)封装OrderService

代码语言:javascript复制
public class OrderService : IOrderService
{
    private readonly ICapPublisher _eventPublisher;
    private readonly IMongoCollection<Order> _orders;
    private readonly IMongoClient _client;

    public OrderService(IOrderDatabaseSettings settings, ICapPublisher eventPublisher)
    {
        _client = new MongoClient(settings.ConnectionString);
        _orders = _client
            .GetDatabase(settings.DatabaseName)
            .GetCollection<Order>(settings.OrderCollectionName);
        _eventPublisher = eventPublisher;
    }

    public async Task<IList<Order>> GetAllOrders()
    {
        return await _orders.Find(o => true).ToListAsync();
    }

    public async Task<Order> GetOrder(string orderId)
    {
        return await _orders.Find(o => o.OrderId == orderId).FirstOrDefaultAsync();
    }

    public async Task CreateOrder(Order order)
    {
        // 本地事务集成示例
        using (var session = _client.StartTransaction(_eventPublisher))
        {
            // 01.订单数据存入MongoDB
            _orders.InsertOne(order);
            // 02.发布订单已生成事件消息
            _eventPublisher.Publish(
                name: EventNameConstants.TOPIC_ORDER_SUBMITTED,
                contentObj: new EventData<NewOrderSubmittedEvent>(new NewOrderSubmittedEvent(order.OrderId, order.ProductId, order.Quantity)),
                callbackName: EventNameConstants.TOPIC_STOCK_DEDUCTED
                );
            // 03.提交事务
            await session.CommitTransactionAsync();
        }
    }

    public async Task UpdateOrder(Order order)
    {
        await _orders.ReplaceOneAsync(o => o.OrderId == order.OrderId, order);
    }
}

(2)Controller修改调用方式

代码语言:javascript复制
[HttpPost]
public async Task<ActionResult<OrderVO>> CreateOrder(OrderDTO orderDTO)
{
    var order = _mapper.Map<Order>(orderDTO);
    // 01.生成订单初始数据
    order.OrderId = SnowflakeGenerator.Instance().GetId().ToString();
    order.CreatedDate = DateTime.Now;
    order.Status = OrderStatus.Pending;
    // 02.订单数据提交
    await _orderService.CreateOrder(order);

    return CreatedAtAction(nameof(GetOrder), new { id = order.OrderId }, _mapper.Map<OrderVO>(order));
}

同理,我们也可以将Consumer端的消费逻辑重构为CAP与本地事务集成,这里不再赘述。

本文示例代码细节:https://github.com/EdisonChou/EDT.EventBus.Sample

4 总结

本文介绍了事务补偿与幂等性的基本概念,并基于CAP组件给了一个事务补偿和幂等性保障的DEMO示例,在实际使用中可能还会借助CAP提供的事务能力将数据持久化和发布消息作为一个事务实现原子性,即CAP与本地事务的集成。

希望本文能够对你有所帮助!

参考资料

CAP官方文档,https://cap.dotnetcore.xyz/user-guide/zh/cap

作者:周旭龙

出处:https://edisonchou.cnblogs.com

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。

0 人点赞