消息队列重难点问题

一、为什么使用消息队列

1.解耦

A系统发送数据到BCD三个系统,通过接口调用发送。如果E系统也要这个数据呢?那如果C系统现在不需要了呢?那A系统就会频繁修改代码,但是实际上这是不合理的,A系统只负责提供基本数据,根本不需要关心数据有哪些系统使用,更不应该关心数据被如何使用,接口调用是否成功等。引入MQ之后,A系统作为生产者将消息发送到MQ指定的topic中,任何需要该数据的系统自行订阅topic,消费消息即可。这样一来,A系统就成功和其他系统解耦了,只负责提供数据,完全不用关系数据被谁使用,是否调用成功等。

2.异步

再来看一个场景,A系统接收一个请求,需要在自己本地写库,还需要在BCD三个系统写库,自己本地写库要 3ms,BCD三个系统分别写库要300ms、450ms、200ms。最终请求总延时是3+300+450+200=953ms,接近1s,用户感觉搞个什么东西,慢死了慢死了。用户通过浏览器发起请求,等待个1s,这几乎是不可接受的。如果使用 MQ,那么A系统连续发送 3 条消息到MQ队列中,假如耗时5ms,A系统从接受一个请求到返回响应给用户,总时长是3+5=8ms,对于用户而言,其实感觉上就是点个按钮,8ms以后就直接返回了,系统的体验十分丝滑。

3.削峰

假设一个场景,A系统大部分时间风平浪静,QPS基本稳定在50左右,但是12点-14点之间,系统访问量急剧增加,QPS达到了5000,大量的请求涌入 MySQL,每秒钟对MySQL执行约5k条SQL。一般的MySQL基本能扛个2000的QPS就不错了,5000QPS可能直接压垮MySQL导致系统不可用。过了14点之后,系统又恢复风平浪静,QPS又基本维持在50左右。这种场景我们可以考虑引入MQ,来进行削峰。每秒5000个请求写入MQ,A 系统每秒钟最多处理2000个请求,因为MySQL每秒钟最多处理2000个。A系统将消费方式改为拉取消息的模式,从MQ中慢慢拉取请求,每秒钟就拉取2000个请求,不要超过自己每秒能处理的最大请求数量就 ok,这样下来,哪怕是高峰期的时候,A系统也绝对不会挂掉。

特性ActiveMQRabbitMQRocketMQKafka
单机吞吐量万级,比 RocketMQ、Kafka 低一个数量级同 ActiveMQ10 万级,支撑高吞吐10 万级,高吞吐,一般配合大数据类的系统来进行实时数据计算、日志采集等场景
topic 数量对吞吐量的影响topic 可以达到几百/几千的级别,吞吐量会有较小幅度的下降,这是 RocketMQ 的一大优势,在同等机器下,可以支撑大量的 topictopic 从几十到几百个时候,吞吐量会大幅度下降,在同等机器下,Kafka 尽量保证 topic 数量不要过多,如果要支撑大规模的 topic,需要增加更多的机器资源
时效性ms 级微秒级,这是 RabbitMQ 的一大特点,延迟最低ms 级延迟在 ms 级以内
可用性高,基于主从架构实现高可用同 ActiveMQ非常高,分布式架构非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用
消息可靠性有较低的概率丢失数据基本不丢经过参数优化配置,可以做到 0 丢失同 RocketMQ
功能支持MQ 领域的功能极其完备基于 erlang 开发,并发能力很强,性能极好,延时很低MQ 功能较为完善,还是分布式的,扩展性好功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用

二、消息队列高可用架构设计

1.RabbitMQ

在这里插入图片描述
顺便说一下集群模式中消费者既可以链接到RabbitMQ的主节点也可以连接到镜像节点,但是数据的读写都是通过主节点完成的,即使连接到镜像节点,读写数据时镜像节点也会路由到主节点完成数据的读写。

2.Kafka

在这里插入图片描述

3.RocketMQ

在这里插入图片描述
在这里插入图片描述

三、如何保证消息不被重复消费

以RocketMQ为例,RocketMQ会为每一条消息生成一个全局的唯一MessageId,正常情况下通过MessageId进行区分,避免投递重复的消息。出现重复消息大概可以归纳为以下三种场景:

