一、消息队列的演进

分布式消息队列中间件是大型分布式系统中常见的中间件。消息队列主要用来解决应用耦合异步消息流量削峰等问题,具有高性能、高可用、可伸缩和最终一致性等特点。消息队列已经逐渐成为企业应用系统内部通信的核心手段,使用较多的消息队列有RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、Pulsar等,此外,利用数据库(如Redis、MySQL等)也可实现消息队列的部分基本功能。

1.基于OS的MQ

单机消息队列可以通过操作系统原生的进程间通信机制来实现,如消息队列、共享内存等。比如我们可以在共享内存中维护一个双端队列,消息产出进程不停地往队列里添加消息,同时消息消费进程不断地从队尾取出这些消息。添加消息的任务称为producer,取出消息的称为consumer。 单机MQ易于实现,但是缺点明显:依赖于单机OS的IPC(进程间通信)机制。无法实现分布式的消息传递,并且消息队列的容量也受限于单机资源

2.基于DB的MQ

使用存储组件(如mysql、redis等)存储消息,然后在消息的生产侧和消费侧实现消息的生产消费逻辑,从而实现MQ功能。以redis为例,可以使用Redis自带的list实现。Redis list使用lpush命令,从队列左边插入数据;使用rpop命令,从队列右边取出数据。 与单机MQ相比,该方案至少满足了分布式,但是仍然带有很多无法接受的缺陷。

  • 热key性能问题:不论使用codis还是twemproxy这种集群方案,对某个队列的读写请求最终都会落到同一台redis实例上,并且无法通过扩容来解决问题。如果对于某个list的并发读写非常高,就产生了无法解决的热key,严重可能导致系统崩溃。
  • 没有消费确认机制:每当执行rpop消费一条数据,那条消息就被从list中永久删除了。如果消费者消费失败,这条消息也没法找回了。
  • 不支持多订阅者:一条消息只能被一个消费者消费。rpop之后就没了。如果队列中存储的是应用的日志,对于同一条消息,监控系统需要消费它来进行可能的报警,BI系统需要消费它来绘制报表,链路追踪需要消费它来绘制调用关系。。。这种场景redis酒办不到了。
  • 不支持二次消费:一条消息 rpop 之后就没了。如果消费者程序运行到一半发现代码有 bug,修复之后想从头再消费一次就不行了。

3.专用分布式MQ中间件

随着发展,一个真正的消息队列,已经不仅仅是一个队列那么简单了,业务对MQ的吞吐量、扩展性、稳定性、可靠性等都提出了严苛的要求。因此,专用的分布式消息中间件开始大量出现。常见的有 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、Pulsar 等等。

二、消息队列的设计要点

消息队列本质上是一个消息的转发系统,把一次RPC就可以直接完成的消息投递,转换成多次RPC间接完成,这其中包含两个关键环节:

  1. 消息转储
  2. 消息投递:时机和对象 基于此,消息队列的整体设计思路是:
    • 确定整体的数据流向:如producer发送给MQ,MQ转发给consumer,consumer回复消费确认,消息删除、消息备份等。
    • 利用RPC将数据流串起来,最好基于现有的RPC框架,尽量做到无状态、方便水平扩展。
    • 存储选型,综合考虑性能、可靠性和开发维护成本等诸多因素。
    • 消息投递,消费模式push、pull。
    • 消费关系维护,单播,多播等,可以利用zk、config server等保存消费关系。
    • 高级特性,如可靠投递、重复消息,顺序消息等,很多高级特性之间是互相制约的关系,这里要充分结合应用场景做出取舍。 消息队列设计

1.MQ基本特性

RPC通信

MQ组件要实现和生产者以及消费者进行通行功能,这里涉及到RPC通信问题。消息队列的RPC,和普通的RPC没有本质区别。对于负载均衡、服务发现、序列化协议等等问题都可以借助现有RPC框架来实现,避免重复造轮子。

存储系统

