话不多说,直接切入正题:
TODO-LIST
- 需求: 购买商品
- 前提: 水平分库分表
- 实现方案:
- [] 2PC(CP)
- 所需要覆盖的测试情况:
- [] 协调者挂,参与者未挂,协调者重启|协调者集群
- [] 协调者没挂,参与者挂 ,参与者重启
- [] 两个都挂了,两个都重启
- 所需要覆盖的测试情况:
- [] 3PC(CP)
- 所需要覆盖的测试情况:
- [] TCC
- MQ最终一致性 (AP)
- [] 2PC(CP)
- 实现方案:
概念解释
CAP
- 名词解析
- C :Consistent 一致性 :
集群中所有的及其状态都是一致的
- A: Avaliable 可用性 :
无论怎样,总能得到请求的处理
- P: Paration 分区容忍性
- C :Consistent 一致性 :
- CAP作为理论基础:
- 关系型数据库(MySQL):CA(放弃容忍性)
- HBase: CP(放弃可用性)
2PC:
3PC:
TCC:
更新日志
- 2019-03-05
- 最后应该会用Golang来写一个demo,至于具体的时间,not sure yet
- 2019-02-17
- 16:41
- 今天重新看了下我那个项目,做了以下的优化:
1. 建立了一个单独的@RabbitMQTransaction ,然后AOP拦截
2. 参数有一个独特的Wrapper:UserRecordAspectWrapper,不仅用于存放事务的数据,而且结合了日志的记录
,只是提供思路,以后也完全可以通过aop简化
- 今天重新看了下我那个项目,做了以下的优化:
- 16:41
- 2019-02-10
- 模块间需要重新设计,看下流程图与总结吧,慢慢更新
- 2018-09-13
- demo应该明天会给出
- 2018-09-18 更:
- 整体架构:3个微服务:server-1,server-2,message //message为消息服务中心
-
基础流程图:
-
上游服务接收到信息,先保存在本地消息表中,保存失败直接返回退出,保存成功则通知消息服务器new一个新的消息对象,状态为NEW,表示这个可能要发送,通知成功(注意这里的通知这一步需要为同步模式,不然会出现这种情况,本地已经消费了,但是却没通知到,这样消息就丢失了)
-
通知成功之后则开始处理本地的业务逻辑,失败的时候手动抛出异常,让业务回滚,成功之后,可以异步/同步的方式通知消息服务器更新状态,在其中的任意环节出现失败都不会影响一致性,这时候是处于软安全的状态,不会影响一致性,为什么不会影响呢,因为本地有表记录着啊,而本地失败的情况下回造成业务回滚,从而记录为空的,如这种情况:最后一步,服务本地业务成功,异步通知消息服务器可以更改状态为ready让其发送消息,假设失败了,意味着消息服务器中的状态依旧为NEW,但是没关系,本地成功了,消息服务器后台会有个线程,自动检测超时状态下的,当处于NEW状态的消息会通过上游服务器提供的接口查询记录是否存在(或者状态是否为消费),如果有记录表明是消费了的,这个消息应该被发送,所以修改状态问ready,然后发送
下游服务器流程图:
-
然后创建表,为了演示只需要简单创建表即可:
以及server-1对应的本地业务表:
至于server-2的就不展示了,大家随意创建一个即可
dao省略,直接进入核心的sevice:
@Service
public class MQTransactionService
{
@Autowired
private MessageDao messageDao;
@Autowired
private UserService userService;
@Autowired
private IMessageServerFeignServiec messageServerFeignServiec;
@Transactional(rollbackFor=Exception.class)
public String testRabbitMqTransaction(String detail)
{
//插入本地消息表
Integer localMessageValidCount = messageDao.insert(detail);
if(localMessageValidCount<=0)
{
return "fail";
}
//通知远程服务,添加消息
try
{
Integer remoteMessageValidCount = messageServerFeignServiec.addMessage(detail);
if(remoteMessageValidCount<=0)
{
throw new RuntimeException("手动抛异常回滚:远程通知服务器失败,插入数据失败");
}
} catch (Exception e)
{
throw new RuntimeException("手动抛异常回滚:远程通知服务器失败",e);
}
//执行本地业务
Integer logicValidCount = userService.insert("joker");
if(logicValidCount<=0)
{
throw new RuntimeException("手动抛异常回滚:本地执行业务失败");
}
//调用其他服务的接口
//通知远程服务器更新状态
Integer updateStautsValidCount = messageServerFeignServiec.updateMsgStatus((long) detail.hashCode(), 1);
if(updateStautsValidCount<=0)
{
throw new RuntimeException("手动抛异常回滚:远程更新消息状态失败");
}
return "succes";
}
}
- 核心思路就是确保每步都成功再执行下一步,不过关于消息通知这块,可以考虑用异步的方式,不过这样的话需要在message-server中添加一个定时器,定时扫描那些消息状态改变了却没发送的,同时上游服务需要开放一个接口,供消息服务器调用查询信息是否存在(因为如果上游消费成功了,是会在db中插入数据的),并且相对的,下游服务器也是需要提供接口的,用于检测任务是否已经被消费(是否存在)
简易demo地址,未深入设计,在项目中是慢慢一点一点深入的
总结
-
消息服务器中消息状态的变化:NEW->READY->WAIT_PUBLISH_CONFIRM->PUBLISH_CONFIRM->CONSUMER_RECEIVED->CONSUMER_CONSUMED
- 上游服务消息状态的分类:
- NEW: 代表本地插入消息表的初始状态(并且消息服务消息创建插入成功)
- LOCAL_FINISHED: 本地业务执行成功(当消息服务查询接口成功时可删除)
- 消息服务:消息状态的分类:
- NEW: 上游服务器本地插入消息
同步消息服务器中新建消息,状态为NEW
(ps:流程可以修改,既异步改为同步的情况下,消息的新建可以在本地业务执行完毕之后才通知消息服务器,并且这个消息状态直接为READY状态) - READY: 上游服务器本地业务执行成功,
异步通知消息服务器修改消息状态为READY,可以发送
(业务而定,具体看压力而定,压力小直接发送,压力大作为任务处理) - WAIT_PUBLISH_CONFIRM: 消息发送的时候修改状态,表明这个消息
已经发送,但是未确认发送成功
,通过publish-confirm broker与消息服务自主交互修改状态 - PUBLISH_CONFIRM: 代表消息发送成功,但是
未被下游服务消费
- CONSUMER_RECEIVED: 代表
下游服务成功收到消息
- CONSUMER_CONSUMED: 代表
下游服务消费成功,同时这个任务会被删除
- CANCEL: 可有可无,具体看压力而定,压力小直接删除,压力大作为任务处理
- NEW: 上游服务器本地插入消息
- 下游服务消息状态的分类:
- RECEIVED: 代表消息收到了,但是还未处理
- CONSUMED: 代表消息已经成功被消费了
- 上游服务消息状态的分类:
-
生产者消息状态确认
- 消息服务器会定时轮询消息表的状态,当发现消息为NEW
并且超时
的时候,意味着- 上游服务前半程就挂了(通知远程服务创建消息成功之后,本地新建消息记录失败:如服务宕机或者未知原因)
- 上游服务本地插入成功,但是上游服务通知失败(如:服务宕机,或者网络通讯通知消息服务失败)
解决方法:
上游服务开放消息查询接口,消息服务调用,然后进行状态匹配,如果上游的消息状态处于LOCAL_FINISHED,则修改消息状态为READY,或者直接发送(业务而定,业务可以有level评级)
- 消息服务器会定时轮询消息表的状态,当发现消息为NEW
-
消费者消息状态确认
- 消息服务器定时轮询消息表的状态,
- 当发现消息为
PUBLISH_CONFIRMED并且超时
- 可能是消费者过慢,或者是超时时间过段,因而这里可以动态调整
- 当发现消息为
CONSUMER_RECEIVED并且超时:
- 可能是消费者本地插入失败
- 可能是消费者消费成功了,但是通知失败了
解决方法:
下游服务开放消息查询接口,消息服务调用,然后进行状态匹配,如果下游的消息处于CONSUMED,通常可能是网络延迟,或者是业务逻辑耗时与超时不匹配则消息服务直接更改消息状态为CONSUMER_CONSUMED即可,如果无记录则可能是服务宕机了,则消息服务修改状态为READY,准备再次发送
- 当发现消息为
- 消息服务器定时轮询消息表的状态,
-
上游服务如何得知下游服务消费成功了:
- 答: 本质上是MQ+RPC的通信,方式很简单,
重点在于发送的对象AppEvent,内部添加correlationId和replyTo
当下游服务器消费成功之后,携带correlationId自动往replyTo的queue发送消息,上游服务器监听,创建的是临时队列,因而不需要担心内存问题
- 答: 本质上是MQ+RPC的通信,方式很简单,
-
如何保证接口的幂等性(既防止重复消费)
- 答:
通过消息表
,每个服务都会有一个消息表,处理逻辑之前都先判断是否已经存在记录
- 答:
-
如何确认消息发送成功
- 答: 有两种确认方式:
通过下游服务器的消息表
,下游服务器开放一个接口,消息服务器后台线程对超时的消息手动发出http请求校验状态publisher 使用publish-confirm机制
: 消息属性为persistent的消息会持久化到硬盘之后才confirm,而transient的消息则会入队就confirm
- 答: 有两种确认方式:
-
如何解决MQ的乱序,有两种乱序的情况
- 第一种是发送的时候乱序:在这种情况下,很可能会M2先于M1被发送,因而解决方法是:
- 核心是
一个生产者producer对应一个MQServer
: 因为消息服务是可靠的,也就意味着肯定会有集群,eureka有个api能够获取到某个名称服务的所有服务列表,因而同一业务的设置同一关键字key,key%服务的list长度,这样就使得某个业务的所有消息体AppEvent都会被发送到某个服务器中,而这个服务器可以申明BlockingQueue作为容器存储某些有次序的消息,然后发送的时候一个一个取即可
:也就意味着消息有分类,有些强调次序,有些次序无关
- 核心是
- 第二种是消费的乱序,上述虽然保证了发送的顺序,但是不能保证消费的顺序
- 解决方法是: 通过回调函数,既发送了M1之后,消费者消费之后手动调用AppEvent的callBack函数:通知消息服务器可以发送M2了,
对于MQServer而言
:消息体AppEvent的设置可以参考tcp协议中的部分,如设置二进制位MA(more AppEvent)位,代表后序还有消息需要消费,然后回调函数的时候根据某种特征(可以名字+数字,然后数字++的形式)通知消息服务器取出这个特定的消息然后发送,这里又延伸出几个小问题
:- 如果发布到了其他消费者怎么办,其他消费者不知道该不该消费这个
- 无关紧要,因为当消息发出的时候,要么是第一个消息自动发送的,要么是下个消息,而下个消息必定是收到了通知才发送出去的,也就意味着上一个消息必然是完成了的
如果消息超时了还未发出
:可能的原因有:- 消费者宕机了:这个无关紧要,重新发送即可
- 消费者消费成功了,但是网络阻塞,延迟收到:也无关紧要,继续重新发送,原因在下面?
- 总结:
如果非第一个消息长久未发送
:啥也不需要管,直接发送即可;注意:这里的消息特指非第一个消息,为什么?因为我们的消息是通过通知发送的,而第一个消息是不需要通知即可发送的,因此我们同样也模仿tcp头结构
,只在第一个消息体上设置某个二进制位为1代表是第一个消息
- 如果发布到了其他消费者怎么办,其他消费者不知道该不该消费这个
对于消费者而言
:- 先回答上面的问题,上面的问题就是
如何防止消息的重复消费
,解决方法是消费者本地消息记录,但是呢,当设计分库分表的时候还是可取的,因为这时候只会是分表,而不会分库,因而完全ok
- 先回答上面的问题,上面的问题就是
- 解决方法是: 通过回调函数,既发送了M1之后,消费者消费之后手动调用AppEvent的callBack函数:通知消息服务器可以发送M2了,
- 第一种是发送的时候乱序:在这种情况下,很可能会M2先于M1被发送,因而解决方法是:
注意点
-
上游服务本地消息表插入记录与消息服务插入记录的先后顺序,
消息服务消息表插入要先与本地服务
:- 理由: 反证法:试想这种情况,本地消息先插入了,但是通知的时候服务宕机了,并且
并没有开启事务,而是手动rollback
,则本地消息表就会多了冗余的一条记录,(本地也可以轮询消息表,但是很不推荐,因为职责就不单一了,并且没必要
),而当消息服务先插入之后,消息服务会有一个后台线程轮询,当发现状态为NEW,且超时,则会上访上游接口,上游接口反馈没有,则消息服务删除即可
- 理由: 反证法:试想这种情况,本地消息先插入了,但是通知的时候服务宕机了,并且
-
下游服务本地消息表插入基于与消息通知的先后顺序,
本地插入先于消息通知
:- 理由: 这个逻辑很简单,就是如果插入失败了,则消息服务消息的状态会为
CONSUMER_RECEIVED
,而本地却没有,消息就遗漏了;当然,消息服务有一个后台线程自动轮询,因而其实这个顺序无关紧要
- 理由: 这个逻辑很简单,就是如果插入失败了,则消息服务消息的状态会为