复习专栏之---消息队列

1. 序

与其说消息队列是一种技术(框架),不如说它是一种应对特殊场景的解决方案。技术服务于业务,消息队列的产生同样基于一些特殊的场景。

最基本的特征就是随着全球数据量的增大,业务模式的丰富,衍生了众多的技术,感觉消息队列和大数据生态圈是一种产物,都是大数据量下的衍生品。同样也是不得已而为之,主要应对三种业务场景:异步、解耦、削峰。

异步和解耦其实很多时候是同时出现的,解耦之后自然就是异步了,要想异步也必须先解耦,理解消息队列最好的方式就是理解一个电商系统。下述为例:

2. 异步

异步的出现是为了应对日益复杂的业务模式,以前的电商平台,无非就是买卖,你付钱我发货,贯穿其中的只是金钱,但是慢慢的资本竞争之下,营销手段层出不穷,优惠券,积分系统,短信提醒…

场景越复杂,代码处理流程就会越多,RT(respond time)便会越来越大,试问一个卡顿延迟的系统谁会去买东西?

在这里插入图片描述
并且业务只会越来越复杂,如果一味的在一个流程中增加处理逻辑,即使用户可以忍受,系统也有处理不过来的时候,并且出现问题时根本不知道究竟在哪个部分出问题。所以必须异步:

在这里插入图片描述
有没有发现异步其实就是解耦,就是微服务的模式,把多个服务拆分开来,异步处理。这样无论加多少个促销模式都不会影响主线的处理模式,并且还可以方便定位错误,哪个服务出错直接处理哪个服务,附加服务的异常不会导致主流程的异常。

当然以上做法必须要基于一定的场景:那就是场景允许延迟!
试想一下,买完东西以后,短信你迟几秒发给他、优惠券延迟几秒再作废、积分等等延迟再到账,他根本不在意的,并且对于后续的系统增加一些校验,完全可以无论在营销模式多么复杂的情况下100毫秒用户知道下单成功了。

最后再说一下既然要实现异步,那我用线程,线程池去做不是一样的么?
ok,用线程去做,一个订单流程,你扣积分,扣优惠券,发短信,扣库存。。。等等一个业务要调用一个的接口,每次加一个你要调用一个接口然后还要重新发布系统,频繁维护导致服务不可用本身就容易出问题!而且真的全部都写在一起的话,不单单是耦合这一个问题,你出问题排查也麻烦,流程里面随便一个地方出问题搞不好会影响到其他的点,小伙伴说我每个流程都try catch不就行了,相信我别这么做,这样的代码就像个定时炸弹 ,你不知道什么时候爆炸,平时不炸偏偏在你做活动的时候炸,你就领个P0故障收拾书包提前回家过年吧。

3. 解耦

解耦就是典型的服务分离,跟异步其实纠葛很深。
以上为例,说白了就是我支付完把支付结果放到一个地方,其他所有的系统监听这个地方然后做对应的额操作,这样就做到了主服务和附加服务的解耦。
在这里插入图片描述

其实解耦完了之后就是一个典型的分布式系统了,也可以叫做微服务,各自负责自己的模块,只负责自己的流程。
比如支付完成之后,不需要管积分是否完成,也不用关心短信。

4. 削峰

削峰就是为了处理qps波动。当然应对qps高也可以增加服务节点,在qps高的时候增加服务节点,qps低的是否再减少服务节点也是一种办法,只是谁有那么多备用服务器呢?
所以最好的削峰办法就是使用消息队列,不需要增加额外的服务器,也不用有人盯着服务器手动调整服务数量。

比如要做零点秒杀,00 :00的时候流量疯狂怼进来,你的服务器,Redis,MySQL各自的承受能力都不一样,你直接全部流量照单全收肯定有问题啊,直接就打挂了。
所以只能把请求放到队列里面,然后至于每秒消费多少请求,就看自己的服务器处理能力,你能处理5000QPS你就消费这么多,可能会比正常的慢一点,但是不至于打挂服务器,等流量高峰下去了,你的服务也就没压力了。就像电商的双十一、六一八的时候,巨大流量瞬间涌进去,有时候是不是会慢一点,但是人家没挂啊,或者降级给你个友好的提示页面,等高峰过去了又是一条好汉了。

所以应对不稳定的qps最好的办法就是使用消息队列,也就是削峰的方式。平缓处理。

5. 选型

5.1 背景

消息队列,一般我们会简称它为MQ(Message Queue),其实就是一种存储消息的队列,如果说不在乎消息的话,队列这种东西,其实就是一种先进先出的数据结构,在Java里边,已经实现了不少的队列了:
在Java里边,已经实现了不少的队列了:
那为什么还需要消息队列(MQ)这种中间件呢???其实这个问题,跟之前我学Redis的时候很像。Redis是一个以key-value形式存储的内存数据库,明明我们可以使用类似HashMap这种实现类就可以达到类似的效果了,那还为什么要Redis? 其实都是根据特殊的场景需求,消息队列不得不用的原因就是消息队列框架额外附带的功能。

5.2. 消息队列模式

消息队列目前主要2种模式,分别为“点对点模式”和“发布/订阅模式”。

  • 点对点模式
    一个具体的消息只能由一个消费者消费。多个生产者可以向同一个消息队列发送消息;但是,一个消息在被一个消息者处理的时候,这个消息在队列上会被锁住或者被移除并且其他消费者无法处理该消息。需要额外注意的是,如果消费者处理一个消息失败了,消息系统一般会把这个消息放回队列,这样其他消费者可以继续处理。
    在这里插入图片描述
  • 发布/订阅模式
    单个消息可以被多个订阅者并发的获取和处理。一般来说,订阅有两种类型:
    • 临时(ephemeral)订阅,这种订阅只有在消费者启动并且运行的时候才存在。一旦消费者退出,相应的订阅以及尚未处理的消息就会丢失。
    • 持久(durable)订阅,这种订阅会一直存在,除非主动去删除。消费者退出后,消息系统会继续维护该订阅,并且后续消息可以被继续处理。在这里插入图片描述

5.3. 选型标准

对消息队列进行技术选型时,需要通过以下指标衡量你所选择的消息队列,是否可以满足你的需求:

  • 消息顺序:发送到队列的消息,消费时是否可以保证消费的顺序,比如A先下单,B后下单,应该是A先去扣库存,B再去扣,顺序不能反。
  • 消息路由:根据路由规则,只订阅匹配路由规则的消息,比如有A/B两者规则的消息,消费者可以只订阅A消息,B消息不会消费。
  • 消息可靠性:是否会存在丢消息的情况,比如有A/B两个消息,最后只有B消息能消费,A消息丢失。
  • 消息时序:主要包括“消息存活时间”和“延迟/预定的消息”,“消息存活时间”表示生产者可以对消息设置TTL,如果超过该TTL,消息会自动消失;“延迟/预定的消息”指的是可以延迟或者预订消费消息,比如延时5分钟,那么消息会5分钟后才能让消费者消费,时间未到的话,是不能消费的。
  • 消息留存:消息消费成功后,是否还会继续保留在消息队列。
  • 容错性:当一条消息消费失败后,是否有一些机制,保证这条消息是一种能成功,比如异步第三方退款消息,需要保证这条消息消费掉,才能确定给用户退款成功,所以必须保证这条消息消费成功的准确性。
  • 伸缩:当消息队列性能有问题,比如消费太慢,是否可以快速支持库容;当消费队列过多,浪费系统资源,是否可以支持缩容。
  • 吞吐量:支持的最高并发数。

5.4. 消息队列比较

其实理解消息队列的使用场景,完全可以根据需求自己想办法,不过避免重复造轮子,可以直接使用市面上的现成工具:

  • ZeroMQ
  • 推特的Distributedlog
  • ActiveMQ:Apache旗下的老牌消息引擎
  • RabbitMQ、Kafka:AMQP的默认实现。
  • RocketMQ
  • Artemis:Apache的ActiveMQ下的子项目
  • Apollo:同样为Apache的ActiveMQ的子项目的号称下一代消息引擎
  • 商业化的消息引擎IronMQ
  • 以及实现了JMS(Java Message Service)标准的OpenMQ。

以上几乎完全列举了当下比较知名的消息引擎,但是目前在市面上比较主流的消息队列中间件还是Kafka、ActiveMQ、RabbitMQ、RocketMQ 之流。

ActiveMQ和RabbitMQ这两着因为吞吐量还有GitHub的社区活跃度的原因,在各大互联网公司都已经基本上绝迹了,业务体量一般的公司会是有在用的,但是越来越多的公司更青睐RocketMQ和kafka这样的消息中间件了。
在这里插入图片描述
在这里插入图片描述

  • Kafka:Apache Kafka它最初由LinkedIn公司基于独特的设计实现为一个分布式的提交日志系统( a distributed commit log),目前归属于Apache顶级项目。Kafka主要为高吞吐量的订阅发布系统而设计,追求速度与持久化。kafka中的消息由键、值、时间戳组成,kafka不记录每个消息被谁使用,只通过偏移量记录哪些消息是未读的,kafka中可以指定消费组来实现订阅发布的功能。号称大数据的杀手锏,谈到大数据领域内的消息传输,则绕不开Kafka,这款为大数据而生的消息中间件,以其百万级TPS的吞吐量名声大噪,迅速成为大数据领域的宠儿,在数据采集、传输、存储的过程中发挥着举足轻重的作用。

  • RabbitMQ:RabbitMQ 2007年发布,是使用Erlang语言开发的开源消息队列系统,基于AMQP协议来实现。AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。AMQP协议 更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次

  • RocketMQ:是阿里开源的消息中间件,它是纯Java开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点。RocketMQ思路起源于Kafka,但并不是Kafka的一个Copy,它对消息的可靠传输及事务性做了优化,目前在阿里集团被广泛应用于交易、充值、流计算、消息推送、日志流式处理、binglog分发等场景。可以说是兼容了稳定性和吞吐量,只支持java

  • ActiveMQ:是Apache出品,最流行的,能力强劲的开源消息总线。官方社区现在对ActiveMQ 5.x维护越来越少,较少在大规模吞吐的场景中使用,所以该消息队列也不是我们文章中重点讨论的内容。

5.5. 优缺点

