【分布式事务框架CAP】1、入门
简介
CAP 是一个EventBus,同时也是一个在微服务或者SOA系统中解决分布式事务问题的一个框架。它有助于创建可扩展,可靠并且易于更改的微服务系统。
分布式事务是在分布式系统中不可避免的一个硬性需求,CAP 没有采用两阶段提交(2PC)这种事务机制,而是采用的 本地消息表+MQ 这种经典的实现方式,这种方式又叫做 异步确保。
CAP 实现了 EventBus 中的发布/订阅,它具有 EventBus 的所有功能。也就是说你可以像使用 EventBus 一样来使用 CAP,另外 CAP 的 EventBus 是具有高可用性的, CAP 借助于本地消息表来对 EventBus 中的消息进行了持久化,这样可以保证 EventBus 发出的消息是可靠的,当消息队列出现宕机或者连接失败的情况时,消息也不会丢失。
快速开始
安装:
PM> Install-Package DotNetCore.CAP
为了快速启动,我们使用基于内存的事件存储和消息队列。
PM> Install-Package DotNetCore.CAP.InMemoryStorage
PM> Install-Package Savorboard.CAP.InMemoryMessageQueue
在 Startup.cs 中,添加以下配置:
public void ConfigureServices(IServiceCollection services)
{
services.AddCap(x =>
{
x.UseInMemoryStorage();
x.UseInMemoryMessageQueue();
});
}
发布消息:
public class PublishController : Controller
{
[Route("~/send")]
public IActionResult SendMessage([FromServices]ICapPublisher capBus)
{
capBus.Publish("test.show.time", DateTime.Now);
return Ok();
}
}
处理消息:
public class ConsumerController : Controller
{
[NonAction]
[CapSubscribe("test.show.time")]
public void ReceiveMessage(DateTime time)
{
Console.WriteLine("message time is:" + time);
}
}
配置
最低配置
至少你要配置一个消息队列和一个事件存储,如果你想快速开始你可以使用下面的配置:
services.AddCap(config =>
{
config.UseInMemoryMessageQueue();
config.UseInMemoryStorage();
});
CapOptions
在 AddCap
中 CapOptions
对象是用来存储配置相关信息,默认情况下它们都具有一些默认值,有些时候你可能需要自定义。
- DefaultGroupName
默认的消费者组的名字,默认值:cap.queue.{程序集名称}.v1
- GroupNamePrefix
为订阅Group
统一添加前缀。默认值:Null
- Version
用于给消息指定版本来隔离不同版本服务的消息,常用于A/B测试或者多服务版本的场景,默认值:v1。以下是其应用场景:- 业务快速迭代,需要向前兼容
由于业务的快速迭代,在各个服务集成的过程中,消息的数据结构并不是固定不变的,有些时候我们为了适应新引入的需求,会添加或者修改一些数据结构。如果你是一套全新的系统这没有什么问题,但是如果你的系统已经部署到生产环境了并且正在服务客户,这就会导致新的功能在上线的时候和旧的数据结构发生不兼容,那么这些改变可能会导致出现严重的问题,要想解决这个问题,只能把消息队列和持久化的消息全部清空,然后才能启动应用程序,这对于生产环境来说显然是致命的。 - 多个版本的服务端
有些时候,App的服务端需要提供多套接口,来支持不同版本的App,这些不同版本的App相同的接口和服务端交互的数据结构可能是不一样的,所以通常情况下服务端提供不用的路由地址来适配不同版本的App调用。 - 不同实例,使用相同的持久化表/集合
希望多个不同实例的程序可以公用相同的数据库,在 2.4 之前的版本,我们可以通过指定不同的表名来隔离不同实例的数据库表,即在CAP配置的时候通过配置不同的表名前缀来实现。
- 业务快速迭代,需要向前兼容
查看博客来了解更多关于 Version 的信息: https://www.cnblogs.com/savorboard/p/cap-2-4.html
- FailedRetryInterval
默认值:60 秒
在消息发送的时候,如果发送失败,CAP将会对消息进行重试,此配置项用来配置每次重试的间隔时间。
在消息消费的过程中,如果消费失败,CAP将会对消息进行重试消费,此配置项用来配置每次重试的间隔时间。
重试 & 间隔
在默认情况下,重试将在发送和消费消息失败的 4分钟后 开始,这是为了避免设置消息状态延迟导致可能出现的问题。
发送和消费消息的过程中失败会立即重试 3 次,在 3 次以后将进入重试轮询,此时FailedRetryInterval
配置才会生效。
- ConsumerThreadCount
默认值:1
消费者线程并行处理消息的线程数,当这个值大于1时,将不能保证消息执行的顺序。 - CollectorCleaningInterval
默认值:300 秒
收集器删除已经过期消息的时间间隔。 - FailedRetryCount
默认值:50
重试的最大次数。当达到此设置值时,将不会再继续重试,通过改变此参数来设置重试的最大次数。 - FailedThresholdCallback
默认值:NULL
类型:Action<FailedInfo>
重试阈值的失败回调。当重试达到FailedRetryCount
设置的值的时候,将调用此Action
回调,你可以通过指定此回调来接收失败达到最大的通知,以做出人工介入。例如发送邮件或者短信。 - SucceedMessageExpiredAfter
默认值:24*3600 秒(1天后)。
成功消息的过期时间(秒)。 当消息发送或者消费成功时候,在时间达到SucceedMessageExpiredAfter
秒时候将会从Persistent
中删除,你可以通过指定此值来设置过期的时间。 - UseDispatchingPerGroup
默认值: false。
默认情况下,CAP
会将所有消费者组的消息都先放置到内存同一个Channel
中,然后线性处理。 如果设置为true
,则每个消费者组都会根据ConsumerThreadCount
设置的值创建单独的线程进行处理。
消息传输(Transport)
目前, CAP 同时支持使用 RabbitMQ
,Kafka
,Azure Service Bus
等进行底层之间的消息发送,你不需要具备这些消息队列的使用经验,仍然可以轻松的集成到项目中。
支持内存消息队列(2.5版本),需要引入DotNetCore.CAP.InMemoryStorage,并UseInMemoryStorage,此模式用于开发环境下没有Kafka或者RabbitMQ时,可以使用内存队列来模拟
RabbitMQ Options
NAME | DESCRIPTION | TYPE | DEFAULT |
---|---|---|---|
HostName | 宿主地址 | string | localhost |
UserName | 用户名 | string | guest |
Password | 密码 | string | guest |
VirtualHost | 虚拟主机 | string | / |
Port | 端口号 | int | -1 |
ExchangeName | CAP默认Exchange名称 | string | cap.default.topic |
QueueArguments | 创建队列额外参数 x-arguments | QueueArgumentsOptions | N/A |
ConnectionFactoryOptions | RabbitMQClient原生参数 | ConnectionFactory | N/A |
CustomHeaders | 订阅者自定义头信息 | Func>> | N/A |
ConnectionFactoryOptions:
如果你需要 更多 原生 ConnectionFactory 相关的配置项,可以通过 ConnectionFactoryOptions 配置项进行设定:
services.AddCap(x =>
{
x.UseRabbitMQ(o =>
{
o.HostName = "localhost";
o.ConnectionFactoryOptions = opt => {
//rabbitmq client ConnectionFactory config
};
});
});
CustomHeaders Options:
当需要从异构系统或者直接接收从RabbitMQ 控制台发送的消息时,由于 CAP 需要定义额外的头信息才能正常订阅,所以此时会出现异常。通过提供此参数来进行自定义头信息的设置来使订阅者正常工作。
x.UseRabbitMQ(aa =>
{
aa.CustomHeaders = e => new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>(Headers.MessageId, SnowflakeId.Default().NextId().ToString()),
new KeyValuePair<string, string>(Headers.MessageName, e.RoutingKey),
};
});
RabbitMQ服务器
当运行订阅应用,会创建交换器、队列、绑定
交换器名称是cap.default.router
,交换器类型是topic
队列名是消费者组名,默认是cap.queue.{程序集名称}.v1
,CAP
是根据组名创建队列的,有多少个组就有多少个队列
binding key
是订阅时指定的,如:[CapSubscribe("xxxxx")]
如下:
[NonAction]
[CapSubscribe("Meshop.PayService.Refund")]
public Task Refund1(RefundMessage message)
{
Console.WriteLine($"=====================orderID:{message.OrderID},refundPrice:{message.RefundPrice}");
return Task.CompletedTask;
}
我的项目名是CAP.Consumer
,所以生成的队列名是cap.queue.cap.consumer.v1
生成了一个绑定:MeShop.PayService.Refund
当我们创建了多个订阅,订阅Name不同时,实际上创建了多个绑定,消息还是发送到同一个队列
[NonAction]
[CapSubscribe("Meshop.PayService.Refund")]
public Task Refund1(RefundMessage message)
{
Console.WriteLine($"=====================orderID:{message.OrderID},refundPrice:{message.RefundPrice}");
return Task.CompletedTask;
}
[NonAction]
[CapSubscribe("Meshop.PayService.Refund.Header")]
public Task Refund1(RefundMessage message, [FromCap] CapHeader header)
{
Console.WriteLine($"=====================orderID:{message.OrderID},refundPrice:{message.RefundPrice},first:{header["my.header.first"]}");
return Task.CompletedTask;
}
查看队列绑定:
当我们为订阅指定了多个分组,CAP
创建了多个队列:
[NonAction]
[CapSubscribe("Meshop.PayService.Refund", Group = "g1")]
public Task Refund3(RefundMessage message)
{
Console.WriteLine($"=====================g1,Refund3,orderID:{message.OrderID},refundPrice:{message.RefundPrice}");
return Task.CompletedTask;
}
[NonAction]
[CapSubscribe("Meshop.PayService.Refund", Group = "g1")]
public Task Refund4(RefundMessage message)
{
Console.WriteLine($"=====================g1,Refund4,orderID:{message.OrderID},refundPrice:{message.RefundPrice}");
return Task.CompletedTask;
}
[NonAction]
[CapSubscribe("Meshop.PayService.Refund", Group = "g2")]
public Task Refund5(RefundMessage message)
{
Console.WriteLine($"=====================g3,Refund5,orderID:{message.OrderID},refundPrice:{message.RefundPrice}");
return Task.CompletedTask;
}
查看队列:
这两个队列的相同的binding key
绑定到交换器上,当发布者发布routing key
为Meshop.PayService.Refund
的消息时,两个队列都可以接收到消息
持久化(Persistent)
CAP
需要持久化事件消息,例如通过数据库或者其他NoSql设施。CAP
使用这种方式来应对一切环境或者网络异常导致消息丢失的情况,消息的可靠性是分布式事务的基石,所以在任何情况下消息都不能丢失。
发送前
在消息进入到消息队列之前,CAP使用本地数据库表对消息进行持久化,这样可以保证当消息队列出现异常或者网络错误时候消息是没有丢失的。
为了保证这种机制的可靠性,CAP使用和业务代码相同的数据库事务来保证业务操作和CAP的消息在持久化的过程中是强一致的。也就是说在进行消息持久化的过程中,任何一方发生异常情况数据库都会进行回滚操作。
发送后
消息进入到消息队列之后,CAP会启动消息队列的持久化功能
消息表
CAP框架会在数据库中自动添加两个表,以保证消息在任何情况下都会被成功发送、消费
- cap.Published:消息在发送到队列之前会在此表中添加一条记录,防止特殊原因导致消息未成功发送到队列
- cap.Received:在从队列接收到消息后在此表添加一条记录,CAP定时轮询从此表读取未成功消费的消息,交给订阅方法处理,直到成功消费(订阅方法内部不抛出异常即成功消费);同时也能防止消息被重复消费
Published 表结构:
NAME | DESCRIPTION | TYPE |
---|---|---|
Id | Message Id | int |
Version | Message Version | string |
Name | Topic Name | string |
Content | Json Content | string |
Added | Added Time | DateTime |
ExpiresAt | Expire time | DateTime |
Retries | Retry times | int |
StatusName | Status Name | string |
Received 表结构:
NAME | DESCRIPTION | TYPE |
---|---|---|
Id | Message Id | int |
Version | Message Version | string |
Name | Topic Name | string |
Group | Group Name | string |
Content | Json Content | string |
Added | Added Time | DateTime |
ExpiresAt | Expire time | DateTime |
Retries | Retry times | int |
StatusName | Status Name | string |
包装器对象
CAP 在进行消息发送到时候,会对原始消息对象进行一个二次包装存储到 Content 字段中,以下为包装 Content 的 Message 对象数据结构:
NAME | DESCRIPTION | TYPE |
---|---|---|
Id | CAP生成的消息编号 | string |
Timestamp | 消息创建时间 | string |
Content | 内容 | string |
CallbackName | 回调的订阅者名称 | string |
支持数据库
CAP 目前支持使用 Sql Server,MySql,PostgreSql,MongoDB 数据库的项目。
CAP 同时支持使用 EntityFrameworkCore 和 ADO.NET 的项目,你可以根据需要选择不同的配置方式。
CAP支持生产端、消费端使用不同类型的数据库
SqlServer2008版本的数据库需要在UseSqlServer()的配置方法中调用UseSqlServer2008(),因为Cap的UseDashboard在SqlServer2012+版本上使用了新的语法Format内置函数
多个服务共用一个数据库
通过设置表前缀,使用不同的表隔离消息
//服务1数据库配置
option.UseMySql(options =>
{
options.ConnectionString = "Server=xxx;Port=3306;Database=CAPDB; User=root;Password=xxx;";
options.TableNamePrefix = "publisher_";
});
//服务2数据库配置
option.UseMySql(options =>
{
options.ConnectionString = "Server=xxx;Port=3306;Database=CAPDB; User=root;Password=xxx;";
options.TableNamePrefix = "receiver_";
});
生成如下表:
原理图
图中实线部分代表用户代码,虚线部分代表CAP内部实现。
参考:
https://cap.dotnetcore.xyz/user-guide/zh/getting-started/quick-start/
https://www.cnblogs.com/cmliu/p/11767343.html
https://www.cnblogs.com/savorboard/p/cap.html