消息中间件(MQ)总结
- 总介绍
- Kafka
- 1、broker
- 2、topic
- 3、Partitions
- 4、Producer
- 5、Consumer
- 6、消息发送语义
- 7、可用性
- 8、一致性
- 总结
- RabbitMQ
- 1、基本架构及概念
- 2、Queue
- 3、通信过程
- 4、持久化
- 消息什么时候需要持久化?
- 消息什么时候会刷到磁盘?
- 文件何时删除?
- 参考链接
总介绍
1、为什么要用消息队列
- 实现分布式系统之间的解耦调用。
在分布式系统中,经常会出现一个服务会有多个消费端调用,而且可能每个消费方需要接入的逻辑不一致,又或者随着项目的不断发展,我们需要接口的未来维护和发展确定一套可扩展的规范,引入消息系统,在处理过程中间插入了一个隐含的、基于数据的接口层,两边的处理过程都要实现这一接口。这允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。
- 实现异步调用
有时候我们会遇到这样的场景,用户在客户端提交了一个请求,后端处理这个请求的业务相对比较复杂,如果这个请求使用的是同步调用,客户端就会出现发送请求后过了很久才相应的情况,这对用户体验来说是十分致命的。如果说用户并不关心请求是否处理,对于一些耗时的非事务性的业务处理,我们可以使用mq异步请求的方式,将处理信息放入队列,由后端监听队列自行处理,在将消息存入队列后直接返回相应客户端,加快响应速度。
- 削峰,解决高并发问题
例如秒杀活动,可能在短时间内会有很大请求同时到后端,如果后端对每个请求都执行业务操作,例如查询数据库和写数据库,会造成服务器压力过大,同时,在同一时间进行大量数据库操作,可能会出现数据异常,我们可以使用mq实现缓冲,将所有请求先放入消息队列中,服务端每次处理业务先从消息队列获取
2、使用mq的缺点
- 系统可用性降低
本来其他系统只要运行好好的,那你的系统就是正常的。现在你非要加个消息队列进去,那消息队列挂了,你的系统不是呵呵了。因此,系统可用性降低,这个问题我们可以通过部署高可用mq解决,但这又会引发下面的问题.
- 系统复杂性增加
要多考虑很多方面的问题,比如一致性问题、如何保证消息不被重复消费,如何保证保证消息可靠传输,出现问题需要排查的范围扩大。因此,需要考虑的东西更多,系统复杂性增大。
Kafka
1、broker
即中间的kafka集群,也可指单个kafka服务器或者多个server组成的集群,用于存储消息。
- broker 接收来自生产者的消息,并将消息落地到磁盘保存
- broker 接收来自消费者的请求,返回已经落地到磁盘的消息
- broker 负责 “消息保留策略”:当消息超过保留时间,或者消息的总量达到大小限制,旧的消息将过期并且被删除。
2、topic
kafka给消息提供的分类方式。broker用来存储不同topic的消息数据。
- kafka将所有消息组织成多个topic的形式存储,而每个topic又可以拆分成多个partition,每个partition又由一个一个消息组成。每个消息都被标识了一个递增序列号(也被称为offset)代表其进来的先后顺序,并按顺序存储在partition中。
- producer选择一个topic,生产消息,消息会通过分配策略append到某个partition末尾。
- consumer选择一个topic,通过id指定从哪个位置开始消费消息。消费完成之后保留id,下次可以从这个位置开始继续消费,也可以从其他任意位置开始消费。
这样设计产生 以下好处:
- 消费者可以根据需求,灵活指定offset消费。
- 保证了消息不变性,为并发消费提供了线程安全的保证。每个consumer都保留自己的offset,互相之间不干扰,不存在线程安全问题。
- 消息访问的并行高效性。每个topic中的消息被组织成多个partition,partition均匀分配到集群server中。生产、消费消息的时候,会被路由到指定partition,减少竞争,增加了程序的并行能力。
- 增加消息系统的可伸缩性。每个topic中保留的消息可能非常庞大,通过partition将消息切分成多个子消息,并通过负责均衡策略将partition分配到不同server。这样当机器负载满的时候,通过扩容可以将消息重新均匀分配。
- 保证消息可靠性。消息消费完成之后不会删除,可以通过重置offset重新消费,保证了消息不会丢失。
- 灵活的持久化策略。可以通过指定时间段(如最近一天)来保存消息,节省broker存储空间。
- 备份高可用性。消息以partition为单位分配到多个server,并以partition为单位进行备份。备份策略为:1个leader和N个followers,leader接受读写请求,followers被动复制leader。leader和followers会在集群中打散,保证partition高可用。
3、Partitions
每个Topics划分为一个或者多个Partition,并且Partition中的每条消息都被标记了一个递增id ,也就是offset。并且存储的每条消息数据是可配置存储时间。【如果消息的保存策略被设置为2天,那么在一个消息被发布的两天时间内,它都是可以被消费的,甚至可以消费多次。】
我们来思考一下,如果一个partition只有一个数据文件会怎么样?
- 新数据是添加在文件末尾(调用FileMessageSet的append方法),不论文件数据文件有多大,这个操作永远都是O(1)的。
- 查找某个offset的Message(调用FileMessageSet的searchFor方法)是顺序查找的。因此,如果数据文件很大的话,查找的效率就低。
那Kafka是如何解决查找效率的的问题呢?有两大法宝:①分段②索引。
数据文件的分段
Kafka解决查询效率的手段之一是将数据文件分段,比如有100条Message,它们的offset是从0到99。
假设将数据文件分成5段,第一段为0-19,第二段为20-39,以此类推,每段放在一个单独的数据文件里面,数据文件以该段中最小的offset命名。这样在查找指定offset的Message的时候,用二分查找就可以定位到该Message在哪个段中。
为数据文件建索引
数据文件分段使得可以在一个较小的数据文件中查找对应offset的Message了,但是这依然需要顺序扫描才能找到对应offset的Message。
为了进一步提高查找的效率,Kafka为每个分段后的数据文件建立了索引文件,文件名与数据文件的名字是一样的,只是文件扩展名为.index。
索引文件中包含若干个索引条目,每个条目表示数据文件中一条Message的索引。索引包含两个部分(均为4个字节的数字),分别为相对offset和position。
- 相对offset:因为数据文件分段以后,每个数据文件的起始offset不为0,相对offset表示这条Message相对于其所属数据文件中最小的offset的大小。举例,分段后的一个数据文件的offset是从20开始,那么offset为25的Message在index文件中的相对offset就是25-20 = 5。存储相对offset可以减小索引文件占用的空间。
- position:表示该条Message在数据文件中的绝对位置。只要打开文件并移动文件指针到这个position就可以读取对应的Message了。
index文件中并没有为数据文件中的每条Message建立索引,而是采用了稀疏存储的方式,每隔一定字节的数据建立一条索引。这样避免了索引文件占用过多的空间,从而可以将索引文件保留在内存中。
但缺点是没有建立索引的Message也不能一次定位到其在数据文件的位置,但是kafka索引中可以通过索引类的lookup方法【用二分查找的方式去查找小于或等于给定offset中最大的那个offset】再做一次顺序扫描,但是这次顺序扫描的范围就很小了。
4、Producer
往broker中某个topic里面生产数据。
我们以几张图来总结一下Message是如何在Kafka中存储的,以及如何查找指定offset的Message的。
Message是按照topic来组织,每个topic可以分成多个的partition,比如:有5个partition的名为为page_visits的topic的目录结构为:
partition是分段的,每个段叫LogSegment,包括了一个数据文件和一个索引文件,下图是某个partition目录下的文件:
可以看到,这个partition有4个LogSegment。借用博主@lizhitao博客上的一张图来展示是如何查找Message的。
比如:要查找绝对offset为7的Message:
- 首先是用二分查找确定它是在哪个LogSegment中,自然是在第一个Segment中。
- 打开这个Segment的index文件,也是用二分查找找到offset小于或者等于指定offset的索引条目中最大的那个offset。自然offset为6的那个索引是我们要找的,通过索引文件我们知道offset为6的Message在数据文件中的位置为9807。
- 打开数据文件,从位置为9807的那个地方开始顺序扫描直到找到offset为7的那条Message。
这套机制是建立在offset是有序的的基础上。索引文件被映射到内存中,所以查找的速度还是很快的。
一句话,Kafka的Message存储采用了分区(partition),分段(LogSegment)和稀疏索引这几个手段来达到了高效性。
5、Consumer
从broker中某个topic获取数据。传统消息系统有两种模式:①队列②发布订阅
kafka通过consumer group将两种模式统一处理:每个consumer将自己标记consumer group名称,之后系统会将consumer group按名称分组,将消息复制并分发给所有分组,每个分组只有一个consumer能消费这条消息。如下图:
为什么说是统一处理呢,因为consumer group存在两个极端情况:
- 当所有consumer的consumer group相同时,系统变成队列模式
- 当每个consumer的consumer group都不相同时,系统变成发布订阅
注意:
- consumer groups 提供了topics和partitions的隔离, 如上图consumer groups A中的consumer-C2挂掉,consumer-C1会接收P1,P2,即一个consumer Group中有其他consumer挂掉后能够重新平衡。如下图:
2. 多consumer并发消费消息时,容易导致消息乱序,通过限制消费者为同步,可以保证消息有序,但是这大大降低了程序的并发性。kafka通过partition的概念,保证了partition内消息有序性,缓解了上面的问题。partition内消息会复制分发给所有consumer groups,每个consumer groups只有一个consumer能消费这条消息。这个语义保证了某个分组消费某个分区的消息,是同步而非并发的。如果一个topic只有一个partition,那么这个topic并发消费有序,否则只是单个partition有序【因为只有一个队列】。
一般消息系统,consumer存在两种消费模型:
- push:优势在于消息实时性高。劣势在于没有考虑consumer消费能力和饱和情况,容易导致producer压垮consumer。
- pull:优势在可以控制消费速度和消费数量,保证consumer不会出现饱和。劣势在于当没有数据,会出现空轮询,消耗cpu。
kafka采用pull,并采用可配置化参数保证当存在数据并且数据量达到一定量的时候,consumer端才进行pull操作,否则一直处于block状态。kakfa采用整数值consumer position来记录单个分区的消费状态,并且单个分区单个消息只能被consumer group内的一个consumer消费,维护简单开销小。消费完成,broker收到确认,position指向下次消费的offset。由于消息不会删除,在完成消费,position更新之后,consumer依然可以重置offset重新消费历史消息。
6、消息发送语义
producer视角
- 消息最多发送一次:producer异步发送消息,或者同步发消息但重试次数为0。
- 消息至少发送一次:producer同步发送消息,失败、超时都会重试。
- 消息发且仅发一次:后续版本支持。
consumer视角
- 消息最多消费一次:consumer先读取消息,再确认position,最后处理消息。
- 消息至少消费一次:consumer先读取消息,再处理消息,最后确认position。
- 消息消费且仅消费一次。
注意:
- 如果消息处理后的输出端(如db)能保证消息更新幂等性,则多次消费也能保证exactly once语义。
- 如果输出端能支持两阶段提交协议,则能保证确认position和处理输出消息同时成功或者同时失败。
- 在消息处理的输出端存储更新后的position,保证了确认position和处理输出消息的原子性。
7、可用性
在kafka中,正常情况下所有node处于同步中状态,当某个node处于非同步中状态,也就意味着整个系统出问题,需要做容错处理。
同步中代表了:
- 该node与zookeeper能连通。
- 该node如果是follower,那么consumer position与leader不能差距太大(差额可配置)。
ISR即某个分区内同步中的node组成的一个集合。
kafka通过两个手段容错:
- 数据备份:以partition为单位备份,副本数可设置。当副本数为N时,代表1个leader,N-1个followers,followers可以视为leader的consumer,拉取leader的消息,append到自己的系统中
- failover:
1. 当leader处于非同步中时,系统从followers中选举新leader
2. 当某个follower状态变为非同步中时,leader会将此follower剔除ISR,当此follower恢复并完成数据同步之后再次进入 ISR。
另外,kafka有个保障:当producer生产消息时,只有当消息被所有ISR确认时,才表示该消息提交成功。只有提交成功的消息,才能被consumer消费。因此,当有N个副本时,N个副本都在ISR中,N-1个副本都出现异常时,系统依然能提供服务。
假设N副本全挂了,node恢复后会面临同步数据的过程,这期间ISR中没有node,会导致该分区服务不可用。kafka采用一种降级措施来处理:选举第一个恢复的node(不一定是ISR中的)作为leader提供服务,以它的数据为基准,这个措施被称为脏leader选举【选择第一个“活”过来的node为Leader,而这个node不一定是ISR中的Replica,就说明它并不保证已经包含了所有已commit的消息】。
由于leader是主要提供服务的,kafka broker将多个partition的leader均分在不同的server上以均摊风险。每个parition都有leader,如果在每个partition内运行选主进程,那么会导致产生非常多选主进程。kakfa不采用parition上的选主进程,采用一种轻量级的方式:从broker集群中选出一个作为controller,这个controller监控挂掉的broker,为上面的分区批量选主。
8、一致性
上面的方案保证了数据高可用,有时高可用是体现在对一致性的牺牲上。如果希望达到强一致性,可以采取如下措施:
- 禁用脏leader选举,即等待ISR中的任一个Replica“活”过来,并且选它作为Leader。但如果ISR没有node时,宁可不提供服务也不要未完全同步的node【然而脏选举才是默认设置】。
- 设置最小ISR数量min_isr,保证消息至少要被min_isr个node确认才能提交。
总结
基于以下几点事实,kafka重度依赖磁盘而非内存来存储消息。
- 硬盘便宜,内存贵
- 顺序读+预读取操作,能提高缓存命中率
- 操作系统利用富余的内存作为pagecache,配合预读取(read-ahead)+写回(write-back)技术,从cache读数据,写到cache就返回(操作系统后台flush),提高用户进程响应速度
- java对象实际大小比理想大小要大,使得将消息存到内存成本很高
- 当堆内存占用不断增加时,gc抖动较大
- 基于文件顺序读写的设计思路,代码编写简单
- 在持久化数据结构的选择上,kafka采用了queue而不是Btree
- kafka只有简单的根据offset读和append操作,所以基于queue操作的时间复杂度为O(1),而基于Btree操作的时间复杂度为O(logN)
- 在大量文件读写的时候,基于queue的read和append只需要一次磁盘寻址,而Btree则会涉及多次。磁盘寻址过程极大降低了读写性能
对于一些常规的消息系统,kafka是个不错的选择,因为kafka具有良好的扩展性和性能优势。不过kafka并没有提供JMS中的事务性、消息传输担保(消息确认机制)、消息分组等企业级特性,kafka只能使用作为"常规"的消息系统,在一定程度上,尚未确保消息的发送与接收绝对可靠(比如,消息重发,消息发送丢失等)
因此kafka可以作为网站活性跟踪的最佳工具【可以将网页/用户操作等信息发送到kafka中,并实时监控,或者离线统计分析等】;或者作为日志收集中心,可以将操作日志批量异步的发送到kafka集群中,而不是保存在本地或者DB中。
kafka可以批量提交消息/压缩消息等,这对producer端而言,几乎感觉不到性能的开支。此时consumer端可以使用hadoop等其他系统化的存储和分析系统
RabbitMQ
1、基本架构及概念
组成部分说明如下:
- Broker :消息队列服务进程,即rabbitMQ server,此进程包括两个部分:Exchange和Queue。
- Exchange :消息队列交换机,按一定的规则将消息路由转发到某个队列,对消息进行过滤。
- Queue :消息队列,存储消息的队列,消息到达队列并转发给指定的消费方。
- Producer :消息生产者,即生产方客户端,生产方客户端将消息发送到MQ。
- Consumer :消息消费者,即消费方客户端,接收MQ转发的消息。
- Connection :mq连接,是生产者消费者中间的桥梁。对于RabbitMQ而言,其实就是一个位于客户端和Broker之间的TCP连接。
- Channel :会话,每次生产者或消费者与rabbitmq的连通发送消息就是一次会话,一个客户端存在多次会话
2、Queue
消息队列,提供了FIFO的处理机制,具有缓存消息的能力。rabbitmq中,队列消息可以设置为持久化,临时或者自动删除。
- 设置为持久化的队列,queue中的消息会在server本地硬盘存储一份,防止系统crash,数据丢失
- 设置为临时队列,queue中的数据在系统重启之后就会丢失
- 设置为自动删除的队列,当不存在用户连接到server,队列中的数据会被自动删除
3、通信过程
假设P1和C1注册了相同的Broker,Exchange和Queue。P1发送的消息最终会被C1消费。基本的通信流程大概如下所示:
- P1生产消息,发送给服务器端的Exchange
- Exchange收到消息,根据ROUTINKEY,将消息转发给匹配的Queue1
- Queue1收到消息,将消息发送给订阅者C1
- C1收到消息,发送ACK给队列确认收到消息
- Queue1收到ACK,删除队列中缓存的此条消息
Consumer收到消息时需要显式的向rabbit broker发送basic.ack消息或者consumer订阅消息时设置auto_ack参数为true。在通信过程中,队列对ACK的处理有以下几种情况:
- 如果consumer接收了消息,发送ack,rabbitmq会删除队列中这个消息,发送另一条消息给consumer。
- 如果cosumer接受了消息, 但在发送ack之前断开连接,rabbitmq会认为这条消息没有被deliver,在consumer在次连接的时候,这条消息会被redeliver。
- 如果consumer接受了消息,但是程序中有bug,忘记了ack,rabbitmq不会重复发送消息。
- rabbitmq2.0.0和之后的版本支持consumer reject某条(类)消息,可以通过设置requeue参数中的reject为true达到目地,那么rabbitmq将会把消息发送给下一个注册的consumer。
4、持久化
RabbitMQ有一个消息确认机制来保证消息的不丢失:客户端从队列中取出消息之后,可能需要一段时间才能处理完成。如果在这个过程中,客户端出错了,异常退出了,而数据还没有处理完成,那么非常不幸,这段数据就丢失了,因为RabbitMQ默认会把此消息标记为已完成,然后从队列中移除。消息确认是客户端从RabbitMQ中取出消息,并处理完成之后,会发送一个ack告诉RabbitMQ,消息处理完成,当RabbitMQ收到客户端的获取消息请求之后,或标记为处理中,当再次收到ack之后,才会标记为已完成,然后从队列中删除。当RabbitMQ检测到客户端和自己断开链接之后,还没收到ack,则会重新将消息放回消息队列,交给下一个客户端处理,保证消息不丢失,也就是说,RabbitMQ给了客户端足够长的时间来做数据处理。
消息什么时候需要持久化?
根据 官方博文 的介绍,RabbitMQ在两种情况下会将消息写入磁盘:
- 消息本身在publish的时候就要求消息写入磁盘;
- 内存紧张,需要将部分内存中的消息转移到磁盘;
消息什么时候会刷到磁盘?
- 写入文件前会有一个Buffer,大小为1M(1048576),数据在写入文件时,首先会写入到这个Buffer,如果Buffer已满,则会将Buffer写入到文件(未必刷到磁盘);
- 有个固定的刷盘时间:25ms,也就是不管Buffer满不满,每隔25ms,Buffer里的数据及未刷新到磁盘的文件内容必定会刷到磁盘;
- 每次消息写入后,如果没有后续写入请求,则会直接将已写入的消息刷到磁盘:使用Erlang的receive x after 0来实现,只要进程的信箱里没有消息,则产生一个timeout消息,而timeout会触发刷盘操作。
文件何时删除?
- 当所有文件中的垃圾消息(已经被删除的消息)比例大于阈值(GARBAGE_FRACTION = 0.5)时,会触发文件合并操作(至少有三个文件存在的情况下),以提高磁盘利用率。
- publish消息时写入内容,ack消息时删除内容(更新该文件的有用数据大小),当一个文件的有用数据等于0时,删除该文件。
参考链接
- 为什么要使用mq
- kafka工作原理介绍
- Kafka的Log存储解析
- RabbitMQ入门
- RabbitMQ的架构
- RabbitMQ原理
- RabbitMQ之消息持久化