有两种:持久化和非持久化 对于要求投递性能的:非持久化存储。消息不落地直接暂存内存,尝试几次failover,最终投递出去也未尝不可。 对于要求消息的可靠性的(断电不丢失):持久化存储。

高可用

MQ的高可用,依赖于RPC和存储的高可用。冗长RPC服务自身都具有服务自动发现,负载均衡等功能,保证了其高可用。 存储的高可用,例如kafka,使用分区加主备模式,保证每一个分区的高可用性,也就是每一个分区至少要有一个备份且需要做数据的同步。

推拉模型

push和pull模型各有利弊

  1. 慢消费 慢消费是push模型最大的致命伤,如果消费者的速度比发送者的速度慢的多,会出现两种恶劣的情况: 1. 消息在broker的堆积。假设这些消息都是有用切无法丢弃的,则这些消息就要一直在broker中保存。 2. broker推送给consumer的消息consumer无法处理,此时consumer只能拒绝或者返回错误。 而pull模式下,consumer可以按需消费,主动去拉消息。而broker堆积消息也会相对简单,无需记录每一个要发送消息的状态,只需维护所有消息的队列和偏移量就可以,所以对于慢消费,消息量有限且到来的速度不均匀的情况,pull模式比较合适。
  2. 消息延迟与忙等 这是pull模式最大的短板。由于主动权在消费方,消费方无法准确地决定何时去拉取最新的消息。如果没有pull到消息,则需要等待一段时间重新pull。

消息投放时机

即消费者应该在什么时机消费消息。有三种方式:

  1. 攒够了一定数量就投放
  2. 到达了一定时间就投放
  3. 有新的数据到来就投放 要根据具体的业务场景来决定选择哪种方式。比如,对及时性要求高的数据,可采用方式3.

消息投放对象

不管是JMS规范中的Topic/Queue,kafka里面的Topic/Partition/ConsumerGroup,还是AMQP(如RabbitMQ)的exchange等等,都是为了维护消息的消费关系而抽象出来的概念。本质上,消息的消费无外乎点到点的一对一单播、或一对多广播。另外比较特殊的场景是组间广播、组内单播。比较通用的设计是,不同的组注册不同的订阅,支持组间广播。组内不同的机器,如果注册一个相同的ID,则单播;如果注册不同的ID(如IP地址+端口号),则广播。 例如pulsar支持订阅模型有:

  • Exclusive:独占性,一个订阅只能有一个消费者消费消息。
  • Failover:灾备型,一个订阅同时只有一个消费者,可以用多个备消费者。一旦主消费者故障则备消费者接管。不会出现同时有两个活跃的消费者。
  • Shared:共享型,一个订阅中同时可以有多个消费者,多个消费者共享topic中的消息。
  • Key_Shared:键共享型,多个消费者各取一部分消息。 通常会在公共存储上维护广播关系,如config server、zookeeper等。

2.队列的高级特性

常见的高级特性有可靠投递、消息丢失、消失重复、事务等等,他们并非是MQ必备的特性。由于这些特性可能是相互制约的,所以不可能完全兼顾。所以要依照业务的需求,来仔细衡量各种特性实现的成本、利弊,最终做出最为合理的设计。

可靠投递

如何保证消息完全不丢失?

直观的方案是,在任何不可靠操作之前,先将消息落地,然后操作。当失败或者不知道结果(比如超时)时,消息状态是待发送,定时任务不停轮询所有待发送消息,最终一定可以送达。但是,这样必然导致消息可能会重复,并且在异常情况下,消息延迟较大。

例如:

  • producer 往 broker 发送消息之前,需要做一次落地。
  • 请求到 server 后,server 确保数据落地后再告诉客户端发送成功。
  • 支持广播的消息队列需要对每个接收者,持久化一个发送状态,直到所有接收者都确认收到,才可删除消息。

即对于任何不能确认消息已送达的情况,都要重推消息。但是,随着而来的问题就是消息重复。在消息重复和消息丢失之间,无法兼顾,要结合应用场景做出取舍。

消费确认