kafka
优点:

  • 高吞吐、低延迟:kakfa 最大的特点就是收发消息非常快,kafka 每秒可以处理几十万条消息,它的最低延迟只有几毫秒;
  • 高伸缩性:每个主题(topic) 包含多个分区(partition),主题中的分区可以分布在不同的主机(broker)中;
  • 持久性、可靠性:Kafka 能够允许数据的持久化存储,消息被持久化到磁盘,并支持数据备份防止数据丢失,Kafka 底层的数据存储是基于 Zookeeper 存储的,Zookeeper 我们知道它的数据能够持久存储;
  • 容错性:非常高,kafka是分布式的,一个数据多个副本,某个节点宕机,Kafka 集群能够正常工作;
  • 消息有序:消费者采用Pull方式获取消息,消息有序,通过控制能够保证所有消息被消费且仅被消费一次;
  • 有优秀的第三方Kafka Web管理界面Kafka-Manager,在日志领域比较成熟,被多家公司和多个开源项目使用;
  • 功能支持:功能较为简单,主要支持简单的MQ功能,在大数据领域的实时计算以及日志采集被大规模使用。

缺点:

  • Kafka单机超过64个队列/分区,Load会发生明显的飙高现象,队列越多,load越高,发送消息响应时间变长;
  • 使用短轮询方式,实时性取决于轮询间隔时间;
  • 消费失败不支持重试;
  • 支持消息顺序,但是一台代理宕机后,就会产生消息乱序;
  • 社区更新较慢。

总结:

  • Kafka主要特点是基于Pull的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输,适合产生大量数据的互联网服务的数据收集业务。
  • 大型公司建议可以选用,如果有日志采集功能,肯定是首选kafka。

RabbitMQ

优点:

  • 异步消息传递:支持多种消息协议,消息队列,传送确认,灵活的路由到队列,多种交换类型;
  • 支持几乎所有最受欢迎的编程语言:Java,C,C ++,C#,Ruby,Perl,Python,PHP等等;
  • 可以部署为高可用性和吞吐量的集群;,跨多个可用区域和区域进行联合;
  • 可插入的身份验证,授权,支持TLS和LDAP;
  • 提供了许多插件,来从多方面进行扩展,也可以编写自己的插件;
  • 提供了一个易用的用户界面,使得用户可以监控和管理消息Broker,社区活跃度高。

缺点:

  • erlang开发,很难去看懂源码,基本职能依赖于开源社区的快速维护和修复bug,不利于做二次开发和维护;
  • RabbitMQ确实吞吐量会低一些,这是因为他做的实现机制比较重;
  • 需要学习比较复杂的接口和协议,学习和维护成本较高。

总结:

  • 结合erlang语言本身的并发优势,性能较好,社区活跃度也比较高,但是不利于做二次开发和维护。不过RabbitMQ的社区十分活跃,可以解决开发过程中遇到的bug。
  • 如果你的数据量没有那么大,小公司优先选择功能比较完备的RabbitMQ。

RocketMQ

优点:

  • 支持发布/订阅(Pub/Sub)和点对点(P2P)消息模型;
  • 在一个队列中可靠的先进先出(FIFO)和严格的顺序传递;
  • 支持拉(pull)和推(push)两种消息模式;
  • 单一队列百万消息的堆积能力;
  • 支持多种消息协议,如 JMS、MQTT 等;
  • 可靠的FIFO和严格的有序消息传递在同一队列中;
  • 灵活的分布式横向扩展部署架构,满足至少一次消息传递语义;
  • 提供 docker 镜像用于隔离测试和云集群部署;
  • 提供配置、指标和监控等功能丰富的 Dashboard。

缺点:

  • 支持的客户端语言不多,目前是java及c++,其中c++不成熟
  • 社区活跃度一般
  • 没有在 mq 核心中去实现JMS等接口,有些系统要迁移需要修改大量代码

总结:

  • 天生为金融互联网领域而生,对于可靠性要求很高的场景,尤其是电商里面的订单扣款,以及业务削峰,在大量交易涌入时,后端可能无法及时处理的情况。
  • RoketMQ在稳定性上可能更值得信赖,这些业务场景在阿里双11已经经历了多次考验,如果你的业务有上述并发场景,建议可以选择RocketMQ。

ActiveMQ

优点

  • 支持来自Java,C,C ++,C#,Ruby,Perl,Python,PHP的各种跨语言客户端和协议;
  • 完全支持JMS客户端和Message Broker中的企业集成模式;
  • 支持许多高级功能,如消息组,虚拟目标,通配符和复合目标;
  • 完全支持JMS 1.1和J2EE 1.4,支持瞬态,持久,事务和XA消息;
  • Spring支持,以便ActiveMQ可以轻松嵌入到Spring应用程序中,并使用Spring的XML配置机制进行配置;
  • 专为高性能集群,客户端 - 服务器,基于对等的通信而设计;
  • CXF和Axis支持,以便ActiveMQ可以轻松地放入这些Web服务堆栈中以提供可靠的消息传递;
  • 可以用作内存JMS提供程序,非常适合单元测试JMS;
  • 支持可插拔传输协议,例如in-VM,TCP,SSL,NIO,UDP,多播,JGroups和JXTA传输;
  • 使用JDBC和高性能日志支持非常快速的持久性。

缺点:

  • 官方社区现在对ActiveMQ 5.x维护越来越少,较少在大规模吞吐的场景中使用。

6. kafka

6.1. 基本概念

  • 消息:Kafka 中的数据单元被称为消息,也被称为记录,可以把它看作数据库表中某一行的记录。
  • 批次:为了提高效率, 消息会分批次写入 Kafka,批次就代指的是一组消息。
  • 主题:消息的种类称为 主题(Topic),可以说一个主题代表了一类消息,相当于是对消息进行分类。主题就像是数据库中的表。
  • 分区:主题可以被分为若干个分区(partition),同一个主题中的分区可以不在一个机器上,有可能会部署在多个机器上,由此来实现 kafka 的伸缩性,单一主题中的分区有序,但是无法保证主题中所有的分区有序。
    在这里插入图片描述
  • 生产者:向主题发布消息的客户端应用程序称为生产者(Producer),生产者用于持续不断的向某个主题发送消息。
  • 消费者:订阅主题消息的客户端程序称为消费者(Consumer),消费者用于处理生产者产生的消息。
  • 消费者群组:生产者与消费者的关系就如同餐厅中的厨师和顾客之间的关系一样,一个厨师对应多个顾客,也就是一个生产者对应多个消费者,消费者群组(Consumer Group)指的就是由一个或多个消费者组成的群体。
    在这里插入图片描述
  • 偏移量:偏移量(Consumer Offset)是一种元数据,它是一个不断递增的整数值,用来记录消费者发生重平衡时的位置,以便用来恢复数据。
  • broker: 一个独立的 Kafka 服务器就被称为 broker,broker 接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。
  • broker 集群:broker 是集群 的组成部分,broker 集群由一个或多个 broker 组成,每个集群都有一个 broker 同时充当了集群控制器的角色(自动从集群的活跃成员中选举出来)。
  • 副本:Kafka 中消息的备份又叫做 副本(Replica),副本的数量是可以配置的,Kafka 定义了两类副本:领导者副本(Leader Replica) 和 追随者副本(Follower Replica),前者对外提供服务,后者只是被动跟随。
  • 重平衡:Rebalance。消费者组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。Rebalance 是 Kafka 消费者端实现高可用的重要手段。

6.2. 系统架构

一个典型的 Kafka 集群中包含若干Producer(可以是web前端产生的Page View,或者是服务器日志,系统CPU、Memory等),若干broker(Kafka支持水平扩展,一般broker数量越多,集群吞吐率越高),若干Consumer Group,以及一个Zookeeper集群。Kafka通过Zookeeper管理集群配置,选举leader,以及在Consumer Group发生变化时进行rebalance。Producer使用push模式将消息发布到broker,Consumer使用pull模式从broker订阅并消费消息。
在这里插入图片描述
Kafak的高吞吐和低延迟是怎么实现的?

  1. 页缓存技术+磁盘顺序写
    Kafka的消息数据是写在硬盘上的,保证了消息数据的可靠性,但写硬盘还能保证几十万条/秒的消息处理速度,是怎么做到的?答案是基于操作系统的页缓存和磁盘顺序写来实现的。

    页缓存Page Cache—操作系统自己管理的内存缓存(os cache)。Kafka在写入消息时是直接写入页缓存,然后由操作系统决定什么时候把页缓存里的数据刷入磁盘文件中。这样一来,消息写入性能就变成了写内存,不是在写磁盘
    在这里插入图片描述
    常规的磁盘写入都是随机写,随便找到文件的某个位置来写数据,这样的性能非常差,但是通过追加文件末尾按照顺序的方式来写数据的话,其写入性能跟写内存的性能都相差无几的,Kafak就是采用顺序写的方案。再加上页缓存的应用,才能做到在普通服务器上每秒写入几十万条消息,实现了数据写入的超高性能 。

  2. 零拷贝技术

    解决了写入问题,那消息读取呢?频繁的从磁盘读数据然后发给消费者,性能又是如何保证的?Kafka为了解决这个问题,在读数据的时候是引入零拷贝技术。

    先看下图是常规的硬盘读写流程,操作系统读取硬盘数据后放在OS Cache,然后需要拷贝一次到Kafka进程,然后Kafka再将数据拷贝到Socket缓存才能发送到网卡,这样流程的性能当然没有保障。
    在这里插入图片描述

    再看下图, Kafka的设计是直接将操作系统OS Cache中的数据发送到网卡,跳过了两次拷贝数据的步骤,Socket缓存中仅仅会拷贝一个文件描述符过去,不会拷贝数据到Socket缓存,大大提升了数据读取性能。
    在这里插入图片描述

6.3. 生产者

在 Kafka 中,我们把产生消息的那一方称为生产者,比如我们经常回去淘宝购物,你打开淘宝的那一刻,你的登陆信息,登陆次数都会作为消息传输到 Kafka 后台,当你浏览购物的时候,你的浏览信息,你的搜索指数,你的购物爱好都会作为一个个消息传递给 Kafka 后台,然后淘宝会根据你的爱好做智能推荐,致使你的钱包从来都禁不住诱惑,那么这些生产者产生的消息是怎么传到 Kafka 应用程序的呢?发送过程是怎么样的呢?

尽管消息的产生非常简单,但是消息的发送过程还是比较复杂的,如图:

在这里插入图片描述
我们从创建一个ProducerRecord 对象开始,ProducerRecord 是 Kafka 中的一个核心类,它代表了一组 Kafka 需要发送的 key/value 键值对,它由记录要发送到的主题名称(Topic Name),可选的分区号(Partition Number)以及可选的键值对构成。

在发送 ProducerRecord 时,我们需要将键值对对象由序列化器转换为字节数组,这样它们才能够在网络上传输。然后消息到达了分区器。如果发送过程中指定了有效的分区号,那么在发送记录时将使用该分区。如果发送过程中未指定分区,则将使用key 的 hash 函数映射指定一个分区。如果发送的过程中既没有分区号也没有,则将以循环的方式分配一个分区。选好分区后,生产者就知道向哪个主题和分区发送数据了。ProducerRecord 还有关联的时间戳,如果用户没有提供时间戳,那么生产者将会在记录中使用当前的时间作为时间戳。Kafka 最终使用的时间戳取决于 topic 主题配置的时间戳类型。然后,这条消息被存放在一个记录批次里,这个批次里的所有消息会被发送到相同的主题和分区上。由一个独立的线程负责把它们发到 Kafka Broker 上。