1.发送时消息重复

当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

2.消费时消息重复

消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

3.负载均衡时消息重复(包括但不限于网络抖动、Broker 重启以及订阅方应用重启)

当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。

消息的幂等性主要还是要靠业务系统自行保证,同时重复消费其实并不可怕,核心问题是业务系统如何保证消费的幂等性。这个就要根据实际情况具体分析了,常见的思路如下:

  • 通过唯一键来判断,比如唯一编码,连续出现两个唯一编码相同的消息,但是最后落库只有一条数据,另一条更新或者不处理,这样也是一定程度上的保证了幂等性。
  • 写Redis,Redis每次都是set操作,天然具有幂等性。
  • 在消息中携带一个全局唯一id,消费者每次消费之前都拿全局唯一id去查一下,判断有没有数据、状态是否正确等,只有满足条件才进行处理。

四、如何保证消息传输的可靠性

以RocketMQ为例

1.生产者弄丢消息

生产者在将消息发送给RocketMQ时,因为网络问题或者其他什么原因导致消息丢失。此时可以选择开启事务消息,在生产者发送数据之前,开启RocketMQ事务,同时实现RocketMQLocalTransactionListener接口,检查消息正确性并设置事务状态,如果消息正确则将事务状态设置为COMMIT,事务消息成功提交,生产者发送消息成功。如果消息错误则将事务状态设置为ROLLBACK,生产者会收到异常报错,此时就可以回滚事务。

2.MQ服务自身弄丢消息

这种情况基本必须要开始RocketMQ持久化,消息发送到RocketMQ服务后,会持久化到磁盘,哪怕RocketMQ宕机了,回复之后也会重新加载存储的数据。只有一种极端情况,就是RocketMQ服务接收到消息后,还没来得及持久化到磁盘,这个时候服务宕机了,恢复之后也只有之前持久化的数据,本次的数据还是丢失了,但是这种情况概率很小,RocketMQ有高可用的架构设计,稳定性还是可以保证的,主要还是考虑业务系统丢消息的情况。

3.消费者弄丢消息

消费者在消费消息时,刚获取到消息,还没消费完,这个时候消费者的进程挂了,这时候就会导致消息丢失。这种情况只需要开启手动ack即可,只有当消息消费成功才返回ack,RocketMQ默认就是手动ack,DefaultRocketMQListenerContainer类中try-catch了真正handleMessage()的过程,只有消费成功才会返回SUCCESS,发生异常就会RECONSUME_LATER,消息会重新进入消息队列。对于RabbitMQ需要手动开启ack。

五、如何保证消息的顺序性

还是以RocketMQ为例,由于RocketMQ的分布式设计,顺序性包含两个层面:分区顺序性和全局顺序性。分区顺序性:每个分区内部的queue中的消息保证顺序性,整个topic所有的消息依然是无序的,而对于全局顺序性只需要设置1个topic只有1个queue即可,保证queue内部的顺序性即可,因此核心逻辑和大部分使用场景还是分区顺序性。分区顺序性主要包含以下三个方面:

1.生产者发送消息的顺序性

生产者发送消息是只需要指定特定的hashKey即可,对于同样的hashKeyRocketMQ会自动将消息分配到同一个分区的队列中,这样就可以保证RocketMQ接收到的消息严格按照生产者的发送顺序。

2.MQ服务存储消息的顺序性

MQ服务接收到消息后,需要进行存储,这一阶段RocketMQ在同一个分区内部是天然的接收到的消息的顺序存储的,严格保证了存储的顺序性。

3.消费者消费消息的顺序性

其实顺序性问题最难最核心的就是消费的顺序性,在实际的使用场景中消费者往往是集群的,即使是单节点的服务,往往为了提高吞吐量也会开启多线程进行消费,此时,即使保证了消息发送的顺序性和存储的顺序性,由于多线程消费时每个线程耗费的时间不一致,导致仍然可能出现无序消费的情况。所以要保证消费的顺序性核心思想就是要确保同一个queue同一时间只能有一个线程在消费,对此RocketMQ也有响应的配置,可以通过配置consumeMode=ConsumeMode.ORDERLY来将消费者设置为顺序消费,默认是ConsumeMode.CONCURRENTLY并发消费。顺序消费的核心源码在ConsumeMessageOrderlyService.run()方法中,

