通过rabbitMQ消息队列实现分布式环境下的最终一致性

话不多说,直接切入正题:

TODO-LIST

  • 需求: 购买商品
  • 前提: 水平分库分表
    • 实现方案:
      • [] 2PC(CP)
        • 所需要覆盖的测试情况:
          • [] 协调者挂,参与者未挂,协调者重启|协调者集群
          • [] 协调者没挂,参与者挂 ,参与者重启
          • [] 两个都挂了,两个都重启
      • [] 3PC(CP)
        • 所需要覆盖的测试情况:
      • [] TCC
      • MQ最终一致性 (AP)

概念解释

CAP

  • 名词解析
    • C :Consistent 一致性 : 集群中所有的及其状态都是一致的
    • A: Avaliable 可用性 : 无论怎样,总能得到请求的处理
    • P: Paration 分区容忍性
  • 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简化
  • 2019-02-10
    • 模块间需要重新设计,看下流程图与总结吧,慢慢更新
  • 2018-09-13
    • demo应该明天会给出
  • 2018-09-18 更:
    • 整体架构:3个微服务:server-1,server-2,message //message为消息服务中心

  • 基础流程图:
    通过rabbitMQ消息队列实现分布式环境下的最终一致性_第1张图片

  • 上游服务接收到信息,先保存在本地消息表中,保存失败直接返回退出,保存成功则通知消息服务器new一个新的消息对象,状态为NEW,表示这个可能要发送,通知成功(注意这里的通知这一步需要为同步模式,不然会出现这种情况,本地已经消费了,但是却没通知到,这样消息就丢失了)

  • 通知成功之后则开始处理本地的业务逻辑,失败的时候手动抛出异常,让业务回滚,成功之后,可以异步/同步的方式通知消息服务器更新状态,在其中的任意环节出现失败都不会影响一致性,这时候是处于软安全的状态,不会影响一致性,为什么不会影响呢,因为本地有表记录着啊,而本地失败的情况下回造成业务回滚,从而记录为空的,如这种情况:最后一步,服务本地业务成功,异步通知消息服务器可以更改状态为ready让其发送消息,假设失败了,意味着消息服务器中的状态依旧为NEW,但是没关系,本地成功了,消息服务器后台会有个线程,自动检测超时状态下的,当处于NEW状态的消息会通过上游服务器提供的接口查询记录是否存在(或者状态是否为消费),如果有记录表明是消费了的,这个消息应该被发送,所以修改状态问ready,然后发送

    下游服务器流程图:
    通过rabbitMQ消息队列实现分布式环境下的最终一致性_第2张图片

    在这里插入图片描述

  • 然后创建表,为了演示只需要简单创建表即可:
    通过rabbitMQ消息队列实现分布式环境下的最终一致性_第3张图片
    以及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: 可有可无,具体看压力而定,压力小直接删除,压力大作为任务处理
    • 下游服务消息状态的分类:
      • RECEIVED: 代表消息收到了,但是还未处理
      • CONSUMED: 代表消息已经成功被消费了
  • 生产者消息状态确认

    • 消息服务器会定时轮询消息表的状态,当发现消息为NEW并且超时的时候,意味着
      • 上游服务前半程就挂了(通知远程服务创建消息成功之后,本地新建消息记录失败:如服务宕机或者未知原因)
      • 上游服务本地插入成功,但是上游服务通知失败(如:服务宕机,或者网络通讯通知消息服务失败)
      • 解决方法: 上游服务开放消息查询接口,消息服务调用,然后进行状态匹配,如果上游的消息状态处于LOCAL_FINISHED,则修改消息状态为READY,或者直接发送(业务而定,业务可以有level评级)
  • 消费者消息状态确认

    • 消息服务器定时轮询消息表的状态,
      • 当发现消息为PUBLISH_CONFIRMED并且超时
        • 可能是消费者过慢,或者是超时时间过段,因而这里可以动态调整
      • 当发现消息为CONSUMER_RECEIVED并且超时:
        • 可能是消费者本地插入失败
        • 可能是消费者消费成功了,但是通知失败了
        • 解决方法: 下游服务开放消息查询接口,消息服务调用,然后进行状态匹配,如果下游的消息处于CONSUMED,通常可能是网络延迟,或者是业务逻辑耗时与超时不匹配则消息服务直接更改消息状态为CONSUMER_CONSUMED即可,如果无记录则可能是服务宕机了,则消息服务修改状态为READY,准备再次发送
  • 上游服务如何得知下游服务消费成功了:

    • 答: 本质上是MQ+RPC的通信,方式很简单,重点在于发送的对象AppEvent,内部添加correlationId和replyTo 当下游服务器消费成功之后,携带correlationId自动往replyTo的queue发送消息,上游服务器监听,创建的是临时队列,因而不需要担心内存问题
  • 如何保证接口的幂等性(既防止重复消费)

    • 答: 通过消息表,每个服务都会有一个消息表,处理逻辑之前都先判断是否已经存在记录
  • 如何确认消息发送成功

    • 答: 有两种确认方式:
      • 通过下游服务器的消息表,下游服务器开放一个接口,消息服务器后台线程对超时的消息手动发出http请求校验状态
      • publisher 使用publish-confirm机制: 消息属性为persistent的消息会持久化到硬盘之后才confirm,而transient的消息则会入队就confirm
  • 如何解决MQ的乱序,有两种乱序的情况

    通过rabbitMQ消息队列实现分布式环境下的最终一致性_第4张图片

    • 第一种是发送的时候乱序:在这种情况下,很可能会M2先于M1被发送,因而解决方法是:
      • 核心是一个生产者producer对应一个MQServer: 因为消息服务是可靠的,也就意味着肯定会有集群,eureka有个api能够获取到某个名称服务的所有服务列表,因而同一业务的设置同一关键字key,key%服务的list长度,这样就使得某个业务的所有消息体AppEvent都会被发送到某个服务器中,而这个服务器可以申明BlockingQueue作为容器存储某些有次序的消息,然后发送的时候一个一个取即可:也就意味着消息有分类,有些强调次序,有些次序无关
        通过rabbitMQ消息队列实现分布式环境下的最终一致性_第5张图片
    • 第二种是消费的乱序,上述虽然保证了发送的顺序,但是不能保证消费的顺序
      • 解决方法是: 通过回调函数,既发送了M1之后,消费者消费之后手动调用AppEvent的callBack函数:通知消息服务器可以发送M2了,
        • 对于MQServer而言:消息体AppEvent的设置可以参考tcp协议中的部分,如设置二进制位MA(more AppEvent)位,代表后序还有消息需要消费,然后回调函数的时候根据某种特征(可以名字+数字,然后数字++的形式)通知消息服务器取出这个特定的消息然后发送,这里又延伸出几个小问题:
          • 如果发布到了其他消费者怎么办,其他消费者不知道该不该消费这个
            • 无关紧要,因为当消息发出的时候,要么是第一个消息自动发送的,要么是下个消息,而下个消息必定是收到了通知才发送出去的,也就意味着上一个消息必然是完成了的
          • 如果消息超时了还未发出:可能的原因有:
            • 消费者宕机了:这个无关紧要,重新发送即可
            • 消费者消费成功了,但是网络阻塞,延迟收到:也无关紧要,继续重新发送,原因在下面
            • 总结:如果非第一个消息长久未发送:啥也不需要管,直接发送即可;注意:这里的消息特指非第一个消息,为什么?因为我们的消息是通过通知发送的,而第一个消息是不需要通知即可发送的,因此我们同样也模仿tcp头结构,只在第一个消息体上设置某个二进制位为1代表是第一个消息
        • 对于消费者而言:
          • 先回答上面的问题,上面的问题就是如何防止消息的重复消费,解决方法是消费者本地消息记录,但是呢,当设计分库分表的时候还是可取的,因为这时候只会是分表,而不会分库,因而完全ok

注意点

  • 上游服务本地消息表插入记录与消息服务插入记录的先后顺序,消息服务消息表插入要先与本地服务:

    • 理由: 反证法:试想这种情况,本地消息先插入了,但是通知的时候服务宕机了,并且并没有开启事务,而是手动rollback,则本地消息表就会多了冗余的一条记录,(本地也可以轮询消息表,但是很不推荐,因为职责就不单一了,并且没必要),而当消息服务先插入之后,消息服务会有一个后台线程轮询,当发现状态为NEW,且超时,则会上访上游接口,上游接口反馈没有,则消息服务删除即可
  • 下游服务本地消息表插入基于与消息通知的先后顺序,本地插入先于消息通知:

    • 理由: 这个逻辑很简单,就是如果插入失败了,则消息服务消息的状态会为CONSUMER_RECEIVED,而本地却没有,消息就遗漏了;当然,消息服务有一个后台线程自动轮询,因而其实这个顺序无关紧要

你可能感兴趣的:(微服务,Spring,RabbitMQ)