Kafka Broker 在收到消息时会返回一个响应,如果写入成功,会返回一个 RecordMetaData 对象,它包含了主题和分区信息,以及记录在分区里的偏移量,上面两种的时间戳类型也会返回给用户。如果写入失败,会返回一个错误。生产者在收到错误之后会尝试重新发送消息,几次之后如果还是失败的话,就返回错误消息。

上面写的有点多,总结一下流程:创建对象(主题、分区、key/value)-> 序列化数据 -> 到达分区(可自己指定,也可以通过key hash)-> 放入批次(相同主题和分区) -> 独立线程发送 -> 返回主题/分区/分区偏移量/时间戳。

6.4. 分区策略

Kafka 对于数据的读写是以分区为粒度的,分区可以分布在多个主机(Broker)中,这样每个节点能够实现独立的数据写入和读取,并且能够通过增加新的节点来增加 Kafka 集群的吞吐量,通过分区部署在多个 Broker 来实现负载均衡的效果,下面我们看看数据如何选择分区。

方式1:顺序轮询

顺序分配,消息是均匀的分配给每个 partition,即每个分区存储一次消息,见下图。轮训策略是 Kafka Producer 提供的默认策略,如果你不使用指定的轮训策略的话,Kafka 默认会使用顺序轮训策略的方式。
在这里插入图片描述
方式2:随机轮询

本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。

在这里插入图片描述
方式3:key hash

这个策略也叫做 key-ordering 策略,Kafka 中每条消息都会有自己的key,一旦消息被定义了 Key,那么你就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略,如下图所示

在这里插入图片描述

6.5. 消费者、消费者群组

应用程序使用 KafkaConsumer 从 Kafka 中订阅主题并接收来自这些主题的消息,然后再把他们保存起来。应用程序首先需要创建一个 KafkaConsumer 对象,订阅主题并开始接受消息,验证消息并保存结果。一段时间后,生产者往主题写入的速度超过了应用程序验证数据的速度,这时候该如何处理?如果只使用单个消费者的话,应用程序会跟不上消息生成的速度,就像多个生产者像相同的主题写入消息一样,这时候就需要多个消费者共同参与消费主题中的消息,对消息进行分流处理。Kafka 消费者从属于消费者群组。一个群组中的消费者订阅的都是相同的主题,每个消费者接收主题一部分分区的消息。下面是一个 Kafka 分区消费示意图。
在这里插入图片描述
上图中的主题 T1 有四个分区,分别是分区0、分区1、分区2、分区3,我们创建一个消费者群组1,消费者群组中只有一个消费者,它订阅主题T1,接收到 T1 中的全部消息。由于一个消费者处理四个生产者发送到分区的消息,压力有些大,需要帮手来帮忙分担任务,于是就演变为下图
在这里插入图片描述
这样一来,消费者的消费能力就大大提高了,但是在某些环境下比如用户产生消息特别多的时候,生产者产生的消息仍旧让消费者吃不消,那就继续增加消费者。
在这里插入图片描述
如上图所示,每个分区所产生的消息能够被每个消费者群组中的消费者消费,如果向消费者群组中增加更多的消费者,那么多余的消费者将会闲置,如下图所示。
在这里插入图片描述
向群组中增加消费者是横向伸缩消费能力的主要方式。总而言之,我们可以通过增加消费组的消费者来进行水平扩展提升消费能力。这也是为什么建议创建主题时使用比较多的分区数,这样可以在消费负载高的情况下增加消费者来提升性能。另外,消费者的数量不应该比分区数多,因为多出来的消费者是空闲的,没有任何帮助。

Kafka 一个很重要的特性就是,只需写入一次消息,可以支持任意多的应用读取这个消息。换句话说,每个应用都可以读到全量的消息。为了使得每个应用都能读到全量消息,应用需要有不同的消费组。对于上面的例子,假如我们新增了一个新的消费组 G2,而这个消费组有两个消费者,那么就演变为下图这样。在这个场景中,消费组 G1 和消费组 G2 都能收到 T1 主题的全量消息,在逻辑意义上来说它们属于不同的应用。
在这里插入图片描述
总结起来就是如果应用需要读取全量消息,那么请为该应用设置一个消费组;如果该应用消费能力不足,那么可以考虑在这个消费组里增加消费者。

6.6. 消费者重平衡

我们从上面的消费者演变图中可以知道这么一个过程:最初是一个消费者订阅一个主题并消费其全部分区的消息,后来有一个消费者加入群组,随后又有更多的消费者加入群组,而新加入的消费者实例分摊了最初消费者的部分消息,这种把分区的所有权通过一个消费者转到其他消费者的行为称为重平衡,英文名也叫做 Rebalance 。如下图所示。
在这里插入图片描述
重平衡非常重要,它为消费者群组带来了高可用性 和 伸缩性,我们可以放心的添加消费者或移除消费者,不过在正常情况下我们并不希望发生这样的行为。在重平衡期间,消费者无法读取消息,造成整个消费者组在重平衡的期间都不可用。另外,当分区被重新分配给另一个消费者时,消息当前的读取状态会丢失,它有可能还需要去刷新缓存,在它重新恢复状态之前会拖慢应用程序。

消费者通过向组织协调者(Kafka Broker)发送心跳来维护自己是消费者组的一员并确认其拥有的分区。对于不同不的消费群体来说,其组织协调者可以是不同的。只要消费者定期发送心跳,就会认为消费者是存活的并处理其分区中的消息。当消费者检索记录或者提交它所消费的记录时就会发送心跳。如果过了一段时间 Kafka 停止发送心跳了,会话(Session)就会过期,组织协调者就会认为这个 Consumer 已经死亡,就会触发一次重平衡。如果消费者宕机并且停止发送消息,组织协调者会等待几秒钟,确认它死亡了才会触发重平衡。在这段时间里,死亡的消费者将不处理任何消息。在清理消费者时,消费者将通知协调者它要离开群组,组织协调者会触发一次重平衡,尽量降低处理停顿。

重平衡是一把双刃剑,它为消费者群组带来高可用性和伸缩性的同时,还有有一些明显的缺点(bug),而这些 bug 到现在社区还无法修改。重平衡的过程对消费者组有极大的影响。因为每次重平衡过程中都会导致万物静止,参考 JVM 中的垃圾回收机制,也就是 Stop The World ,STW。也就是说,在重平衡期间,消费者组中的消费者实例都会停止消费,等待重平衡的完成。而且重平衡这个过程很慢…

6.7. 特性分析

这里才是内容的重点,不仅需要知道Kafka的特性,还需要知道支持这些特性的原因:

  • 消息路由(不支持):Kafka在处理消息之前是不允许消费者过滤一个主题中的消息。一个订阅的消费者在没有异常情况下会接受一个分区中的所有消息。
  • 消息有序(支持):当消费消息时,如果消费失败,消息不会被放回,所以整个消费过程都是有序进行;
  • 消息时序(不支持):消息直接发送,不会延迟发送,或者指定消息的TTL。
  • 容错处理(集群支持/消息不支持):集群容错能力高,因为是分布式部署,但是消息容错处理弱,因为消息消费失败,需要程序员手动处理,Kafka不支持消息重新进行消费。
  • 伸缩(非常好):通过扩充分区和消费者数量,实现分区扩容,并提升消费速度。
  • 持久化(非常好):数据存储在磁盘,可以随时订阅消费,消费完后,数据仍然保留。
  • 消息回溯(支持):因为消息支持持久化,就支持回溯,可以理解是附带的功能。
  • 高吞吐(非常好):因为Kafka内部同一个主题包含多个分区,所以实现分布式存储,然后消费者数量可以扩充到和分区数量一致,保证了Kafka的高吞吐。

6.8. 定位

Kafka 依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息。在我们的印象中,对于各个存储介质的速度认知大体同下图所示的相同,层级越高代表速度越快。很显然,磁盘处于一个比较尴尬的位置,这不禁让我们怀疑 Kafka 采用这种持久化形式能否提供有竞争力的性能。在传统的消息中间件 RabbitMQ 中,就使用内存作为默认的存储介质,而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。然而,事实上磁盘可以比我们预想的要快,也可能比我们预想的要慢,这完全取决于我们如何使用它。

在这里插入图片描述
有关测试结果表明,一个由6块 7200r/min 的 RAID-5 阵列组成的磁盘簇的线性(顺序)写入速度可以达到 600MB/s,而随机写入速度只有 100KB/s,两者性能相差6000倍。操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存)和后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快,如下图所示:在这里插入图片描述
页缓存的魅力

Kafka 在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算Kafka使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。但这并不是让 Kafka 在性能上具备足够竞争力的唯一因素,我们不妨继续分析。

页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘 I/O 的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。为了弥补性能上的差异,现代操作系统越来越“激进地”将内存作为磁盘缓存,甚至会非常乐意将所有可用的内存用作磁盘缓存,这样当内存回收时也几乎没有性能损失,所有对于磁盘的读写也将经由统一的缓存。

当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的 I/O 操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。

同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。

Linux 操作系统中的 vm.dirty_background_ratio 参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发 pdflush/flush/kdmflush 等后台回写进程的运行来处理脏页,一般设置为小于10的值即可,但不建议设置为0。与这个参数对应的还有一个 vm.dirty_ratio 参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的 I/O 请求会被阻挡直至所有脏页被冲刷到磁盘中。对脏页有兴趣的读者还可以自行查阅 vm.dirty_expire_centisecs、vm.dirty_writeback.centisecs 等参数的使用说明。

对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且,除非使用 Direct I/O 的方式,否则页缓存很难被禁止。此外,用过 Java 的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java 的垃圾回收会随着堆内数据的增多而变得越来越慢。基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护一个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在32GB的机器上使用28GB至30GB的内存而不用担心 GC 所带来的性能问题。

此外,即使 Kafka 服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。

Kafka 中大量使用了页缓存,这是 Kafka 实现高吞吐的重要因素之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务的,但在 Kafka 中同样提供了同步刷盘及间断性强制刷盘(fsync)的功能,这些功能可以通过 log.flush.interval.messages、log.flush.interval.ms 等参数来控制。

同步刷盘可以提高消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过笔者并不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。

Linux 系统会使用磁盘的一部分作为 swap 分区,这样可以进行进程的调度:把当前非活跃的进程调入 swap 分区,以此把内存空出来让给活跃的进程。对大量使用系统页缓存的 Kafka 而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。