try {
	this.processQueue.getLockConsume().lock();
	if (this.processQueue.isDropped()) {
		break;
	}
	status = messageListener.consumeMessage(Collections.unmodifiableList(msgs), context);
} catch (Throwable e) {
	hasException = true;
} finally {
	this.processQueue.getLockConsume().unlock();
}

其本质是采用JUC提供的ReentrantLock对broker的queue进行加锁,保证同一个queue同一时间只能有一个线程在消费,当queue正在被消费时,其他线程由于拿不到锁会阻塞,因此在使用顺序消费时,我们要十分注意处理消费者中的业务逻辑的异常,如果手动捕获了异常却不处理,很可能导致当前队列的锁无法释放。这是RocketMQ底层自身提供的实现,同样的我们也可以自己采用分布式锁来对队列加锁实现消费者的顺序性,反正核心思想就是同一个queue同一时间只能有一个线程在消费

六、如何解决消息积压

消息积压问题一般情况下不会遇到,但是一旦出现就是P0级别的灾难性问题。解决这个问题的理论很简单:无非就是消费者无法正常消费消息才会导致消息积压,因此只要解决消费者无法消费的Bug,然后慢慢的自行消费即可。理论上确实可行,但是考虑一个实际情况:假如现在堆积了500万条消息,而消费者最大的吞吐量只有1000条/秒,即使光速修复了消费者无法消费的问题,那也要1个小时才能消费完积压的消息,同时当MQ的消息总量达到一定比率的时候,MQ一般都会开启保护机制,拒绝写入新的消息,也就是说这种方案只要有1个小时以上MQ是不可用的,这在生产环境当然是不被允许的(为了解决P0级Bug然而又引入了另一个P0级Bug)。

出现这积压问题,实际的解决方案一般是紧急扩容,还是以RocketMQ为例,步骤如下:

  1. 先修复消费者无法消费消息的问题,确保恢复消费速度。(这是必须要解决的问题)
  2. 然后暂时将现有的消费者停掉,新建一个topic,partition是原来的10倍,临时建立原来10倍数量的queue。
  3. 再写一个临时分发消息的消费者程序,这个程序逻辑很简单就是将原来积压的消息直接写入临时建立的10倍数量的queue中,让原来堆积消息的queue能够正常使用。
  4. 接着临时征用10倍的机器部署原来的消费者,消费上面临时建立的10倍queue中的消息。
  5. 最后等积压的消息完全消费完之后,恢复原来的架构部署即可。

上面是我遇到的一个真实的场景,当时积压了大概200多万的消息,整体处理下来,大概耗时1小时左右。看到这个时效有人不禁要问了,上面说的理论决策也是1个小时,这种方案并没有提高多少效率啊,只看时间确实没有什么优势,但是这种处理方案,原来的消费者服务和MQ服务仍然可以正常使用,不会造成MQ拒绝写入消息的问题,同时我所说的耗时1小时是包含处理消费者无法消费消息以及临时扩建队列等耗时的。

七、如果要自己实现一个消息队列,架构设计应该是怎样的

其实这个问题就是对前面几个问题的一个总结,上面介绍了MQ的可伸缩性、可用性、可靠性等问题,目前主流的MQ基本都满足这三个要素,因此我们要想自己实现一个消息队列,最低也要支持三个核心要素:

1.可伸缩性

可伸缩性肯定是要优先考虑的,也就是在业务量增加的情况下快速扩容就能提高吞吐量。这个很简单,可以参考一下RocketMQ的分布式架构设计。同一个topic根据broker的数量进行均匀分区,每个broker上都有n个queue,每条消息都会均匀的路由到每个queue中,后续如果想要提高吞吐量,只需要加机器增加broker的数量即可,伸缩性很好。

2.可用性

高可用现在是各个系统的主要目标之一,因此要想设计一个MQ,高可用也是必须要要满足的条件。目前主流的MQ的高可用设计基本都是基于主从架构的,master挂了之后,slave上仍然有消息,仍然可以对外提供服务。同时还可以像RocketMQ一样,设计一个注册中心的概念,向broker发送心跳,实时检测服务的可用性,以及主从的自动切换。

