-- 基于RocketMQ和Kafka
面试中常见问题
今天我们来盘下面试中经常被问到哪些问题?比如
1、如果保证消息不丢(可用性一般是业务场景中最重要的)
2、如果处理重复消息(换言之,如何保证业务中的幂等性)
3、如何保证消息的有序性(业务场景要求顺序性怎么办)
4、如果应对消息堆积(内存是有限的,磁盘容量也是有限的,当消息堆积到一定量,消息消费速度,检索速度都会大打折扣)
5、如何设计一个消息队列
......
什么是消息队列
先了解下什么事消息队列,我搬来了维基百科
In computer science, message queues and mailboxes are software-engineeringcomponents typically used for inter-process communication (IPC), or for inter-threadcommunication within the same process. They use a queue for messaging – the passing ofcontrol or of content. Group communication systems provide similar kinds of functionality.
就是说:在计算机科学领域,消息队列和邮箱是软件工程的组件,通常用于进程间或一组进程内的线程通信。他们通过队列传递消息-传递控制消息或内容。群组通信系统提供类似的功能。
上面的定义可概括为:消息队列是使用队列来通信的组件
如何保证消息不丢失
只要配置得当,消息就不会丢
一共三个阶段,生产消息,存储消息和消费消息,我们来看看这三个阶段怎么保证消息不丢失。
- 生产消息
生产者发消息到Broker,需要等待Broker的响应(不能用单向),不论是同步还是异步都要做好异常处理,如果Broker返回失败,需要重试发送。当多次发送失败,需要预警或日志记录,然后人工处理或者异步补偿调度处理发送失败的消息。这样可以保证生产消息阶段不丢消息。
- 存储消息
消息刷盘有两种策略:同步刷盘和异步刷盘。这里需要选择同步刷盘,也就是消息需要刷到文件里再返回给生产者响应。而且Broker需要集群部署,即消息不仅要写到master上,还要同步到slave上,这样当master宕机时,slave还可以补上。
- 消费消息
如果消息获取到然后消费者宕机了怎么办?你需要保证消费者真正执行完业务逻辑后再返回给Broker消费成功的标识,这样的话消费者宕机了大不了其他消费者重新消费。
如果Broker宕机了怎么办?消费者这边维护了消费队列的索引,这样当Broker恢复之后也可以重新消费。
消息重复了怎么处理
先来分析下消息重复的场景:
- 发消息发重复:生产者往Broker发消息得等到Broker的响应,如果因为网络原因生产者迟迟收不到Broker的响应,生产者就会重发一次。
- 消费消息重复:消费者消费消息,业务逻辑走完事务也提交了,此时需要更新Consumer offset,此时这个消费者挂了,另一个消费者顶上,由于Consumer offset还没更新,于是又拿到了刚才的消息,业务又被执行了一遍
处理消息重复关键是幂等(同样的参数调用同一个接口n次产生的结果都是一致的)
1、可以用数据库版本号控制,对比消息中的版本号和数据库中的版本号,相等才做更新,数据库乐观锁机制
2、通过数据库的约束例如唯一键,例如 insert into update on duplicate key...
3、或者记录某个业务id,有消息过来,先通过id查一下缓存或者数据库,如果id已经存在则表示已经处理过了
......
如何保证消息的有序性
看你是需要全局有序还是局部有序了
全局有序
这种情况,只能由一个生产者往Topic发送消息,并且Topic里只有一个队列/分区,消费者也必须单线程消费这个队列。这样的消息就是全局有序的。
部分有序
绝大部分需求都只是要求部分有序,这种情况下我们可以将Broker内部划分成我们需要的队列数,某一个Topic/Tag的消息发送到固定的队列中,然后这些队列对应一个单线程处理的消费者。
如何处理消息堆积
不考虑代码bug,消息堆积最常见的原因是:消费者的消费速度跟不上生产者的生产消息的速度。
还可能是因为消息消费失败反复重试造成的。因此我们要先定位消费慢的原因,如果是bug则处理bug,如果消费代码性能不佳就考虑优化逻辑。假如逻辑我们优化了消费的还是很慢,那就要考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数和消费者数一定要同时增加,不然新增加的消费者是没东西消费的,因为一个Topic中,一个队列只会分配给一个消费者。
如何设计一个消息队列
这类设计问题大家都不陌生,它没有固定的答案,要结合理论知识和你的思想有理有据。诸如此类的有如何实现一个线程安全的map,如何设计一个缓存,如何写一个线程池,如何写一个RPC框架等。这里考验的不是细节,不是代码,而是设计理念、整体架构。
总结了以下几点思考方向和面试技巧:
1、实际结合理论,这个框架需要哪些组件。就像做馒头首先得有面粉,水和发酵粉,面粉和水是基础,发酵粉是锦上添花且不可或缺。
2、把握要点,将关键的流程说清楚,例如怎么交互的,怎么存储的,怎么实现框架的功能。
3、提些两点,例如为了考虑到性能怎么做,考虑到可靠性怎么做,考虑幂等...
4、回答要BFS(广度),但不是要求DFS(深度)。什么意思呢?就是说我们要从大局上说出设计的东西的重点,然后等待面试官的深挖和细究。
回到如何设计一个消息队列的问题上。
- 首先我们需要明确提出消息中间件的几个重要角色:生产者、消费者、Broker(消息存储中心)、注册中心(NameServer/Zookeeper等)
- 简述下消息中间件数据流转过程:无非就是服务注册、服务发现,消息发送,消息存储以及消息消费。几个组件的职能。例如生产者根据Topic到注册中心寻找路由,负责消息的发送。Broker负责消息的存储。消费者根据Topic到注册中心寻找路由,到Broker指定的队列中拉取消息进行消费。注册中心就是管理Broker和提供路由的。
- 然后简述要点,通信可以基于Netty然后自定义协议实现。注册中心可以利用Zookeeper、consul、eureka、nacos等等,也可以像RocketMQ一样实现简单的nameServer。
- 为了考虑扩容和整体性能,利用分布式思想,像Kakfa一样采取分区理念,一个Topic分成多个Partition,并且考虑可靠性,采取多副本存储,即Master和Slave,而且提供同步和异步两种刷盘方式可配置。并且利用选择算法Master挂了,Slave可以顶上,保证Broker的高可用。
- 同样为了提高消息队列的可靠性利用本地文件存储消息,并考虑使用顺序写、mappedFile以及sendFile(零拷贝)提高性能。
至此差不多了,要点、重点、亮点都说了...面试官会觉得你这人有点东西
紧接着面试官可能会开始深挖了...
例如你提到的Netty,各种注册中心,会让回答下各注册中心的选型对比?你提到分区,会问到Kafka和RocketMQ的队列有啥不同啊?你提到了顺序写,mappedFile以及零拷贝都是什么,RocketMQ和Kafka是怎么实现的?
消费消息到底是推还是拉比较好
先来看看推模式和拉模式都是什么样的?
推模式
指的是Broker把消息主动推给Consumer。那推模式有什么好处呢?
1、消息实时性高,Broker存储完消息之后就立刻推送给了Consumer消费。
2、对于消费者来说实现简单,就等着消息过来处理就行了。
那推模式有什么缺点呢?那就是推送速度远大于消费速度,Consumer消费不过来,会导致消息积压在Consumer,内存崩盘。
拉模式
拉模式指的是Consumer主动向Broker请求拉取消息。
我们想想拉模式有什么好处?拉模式的主动权在Consumer,消费者可以根据自身的内存、cpu处理速度状况选择什么时候去拉取消息。假设当前消费者觉得自己消费不过来,它可以根据一定的策略停止拉取或者间隔拉取都可以。反正消息都在Broker存着,Broker本来就是要存储消息的,积压在Broker端完全没有问题。
拉模式有什么缺点呢?消息延迟,毕竟是消费者去拉取消息,但是消费者不知道新消息啥时候到,所以只能不断地去拉取,但是又不能太频繁,所以就是每隔几秒(例如2秒)去拉一次,那么消息就延迟了几秒。
但是如果一直没有消息产生,消费者就是盲请求了。
到底是推还是拉
可以看到推模式适用于对消息实时性要求高但是消息量不大的场景,拉模式适用于消息量比较大实时性要求不高,可靠性要求比较高的场景。如何选择要结合实际情况。个人觉得拉模式更合适,因为现在的消息队列都会持久化消息,它的使命就是保存好消息,等着消费者来消费即可。而消费者多种多样,作为Broker不应该有依赖消费者的倾向,我已经保存好消息了,谁来拿我就给。虽然说Broker一般不会成为瓶颈,但是Broker毕竟是存储消息的中心,可靠性要求比较高,能轻量就尽量轻量。
RocketMQ和Kafka为啥都是拉模式
RocketMQ和Kafka都是利用“长轮询”实现拉模式,我们来看看它们是如何操作的。
RocketMQ中的长轮询
RocketMQ中的PushConsumer其实是拉模式,只是看起来像推模式而已,RocketMQ在背后偷偷帮我们去Broker请求数据了。后台有个RebalanceService线程,这个线程会根据topic的队列数量和当前消费组的消费者个数做负载均衡,每个队列产生的pullRequest放入阻塞队列pullRequestQueue中,然后又有个PullRequestService现成不断从阻塞队列pullRequestQueue中获取pullRequest,通过网络请求Broker,这样实现的准实时拉取消息。
然后Broker的PullMessageProcessor里面的processRequest方法是用来处理拉消息请求的,有消息就返回,没有消息的话如果Broker允许而且请求允许就挂起消息。这部分代码如下:
来看下suspendPullRequest方法做了什么
PullRequestHoldService这个线程会每5秒从pullRequestTable中获取pullRequest请求,然后看看待拉取消息的偏移量是否小于当前消费队列最大偏移量,如果小于说明有新消息到了。
学到了,借助队列和异步线程来处理业务逻辑中的异步任务。当一些业务需要长轮询,而且有结果了才需要返回,可以将调用方的请求存放到队列里,然后后台线程定时从队列中获取请求,执行业务逻辑查找数据,如果找到了,返回结果,并移除队列中的请求。
当然了,这里你会觉得5秒延迟太久了,还有个ReputMessageService线程,这个线程不断从CommitLog中解析数据并分发请求,构建出ConsumeQueue和IndexFile两种类型的数据,并且也有唤醒请求的动作,来弥补5秒的延迟。
Kafka的长轮询
Kafka的消费者去Broker拉消息时会定义一个超时时间,意思就是消费者去请求消息,如果Broker有消息的话就立刻返回,如果没有的话消费者等待直到超时时间结束,然后再次发起拉消息请求。Broker端也要配合,消费者请求过来,有消息的话立刻返回,没有消息就建立一个延迟操作等有消息/超时了再返回。
消息队列之事务消息
说起事务首先想到的是ACID,大家都不陌生,通常我们理解的事务就是为了保证一系列的更新操作要么全部成功要么全部失败,不会存在部分成功/失败的情况产生。单体系统上一般还比较容易遵循ACID的约束来实现事务,分布式系统就比较困难了。分布式系统遵循CAP定理,往往为了保证可用性和分区容忍性,会牺牲掉一致性,而实现最终一致性。而实现ACID的事务代价就很大,想要维护这么多系统的数据且不允许有中间状态的数据可读取,就意味着事务的执行是阻塞的,资源被长时间锁住,在多数实际场景中是是不合适的。
事务消息主要用于异步更新的场景,并且对数据的实时性要求不高的地方,它的目的是解决消息生产者和消息消费者的数据一致性问题。
RocketMQ的事务消息
RocketMQ的事务消息可以认为是一个两阶段提交,简单来说就是半消息+消息回查。(半消息的意思就是这个消息此时对于Consumer来说是不可见的,且会放在Broker的特殊队列中,Consumer消费不了)
然后我们分析源码来看看是怎么实现的,首先看下sendMessageInTransaction方法
Producer端就是这样,将消息塞入一些属性表明这个消息还是个半消息,然后发送至Broker,执行本地事务,然后将本地事务的执行状态发送至Broker,我们再来看下Broker是怎么处理半消息的。
在Broker的SendMessageProcessor#sendMessage中会处理这个半消息请求。sendMessage中查到接受来的消息的属性里面MessageConst.PROPERY_TRANSACTION_PREPARED是true,得知这个消息是事务消息,然后再判断一下这条消息是否超过最大消费次数,是否要延迟,Broker是否接受事务消息等等,将这条消息真正的topic和队列存入属性中,然后重置消息的topic为RMQ_SYS_TRANS_HALF_TOPIC,并且队列是0的队列中,使得消费者无法消费。
Broker处理提交或者回滚消息的处理方法是EndTransactionProcessor#processRequest,我们来看看它做了什么操作
可以看到,如果是提交事务就把皮换回来写入真正的topic所属的队列中,供消费者消费,如果是回滚则是将半消息记录到一个half_op的主题下,到时候后台服务扫描半小时时就依据这个来判断这个消息已经处理过了。这个后台服务就是TransactionalMessageCheckService服务,它会定时扫描半消息队列,去请求反查接口看看事务成功了没,具体执行的是TransactionalMessageServiceImpl#check方法。首先取半消息topic即RMQ_SYS_TRANS_HALF_TOPIC下的所有队列,半消息写入的时id为0的队列,然后取出这个队列对应的half_op主题下的队列,即RMQ_SYS_TRANS_HALF_TOPIC主题下的队列。这个half_op是为了记录这个事务消息已经被处理过,也就是说已经得知此事务消息是提交/回滚的消息会被记录到half_op中。然后调用fillOpRemoveMap方法,从half_op取一批已经处理过得消息来去重,将那些没有记录在half_op里面的半消息调用putBackHalfMsgQueue又写入了commitLog中,然后发送事务反查请求,这个反查请求是oneWay的(不会等待响应)。然后producer中的processRequest会处理这个请求,会把任务扔到TransactionMQProducer的线程池中进行,最终会调用上面我们发消息时定义的checkLocalTransactionState方法,然后将事务状态发送给Broker。
Kafka的事务消息
Kafka的事务消息和RocketMQ不一样,RocketMQ解决的事本地事务的执行和发消息这两个动作满足事务,而Kafka则是解决多个发消息满足事务的情况,即多个消息要么都成功,要么都失败。
Kafka的事务有事务协调者角色,事务协调者其实就是Broker的一部分,在开始事务的时候,生产者会向事务协调者发起请求表示事务开启,事务协调者会将这个消息记录到特定的日志(事务日志)中,然后生产者再发送真正想要发送的消息,这里Kafka和RocketMQ处理不一样,Kafka会将事务消息视为正常消息,由消费端来过滤这个消息。然后发送完毕后生产者会向事务协调者发送提交/回滚请求,由事务协调者进行两阶段提交。如果是提交会先执行预提交,即把事务的状态置为预提交然后写入事务日志,然后再向所有事务有关的分区写入一条类似事务结束的消息,这样消费端消费到这个消息的时候就知道事务结束了,可以把消息放出来了。
更好的事务消息实现-qmq
分布式事务实现方式里有一种是本地消息表,利用了关系型数据库的事务能力,在数据库中建一张本地事务消息表,在本地事务操作中加入本地消息表的插入,将业务执行和存储消息的操作放在一个事务中提交。这样本地事务成功的话,消息肯定也插入成功,然后再调用其他服务,如果调用其他服务也成功,就可以修改消息状态,否则可以一直重试直至成功。这是一种尽最大努力交付的思想。
想要实现qmq的事务消息,也是在数据库中建一张本地消息表
create table qmq_msg_queue
(
id bigint(20) not null auo_increment,
content longtext not null,
status int(10) not null default '0' comment '消息状态',
error int(10) not null default '0' comment '错误次数',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '更新时间',
primary key(id)
)comment='记录业务系统消息';
qmq的使用很简单,直接调用sendMessage()就好了,插入消息和发送消息的操作都封装好了。
@Transactional // 在一个事务中
public void yes(){
Order order = buildOrder();
orderDao.insert(order);
//封装插入消息、发送消息、删除消息的逻辑
producer.sendMessage(buildMessage(order));
}
结语
本文从消息队列常见问题入手(消息不丢失、消息重复、消息有序性、消息堆积),讨论了消息队列的推拉模式的优点和缺点,分析了RocketMQ和Kafka拉模式的方式,最后从源码分析了一波RocketMQ的事务消息实现,展开讲了我们日常可以像qmq一样使用本地消息表实现事务消息。
本文正在参加「金石计划 . 瓜分6万现金大奖」