我们可以通过修改 vm.swappiness 参数(Linux 系统参数)来进行调节。vm.swappiness 参数的上限为100,它表示积极地使用 swap 分区,并把内存上的数据及时地搬运到 swap 分区中;vm.swappiness 参数的下限为0,表示在任何情况下都不要发生交换(vm.swappiness = 0 的含义在不同版本的 Linux 内核中不太相同,这里采用的是变更后的最新解释),这样一来,当内存耗尽时会根据一定的规则突然中止某些进程。笔者建议将这个参数的值设置为1,这样保留了 swap 的机制而又最大限度地限制了它对 Kafka 性能的影响。

kafka虽然使用的是顺序写磁盘,但是它对内存的依赖也不容小觑,所以虽然内存对它来说不是必须的,但也是越大越好!

kafka其实也没想象中的那么别出心裁,因为对此磁盘的利用,kafka并不是独一份,更不是头一份,甚至使用磁盘也有不得已的苦衷。

Kafka作为一个支持大数据量写入写出的消息队列,由于是基于Scala和Java实现的,而Scala和Java均需要在JVM上运行,所以如果是基于内存的方式,即JVM的堆来进行数据存储则需要开辟很大的堆来支持数据读写,从而会导致GC频繁影响性能。考虑到这些因素,kafka是使用磁盘存储数据的。

Linux对于磁盘的读写优化也比较多,包括read-ahead和write-behind,磁盘缓存等。如果在内存做这些操作的时候,一个是Java对象的内存开销很大,另一个是随着堆内存数据的增多,JAVA的GC时间会变得很长,使用磁盘操作有以下几个好处:

  • 磁盘缓存由Linux系统维护,减少了程序员的不少工作。
  • 磁盘顺序读写速度超过内存随机读写。
  • JVM的GC效率低,内存占用大。使用磁盘可以避免这一问题。
  • 系统冷启动后,磁盘缓存依然可用。

7. RocketMQ

7.1. 基本概念

  • NameServer:一个功能齐全的服务器,其角色类似Dubbo中的Zookeeper。
  • Producer:消息生产者,负责产生消息,一般由业务系统负责产生消息。
  • Consumer:消息消费者,负责消费消息,一般是后台系统负责异步消费。
  • Broker:消息中转角色,负责存储消息,转发消息。
  • Message:消息,一条消息必须有一个主题(Topic),主题可以看做是你的信件要邮寄的地址。(一条消息也可以拥有一个可选的标签(Tag)和额处的键值对,它们可以用于设置一个业务 Key 并在 Broker 上查找此消息以便在开发期间查找问题。)
  • Topic:主题,可以看做消息的规类,它是消息的第一级类型。(比如一个电商系统可以分为:交易消息、物流消息等,一条消息必须有一个 Topic 。Topic 与生产者和消费者的关系非常松散,一个 Topic 可以有0个、1个、多个生产者向其发送消息,一个生产者也可以同时向不同的 Topic 发送消息。一个 Topic 也可以被 0个、1个、多个消费者订阅。)
  • Tag:子主题,它是消息的第二级类型,用于为用户提供额外的灵活性。(使用标签,同一业务模块不同目的的消息就可以用相同 Topic 而不同的 Tag 来标识。比如交易消息又可以分为:交易创建消息、交易完成消息等,一条消息可以没有 Tag 。标签有助于保持您的代码干净和连贯,并且还可以为 RocketMQ 提供的查询系统提供帮助。)
  • Group:分组,一个组可以订阅多个Topic。(分为ProducerGroup,ConsumerGroup,代表某一类的生产者和消费者,一般来说同一个服务可以作为Group,同一个Group一般来说发送和消费的消息都是一样的。)
  • Producer Group:生产者组,代表某一类的生产者,比如我们有多个秒杀系统作为生产者,这多个合在一起就是一个 Producer Group 生产者组,它们一般生产相同的消息。
  • Consumer Group:消费者组,代表某一类的消费者,比如我们有多个短信系统作为消费者,这多个合在一起就是一个 Consumer Group 消费者组,它们一般消费相同的消息。
  • Queue:队列,在Kafka中叫Partition。(每个Queue内部是有序的,在RocketMQ中分为读和写两种队列,一般来说读写队列数量一致,如果不一致就会出现很多问题。)
  • Message Queue:消息队列,主题被划分为一个或多个子主题,即消息队列。(一个 Topic 下可以设置多个消息队列,发送消息时执行该消息的 Topic ,RocketMQ 会轮询该 Topic 下的所有队列将消息发出去。消息的物理管理单位。一个Topic下可以有多个Queue,Queue的引入使得消息的存储可以分布式集群化,具有了水平扩展能力。)

7.2. 消息模型

RockerMQ 中的消息模型就是按照主题模型所实现的,在主题模型中,消息的生产者称为发布者(Publisher),消息的消费者称为订阅者(Subscriber),存放消息的容器称为主题(Topic)。RocketMQ 中的主题模型到底是如何实现的呢?
在这里插入图片描述
我们可以看到在整个图中有 Producer Group、Topic、Consumer Group 三个角色,你可以看到图中生产者组中的生产者会向主题发送消息,而主题中存在多个队列,生产者每次生产消息之后是指定主题中的某个队列发送消息的。

每个主题中都有多个队列(这里还不涉及到 Broker),集群消费模式下,一个消费者集群多台机器共同消费一个 topic 的多个队列,一个队列只会被一个消费者消费。如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。就像上图中 Consumer1 和 Consumer2 分别对应着两个队列,而 Consuer3 是没有队列对应的,所以一般来讲要控制消费者组中的消费者个数和主题中队列个数相同。这个简直和kafak一毛一样啊!

当然也可以消费者个数小于队列个数,只不过不太建议。如下图:
在这里插入图片描述
每个消费组在每个队列上维护一个消费位置,为什么呢?因为我们刚刚画的仅仅是一个消费者组,我们知道在发布订阅模式中一般会涉及到多个消费者组,而每个消费者组在每个队列中的消费位置都是不同的。如果此时有多个消费者组,那么消息被一个消费者组消费完之后是不会删除的(因为其它消费者组也需要呀),它仅仅是为每个消费者组维护一个消费位移(offset),每次消费者组消费完会返回一个成功的响应,然后队列再把维护的消费位移加一,这样就不会出现刚刚消费过的消息再一次被消费了。
在这里插入图片描述
可能你还有一个问题,为什么一个主题中需要维护多个队列?答案是提高并发能力。的确,每个主题中只存在一个队列也是可行的。你想一下,如果每个主题中只存在一个队列,这个队列中也维护着每个消费者组的消费位置,这样也可以做到发布订阅模式。如下图:
在这里插入图片描述
但是,这样我生产者是不是只能向一个队列发送消息?又因为需要维护消费位置所以一个队列只能对应一个消费者组中的消费者,这样是不是其他的 Consumer 就没有用武之地了?从这两个角度来讲,并发度一下子就小了很多。

所以总结来说,RocketMQ 通过使用在一个 Topic 中配置多个队列,并且每个队列维护每个消费者组的消费位置,实现了主题模式/发布订阅模式。

7.3. 系统架构

讲完了消息模型,我们理解起 RocketMQ 的技术架构起来就容易多了。RocketMQ 技术架构中有四大角色 NameServer、Broker、Producer、Consumer。这4大角色,已经在基本概念中简单解释过,对于相关词汇,这里再重点解释一下。

  • Broker:主要负责消息的存储、投递和查询以及服务高可用保证。说白了就是消息队列服务器嘛,生产者生产消息到 Broker,消费者从 Broker 拉取消息并消费。这里,我还得普及一下关于 Broker、Topic 和队列的关系。上面我讲解了 Topic 和队列的关系——一个 Topic 中存在多个队列,那么这个 Topic 和队列存放在哪呢?一个 Topic 分布在多个 Broker 上,一个 Broker 可以配置多个 Topic,它们是多对多的关系。如果某个 Topic 消息量很大,应该给它多配置几个队列,并且尽量多分布在不同 Broker 上,以减轻某个 Broker 的压力。Topic 消息量都比较均匀的情况下,如果某个 broker 上的队列越多,则该 broker 压力越大。
  • NameServer:不知道你们有没有接触过 ZooKeeper 和 Spring Cloud 中的 Eureka,它其实也是一个注册中心,主要提供两个功能:Broker 管理和路由信息管理。说白了就是 Broker 会将自己的信息注册到 NameServer 中,此时 NameServer 就存放了很多 Broker 的信息(Broker的路由表),消费者和生产者就从 NameServer 中获取路由表然后照着路由表的信息和对应的 Broker 进行通信(生产者和消费者定期会向 NameServer 去查询相关的 Broker 的信息)。
  • Producer:消息发布的角色,支持分布式集群方式部署。
  • Consumer:消息消费的角色,支持分布式集群方式部署。支持以 push 推,pull 拉两种模式对消息进行消费,同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制。

在这里插入图片描述
听完了上面的解释你可能会觉得,这玩意好简单。不就是这样的么?
在这里插入图片描述嗯?你可能会发现一个问题,这老家伙 NameServer 干啥用的,这不多余吗?直接 Producer、Consumer 和 Broker 直接进行生产消息,消费消息不就好了么?但是,我们上文提到过 Broker 是需要保证高可用的,如果整个系统仅仅靠着一个 Broker 来维持的话,那么这个 Broker 的压力会不会很大?所以我们需要使用多个 Broker 来保证负载均衡。如果说,我们的消费者和生产者直接和多个 Broker 相连,那么当 Broker 修改的时候必定会牵连着每个生产者和消费者,这样就会产生耦合问题,而 NameServer 注册中心就是用来解决这个问题的。

当然,RocketMQ 中的技术架构肯定不止前面那么简单,因为上面图中的四个角色都是需要做集群的。我给出一张官网的架构图,大家尝试理解一下。
在这里插入图片描述
其实和我们最开始画的那张乞丐版的架构图也没什么区别,主要是一些细节上的差别。

  • 第一、我们的 Broker 做了集群并且还进行了主从部署,由于消息分布在各个 Broker 上,一旦某个 Broker 宕机,则该 Broker 上的消息读写都会受到影响。所以 RocketMQ 提供了 master/slave 的结构,salve 定时从 master 同步数据(同步刷盘或者异步刷盘),如果 master 宕机,则 slave 提供消费服务,但是不能写入消息(后面我还会提到)。
  • 第二、为了保证 HA,我们的 NameServer 也做了集群部署,但是请注意它是去中心化的。也就意味着它没有主节点,你可以很明显地看出 NameServer 的所有节点是没有进行 Info Replicate 的,在 RocketMQ 中是通过单个 Broker 和所有 NameServer 保持长连接,并且在每隔 30 秒 Broker 会向所有 Nameserver 发送心跳,心跳包含了自身的 Topic 配置信息,这个步骤就对应这上面的 Routing Info。
  • 第三、在生产者需要向 Broker 发送消息的时候,需要先从 NameServer 获取关于 Broker 的路由信息,然后通过轮询的方法去向每个队列中生产数据以达到负载均衡的效果。
  • 第四、消费者通过 NameServer 获取所有 Broker 的路由信息后,向 Broker 发送 Pull 请求来获取消息数据。Consumer 可以以两种模式启动—— 广播(Broadcast)和集群(Cluster)。广播模式下,一条消息会发送给同一个消费组中的所有消费者,集群模式下消息只会发送给一个消费者。