3.可靠性

可靠性也是要保证的,毕竟不能随便就把消息弄丢了吧,所以要支持消息0丢失。要支持0丢失,那么MQ服务肯定是要支持持久化存储数据的。对于生产者来说MQ服务要支持事务消息,保证生产者发消息和业务逻辑在同一个事务中,这样才能保证生产者不丢消息。对于消费者来说,要能够支持手动ack确认消息的消费状态,确保不会因为消费者进程挂掉而丢失消息。

八、kafka为什么能实现高吞吐量

1.Page Cache

为了优化数据读写的性能,kafka直接利用了操作系统本身的Page Cache,利用操作系统自身的内存而不是JVM的内存空间。相比于使用JVM或in-memory cache等数据结构,利用操作系统的Page Cache更加简单可靠。首先,操作系统层面的缓存利用率会更高,因为存储的都是紧凑的字节结构而不是独立的对象。其次,操作系统本身也对于Page Cache做了大量优化,提供了 write-behind、read-ahead以及flush等多种机制。再者,即使服务进程重启,系统缓存依然不会消失,避免了in-process cache重建缓存的过程。通过操作系统的Page Cache,Kafka的读写操作基本上是基于内存的,读写速度得到了极大的提升。

2. 零拷贝

传统的文件传输流程:
在这里插入图片描述

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

零拷贝机制,以sendfild为例:
在这里插入图片描述
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,**只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。**通过这种 “零拷贝” 的机制,Page Cache 结合 sendfile 方法,Kafka消费端的性能也大幅提升。这也是为什么有时候消费端在不断消费数据时,我们并没有看到磁盘io比较高,此刻正是操作系统缓存在提供数据。

3.顺序读写

众所周知Kafka是将消息记录持久化到本地磁盘中的,一般情况下,大家都会认为磁盘读写性能差。实际上不管是内存还是磁盘,快或慢关键在于寻址的方式,磁盘分为顺序读写与随机读写,内存也一样分为顺序读写与随机读写。基于磁盘的随机读写确实很慢,但磁盘的顺序读写性能却很高,一些情况下磁盘顺序读写性能甚至要高于内存随机读写。在做顺序读写的时候,磁头几乎不用换道,或者换道的时间很短;而对于随机读写,如果这个 I/O 很多的话,会导致磁头不停地换道,造成效率的极大降低。

4.分区分段+索引

Kafka的message是按topic分类存储的,topic中的数据又是按照一个一个的partition即分区存储到不同broker节点。每个partition对应了操作系统上的一个文件夹,partition实际上又是按照segment分段存储的。这也非常符合分布式系统分区分桶的设计思想。通过这种分区分段的设计,Kafka的message消息实际上是分布式存储在一个一个小的segment中的,每次文件操作也是直接操作的segment。为了进一步的查询优化,Kafka又默认为分段后的数据文件建立了索引文件,就是文件系统上的.index文件。这种分区分段+索引的设计,不仅提升了数据读取的效率,同时也提高了数据操作的并行度。

5.批量读写

除了利用底层的技术外,Kafka还在应用程序层面提供了一些手段来提升性能。最明显的就是使用批次。在向Kafka写入数据时,可以启用批次写入,这样可以避免在网络上频繁传输单个消息带来的延迟和带宽开销。假设网络带宽为10MB/S,一次性传输10MB的消息比传输1KB的消息10000万次显然要快得多。

6.批量压缩

在很多情况下,系统的瓶颈不是CPU或磁盘,而是网络IO,对于需要在广域网上的数据中心之间发送消息的数据流水线尤其如此。进行数据压缩会消耗少量的CPU资源,不过对于kafka而言,网络IO更应该需要考虑。

  • 如果每个消息都压缩,但是压缩率相对很低,所以Kafka使用了批量压缩,即将多个消息一起压缩而不是单个消息压缩
  • Kafka允许使用递归的消息集合,批量的消息可以通过压缩的形式传输并且在日志中也可以保持压缩格式,直到被消费者解压缩
  • Kafka支持多种压缩协议,包括Gzip和Snappy压缩协议

Kafka速度的秘诀在于,它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优;读取数据的时候配合sendfile直接暴力输出。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值