写在最前,本人也只是个大三的学生,如果你发现任何我写的不对的,请在评论中指出。
本篇默认对各位对安装、RocketMQ的架构已有了解
接触过rocketmq开发的开发者应该都知道虽然rocketmq架构图中有很多的执行角色,但是平时我们接触的最多的还是消费者和生产者,生产者和消费者是消息队列中两个重要的角色,生产者向消息队列写入数据,消费者从消息队列中读取数据。它们的一些基础用法是本篇的基础。
生产者基础用法
生产者发送消息默认使用的是DefaultMQProducer
类,我会结合最近学习时遇到的一些场景给出一个示例代码以此来解释合适生产者的场景。
假设现在上头给你一个任务,要你给今日第一次登录的用户发放一张优惠券,你会怎么做?这你肯定会觉得很简单,实际实现流程也就是:
- 判断用户是否是第一次登录: 这个好理解,用户每次登录都在数据库中记录一个状态,下次登录比较这个状态就可以了。
- 发放优惠券,调用couponService来实现,是同步调用的。
- 验证登录消息。
这套传统的登录发放优惠券流程,当用户登录的时候会判断“是否是第一次登录” 当用户满足第一次登录条件以后,后台会在数据库中记录登录的信息,然后发放优惠券。然后需要注意的是,**我们在记录第一次登录信息的同时,可以不用等待记录成功才进行优惠券的方法。**这也是RocketMQ的优点之一,它可以帮助我们对应用进行解耦, 毕竟“登录”和“发放优惠券”是两个功能模块。
也就是说,我们将原先同步的修改为异步,具体流程如下图所示:
那么具体流程都清晰之后,我们来采用部分代码来模拟该场景:
// 1. 首先注入该场景下的producer
@Bean
public DefaultMQProducer loginMqProducer() throws MQClientException{
DefaultMQProducer producer = new DefaultMQProducer("loginProducerGroup");
producer.setNamesrvAddr("localhost:9876");
// 这里还可以设置其他额外参数 比如线程数之类的 但是我要提醒一句 线程数不是越多越好 有时候反而会适得其反
// 在配置和启动过程之后,此类可以视为线程安全的,并在多个线程上下文中使用
producer.start();
return producer;
}
// 2.注入loginMqProducer之后就可以用来在记录登录消息的时候发送消息到coupon消费者处
public class loginServiceImpl{
public void firstLoginDistributeCoupon(LoginInfo loginInfo){
if(!isFirstLogin(loginInfo)){
// 如果不是第一次登录
LOGGER.info("当前用户不是第一次登录")
}
// 更新第一次登录的标识位
this.updateFirstLoginStatus(loginInfo, FirstLoginStatusEnum.NO);
// 发送第一次登录成功的消息
this.sendFirstLoginMessage(loginInfo);
}
}
// 3.更新第一次登录这种调用数据库就不说了, 看一下发送登陆成功消息的过程
// 方法中定义了RocketMQ Message的实体, 并且设置了对应的topic(loginTopic),这个topic来自你配置文件的定义。最后通过loginMqProducer的send方法将message发送到RocketMQ对应的Topic中,从而等待下游服务中的conusmer来消息。
private void sendFirstLoginMessage(LoginInfo loginInfo){
// 异步发送登录消息到mq中
Message message = new Message();
message.setTopic(loginTopic);
// 消息内容是用户信息
message.setBody(JSON.toJSONString(loginInfo).getBytes(StandarCharsets.UTF_8));
try {
SendResult sendResult = loginMqProducer().send(message);
// 日志
}catch (Exception e){
// 日志
}
}
到这里,一个最普通的生产者案例就讲完了,这块的主要作用就是通过对登录服务和优惠券服务进行解耦,提高程序的运行效率,不必在记录登录信息的时候还要傻傻的同步等待优惠券服务调用发放优惠券。
一些最佳实践
- 一个服务尽量只用一个Topic,这样该服务下的消息子类只要标识好tags就能来做消息过滤。
- 最好你发送的每个message都在业务层面设置keys(
message.setKeys("唯一id");
),这样做的好处是方便定位丢失的消息,服务器会为每个mesaage按照hash的方式创建索引,所以尽可能保证设置的keys唯一。 producer.send(msg)
只要不抛出异常,就是消息发送成功,但是消息是否有被消息就不一定了。所以一定要打印日志,记录SendResult跟key字段,写一个高质量的生产者程序,重点是在对发送结果的处理。
public enum SendStatus {
// 表示发送成功 这个状态简单的理解就是以下场景没问题
// 消息是否被存储到磁盘?消息是否被同步到slave? 消息在slave上是否被写入磁盘
SEND_OK,
// 规定时间内未完成刷盘
FLUSH_DISK_TIMEOUT,
// 没有在设定时间内完成主从同步
FLUSH_SLAVE_TIMEOUT,
// 没有找到被配置成slave的broker
SLAVE_NOT_AVAILABLE,
}
Producer其他的发送方式
1. 发送延迟消息
延迟消息的使用方法是在创建Message对象的时候,调用message.setDelayTimeLevel(int level)
方法设置延迟时间,然后再把消息发送出去,再预计投递时间未到之前,消息对消费者不可见,消费者此时无法立刻消息。目前延迟的时候不支持任意设置,仅支持预设值的时间长度(1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/20m/30m/1h/2h)。
遇到的场景应该是**交易场景,**对时间有要求的,比如在电商交易场景中,交易中超时未支付的订单需要被关闭的场景。是在订单被创建时会发送一条延时消息, 30分钟后被投递给消费者,消费者收到此消息后,就判断对应的订单是否完成支付;如果未支付,就关闭订单。
2. 自定义消息发送规则
实际上就是指定了目标消息队列,我们知道一个Topic可以有多个queue。如果业务需要我们把消息发送到指定的Message Queue里,可以采用MessageQueueSelector
,如下:
SendResult sendResult = loginMqProducer().send(message, new MessageQueueSelector() {
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
int id= Integer.parseInt(arg.toString()) ;
int idMainindex = id/ 100 ;
int size = mqs . size() ;
int index = idMainindex%size;
return mqs.get(index);
}
}, loginInfo.getId());
3. 支持事务(但是我还没学到)
仅给出《Rocket实战》中描述的具体流程:
1)发送方向rocketmq发送“待确认消息”。
2)rocketmq将受到的“待确认”消息持久化成功后,向发送方回复消息已经发送成功,此时第一阶段消息发送完成。
3) 发送方开始执行本地事件逻辑
4)发送方根据本地执行结果向rocketmq发送二次确认(Commit或时rollback)消息,rocketmq收到commit状态则将第一阶段消息标记为可投递,订阅方将能够收到该消息;收到rollback状态则删除第一阶段的消息,订阅方就收不到该消息。
5)如果出现异常, 步骤4)的二次确认最终未到达rocketmq,服务器在经过固定时间后将对“待确认”消息发起回查请求。
6)发送方收到消息回查请求后,通过检查对应消息的本地时间执行结果返回对应状态
7)rocketmq收到回查请求后,执行步骤4)
消费者的基础用法
回到上部分的第一次登录发放优惠券的场景,根据流程图,我们这次把重心放到优惠券消费者模块处,实际的代码实现逻辑还是大差不差的:注入消费者的bean,为该bean配置监听器来监听登录消息来消费。具体如下:
@Bean(value = "loginConsumer")
public DefaultMQPushConsumer loginConsumer(@Qualifier(value = "firstLoginMessageListener")
FirstLoginMessageListener listener) throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(loginConsumerGroup);
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe(loginTopic, "*");
consumer.setMessageListener(listener);
consumer.start();
return consumer;
}
@Component(value = "firstLoginMessageListener")
class FirstLoginMessageListener implements MessageListenerConcurrently {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
// 发放优惠券
try {
// 拿到消息实体
String body = new String(msg.getBody(), StandardCharsets.UTF_8);
FirstLoginMessageDTO firstLoginMessageDTO = JSON.parseObject(body, FirstLoginMessageDTO.class);
couponService.distributeCoupon(/*参数*/);
// 日志
}catch (Exception e){
// 日志 失败
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
}
// 这样一整个登录发放优惠券达成,虽然消费者这块还有点问题,下文提起。
根据使用者对读取操作的控制情况,消费者可以分为两种类型。一个是DefaultMQPushConsumer
,由系统控制读取操作,收到消息后自动调用传入的处理方法来处理;另一个是DefaultMQPullConusumer
,读取操作中的大部分功能由使用者自主控制。
push方式是服务端接受到消息后,主动把消息推送到客户端,实时性高。但是push的方式有可能导致客户端不能及时处理服务端推送过来的消息,造成各种潜在问题。
pull方式是客户端循环地从服务端拉取消息,主动权在客户端手里,当拉取一定量的消息后,处理妥当了再接着取。pull方式的时间间隔不好设定,时间短就可能处在一个“忙等”的状态,浪费资源;时间太长,就可能导致没来得及处理某些消息。
一些最佳实践
- rocketmq无法避免重复消息,可以借助一些数据库保证msgId或者消息内容中的唯一标志性和字段来避免问题。
// 比如我们上面这个发放优惠券的过程就无法避免重复消息
// 这时候就可以根据用户id 用redis来保证幂等性
redisApi.setnx(userId, /*其他参数*/)
- 如果你消费者要消费有序消息,消费者会锁定每个消息队列,以确保每个消息被消费,但会导致性能下降。
- 有序消息不建议抛出异常,更加建议返回
ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT
(暂停一下当前队列,让消费者等待片刻,因为要保证有序性,不能选择LATER,这种跳过的选项)。 - 并行异常时,也不建议抛出异常,而是选择返回
ConsumeConcurrentlyStatus.RECONSUME_LATER
(稍后尝试),总之消费端发生异常时是不建议急着抛出异常。