7.4. 高级特性&常见问题

7.4.1. 顺序消费

在上面的技术架构介绍中,我们已经知道了 RocketMQ 在主题上是无序的、它只有在队列层面才是保证有序的。这又扯到两个概念——普通顺序和严格顺序。

所谓普通顺序是指消费者通过同一个消费队列收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的。普通顺序消息在 Broker 重启情况下不会保证消息顺序性(短暂时间)。
所谓严格顺序是指消费者收到的所有消息均是有顺序的。严格顺序消息即使在异常情况下也会保证消息的顺序性。但是,严格顺序看起来虽好,实现它可会付出巨大的代价。如果你使用严格顺序模式,Broker 集群中只要有一台机器不可用,则整个集群都不可用。你还用啥?现在主要场景也就在 binlog 同步。一般而言,我们的 MQ 都是能容忍短暂的乱序,所以推荐使用普通顺序模式。

那么,我们现在使用了普通顺序模式,我们从上面学习知道了在 Producer 生产消息的时候会进行轮询(取决你的负载均衡策略)来向同一主题的不同消息队列发送消息。那么如果此时我有几个消息分别是同一个订单的创建、支付、发货,在轮询的策略下这三个消息会被发送到不同队列,因为在不同的队列此时就无法使用 RocketMQ 带来的队列有序特性来保证消息有序性了。

在这里插入图片描述
那么,怎么解决呢?其实很简单,我们需要处理的仅仅是将同一语义下的消息放入同一个队列(比如这里是同一个订单),那我们就可以使用 Hash 取模法来保证同一个订单在同一个队列中就行了。

7.4.2. 重复消费

就两个字——幂等。在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。比如说,这个时候我们有一个订单的处理积分的系统,每当来一个消息的时候它就负责为创建这个订单的用户的积分加上相应的数值。可是有一次,消息队列发送给订单系统 FrancisQ 的订单信息,其要求是给 FrancisQ 的积分加上 500。但是积分系统在收到 FrancisQ 的订单信息处理完成之后返回给消息队列处理成功的信息的时候出现了网络波动(当然还有很多种情况,比如 Broker 意外重启等等),这条回应没有发送成功。

那么,消息队列没收到积分系统的回应会不会尝试重发这个消息?问题就来了,我再发这个消息,万一它又给 FrancisQ 的账户加上 500 积分怎么办呢?所以我们需要给我们的消费者实现幂等,也就是对同一个消息的处理结果,执行多少次都不变。

那么如何给业务实现幂等呢?这个还是需要结合具体的业务的。你可以使用写入 Redis 来保证,因为 Redis 的 key 和 value 就是天然支持幂等的。当然还有使用数据库插入法,基于数据库的唯一键来保证重复数据不会被插入多条。不过最主要的还是需要根据特定场景使用特定的解决方案,你要知道你的消息消费是否是完全不可重复消费还是可以忍受重复消费的,然后再选择强校验和弱校验的方式。毕竟在 CS 领域还是很少有技术银弹的说法。

简单了来说,幂等的校验,还是需要业务方来支持,因为你解决不了网络抖动问题哈~~

7.4.3. 分布式事务

如何解释分布式事务呢?事务大家都知道吧?要么都执行要么都不执行。在同一个系统中我们可以轻松地实现事务,但是在分布式架构中,我们有很多服务是部署在不同系统之间的,而不同服务之间又需要进行调用。比如此时我下订单然后增加积分,如果保证不了分布式事务的话,就会出现A系统下了订单,但是B系统增加积分失败或者A系统没有下订单,B系统却增加了积分。前者对用户不友好,后者对运营商不利,这是我们都不愿意见到的。那么,如何去解决这个问题呢?

如今比较常见的分布式事务实现有 2PC、TCC 和事务消息(half 半消息机制)。每一种实现都有其特定的使用场景,但是也有各自的问题,都不是完美的解决方案。在 RocketMQ 中使用的是事务消息加上事务反查机制来解决分布式事务问题的。

在这里插入图片描述
上图的执行流程:

  • A服务先发送个Half Message给Brock端,消息中携带 B服务 即将要+100元的信息。
  • 当A服务知道Half Message发送成功后,那么开始第3步执行本地事务。
  • 执行本地事务(会有三种情况1、执行成功。2、执行失败。3、网络等原因导致没有响应)
  • 如果本地事务成功,那么Product像Brock服务器发送Commit,这样B服务就可以消费该message。
  • 如果本地事务失败,那么Product像Brock服务器发送Rollback,那么就会直接删除上面这条半消息。
  • 如果因为网络等原因迟迟没有返回失败还是成功,那么会执行RocketMQ的回调接口,来进行事务的回查。

7.4.4. 消息堆积

消息中间件的主要功能是异步解耦,还有个重要功能是挡住前端的数据洪峰,保证后端系统的稳定性,这就要求消息中间件具有一定的消息堆积能力,消息堆积分以下两种情况:

  • 消息堆积在内存Buffer,一旦超过内存Buffer,可以根据一定的丢弃策略来丢弃消息,如CORBA Notification规范中描述。适合能容忍丢弃消息的业务,这种情况消息的堆积能力主要在于内存Buffer大小,而且消息堆积后,性能下降不会太大,因为内存中数据多少对于对外提供的访问能力影响有限。
  • 消息堆积到持久化存储系统中,例如DB,KV存储,文件记录形式。当消息不能在内存Cache命中时,要不可避免的访问磁盘,会产生大量读IO,读IO的吞吐量直接决定了消息堆积后的访问能力。

评估消息堆积能力主要有以下四点:

  • 消息能堆积多少条,多少字节?即消息的堆积容量。
  • 消息堆积后,发消息的吞吐量大小,是否会受堆积影响?
  • 消息堆积后,正常消费的Consumer是否会受影响?
  • 消息堆积后,访问堆积在磁盘的消息时,吞吐量有多大?

简单来说,RocketMQ支持大量消息堆积,消息会存在内存,超出内存的消息会持久化到磁盘中。

7.4.5. 定时消息

定时消息是指消息发到Broker后,不能立刻被Consumer消费,要到特定的时间点或者等待特定的时间后才能被消费。如果要支持任意的时间精度,在Broker层面,必须要做消息排序,如果再涉及到持久化,那么消息排序要不可避免的产生巨大性能开销。RocketMQ支持定时消息,但是不支持任意时间精度,支持特定的level,例如定时5s,10s,1m等。

简单来说,RocketMQ支持定时消息,但是只支持固定时间,不支持任意精度时间。

7.4.6. 回溯消费

7.4.6.1. 同步刷盘和异步刷盘

在这里插入图片描述
如上图所示,在同步刷盘中需要等待一个刷盘成功的 ACK,同步刷盘对 MQ 消息可靠性来说是一种不错的保障,但是性能上会有较大影响,一般地适用于金融等特定业务场景。而异步刷盘往往是开启一个线程去异步地执行刷盘操作。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了 MQ 的性能和吞吐量,一般适用于如发验证码等对于消息保证要求不太高的业务场景。一般地,异步刷盘只有在 Broker 意外宕机的时候会丢失部分数据,你可以设置 Broker 的参数 FlushDiskType 来调整你的刷盘策略(ASYNC_FLUSH 或者 SYNC_FLUSH)。

简单来说,同步刷盘是刷盘后请求再返回,异步刷盘是直接返回请求,再去慢慢刷盘,可能会导致数据丢失。

7.4.6.2. 同步复制和异步复制

上面的同步刷盘和异步刷盘是在单个结点层面的,而同步复制和异步复制主要是指的 Borker 主从模式下,主节点返回消息给客户端的时候是否需要同步从节点。

  • 同步复制:也叫 “同步双写”,也就是说,只有消息同步双写到主从结点上时才返回写入成功。
  • 异步复制:消息写入主节点之后就直接返回写入成功。异步复制会不会也像异步刷盘那样影响消息的可靠性呢?答案是不会的,因为两者就是不同的概念,对于消息可靠性是通过不同的刷盘策略保证的,而像异步同步复制策略仅仅是影响到了可用性。为什么呢?其主要原因是 RocketMQ 是不支持自动主从切换的,当主节点挂掉之后,生产者就不能再给这个主节点生产消息了。比如这个时候采用异步复制的方式,在主节点还未发送完需要同步的消息的时候主节点挂掉了,这个时候从节点就少了一部分消息。但是此时生产者无法再给主节点生产消息了,消费者可以自动切换到从节点进行消费(仅仅是消费),所以在主节点挂掉的时间只会产生主从结点短暂的消息不一致的情况,降低了可用性,而当主节点重启之后,从节点那部分未来得及复制的消息还会继续复制。

扩展知识1:在单主从架构中,如果一个主节点挂掉了,那么也就意味着整个系统不能再生产了。那么这个可用性的问题能否解决呢?一个主从不行那就多个主从的呗,别忘了在我们最初的架构图中,每个 Topic 是分布在不同 Broker 中的。但是这种复制方式同样也会带来一个问题,那就是无法保证严格顺序。在上文中我们提到了如何保证的消息顺序性是通过将一个语义的消息发送在同一个队列中,使用 Topic 下的队列来保证顺序性的。如果此时我们主节点 A 负责的是订单 A 的一系列语义消息,然后它挂了,这样其他节点是无法代替主节点A的,如果我们任意节点都可以存入任何消息,那就没有顺序性可言了。(这点我并不认同,我理解主从的对列信息应该是一样的,我从主节点读到哪里,如果主节点挂掉,应该是可以到从结点去读取的,如果不能这样,搞个主从就完全没有意义了。因为主从的信息是一样的,对队列的顺序是内有影响的,我不可能把不同的信息,搞两个队列,分别放到主从机器。)

扩展知识2:在 RocketMQ 中采用了 Dledger 解决主从数据同步问题。他要求在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客⼾端返回写⼊成功,并且它是⽀持通过选举来动态切换主节点的。这里我就不展开说明了,读者可以自己去了解。也不是说 Dledger 是个完美的方案,至少在 Dledger 选举过程中是无法提供服务的,而且他必须要使用三个节点或以上,如果多数节点同时挂掉他也是无法保证可用性的,而且要求消息复制板书以上节点的效率和直接异步复制还是有一定的差距的。

