基于CAP组件实现补偿事务与消息幂等性
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:
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用于接收下单请求:
[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名字定义在了一个常量中。
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:
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用于接收库存查询请求:
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用于消费订单下达事件的消息:
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实现的,示例代码如下:
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类,它接受一个泛型,定义如下:
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的商品:
{
"userId": "1002",
"productId": "1003",
"quantity": 5
}
提交成功后,查看库存状态:

然后再查看订单状态:

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

3 CAP与本地事务的集成
在上面的示例代码中,如果订单提交MongoDB成功,但是在发布消息的时候失败了,那么下单逻辑就应该是失败的。这时,我们希望这两个操作可以在一个事务里边进行原子性保障,CAP提供了与本地事务的集成机制,在本地消息表与业务逻辑数据存储为同一个存储类型介质下(如本文例子的MongoDB)可以做到事务的集成。
例如,我们将数据持久化和消息发布/消费重构在一个Service类中进行封装,Controller只需调用即可。
(1)封装OrderService
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修改调用方式
[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/Coder-EdisonZhou/EDT.EventBus.Sample
4 总结
本文介绍了事务补偿与幂等性的基本概念,并基于CAP组件给了一个事务补偿和幂等性保障的DEMO示例,在实际使用中可能还会借助CAP提供的事务能力将数据持久化和发布消息作为一个事务实现原子性,即CAP与本地事务的集成。
希望本文能够对你有所帮助!
参考资料
CAP官方文档,https://cap.dotnetcore.xyz/user-guide/zh/cap

基于CAP组件实现补偿事务与消息幂等性的更多相关文章
- .Net Core 基于CAP框架的事件总线
.Net Core 基于CAP框架的事件总线 CAP 是一个在分布式系统中(SOA,MicroService)实现事件总线及最终一致性(分布式事务)的一个开源的 C# 库,她具有轻量级,高性能,易使用 ...
- 基于.Net实现前端对话框和消息框
关于前端对话框.消息框的优秀插件多不胜数.造轮子是为了更好的使用轮子,并不是说自己造的轮子肯定好.所以,这个博客系统基本上都是自己实现的,包括日志记录.响应式布局等等一些本可以使用插件的.好了,废话不 ...
- 基于forms组件和Ajax实现注册功能
一.基于forms组件的注册页面设计 1.运用forms组件的校验字段功能实现用户注册 views.py: (在钩子中代码解耦,将form放在cnblog/blog/Myforms.py中) f ...
- 基于ajax与msmq技术的消息推送功能实现
周末在家捣鼓了一下消息推送的简单例子,其实也没什么技术含量,欢迎大伙拍砖.我设计的这个推送demo是基于ajax长轮询+msmq消息队列来实现的,具体交互过程如下图: 先说说这个ajax长轮询,多长时 ...
- 自己写的中间层..基于通讯组件 RTC
273265088 我用原生Listbox与你的组件组合...创造了奇迹..搞了一个非常复杂的 UI .. 每个item高度 包括里面的元素 以及事件都是动态的搞了好几个小时感觉UI 非常完美比客户要 ...
- K2 BPM项目 基于COM组件调用SAP RFC 问题
K2 BPM项目 基于COM组件调用SAP RFC 问题 问题前景: 环境:Win 2008 R2 64bit 最近项目中有支流程需求中需要在会计入账环节回写SAP的会计凭证. SAP组给我们提供.N ...
- 基于Form组件实现的增删改和基于ModelForm实现的增删改
一.ModelForm的介绍 ModelForm a. class Meta: model, # 对应Model的 fields=None, # 字段 exclude=None, # 排除字段 lab ...
- 2.1博客系统 |基于form组件和Ajax实现注册登录
基于forms组件和Ajax实现注册功能 1 基于forms组件设计注册页面 --点击头像 === 点击input --头像预览: 修改用户选中的文件对象:获取文件对象的路径:修改img的src属性, ...
- ZH奶酪:基于ionic.io平台的ionic消息推送功能实现
Hybrid App越来越火,Ionic的框架也逐渐被更多的人熟知. 在mobile app中,消息推送是很必要的一个功能. 国内很多ionic应用的推送都是用的极光推送,最近研究了一下Ionic自己 ...
- 3- 功能2:基于forms组件和ajax实现注册功能
1.forms组件的注册页面 url from django.urls import path, re_path from blog import views from django.views.st ...
随机推荐
- 关于ASCII码的一些信息(转载自https://blog.csdn.net/na_tion/article/details/50148883)
ASCII码分基本表(128个字符,从00000000到01111111).扩展表(256个字符,从00000000到11111111)和压缩表(64个字符),我们经常用的是128个的基本表,而在一些 ...
- 魔百和CM311-1a YST线刷精简固件(可救砖)
固件说明:1. 魔百和CM311-1a YST测试可用,其它型号自行测试,请慎重使用: 2.支持原装遥控器,语音蓝牙遥控器:3.固件压缩包有刷机教程,请一定仔细阅读. 4.该固件内置应用商店,可以下载 ...
- CI/CD 概念简介
〇.前言 CI/CD 是现代软件开发的核心实践,通过自动化和协作,显著提升交付效率和质量. 本文将对 CI 和 CD 这两个概念进行简要介绍,供参考. 一.CI/CD 的核心概念 CI/CD 是 De ...
- 关于while循环与for循环
首先看for循环 def test(): print('1') print('2') for i in range(3): test() 执行后的结果,按照下图所示,for循环就是把指定的函数重复执行 ...
- 一天 Star 破万的开源项目「GitHub 热点速览」
虽然现在市面上的 AI 编程助手已经"琳琅满目",但顶流就是顶流!OpenAI 新开源的轻量级编程助手 Codex,发布不到 24 小时 Star 数就轻松破万!姗姗来迟的 Ope ...
- 代码随想录第十三天 | Leecode 144. 二叉树的前序遍历、 94. 二叉树的中序遍历、 145. 二叉树的后序遍历
Leecode 144. 二叉树的前序遍历 题目链接:https://leetcode.cn/problems/binary-tree-preorder-traversal/ 题目描述 给你二叉树的根 ...
- 一个包含 80+ C#/.NET 编程技巧实战练习开源项目!
项目介绍 C#/.NET/.NET Core编程常用语法.算法.技巧.中间件.类库.工作业务实操练习集,配套详细的文章教程讲解,助你快速掌握C#/.NET/.NET Core中各种编程常用语法.算法. ...
- 【记录】LaTeX|Overleaf中ACM的LaTex模板的图片引用出现问号
问题 单张图片引用,出现如下问题: 出问题的LaTeX部分: As is shown in Figure~\ref{img:result}: \begin{figure}[h] \centering ...
- CUDA:页锁定内存(pinned memory)和按页分配内存(pageable memory )
CUDA架构而言,主机端的内存分为两种,一种是可分页内存(pageable memroy), 一种是页锁定内存(page-lock或 pinned). 可分页内存是由操作系统API malloc()在 ...
- File与IO流之字节流
FileOutputStream 创建字节输出流对象FileOutputStream fl =new FileOutputStream() 传入的参数可以是字符串路径或者File对象(实际上如果传入字 ...