当 broker 把消息投递给消费者后,消费者可以立即确认收到了消息。但是,有些情况消费者可能需要再次接收该消息(比如收到消息、但是处理失败),即消费者主动要求重发消息。所以,要允许消费者 主动进行消费确认。

顺序消息

对于 push 模式,要求支持分区且单分区只支持一个消费者消费,并且消费者只有确认一个消息消费后才能 push 另外一个消息,还要发送者保证发送顺序唯一。

对于 pull 模式,比如 kafka 的做法:

  1. producer 对应 partition,并且单线程。
  2. consumer 对应 partition,消费确认(或批量确认),单线程消费。

但是这样也只是实现了消息的分区有序性,并不一定全局有序。总体而言,要求消息有序的 MQ 场景还是比较少的。

三、kafka

Kafka 是一个分布式发布订阅消息系统。它以高吞吐、可持久化、可水平扩展、支持流数据处理等多种特性而被广泛使用(如 Storm、Spark、Flink)。在大数据系统中,数据需要在各个子系统中高性能、低延迟的不停流转。传统的企业消息系统并不是非常适合大规模的数据处理,但 Kafka 出现了,它可以高效的处理实时消息和离线消息,降低编程复杂度,使得各个子系统可以快速高效的进行数据流转,Kafka 承担高速数据总线的作用。

kafka基础概念

  • Broker kafka集群包含一个或多个服务器,这种服务器被称为broker
  • Topic 在逻辑上可以被认定是一个queue,每条消费都必须指定它的Topic,可以简单理解为必须把这条消息放进哪个queue里。为了使得kafka的吞吐率可以线性提高,物理上把topic分成一个或多个Partition,每个Partition在物理上对应一个文件夹,该文件夹下存储这个Partition的所有消息和索引文件。
  • Partition 物理概念,每个topic包含一个或多个Partition。
  • producer 负责发布消息到kafka broker。
  • consumer 消息消费者,向kafka broker读取消息的客户端。
  • consumer group 每个consumer属于一个特性consumer group(可为每个consumer指定group name,若不指定group name则属于默认的group。)

消息队列设计

一个典型的kafka集群包含若干producer、若干个broker(kafka支持水平扩展)、若干个consumer group,以及一个zookeeper集群。producer使用push模式发布到broker。consumer使用pull模式从broker订阅并消费消息。多个broker协同工作,producer和consumer部署在各个业务逻辑中。kafka通过zookeeper管理集群配置及服务协同。 这样就组成了一个高性能的分布式消息发布和订阅系统。kafka有一个细节是和其他mq中间件不同的点,producer发送消息到broker的过程是push,而consumer从broker消费消息的过程是pull,主动去拉数据。而不是broker把数据主动发送给consumer。 producer发送消息到broker时,会根据Partition机制选择将其存储到哪一个Partition。如果Partition机制设置合理,所有消息可以均匀分不到不同的Partition里,这样就实现了负载均衡。如果一个topic对应一个文件,那这个文件所在的机器io将会成为这个topic的性能瓶颈,而有了Partition后,不同的消息可以并行写入不同broker的不同的Partition里,极大的提高了吞吐率。

kafka特点

优点: - 高性能:单机测试能达到100w tps - 低延时:生产和消费的延时都很低 - 可用性高:replicate+isr+选举机制保证 - 工具链成熟:监控 运维 管理 方案齐全 - 生态成熟:大数据场景必不可少kafka stream 不足: - 无法弹性扩容:对Partition的读写都在partition leader所在的broker,如果该broker压力过大,也无法通过新增broker来解决问题 - 扩容成本高,集群中新增的broker只会处理新topic,如果要分担老topic-partition的压力,需要手动迁移partition,这时会占用大量集群带宽 - 消费者新加入和退出都会造成整个消费组rebalance,导致数据重复消费,影响消费速度,增加延迟。 - partition过多会使得性能显著下降,zk压力大,broker上partition过多让磁盘顺序写几乎退化成随机写。