7.4.7.容错机制

在实际使用RocketMQ的时候我们并不能保证每次发送的消息都刚好能被消费者一次性正常消费成功,可能会存在需要多次消费才能成功或者一直消费失败的情况,那作为发送者该做如何处理呢?

RocketMQ提供了ack机制,以保证消息能够被正常消费。发送者为了保证消息肯定消费成功,只有使用方明确表示消费成功,RocketMQ才会认为消息消费成功。中途断电,抛出异常等都不会认为成功——即都会重新投递。当然,如果消费者不告知发送者我这边消费信息异常,那么发送者是不会知道的,所以消费者在设置监听的时候需要给个回调。

为了保证消息是肯定被至少消费成功一次,RocketMQ会把这批消息重发回Broker(topic不是原topic而是这个消费租的RETRY topic),在延迟的某个时间点(默认是10秒,业务可设置)后,再次投递到这个ConsumerGroup。而如果一直这样重复消费都持续失败到一定次数(默认16次),就会投递到DLQ死信队列。应用可以监控死信队列来做人工干预。

简单来说,通过ACK保证消息一定能正常消费,对于异常消息,会重新放回Broker,但是这样就会打乱消息的顺序,所以容错机制和消息严格顺序,鱼和熊掌不可兼得。

7.5. 特性分析

这里才是内容的重点,不仅需要知道RocketMQ的特性,还需要知道支持这些特性的原因:

  • 消息路由(不支持):RocketMQ在处理消息之前是不允许消费者过滤一个主题中的消息。一个订阅的消费者在没有异常情况下会接受一个队列中的所有消息;
  • 消息有序(部分支持):需要将同一类的消息hash到同一个队列Queue中,才能支持消息的顺序,如果同一类消息散落到不同的Queue中,就不能支持消息的顺序,如果设定消息一定要正常消费,那么就不能保证消息顺序。
  • 消息时序(可以支持):可以发送定时消息,但是只能制定系统定义好的时间,不支持自定义时间;
  • 容错处理(支持):通过ACK机制,保证消息一定能正常消费,这个和RabbitMQ很像;
  • 伸缩(支持):整体架构其实和kafaka很像,可以扩容broker和内部队列数,或者增加消费组中的消费组数量,提高消费能力。
  • 持久化(支持):消息可以持久化到磁盘中,所以支持消息的回溯,和kafaka很像。
  • 消息回溯(支持):因为消息支持持久化,就支持回溯,可以理解是附带的功能。
  • 高吞吐(非常好):借鉴kafaka的设计,不会出现rabbitMQ的单Master抗压力问题,可以从多个borker写入和消费消息。

8. RabbitMQ

8.0. AMQP

AMQP和JMS一样,也是一个消息规范。你可能会想,已经有了JMS(Java Message Service) 。为什么还需要一个AMQP。当然是因为AMQP具备了更多优势了。

AMQP 支持跨语言跨平台。AMQP为消息定义了线路层的协议,而JMS所定义的API的规范。这就表示,JMS的API协议能够保证所有的实现都通过通用的API来使用,但是不能保证某个JMS所发送的消息能被另外不同的JMS实现所使用。而AMQP的线路层协议规范了消息的格式,消息在生产者和消费者间传递的时候会遵循这个协议。
AMQP 具有更加灵活和透明的消息模型。对于JMS,仅有点对点和发布-订阅两种模式消息模型可选。而AMQP通过将消息生产者和存放消息的队列解耦实现了包含但不局限于这两种的消息模型。
在JMS中,通道有助于解耦消息的生产者和消费者,但是这两者依然会和通道相耦合。生产者将消息发布到一个特定的队列或者主题中,消费者从特定的队列或主题中接收消息。通道具有了双重责任。而与之不同的是AMQP的生产者并不会直接将消息发布到队列中,AMQP在消息的生产者以及传递信息的队列之间引入了一种间接的机制:Exchange。消息的生产者将信息发布到一个Exchange上。Exchange会绑定到一个或多个队列上,他负责将信息路由到队列上。信息的消费者会从队列中提取数据并进行处理。

AMQP 和 JMS 的区别:

  • JMS是定义了统一的接口,来对消息操作进行统一;AMQP是通过规定协议来统一数据交互的格式
  • JMS限定了必须使用Java语言;AMQP只是协议,不规定实现方式,因此是跨语言的。
  • JMS规定了两种消息模型;而AMQP的消息模型更加丰富

AMQP协议的本质就是如下图所示:

在这里插入图片描述
AMQP协议是具有现代特征的二进制协议。是一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。先了解一下AMQP协议中间的几个重要概念:

  • Broker:我们知道如果要使用RabbitMQ,必须先要安装一个RabbitMQ服务。这个服务就是Broker,中文叫做代理,因为MQ服务器帮我们对消息做了存储和转发。一般情况下为了保证服务的高可用,需要多个Broker。
  • Server:接收客户端的连接,实现AMQP实体服务。
  • Connection:无论生产者或是服务者都需要与Broker建立连接,这个连接就是Connection,它是一个TCP的长连接。
  • Channel:信道,消息读写等操作在信道中进行。为了节省服务器资源,避免建立太多的长连接,我们需要复用连接。于是就有了Channel的概念。我们可以在保持的TCP长连接里面去创建和释放Channel,从而减少资源的消耗。其中Channel是相互隔离的,不能共享。
  • Message:消息,应用程序和服务器之间传送的数据,消息可以非常简单,也可以很复杂。由Properties和Body组成。Properties为外包装,可以对消息进行修饰,比如消息的优先级、延迟等高级特性;Body就是消息体内容。
  • Virtual Host:虚拟主机,用于逻辑隔离。一个虚拟主机里面可以有若干个Exchange和Queue,同一个虚拟主机里面不能有相同名称的Exchange或Queue。
  • Exchange:交换器,接收消息,按照路由规则将消息路由到一个或者多个队列。如果路由不到,或者返回给生产者,或者直接丢弃。RabbitMQ常用的交换器常用类型有direct、topic、fanout、headers四种,后面详细介绍。
  • Binding:绑定,交换器和消息队列之间的虚拟连接,绑定中可以包含一个或者多个RoutingKey。
  • RoutingKey:路由键,生产者将消息发送给交换器的时候,会发送一个RoutingKey,用来指定路由规则,这样交换器就知道把消息发送到哪个队列。路由键通常为一个“.”分割的字符串,例如“com.rabbitmq”。
  • Queue:消息队列,用来保存消息,供消费者消费。连接到Broker以后,就可以收发消息了。在Broker上有一个对象用来存储消息,在RabbitMQ里就是Queue。实际上消息是存储在数据库里的,叫做Mnesia。
    Queue是生产者与消费者的纽带,生产者发送的消息到达队列,在队列中存储,消费者从队列中消费消息。

8.1. 基本概念

RabbitMQ 是一个由 Erlang 语言开发的 AMQP 的开源实现。Erlang是为电话交换机编写的语言,天然对分布式和高并发支持良好。还支持很多其它协议:XMPP, SMTP, STOMP,也正是如此,使的它变的非常重量级,更适合于企业级的开发。同时实现了一个经纪人(Broker)构架,这意味着消息在发送给客户端时先在中心队列排队。对路由(Routing),负载均衡(Load balance)或者数据持久化都有很好的支持。

RabbitMQ 最初起源于金融系统,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现非常优异。具体有以下特点:

  • 可靠性(Reliability)消息服务的可靠性是MQ的基础,RabbitMQ 使用一些机制来保证可靠性,如持久化、传输确认、发布确认等。
  • 灵活的路由(Flexible Routing)在消息进入队列之前,通过 Exchange 来路由消息的。对于典型的路由功能,RabbitMQ 已经提供了一些内置的 Exchange 来实现。针对更复杂的路由功能,可以将多个 Exchange 绑定在一起,也通过插件机制实现自己的 Exchange,下文会有详细的描述。
  • 消息集群(Clustering)多个 RabbitMQ 服务器可以组成一个集群,形成一个逻辑 Broker ,从而提高服务的可用性。
  • 高可用(Highly Available Queues)队列可以在集群中的机器上进行镜像,使得在部分节点出问题的情况下队列仍然可用,从而提高服务的可用性。
  • 多种协议(Multi-protocol)RabbitMQ 支持多种消息队列协议,比如 STOMP、MQTT 等等,应用场景丰富。
  • 多语言客户端(Many Clients)RabbitMQ 几乎支持所有常用语言,比如 Java、.NET、Ruby 等等,使用便捷,提升易用性。
  • 管理界面(Management UI)RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker 的许多方面,从而减小系统的运维成本,提升运维能力。
  • 跟踪机制(Tracing)如果消息异常,RabbitMQ 提供了消息跟踪机制,使用者可以找出发生了什么,便于排查问题。
  • 插件机制(Plugin System)RabbitMQ 提供了许多插件,来从多方面进行扩展,也可以编写自己的插件。

8.2. 系统架构

AMQP协议模型由三部分组成:生产者、消费者和服务端。生产者是投递消息的一方,首先连接到Server,建立一个连接,开启一个信道;然后生产者声明交换器和队列,设置相关属性,并通过路由键将交换器和队列进行绑定。同理,消费者也需要进行建立连接,开启信道等操作,便于接收消息。接着生产者就可以发送消息,发送到服务端中的虚拟主机,虚拟主机中的交换器根据路由键选择路由规则,然后发送到不同的消息队列中,这样订阅了消息队列的消费者就可以获取到消息,进行消费。
在这里插入图片描述
总结一下整体过程:生产者投递消息 -> 和Server建立连接,开启信道 -> 声明交换器和队列,并通过路由键将交换机和队列绑定 -> 投递消息到虚拟主机 -> 消息发送到消息队列 -> 消费者建立连接 -> 消费消息 -> 关系信道和连接。

8.3. 常用交换器

