文章目录
消息队列生态全景图
消息队列的作用
- 异步处理:有些事情适合在异步去做。
- 流量控制,即削峰填谷
- 服务解耦:
各大消息队列优缺点
RabbitMQ
- 缺点:
- 对消息堆积的支持并不好
- 性能很差。每秒钟可以处理几万到十几万条消息
- Erlang 语言编写
- 优点:
- 轻量级、迅捷
- 支持非常灵活的路由配置
RocketMQ
- 缺点:
- 国产的消息队列,相比国外的比较流行的同类产品,在国际上还没有那么流行,与周边生态系统的集成和兼容程度要略逊一筹
- 优点:
- 响应时延很短,做了很多优化,大多数情况下可以做到毫秒级的响应,如果你的应用场景很在意响应时延,那应该选择使用 RocketMQ 。
- 性能还行。每秒钟大概能处理几十万条消息
kafka
- 缺点:
- Kafka 这种异步批量的设计带来的问题是,它的同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka 并不会立即发送出去,而是要等一会儿攒一批再发送,在它的 Broker 中,很多地方都会使用这种“先攒一波再一起处理”的设计。当你的业务场景中,每秒钟消息数量没有那么多的时候,Kafka 的时延反而会比较高。所以,Kafka 不太适合在线业务场景。
- 优点:
- 性能优越。大量使用了批量和异步的思想,大约每秒钟可以处理几十万条消息
- Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域,几乎所有的相关开源软件系统都会优先支持 Kafka
- Kafka 使用 Scala 和 Java 语言开发
零拷贝示意图
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,可以通过一种方式,直接将数据写入内核或从内核中读取数据,再通过 DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
基础概念
队列模型(点对点)
但是这种方式无法解决:将一份消息数据分发给多个消费者,要求每个消费者都能收到全量的消息,例如,对于一份订单数据,风控系统、分析系统、支付系统等都需要接收消息。这个时候,单个队列就满足不了需求,一个可行的解决方式是,为每个消费者创建一个单独的队列,让生产者发送多份。但是这样又会引来另一些问题:同样的一份消息数据被复制到多个队列中会浪费资源,更重要的是,生产者必须知道有多少个消费者。为每个消费者单独发送一份消息,这实际上违背了消息队列“解耦”这个设计初衷。
发布 - 订阅模型(Publish-Subscribe Pattern)
在发布 - 订阅模型中,消息的发送方称为发布者(Publisher),消息的接收方称为订阅者(Subscriber),服务端存放消息的容器称为主题(Topic)。发布者将消息发送到主题中,订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作,同时还可以认为是主题在消费时的一个逻辑副本,每份订阅中,订阅者都可以接收到主题的所有消息。
RabbitMQ 的消息模型
少数依然坚持使用队列模型的产品之一。那它是怎么解决多个消费者的问题呢?在 RabbitMQ 中,Exchange 位于生产者和队列之间,生产者并不关心将消息发送给哪个队列,而是将消息发送给 Exchange,由 Exchange 上配置的策略来决定将消息投递到哪些队列中。
同一份消息如果需要被多个消费者来消费,需要配置 Exchange 将消息发送到多个队列,每个队列中都存放一份完整的消息数据,可以为一个消费者提供消费服务。这也可以变相地实现新发布 - 订阅模型中,“一份消息数据可以被多个订阅者来多次消费”这样的功能
RocketMQ 的消息模型
所有的消息队列产品都使用一种非常朴素的发送以及 Ack 机制!,确保消息不会在传递过程中由于网络或服务器故障丢失。具体的做法也非常简单。在生产端,生产者先将消息发送给服务端,也就是 Broker,服务端在收到消息并将消息写入主题或者队列中后,会给生产者发送确认的响应。
- 如果生产者没有收到服务端的确认或者收到失败的响应,则会重新发送消息;
- 在消费端,消费者在收到消息并完成自己的消费业务逻辑(比如,将数据保存到数据库中)后,也会给服务端发送消费成功的确认,服务端只有收到消费确认后,才认为一条消息被成功消费,否则它会给消费者重新发送这条消息,直到收到对应的消费成功确认。
这个确认机制很好地保证了消息传递过程中的可靠性,但是,引入这个机制在消费端带来了一个不小的问题。什么问题呢?为了确保消息的有序性,在某一条消息被成功消费之前,下一条消息是不能被消费的,否则就会出现消息空洞,违背了有序性这个原则。
也就是说,每个主题在任意时刻,至多只能有一个消费者实例在进行消费,那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题,RocketMQ 在主题下面增加了队列的概念。
每个主题包含多个队列,通过多个队列来实现多实例并行生产和消费。需要注意的是,RocketMQ 只在队列上保证消息的有序性,主题层面是无法保证消息的严格顺序的。
在 Topic 的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要·RocketMQ 为每个消费组在每个队列上维护一个消费位置(Consumer Offset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位置就加一
。这个消费位置是非常重要的概念,我们在使用消息队列的时候,丢消息的原因大多是由于消费位置处理不当导致的。
Kafka 的消息模型
在 Kafka 中,队列这个概念的名称不一样,Kafka 中对应的名称是“分区(Partition)”,其余含义和功能与RocketMQ对应。
消费者如何消费消息?
有两种模式,一种是 Kafka 推送给所有的消费者,另一种是由消费者拉取消息进行消费。
两者的比较如下:
- 推送模式:
- 基于推送模型的消息系统,由消息代理记录消费者的消费状态。 消息代理在将消息推送到消费者后 ,标记这条消息为已消费,但这种方式无法很好地保证消息的处理语义 。 比如,消 息代理把消息发送出去后,当消费进程挂掉或者由于网络原因没有收到这条消息时,就有可能造成消 息丢失(因为消息代理已经把这条消息标记为己消费了,但实际上这条消息并没有被实际处理)。 如果 要保证消息的处理语义,消息代理发送完消息后,要设置状态为“已发送”,只有收到消费者的确认请 求后才更新为“已消费”,这就
需要在消息代理中记录所有消息的消费状态,这种做法也是不可取的
。 消息代理会在消息被消费之后立即删除消息。 如果有不同类型的消费者订阅同一个主题,消息代理可能露要冗余地存储同一条消息 ; 或者等所有消费者都消费完才删除,这就需要消息代理跟踪每个消费者的消费状态, 这种设计很大程度上限制了消息系统的整体吞吐量和处理延迟
。
- 拉取模式:
- 由消费者自己记录消费状态,每个消费者乎相独立地顺序读取每个分区的 消息。 如图 1-4所示,有两个消费者(不同消费组)拉取同一个主题的消息,消费者A的消费进度是3, 消费者B的消费者进度是6。 消费者拉取的最大上限通过最高水位( watermark)控制,生产者最新写 入的消息如果还没有达到备份数量,对消费者是不可见的 。 这种由消费者控制偏移韭-的优点是 :
消费 者可以按照任意的顺序消费消息 。 比如,消费者可以重置到旧的偏移盏,重新处理之前已经消费过的消息 ;或者直接跳到最近的位置,从当前时刻开始消费
。
如果要保持所有的消息完全有序,只能通过设置一个分区完成
消费者拉取数据
消费者要读取服务端分区的消息则通过拉取管理器的拉取线程完成
拉取管理器
拉取管理器会启动一个后台的 LeaderFinderThread线程 , 不断找出已经存在主副本的分区,被选中的分区会被 加 入对应 的 拉取线程 。
拉取管理器管理所有的拉取线程,而每个拉取线程则管理自己的分区和偏移量
,每个角色都各司其职。 拉取管理器不需要关心底层分区的偏移盘, 拉取线程向己会根据偏移茧,执行分区的拉取任务 。
拉取线程更新拉取偏移量,消费线程更新消费偏移量
消费者提交分区偏移量
消费者提交偏移量是为了保存分区的消费进度 。 因为 Kafka保证同一个分区只会分配给消费组中 的唯一消费者,所以即使发生再平衡后,分区和消费者的所有权关系发生变化,新消费者也可以接着 上一个消费者记录的偏移盘位置继续消费消息 。
- 消息重复的情况:
消息消费的完整流程:寻找分区的主副本,读取分区上一 次的消费进度,拉取消息(构建拉取请求、发送请求) , 读取消息 。
如何寻找的分区的主副本?怎么操作的上一次的消费进度?
Q:如何寻找的分区的主副本?
客户端为了获得分区的主副本,可以向任意一个节点发送主题元数据请求( TopicMetadataRequest ), 因为每个节点都保存了集群所有的主题元数据,而且数据都是一致的。 主题元数据包含了多个分区的元数据,而消费者只指定消费特定的分区,所以需要找出对应的分区元数据 。
读取分区的偏移量涉及日志存储???(TODO:)
消费者线程模型
消费者线程要拉取分区消息,需要确定分区的主副本节点, Kafka针对分区有一个限制条件:客户端针对分区的读写请求,只能发生在分区的主副本上 。(这是为什么呐?为什么读请求不能发送到备副本上?)
消费者的消息处理语义
MQTT 协议
在 MQTT 协议中,给出了三种传递消息时能够提供的服务质量标准,这三种服务质量从低到高依次是:
- At most once: 至多一次 。消息最多被处理一次 , 可能会丢失,但绝不会重复传输 。
- At least once: 至少一次 。消息至少被处理一次,不可能丢失,但可能会重复传输 。
- Exactly once:正好一次 。消息正好被处理一次,不可能丢失,也不可能重复传输 。
至多一次
消费者读取消息, 先保存消费进度,然后才处理消息
。这样有可能会出现:消费者保存完消费进度, 但在处理消息之前挂了
。 新的消费者会从保存的位置开始,但实际上在这个位置之前的消息可能并没有 被真正处理。 这种场景对应了“至多一次”的语义, 即消息、有可能丢失(没有被处理)。 Kafka消费者实现“至多一 次”的做法是 : 设置消费者自动提交偏移量,并且设置较短的提交时间 间隔 。
至少一次
消费者读取消息, 先处理消息,最后才保存消费进度
。 这样有可能会出现 :消费者处理完消息, 但是在保存消费进度之前挂了
。 新的消费者从保存的位置开始,有可能会重新处理上一个消费者已经 处理过的消息。 这种场景对应了“至少一次”的语义, 即消息有可能会被重复处理。 Kafka消费者实 现至少一 次的做法是 :设置消费者自动提交偏移量,但设置很长的提交间隔(或者关闭自动提交偏移 盘)。 在处理完消息后,手动调用同步模式的提交偏移量方法。
- 生产者发送一批消息后出现了网 络故障,有可能服务端已经写成功了,但是生产者由于没有收到服务端的应答,它会
重新发
送这一批 记录。 这种场景下, 虽然消息没有丢失,但是消息会重复写入。
正好一次(主要是如何提交偏移量和重发消息)
如何处理提交偏移量?
其实按道理来讲处理消息与保存消费进度应该是有一个事务操作,只要让其成为一个事务那么就能够实现正好一次的处理模式。因此 此方案有两种实现:
- 在保存消费进度和保存消费结果之间,引入两阶段提交协议。
- 让消费者将消费进度和处理结果保存在同一个存储介质中 。
比如,将读取的数据和偏移量一起存储到HDFS, 确保数据和偏移量要么一起被更新,要么都不会更新。 Kafka消费者 实现正好一次的做法是 :设置消费者不自动提交偏移量,订阅主题时设置自定义的消费者再平衡监昕器( ConsulleRebalancelistener )
,消费者再平衡监昕器会在分区发生变化时,分别从外部存储系统写入或读取偏移量。 只要能保证 消费者处理消息的流程和写入偏移量到存储系统是一个原子操作,就可以实现正好一 次的消息处理语 义。
如何处理重发消息?
如图 10-12所示,生产者发送的每条消息都会添加上序号( sequenceNumber 简称seq ), 并连同生 产者编号( produceId,简称PIO) 一起写人日志文件中。 如果生产者没有收到一条消息的应答,它会重新向分区的主副本发送这条消息。 分区的主副本会判断每条消息是否重复,如果是重复的,分区的主副本不会存储这条消息,而是直接返回结果给生产者,通过这种方式实现消息的去重功能 。
注意这种方式只能保证保证生产者写入同一个分区的消息不会重复。(按道理来讲分区不会重复,那么整体就不会重复吧?)
- 图4-42总结Kafka的消费者和其他组件的关系 。 Kafka消费者主要有拉取器、消费者的协调者两个 主要的类。 拉取器会向服务端拉取消息,消费者的协调者会发送心跳和提交偏移量给服务端的协调者 节点。 属于同一个消费组的所有消费者涉及消费组相关的请求,都会和服务端的协调者节点通信。
Kafka 高级特性-事务处理
版本的生产者还支持“事务语义”:多条消息以原子的方式同时写入多个分区,这些消息作为一个整体的单元,要么全部成功写入,要么写入失败进行回滚
。 生产者在一个事务中写入多条消息时,消费者会依据事务的隔离级别读取事务中的消息。 事务的隔离级别有两种:未提交读( read uncommitted )和提交读( read committed )。 “未提 交读”表示即使事务被中断了,消费者仍然可以读取到没有提交的消息 。 “提交读”表示消费者只能读取到已经提交事务的消息。