A系统发送数据到BCD三个系统,通过接口调用发送。如果E系统也要这个数据呢?那如果C系统现在不需要了呢?那A系统就会频繁修改代码,但是实际上这是不合理的,A系统只负责提供基本数据,根本不需要关心数据有哪些系统使用,更不应该关心数据被如何使用,接口调用是否成功等。引入MQ之后,A系统作为生产者将消息发送到MQ指定的topic中,任何需要该数据的系统自行订阅topic,消费消息即可。这样一来,A系统就成功和其他系统解耦了,只负责提供数据,完全不用关系数据被谁使用,是否调用成功等。
再来看一个场景,A系统接收一个请求,需要在自己本地写库,还需要在BCD三个系统写库,自己本地写库要 3ms,BCD三个系统分别写库要300ms、450ms、200ms。最终请求总延时是3+300+450+200=953ms,接近1s,用户感觉搞个什么东西,慢死了慢死了。用户通过浏览器发起请求,等待个1s,这几乎是不可接受的。如果使用 MQ,那么A系统连续发送 3 条消息到MQ队列中,假如耗时5ms,A系统从接受一个请求到返回响应给用户,总时长是3+5=8ms,对于用户而言,其实感觉上就是点个按钮,8ms以后就直接返回了,系统的体验十分丝滑。
假设一个场景,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系统也绝对不会挂掉。
特性 | ActiveMQ | RabbitMQ | RocketMQ | Kafka |
---|---|---|---|---|
单机吞吐量 | 万级,比 RocketMQ、Kafka 低一个数量级 | 同 ActiveMQ | 10 万级,支撑高吞吐 | 10 万级,高吞吐,一般配合大数据类的系统来进行实时数据计算、日志采集等场景 |
topic 数量对吞吐量的影响 | topic 可以达到几百/几千的级别,吞吐量会有较小幅度的下降,这是 RocketMQ 的一大优势,在同等机器下,可以支撑大量的 topic | topic 从几十到几百个时候,吞吐量会大幅度下降,在同等机器下,Kafka 尽量保证 topic 数量不要过多,如果要支撑大规模的 topic,需要增加更多的机器资源 | ||
时效性 | ms 级 | 微秒级,这是 RabbitMQ 的一大特点,延迟最低 | ms 级 | 延迟在 ms 级以内 |
可用性 | 高,基于主从架构实现高可用 | 同 ActiveMQ | 非常高,分布式架构 | 非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 |
消息可靠性 | 有较低的概率丢失数据 | 基本不丢 | 经过参数优化配置,可以做到 0 丢失 | 同 RocketMQ |
功能支持 | MQ 领域的功能极其完备 | 基于 erlang 开发,并发能力很强,性能极好,延时很低 | MQ 功能较为完善,还是分布式的,扩展性好 | 功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用 |
顺便说一下集群模式中消费者既可以链接到RabbitMQ的主节点也可以连接到镜像节点,但是数据的读写都是通过主节点完成的,即使连接到镜像节点,读写数据时镜像节点也会路由到主节点完成数据的读写。
以RocketMQ为例,RocketMQ会为每一条消息生成一个全局的唯一MessageId,正常情况下通过MessageId进行区分,避免投递重复的消息。出现重复消息大概可以归纳为以下三种场景:
当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。
当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。
消息的幂等性主要还是要靠业务系统自行保证,同时重复消费其实并不可怕,核心问题是业务系统如何保证消费的幂等性。这个就要根据实际情况具体分析了,常见的思路如下:
以RocketMQ为例
生产者在将消息发送给RocketMQ时,因为网络问题或者其他什么原因导致消息丢失。此时可以选择开启事务消息,在生产者发送数据之前,开启RocketMQ事务,同时实现RocketMQLocalTransactionListener
接口,检查消息正确性并设置事务状态,如果消息正确则将事务状态设置为COMMIT
,事务消息成功提交,生产者发送消息成功。如果消息错误则将事务状态设置为ROLLBACK
,生产者会收到异常报错,此时就可以回滚事务。
这种情况基本必须要开始RocketMQ持久化,消息发送到RocketMQ服务后,会持久化到磁盘,哪怕RocketMQ宕机了,回复之后也会重新加载存储的数据。只有一种极端情况,就是RocketMQ服务接收到消息后,还没来得及持久化到磁盘,这个时候服务宕机了,恢复之后也只有之前持久化的数据,本次的数据还是丢失了,但是这种情况概率很小,RocketMQ有高可用的架构设计,稳定性还是可以保证的,主要还是考虑业务系统丢消息的情况。
消费者在消费消息时,刚获取到消息,还没消费完,这个时候消费者的进程挂了,这时候就会导致消息丢失。这种情况只需要开启手动ack即可,只有当消息消费成功才返回ack,RocketMQ默认就是手动ack,DefaultRocketMQListenerContainer
类中try-catch
了真正handleMessage()
的过程,只有消费成功才会返回SUCCESS
,发生异常就会RECONSUME_LATER
,消息会重新进入消息队列。对于RabbitMQ需要手动开启ack。
还是以RocketMQ为例,由于RocketMQ的分布式设计,顺序性包含两个层面:分区顺序性和全局顺序性。分区顺序性:每个分区内部的queue中的消息保证顺序性,整个topic所有的消息依然是无序的,而对于全局顺序性只需要设置1个topic只有1个queue即可,保证queue内部的顺序性即可,因此核心逻辑和大部分使用场景还是分区顺序性。分区顺序性主要包含以下三个方面:
生产者发送消息是只需要指定特定的hashKey
即可,对于同样的hashKey
RocketMQ会自动将消息分配到同一个分区的队列中,这样就可以保证RocketMQ接收到的消息严格按照生产者的发送顺序。
MQ服务接收到消息后,需要进行存储,这一阶段RocketMQ在同一个分区内部是天然的接收到的消息的顺序存储的,严格保证了存储的顺序性。
其实顺序性问题最难最核心的就是消费的顺序性,在实际的使用场景中消费者往往是集群的,即使是单节点的服务,往往为了提高吞吐量也会开启多线程进行消费,此时,即使保证了消息发送的顺序性和存储的顺序性,由于多线程消费时每个线程耗费的时间不一致,导致仍然可能出现无序消费的情况。所以要保证消费的顺序性核心思想就是要确保同一个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为例,步骤如下:
上面是我遇到的一个真实的场景,当时积压了大概200多万的消息,整体处理下来,大概耗时1小时左右。看到这个时效有人不禁要问了,上面说的理论决策也是1个小时,这种方案并没有提高多少效率啊,只看时间确实没有什么优势,但是这种处理方案,原来的消费者服务和MQ服务仍然可以正常使用,不会造成MQ拒绝写入消息的问题,同时我所说的耗时1小时是包含处理消费者无法消费消息以及临时扩建队列等耗时的。
其实这个问题就是对前面几个问题的一个总结,上面介绍了MQ的可伸缩性、可用性、可靠性等问题,目前主流的MQ基本都满足这三个要素,因此我们要想自己实现一个消息队列,最低也要支持三个核心要素:
可伸缩性肯定是要优先考虑的,也就是在业务量增加的情况下快速扩容就能提高吞吐量。这个很简单,可以参考一下RocketMQ的分布式架构设计。同一个topic根据broker的数量进行均匀分区,每个broker上都有n个queue,每条消息都会均匀的路由到每个queue中,后续如果想要提高吞吐量,只需要加机器增加broker的数量即可,伸缩性很好。
高可用现在是各个系统的主要目标之一,因此要想设计一个MQ,高可用也是必须要要满足的条件。目前主流的MQ的高可用设计基本都是基于主从架构的,master挂了之后,slave上仍然有消息,仍然可以对外提供服务。同时还可以像RocketMQ一样,设计一个注册中心的概念,向broker发送心跳,实时检测服务的可用性,以及主从的自动切换。
可靠性也是要保证的,毕竟不能随便就把消息弄丢了吧,所以要支持消息0丢失。要支持0丢失,那么MQ服务肯定是要支持持久化存储数据的。对于生产者来说MQ服务要支持事务消息,保证生产者发消息和业务逻辑在同一个事务中,这样才能保证生产者不丢消息。对于消费者来说,要能够支持手动ack确认消息的消费状态,确保不会因为消费者进程挂掉而丢失消息。
为了优化数据读写的性能,kafka直接利用了操作系统本身的Page Cache,利用操作系统自身的内存而不是JVM的内存空间。相比于使用JVM或in-memory cache等数据结构,利用操作系统的Page Cache更加简单可靠。首先,操作系统层面的缓存利用率会更高,因为存储的都是紧凑的字节结构而不是独立的对象。其次,操作系统本身也对于Page Cache做了大量优化,提供了 write-behind、read-ahead以及flush等多种机制。再者,即使服务进程重启,系统缓存依然不会消失,避免了in-process cache重建缓存的过程。通过操作系统的Page Cache,Kafka的读写操作基本上是基于内存的,读写速度得到了极大的提升。
零拷贝机制,以sendfild为例:
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,**只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。**通过这种 “零拷贝” 的机制,Page Cache 结合 sendfile 方法,Kafka消费端的性能也大幅提升。这也是为什么有时候消费端在不断消费数据时,我们并没有看到磁盘io比较高,此刻正是操作系统缓存在提供数据。
众所周知Kafka是将消息记录持久化到本地磁盘中的,一般情况下,大家都会认为磁盘读写性能差。实际上不管是内存还是磁盘,快或慢关键在于寻址的方式,磁盘分为顺序读写与随机读写,内存也一样分为顺序读写与随机读写。基于磁盘的随机读写确实很慢,但磁盘的顺序读写性能却很高,一些情况下磁盘顺序读写性能甚至要高于内存随机读写。在做顺序读写的时候,磁头几乎不用换道,或者换道的时间很短;而对于随机读写,如果这个 I/O 很多的话,会导致磁头不停地换道,造成效率的极大降低。
Kafka的message是按topic分类存储的,topic中的数据又是按照一个一个的partition即分区存储到不同broker节点。每个partition对应了操作系统上的一个文件夹,partition实际上又是按照segment分段存储的。这也非常符合分布式系统分区分桶的设计思想。通过这种分区分段的设计,Kafka的message消息实际上是分布式存储在一个一个小的segment中的,每次文件操作也是直接操作的segment。为了进一步的查询优化,Kafka又默认为分段后的数据文件建立了索引文件,就是文件系统上的.index文件。这种分区分段+索引的设计,不仅提升了数据读取的效率,同时也提高了数据操作的并行度。
除了利用底层的技术外,Kafka还在应用程序层面提供了一些手段来提升性能。最明显的就是使用批次。在向Kafka写入数据时,可以启用批次写入,这样可以避免在网络上频繁传输单个消息带来的延迟和带宽开销。假设网络带宽为10MB/S,一次性传输10MB的消息比传输1KB的消息10000万次显然要快得多。
在很多情况下,系统的瓶颈不是CPU或磁盘,而是网络IO,对于需要在广域网上的数据中心之间发送消息的数据流水线尤其如此。进行数据压缩会消耗少量的CPU资源,不过对于kafka而言,网络IO更应该需要考虑。
Kafka速度的秘诀在于,它把所有的消息都变成一个批量的文件,并且进行合理的批量压缩,减少网络IO损耗,通过mmap提高I/O速度,写入数据的时候由于单个Partion是末尾添加所以速度最优;读取数据的时候配合sendfile直接暴力输出。