RabbitMQ常用的交换器类型有direct、topic、fanout、headers四种:

  • Direct Exchange:见文知意,直连交换机意思是此交换机需要绑定一个队列,要求该消息与一个特定的路由键完全匹配。简单点说就是一对一的,点对点的发送。
    在这里插入图片描述
  • Fanout Exchange:这种类型的交换机需要将队列绑定到交换机上。一个发送到交换机的消息都会被转发到与该交换机绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。简单点说就是发布订阅。
    在这里插入图片描述
  • Topic Exchange:直接翻译的话叫做主题交换机,如果从用法上面翻译可能叫通配符交换机会更加贴切。这种交换机是使用通配符去匹配,路由到对应的队列。通配符有两种:“*” 、 “#”。需要注意的是通配符前面必须要加上".“符号。
    *符号:有且只匹配一个词。比如 a.*可以匹配到"a.b”、“a.c”,但是匹配不了"a.b.c"。
    #符号:匹配一个或多个词。比如"rabbit.#“既可以匹配到"rabbit.a.b”、“rabbit.a”,也可以匹配到"rabbit.a.b.c"。
    在这里插入图片描述
  • Headers Exchange:这种交换机用的相对没这么多。它跟上面三种有点区别,它的路由不是用routingKey进行路由匹配,而是在匹配请求头中所带的键值进行路由。创建队列需要设置绑定的头部信息,有两种模式:全部匹配和部分匹配。如上图所示,交换机会根据生产者发送过来的头部信息携带的键值去匹配队列绑定的键值,路由到对应的队列。
    在这里插入图片描述

8.4. 消费原理

先看几个基本概念:

  • broker:每个节点运行的服务程序,功能为维护该节点的队列的增删以及转发队列操作请求。
  • master queue:每个队列都分为一个主队列和若干个镜像队列。
  • mirror queue:镜像队列,作为master queue的备份。在master queue所在节点挂掉之后,系统把mirror queue提升为master queue,负责处理客户端队列操作请求。注意,mirror queue只做镜像,设计目的不是为了承担客户端读写压力。

集群中有两个节点,每个节点上有一个broker,每个broker负责本机上队列的维护,并且borker之间可以互相通信。集群中有两个队列A和B,每个队列都分为master queue和mirror queue(备份)。那么队列上的生产消费怎么实现的呢?

在这里插入图片描述
对于消费队列,如下图有两个consumer消费队列A,这两个consumer连在了集群的不同机器上。RabbitMQ集群中的任何一个节点都拥有集群上所有队列的元信息,所以连接到集群中的任何一个节点都可以,主要区别在于有的consumer连在master queue所在节点,有的连在非master queue节点上。

因为mirror queue要和master queue保持一致,故需要同步机制,正因为一致性的限制,导致所有的读写操作都必须都操作在master queue上(想想,为啥读也要从master queue中读?和数据库读写分离是不一样的),然后由master节点同步操作到mirror queue所在的节点。即使consumer连接到了非master queue节点,该consumer的操作也会被路由到master queue所在的节点上,这样才能进行消费。

![在这里插入图片描述](https://img-blog.csdnimg.cn/eafc1532f710490face690bde74fd4cf.png

对于生成队列,原理和消费一样,如果连接到非 master queue 节点,则路由过去。
在这里插入图片描述
所以,到这里小伙伴们就可以看到 RabbitMQ的不足:由于master queue单节点,导致性能瓶颈,吞吐量受限。虽然为了提高性能,内部使用了Erlang这个语言实现,但是终究摆脱不了架构设计上的致命缺陷。

8.5. 高级特性

8.5.1. 过期时间

Time To Live,也就是生存时间,是一条消息在队列中的最大存活时间,单位是毫秒,下面看看RabbitMQ过期时间特性:

  • RabbitMQ可以对消息和队列设置TTL。
  • RabbitMQ支持设置消息的过期时间,在消息发送的时候可以进行指定,每条消息的过期时间可以不同。
  • RabbitMQ支持设置队列的过期时间,从消息入队列开始计算,直到超过了队列的超时时间配置,那么消息会变成死信,自动清除。
  • 如果两种方式一起使用,则过期时间以两者中较小的那个数值为准。
  • 当然也可以不设置TTL,不设置表示消息不会过期;如果设置为0,则表示除非此时可以直接将消息投递到消费者,否则该消息将被立即丢弃。

8.5.2. 消息确认

为了保证消息从队列可靠地到达消费者,RabbitMQ提供了消息确认机制。消费者订阅队列的时候,可以指定autoAck参数,当autoAck为true的时候,RabbitMQ采用自动确认模式,RabbitMQ自动把发送出去的消息设置为确认,然后从内存或者硬盘中删除,而不管消费者是否真正消费到了这些消息。当autoAck为false的时候,RabbitMQ会等待消费者回复的确认信号,收到确认信号之后才从内存或者磁盘中删除消息。

消息确认机制是RabbitMQ消息可靠性投递的基础,只要设置autoAck参数为false,消费者就有足够的时间处理消息,不用担心处理消息的过程中消费者进程挂掉后消息丢失的问题。

8.5.3. 持久化

消息的可靠性是RabbitMQ的一大特色,那么RabbitMQ是如何保证消息可靠性的呢?答案就是消息持久化。持久化可以防止在异常情况下丢失数据。RabbitMQ的持久化分为三个部分:交换器持久化、队列持久化和消息的持久化。

交换器持久化可以通过在声明队列时将durable参数设置为true。如果交换器不设置持久化,那么在RabbitMQ服务重启之后,相关的交换器元数据会丢失,不过消息不会丢失,只是不能将消息发送到这个交换器了。

队列的持久化能保证其本身的元数据不会因异常情况而丢失,但是不能保证内部所存储的消息不会丢失。要确保消息不会丢失,需要将其设置为持久化。队列的持久化可以通过在声明队列时将durable参数设置为true。

设置了队列和消息的持久化,当RabbitMQ服务重启之后,消息依然存在。如果只设置队列持久化或者消息持久化,重启之后消息都会消失。

当然,也可以将所有的消息都设置为持久化,但是这样做会影响RabbitMQ的性能,因为磁盘的写入速度比内存的写入要慢得多。对于可靠性不是那么高的消息可以不采用持久化处理以提高整体的吞吐量。鱼和熊掌不可兼得,关键在于选择和取舍。在实际中,需要根据实际情况在可靠性和吞吐量之间做一个权衡。

8.5.4. 死信队列

当消息在一个队列中变成死信之后,他能被重新发送到另一个交换器中,这个交换器成为死信交换器,与该交换器绑定的队列称为死信队列。消息变成死信有下面几种情况:

  • 消息被拒绝。
  • 消息过期
  • 队列达到最大长度

DLX也是一个正常的交换器,和一般的交换器没有区别,他能在任何的队列上面被指定,实际上就是设置某个队列的属性。当这个队列中有死信的时候,RabbitMQ会自动将这个消息重新发送到设置的交换器上,进而被路由到另一个队列,我们可以监听这个队列中消息做相应的处理。

死信队列有什么用?当发生异常的时候,消息不能够被消费者正常消费,被加入到了死信队列中。后续的程序可以根据死信队列中的内容分析当时发生的异常,进而改善和优化系统。

8.5.5. 延迟队列

一般的队列,消息一旦进入队列就会被消费者立即消费。延迟队列就是进入该队列的消息会被消费者延迟消费,延迟队列中存储的对象是的延迟消息,“延迟消息”是指当消息被发送以后,等待特定的时间后,消费者才能拿到这个消息进行消费。

延迟队列用于需要延迟工作的场景。最常见的使用场景:淘宝或者天猫我们都使用过,用户在下单之后通常有30分钟的时间进行支付,如果这30分钟之内没有支付成功,那么订单就会自动取消。除了延迟消费,延迟队列的典型应用场景还有延迟重试。比如消费者从队列里面消费消息失败了,可以延迟一段时间以后进行重试。

8.6. 特性分析

不仅需要知道Rabbit的特性,还需要知道支持这些特性的原因:

  • 消息路由(支持):RabbitMQ可以通过不同的交换器支持不同种类的消息路由;
  • 消息有序(不支持):当消费消息时,如果消费失败,消息会被放回队列,然后重新消费,这样会导致消息无序;
  • 消息时序(非常好):通过延时队列,可以指定消息的延时时间,过期时间TTL等;
  • 容错处理(非常好):通过交付重试和死信交换器(DLX)来处理消息处理故障;
  • 伸缩(一般):伸缩其实没有非常智能,因为即使伸缩了,master queue还是只有一个,负载还是只有这一个master queue去抗,所以我理解RabbitMQ的伸缩很弱(个人理解)。
  • 持久化(不太好):没有消费的消息,可以支持持久化,这个是为了保证机器宕机时消息可以恢复,但是消费过的消息,就会被马上删除,因为RabbitMQ设计时,就不是为了去存储历史数据的。
  • 消息回溯(不支持):因为消息不支持永久保存,所以自然就不支持回溯。
  • 高吞吐(中等):因为所有的请求的执行,最后都是在master queue,它的这个设计,导致单机性能达不到十万级的标准。

8.7. 金融宠儿

  1. 消息如何保障100%的投递成功?

    要保证消息能100%成功,分为这4个步骤:

    • 保障消息的生产者成功发出

    • 保障MQ服务端的成功接收

    • 发送端收到MQ服务端(Broker)确认应答
      如果没有受到确认应答怎么办?所以还需要第4步:

    • 完善的消息进行补偿机制

    那具体怎么做呢:

    方案一: 消息落库,对消息状态进行打标

    在发送消息的时候,需要将消息持久化到数据库中,并给这个消息设置一个状态(未发送、发送中、到达)。当消息状态发生了变化,需要对消息做一个变更。针对没有到达的消息做一个轮训操作,重新发送。对轮训次数也需要做一个限制3-5次。确保消息能够成功地发送。
    在这里插入图片描述
    这种方案对数据有两次入库,一次业务数据入库,一次消息入库。这样对数据的入库是一个瓶颈。其实我们只需要对业务进行入库。除此之外,在极端情况下会出现重复发送情况,比如第一次发送后一直没有接收到响应,这时候补偿任务扫描到没有响应重新发送,而这时候收到了第一次发送的响应。

    方案二:消息的延迟投递,做二次确认,回调检查

    具体内容如下图:
    在这里插入图片描述
    step1:业务消息入库成功后,第一次消息发送。

    step2:同样在消息入库成功后,发送第二次消息,这两条消息是同时发送的。第二条消息是延迟检查,可以设置2min、5min 延迟发送。

    step3:消费端监听指定队列。

    step4:消费端处理完消息后,内部生成新的消息send confirm。投递到MQ Broker。

    step5: Callback Service 回调服务监听MQ Broker,如果收到Downstream service发送的消息,则可以确定消息发送成功,执行消息存储到MSG DB。

    step6:Check Detail检查监听step2延迟投递的消息。此时两个监听的队列不是同一个,5分钟后,Callback service收到消息,检查MSG DB。如果发现之前的消息已经投递成功,则不需要做其他事情。如果检查发现失败,则Callback 进行补偿,主动发送RPC 通信。通知上游生产端重新发送消息。

    这样做的目的:少做了一次DB存储。关注点并不是百分百的投递成功,而是性能。

  2. 如何保证消息幂等

    幂等(idempotent、idempotence)是一个数学与计算机学概念。简单的来说就是一个操作多次执行产生的结果与一次执行产生的结果一致。

    在高并发的情况下,会有大量的消息到达MQ,消费端需要监听大量的消息。这样的情况下,难免会出现消息的重复投递,网络闪断等等。如果不去做幂等,则会出现消息的重复消费。消费端实现幂等性,就意味着,我们的消息永远不会被消费多次,即使我们收到了多条一样的消息,也只会执行一次。

    方案一:唯一ID+指纹码机制

    唯一ID + 指纹码机制,利用数据库主键去重,保证唯一性!

    好处:实现简单
    坏处:高并发下有数据库写入的性能瓶颈
    方案二:Redis 原子特性实现

    最简单使用Redis的自增。

    好处:实现简单,实现唯一ID
    坏处:引入新的组件,可靠性方案需要保障
    3.如何实现延时消息

    比如淘宝里下单之后,15分钟未支付则订单会自动关闭,如何实现呢?这就涉及到延时消息了。我们在下单之后创建一个15分钟的延时消息,到15分钟后再收到一个订单关闭的消息处理订单关闭的逻辑即可。

    那么如何实现一个延时消息呢?这里就要提到RabbitMQ里的死信队列(Dead Letter Queue)了。

    死信队列

    队列有一个消息过期的属性,通过这个设置这个属性,超过了指定时间的消息将会被丢弃。就像过期的密码将会失效一样。这个属性就是:x-messaage-ttl。所有的队列中的消息超时后未被消费都会过期。这样的范围比较大,有没有更细粒度的超时设置呢?所以就有了消息的过期属性,可以针对具体消息设置过期时间。

    有了过期时间,过期后要怎么处理呢?直接丢弃?默认情况下是直接丢弃,只是我们再也消费不到。如果我们还想之后消费呢?显然不能丢弃。那怎么处理呢?于是就有了死信队列!可以理解为一个容器,将过期的消息都遣送到这个容器中。

    队列在创建的时候可以指定一个死信交换机DLX(Dead Letter Exchange)。死信交换机绑定的队列称为死信队列DLQ(Dead Letter Queue),路由到DLQ之后,我们就可以消费了。其实DLX、DLQ与普通的交换机、队列没有任何区别。

    整个过程如下:
    在这里插入图片描述
    通过设置原始队列或者消息的过期时间,然后不对原始队列进行消费,到达时间后原始队列的消息过期后,会发送到死信队列。通过消费死信队列中的消息,来执行响应的处理,这样就达到了数据延时任务的效果。

  3. 消费端限流

    rabbitmq 服务器上有上万条未处理的消息,我们随便打开一个消费者的客户端,会出现下面的情况, 巨量的消息瞬间全部推送过来,但是我们的单个客户端无法同时处理这么多数据,这时候就需要在消费端进行限流操作,以此来保障消费端服务的稳定性。

    RabbitMq 提供了一种qos(服务质量保证) 功能, 即在非自动确认消息的前提下,如果一定数目的消息(通过基于consume或者channel设置Qos 的值)未被确认前,不进行消费新的消息。如果是在限流的情况下,一定不能设置自动签收,autoAck = false; 一定要手动地进行消费。如果自动签收,就达不到限流的效果了。

参考:https://blog.csdn.net/qq_36882793/article/details/101756920

9. 跋

如何保证高可用的?

RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ 为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。

单机模式,就是 Demo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式

普通集群模式,意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的 queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。

镜像集群模式:这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

Kafka 一个最基本的架构认识:由多个 broker 组成,每个 broker 是一个节点;你创建一个 topic,这个 topic 可以划分为多个 partition,每个 partition 可以存在于不同的 broker 上,每个 partition 就放一部分数据。这就是天然的分布式消息队列,就是说一个 topic 的数据,是分散放在多个机器上的,每个机器就放一部分数据。Kafka 0.8 以后,提供了 HA 机制,就是 replica(复制品) 副本机制。每个 partition 的数据都会同步到其它机器上,形成自己的多个 replica 副本。所有 replica 会选举一个 leader 出来,那么生产和消费都跟这个 leader 打交道,然后其他 replica 就是 follower。写的时候,leader 会负责把数据同步到所有 follower 上去,读的时候就直接读 leader 上的数据即可。只能读写 leader?很简单,要是你可以随意读写每个 follower,那么就要 care 数据一致性的问题,系统复杂度太高,很容易出问题。Kafka 会均匀地将一个 partition 的所有 replica 分布在不同的机器上,这样才可以提高容错性。因为如果某个 broker 宕机了,没事儿,那个 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来,大家继续读写那个新的 leader 即可。这就有所谓的高可用性了。写数据的时候,生产者就写 leader,然后 leader 将数据落地写本地磁盘,接着其他 follower 自己主动从 leader 来 pull 数据。一旦所有 follower 同步好数据了,就会发送 ack 给 leader,leader 收到所有 follower 的 ack 之后,就会返回写成功的消息给生产者。(当然,这只是其中一种模式,还可以适当调整这个行为)消费的时候,只会从 leader 去读,但是只有当一个消息已经被所有 follower 都同步成功返回 ack 的时候,这个消息才会被消费者读到。

5、如何保证消息的可靠传输?如果消息丢了怎么办

数据的丢失问题,可能出现在生产者、MQ、消费者中

生产者丢失:生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。吞吐量会下来,因为太耗性能。所以一般来说,如果你要确保说写 RabbitMQ 的消息别丢,可以开启confirm模式,在生产者那里设置开启confirm模式之后,你每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给你回传一个ack消息,告诉你说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调你一个nack接口,告诉你这个消息接收失败,你可以重试。而且你可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么你可以重发。事务机制和cnofirm机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是confirm机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息RabbitMQ 接收了之后会异步回调你一个接口通知你这个消息接收到了。所以一般在生产者这块避免数据丢失,都是用confirm机制的。

MQ中丢失:就是 RabbitMQ 自己弄丢了数据,这个你必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。设置持久化有两个步骤:创建 queue 的时候将其设置为持久化,这样就可以保证 RabbitMQ 持久化 queue 的元数据,但是不会持久化 queue 里的数据。第二个是发送消息的时候将消息的 deliveryMode 设置为 2,就是将消息设置为持久化的,此时 RabbitMQ 就会将消息持久化到磁盘上去。必须要同时设置这两个持久化才行,RabbitMQ 哪怕是挂了,再次重启,也会从磁盘上重启恢复 queue,恢复这个 queue 里的数据。持久化可以跟生产者那边的confirm机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者ack了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到ack,你也是可以自己重发的。注意,哪怕是你给 RabbitMQ 开启了持久化机制,也有一种可能,就是这个消息写到了 RabbitMQ 中,但是还没来得及持久化到磁盘上,结果不巧,此时 RabbitMQ 挂了,就会导致内存里的一点点数据丢失。

消费端丢失:你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ 认为你都消费了,这数据就丢了。这个时候得用 RabbitMQ 提供的ack机制,简单来说,就是你关闭 RabbitMQ 的自动ack,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里ack一把。这样的话,如果你还没处理完,不就没有ack?那 RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。

在这里插入图片描述

不同的产品之所以存在,自然有它的道理,也就是应用场景,对于消息队列,无非就是安全和吞吐量,吞吐量意味着只能分布式,便会衍生可靠性,可用性等各种集群问题

吞吐量:Kafka几乎是最高的,基本为是RabbitMQ的15倍。
系统延迟:Kafka 在较高的吞吐量下提供了最低的延迟,同时还提供了强大的持久性和高可用性。RabbitMQ 可以实现比 Kafka 更低的端到端延迟(在吞吐量低很多的情况下),可以到微妙级别。
安全:MQ自然是最为安全的,这也是导致其吞吐量有限的根本原因。

没有完美的框架,鱼与熊掌不可得兼~

MQ的优点不言而喻,特殊场景下有其对应的好处,解耦、异步、削峰。缺点自然也有:

  • 系统可用性降低 系统引入的外部依赖越多,越容易挂掉。万一 MQ 挂了,MQ 一挂,整套系统崩溃,你不就完了?
  • 系统复杂度提高 硬生生加个 MQ 进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?问题一大堆。
  • 一致性问题 A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了。

Kafka、ActiveMQ、RabbitMQ、RocketMQ 都有什么区别?

  • 对于吞吐量来说kafka和RocketMQ支撑高吞吐,ActiveMQ和RabbitMQ比他们低一个数量级。对于延迟量来说RabbitMQ是最低的。前者高吞吐,后者低延迟且安全。

  • 社区活跃度:按照目前网络上的资料,RabbitMQ 、activeM 、ZeroMQ 三者中,综合来看,RabbitMQ 是首选。

  • 持久化消息:ActiveMq 和RabbitMq 都支持。持久化消息主要是指我们机器在不可抗力因素等情况下挂掉了,消息不会丢失的机制。

  • 综合技术实现:可靠性、灵活的路由、集群、事务、高可用的队列、消息排序、问题追踪、可视化管理工具、插件系统等等。RabbitMq / Kafka 最好,ActiveMq 次之,ZeroMq 最差。当然ZeroMq 也可以做到,不过自己必须手动写代码实现,代码量不小。尤其是可靠性中的:持久性、投递确认、发布者证实和高可用性。

  • 高并发: 毋庸置疑,RabbitMQ 最高,原因是它的实现语言是天生具备高并发高可用的erlang 语言。

RabbitMQ和Kafka的比较

  • RabbitMq 比Kafka 成熟,在可用性上,稳定性上,可靠性上, RabbitMq 胜于 Kafka (理论上)。-
  • Kafka 的定位主要在日志等方面, 因为Kafka 设计的初衷就是处理日志的,可以看做是一个日志(消息)系统一个重要组件,针对性很强,

rabbitMQ在吞吐量方面稍逊于kafka,他们的出发点不一样,rabbitMQ支持对消息的可靠的传递,支持事务,不支持批量的操作;基于存储的可靠性的要求存储可以采用内存或者硬盘。所以如果业务方面还是建议选择 RabbitMq 。还有就是,Kafka 的性能(吞吐量、TPS )比RabbitMq 要高出来很多。

实际场景选择
在实际⽣产应⽤中,通常会使⽤kafka作为消息传输的数据管道,rabbitmq作为交易数据作为数据传输管道,主要的取舍因素则是是否存在
丢数据的可能;rabbitmq在⾦融场景中经常使⽤,具有较⾼的严谨性,数据丢失的可能性更⼩,同事具备更⾼的实时性;⽽kafka优势主要
体现在吞吐量上,虽然可以通过策略实现数据不丢失,但从严谨性⾓度来讲,⼤不如rabbitmq;⽽且由于kafka保证每条消息最少送达⼀
次,有较⼩的概率会出现数据重复发送的情况;

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lipviolet

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值