在前文RocketMQ的安装部署中,介绍了RocketMQ在Linux下的单机、伪集群安装部署及MQ控制台的部署,下面,详细介绍一下SpringBoot下如何配置RocketMQ以及常用API的使用。下面的测试代码,只需要修改rocketMQ的地址即可使用,https://pan.baidu.com/s/1k3expieM0SJDlPzmYezzUA?pwd=56xo 提取码:56xo
首先,在pom文件中引入RocketMQ的相关依赖,这里有两个选择,官方的mq客户端依赖和封装的mq启动依赖,需要注意的是,如果引入官方的mq客户端依赖,需要保证版本和rocketMQ的安装版本保持一致,如下
org.apache.rocketmq
rocketmq-client
4.2.0
org.apache.rocketmq
rocketmq-spring-boot-starter
2.1.1
然后,在配置文件中增加mq的服务地址,例如mq.addr=192.168.1.1:9876
RocketMQ默认的消息生产者是DefaultMQProducer,最基本的创建生产者的方式是DefaultMQProducer producer = new DefaultMQProducer("生产者组")
,在实际项目中,会有多处功能需要发送消息,如果每次都去初始化生产者会显得麻烦而且容易出现问题,因此,我们可以创建一个获取生产者的工具类,比如我这里做了一个单例,如下
package com.example.rocketmq.rocketmq.utils;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.message.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import static com.example.rocketmq.rocketmq.constants.MQConstants.ROCKET_MQ_ADDR;
import static com.example.rocketmq.rocketmq.constants.MQConstants.TEST_GROUP;
/**
* @author zy
* @version 1.0.0
* @ClassName MQUtil.java
* @Description TODO
* @createTime 2022/11/8
*/
public class MQUtil {
static Logger logger = LoggerFactory.getLogger(MQUtil.class);
private static DefaultMQProducer producer = null;
private static volatile boolean flag = false;
private MQUtil(){
}
static{
if(StringUtils.isEmpty(ROCKET_MQ_ADDR)){
logger.error("没有配置RocketMQ地址");
}
}
/**
* 生产者单例(DCL单例,防止项目中各处发消息都初始化)
* @return
*/
public static DefaultMQProducer getProducer(){
if(!flag){
synchronized (MQUtil.class){
if(!flag){
//初始化生产者
producer = new DefaultMQProducer(TEST_GROUP);
//关闭VIP通道
producer.setVipChannelEnabled(false);
//MQ地址
producer.setNamesrvAddr(ROCKET_MQ_ADDR);
//启动
try{
producer.start();
flag = true;
}catch(Exception e){
producer = null;
logger.error("MQ生产者启动失败:{}",e);
}
}
}
}
return producer;
}
在上述代码的静态块中,放了一个MQ的地址常量ROCKET_MQ_ADDR,这个是在项目启动时做的赋值,读取的就是配置文件配置的地址,代码如下
package com.example.rocketmq.rocketmq;
import com.example.rocketmq.rocketmq.constants.MQConstants;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RocketmqApplication implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(RocketmqApplication.class, args);
}
/**
* 这里做全局的mq地址初始化
*/
@Value("${mq.addr}")
private String mqAddr;
@Override
public void run(String... args) throws Exception {
MQConstants.ROCKET_MQ_ADDR = mqAddr;
}
}
到现在我们已经有了一个获取生产者的方法,接下来发送消息。先看下提供的API
上图中,有很多send方法,大致可以分为如下几类:同步消息(返回值为SengResult,这种消息发送过程会进入同步等待过程,保证了消息的可靠性,这个后续讲)、异步消息(返回值为void,这种类型消息可以保证其他业务和发送消息业务不等待,如果要保证消息的可靠性投递,需要针对SendCallBack做文章)、单向消息(这种只是发送消息而不需要等待服务器的响应,适合那些非重要业务的消息发送,例如日志),接下来,针对上述三种消息,分别有对应的demo。为了方便起见,这里做了一个消息格式化,处理输入的文本
/**
* 格式化消息
* @param topic
* @param msg
* @return
*/
public static Message parseMsg(String topic,String msg){
Message message = null;
try{
message = new Message(topic,msg.getBytes(StandardCharsets.UTF_8));
}catch(Exception e){
logger.error("格式化消息失败");
}
return message;
}
下面看下发送同步消息的demo
/**
* 发送同步消息
* @param topic
* @param str
* @return
*/
public static boolean sendMQ(String topic,String str){
DefaultMQProducer producer = getProducer();
Message msg = parseMsg(topic,str);
try{
SendResult sendResult = producer.send(msg);
return sendResult.getSendStatus().equals(SendStatus.SEND_OK);
}catch(Exception e){
logger.error("消息发送失败:{}",e);
return false;
}
}
然后调用这个方法做测试,例如
@RequestMapping("/sendMQ")
public String sendMQ(@RequestParam String msg,String topic){
if(MQUtil.sendMQ(topic,msg)){
return "发送成功";
}else{
return "发送失败";
}
}
实际上,发送消息只要没有报错,都算是发送成功了,但是SendResult有多种情况(在SendStatus枚举类中),我们来看下github上的文档描述:
上面介绍了四种状态,涉及到的是消息的可靠性,在下面的章节单独分析,接下来看下发送单向消息,这个比较简单,只需要发送
/**
* 发送单向消息
* @param topic
* @param str
*/
public static void sendOneWay(String topic,String str){
DefaultMQProducer producer = getProducer();
Message msg = parseMsg(topic,str);
try{
producer.sendOneway(msg);
}catch(Exception e){
logger.error(e.getMessage());
}
}
接下来看下发送异步消息
/**
* 发送异步消息
* @param topic
* @param str
*/
public static void sendSync(String topic,String str){
DefaultMQProducer producer = getProducer();
Message msg = parseMsg(topic,str);
try{
producer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
//消息发送成功了,对应业务逻辑,例如打印信息
logger.info("异步消息发送成功");
}
@Override
public void onException(Throwable throwable) {
//消息发送失败,需要对消息做对应处理,例如入库,重试等
logger.error(throwable.getMessage());
}
});
} catch (Exception e) {
logger.error(e.getMessage());
}
}
在调用异步消息方法时,在发送消息前后增加日志打印用来模拟其他的业务,看下执行结果,异步消息是怎样的
@RequestMapping("/sendSync")
public void sendSync(@RequestParam String msg,String topic){
//这里模拟发送消息的前后业务逻辑
logger.info("*******************************");
MQUtil.sendSync(topic,msg);
logger.info("===============================");
}
可以看到,日志打印并没有受到消息发送的影响,因此,异步消息是生效的。这种方式适用于那些既想快速发送消息,又要保证消息可靠性投递的场景。至此,三种发送消息的方法已经做了示例,下面,看下消费者的相关内容。
和生产者类似,默认的消费者类是DefaultMQPushConsumer,但是,这里不能再去创建统一的消费者工具,因为这里涉及到一个topic订阅的问题,下面给出我订阅TEST主题的示例
@Configuration
public class ConsumerUtil {
Logger logger = LoggerFactory.getLogger(ConsumerUtil.class);
@Autowired
private MQListener mqListener;
@Value("${mq.addr}")
private String mqAddr;
@Bean
public DefaultMQPushConsumer testConsumer(){
if(StringUtils.isEmpty(mqAddr)){
logger.error("rocketMQ地址没有配置");
return null;
}
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(TEST_GROUP);
//mq地址
consumer.setNamesrvAddr(mqAddr);
//最大消费线程
consumer.setConsumeThreadMax(10);
//最小消费线程
consumer.setConsumeThreadMin(1);
//每次消费消息数量
consumer.setConsumeMessageBatchMaxSize(1);
//设置监听器
consumer.registerMessageListener(mqListener);
try{
consumer.subscribe(TEST_TOPIC,"*");
consumer.start();
logger.info("消费者启动成功!!!");
}catch(Exception e){
logger.error("消费者启动失败:{}",e);
return null;
}
return consumer;
}
}
在消费者中,一个非常重要的东西就是监听器,它是消息消费的核心,我们先来看下源码中MessageListener接口的实现
可以看到除了自定义的实现外,默认是两种实现方式,并发消费和顺序消费,这是client包,那么启动包呢?
也是这两种消费方式,只不过它单独封装了一层实现类,并发消费,指的是一个队列中的消息可能同时被消费者的多个线程并发消费;顺序消费,指的是一个队列中的消息同一时间只能被一个消费者的一个线程消费,通过这种方式实现有序的效果。下面是基于顺序消费实现的一个监听器
@Component
public class MQListener implements MessageListenerOrderly {
Logger logger = LoggerFactory.getLogger(MQListener.class);
@Override
public ConsumeOrderlyStatus consumeMessage(List list, ConsumeOrderlyContext consumeOrderlyContext) {
for (MessageExt messageExt : list) {
try{
//消息处理
String msg = new String(messageExt.getBody());
logger.info("消费到的消息:{}",msg);
}catch(Exception e){
}
}
return ConsumeOrderlyStatus.SUCCESS;
}
}
实际上,基于push模式实现的消费者在底层也是采用pull方式拉取消息,只不过push模式让开发者不关注底层是如何实现的,只需要处理好业务代码即可。消费者启动时会启动PullMessageService 线程,PullMessageService线程不断地从内部的队列中取PullRequest,然后使用PullRequest作为请求去拉取消息,拉取得消息存放在ProcessQueue中,当消费成功返回后,消息从ProcessQueue中移除。
上面提到了消费方式,实际上,RocketMQ还有一个消息消费模式的概念:Clustering(集群消费)和Broadcasting(广播消费),默认情况下就是集群消费,这种模式下一个消费者组共同消费一个主题的多个队列,一个队列只会被一个消费者消费,如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。而广播消费消息会发给消费者组中的每一个消费者进行消费。
在上面简单介绍了RocketMQ得生产者和消费者,以及对应的demo示例。这里抛出一个问题,为什么使用MQ呢?我这里想到的我使用MQ的第一个场景就是实现订单的超时自动取消,这还是我刚刚毕业之后接触的一个实际需求,当时经过多方了解,才知道MQ这个东西,下面,重点介绍一下RocketMQ的延时消息,先从一个demo讲起。
先看消费者:
package com.example.rocketmq.rocketmq.test;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageExt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
/**
* @author zy
* @version 1.0.0
* @ClassName DelayMessageConsumer.java
* @Description TODO
* @createTime 2022/11/13
*/
public class DelayMessageConsumer {
static Logger logger = LoggerFactory.getLogger(DelayMessageConsumer.class);
public static void main(String[] args) throws Exception {
//实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TestGroup");
//设置mq地址
consumer.setNamesrvAddr("localhost:9876");
//订阅主题
consumer.subscribe("TEST_YSGS","*");
//注册监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
for (MessageExt messageExt : list) {
//延迟业务处理,这里以打印方式演示
logger.info("收到消息:"+new String(messageExt.getBody()));
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//启动消费者
consumer.start();
logger.info("启动成功.......");
}
}
然后是生产者:
package com.example.rocketmq.rocketmq.test;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* @author zy
* @version 1.0.0
* @ClassName DelayMessageProducer.java
* @Description TODO
* @createTime 2022/11/13
*/
public class DelayMessageProducer {
static Logger logger = LoggerFactory.getLogger(DelayMessageProducer.class);
public static void main(String[] args) throws Exception {
//实例化生产者
DefaultMQProducer producer = new DefaultMQProducer("TestGroup");
//设置mq地址
producer.setNamesrvAddr("localhost:9876");
producer.setVipChannelEnabled(false);
//启动生产者
producer.start();
logger.info("生产者启动成功......");
//初始化消息
Message message = new Message("TEST_YSGS", ("我是一条延时消息......" + new Date().toString()).getBytes(StandardCharsets.UTF_8));
//设置延时等级,目前免费的版本支支持18个固定时间,这里选用30s
message.setDelayTimeLevel(4);
//发送消息
producer.send(message);
logger.info("消息发送成功......");
producer.shutdown();
}
}
首先启动消费者
然后启动生产者,我这里设置的延迟时间是30s,重点关注一下消息发送成功打印日志的时间
然后等待消费者消费的打印,看看消费时间是否在30s之后
可以看到,消费时间-发送时间之后,忽略日志打印的微小耗时波动,刚好是30s,至此,延迟消息已经成功实现。在前面我们提到,设置了30s的延迟时间,这个是通过setDelayTimeLevel实现的,RocketMQ为我们提供了18个延迟等级,延迟时间由低到高依次是1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
然后回到上面的问题,如何定时取消订单就迎刃而解了,我们只需要在下单的时候把订单编号发送一个延迟消息到MQ,例如延迟30分钟,当30分钟后消费者消费到这条消息时,拿着订单编号去查看这个订单是否支付了,如果没有支付,就给它取消,如果支付了,就丢弃这条消息(不处理订单状态,随便做个打印啥的)就好了。
通过上面的一些demo讲解,一条消息从生产到消费的过程可以简化为如下所示
那么,保证消息的可靠性就清晰明了了,分为三个地方,生产者的可靠性投递、Broker的持久化、消费者的可靠性消费,接下来分别讲解如何实现。上面的流程图也就应该更新为这样
如果你之前了解过其他的消息中间件,可能会想,是不是要在producer做一些设置啊,例如RabbitMQ的在信道设置ack机制,实际上,RocketMQ也是存在ack机制的,不过,RocketMQ在发送消息时,已经默认设置了,producer发送消息后,如果没有收到Broker的ack,producer就会自动重试,默认重试两次,重试次数也可以手动设置
// 同步发送设置重试次数为5次
producer.setRetryTimesWhenSendFailed(5);
// 异步发送设置重试次数为5次
producer.setRetryTimesWhenSendAsyncFailed(5);
因此,在同步发送的时候,要注意处理好返回值和异常。如果返回响应OK,表示消息成功发送到了Broker,如果响应失败,或者发生其它异常,都应该重试。在异步发送的时候,要在回调方法里面做好处理,如果发送失败或者异常,都应该进行重试。如果发生超时的情况,也可以通过查询日志的API,来检查是否在Broker存储成功。
存储阶段的可靠性主要依靠同步机制和刷盘机制保证。同步机制要求我们至少有一个主节点Master和一个从节点Slave,这两个节点之间采用同步复制的方式进行消息备份。
同步复制:master和slave均写成功,才返回客户端成功。maste挂了以后可以保证数据不丢失,但是同步复制会增加数据写入延迟,降低吞吐量
异步复制:master写成功,返回客户端成功。拥有较低的延迟和较高的吞吐量,但是当master出现故障后,有可能造成数据丢失
那么仅仅靠同步机制就能保证消息不丢失吗?答案是否定的,因为即使我们的消息已经写入Broker,但是还没持久化到磁盘上,如果这时Broker宕机了,消息还是会丢失的,这时,就要依靠另外一个机制——刷盘机制
异步刷盘:消息被写入内存的PAGECACHE,返回写成功状态,当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入 。吞吐量高,当磁盘损坏时,会丢失消息
同步刷盘:消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,给应用返回消息写成功的状态。吞吐量低,但不会造成消息丢失
但是,我们也不能只顾及消息的可靠性而不去考虑响应时间,如果我们将刷盘机制也设置为同步刷盘,那么响应时间大打折扣,在实际生产中,我们都是尽可能的搭建Broker的集群,设置A的master和B的slave互为主备,这种情况下我们可以将刷盘机制设置为异步刷盘,参照前文安装方式。
消费者在保证消息成功消费的关键是何时确认消息,不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。这样,就保证了消费者的一定消费成功,但是,如果一条消息被发送两次呢?我们还要消费两次吗?这就是消费的幂等性问题。对于消息的幂等性,我们可以采用两种方式处理:业务幂等和消息去重。业务幂等:保证消费逻辑的幂等性,也就是多次调用和一次调用的效果是一样的。这样一来,不管消息消费多少次,对业务都没有影响。消息去重:对重复的消息就不再消费了。这种方法,需要保证每条消息都有一个惟一的编号,通常是业务相关的,比如订单号,消费的记录需要落库,而且需要保证和消息确认这一步的原子性。
在上面的消费者例子中,你可能会发现,消费者在订阅topic时,不仅配置了topic,还写了一个*号,这个就是消息的过滤表达式。在一般情况下,我们可以给消息添加Tag来选择想要的消息,例如Message message = new Message("TOPIC","TAGA", ("aaa......" + new Date().toString()).getBytes(StandardCharsets.UTF_8));
然后消费者consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC");
但是这种方式并不适用于复杂的场景,RocketMQ为我们提供了一种通过SQL表达式筛选消息的方式。先看下相应的规则,显示基本语法
数值比较,比如:>,>=,<,<=,BETWEEN,=;
字符比较,比如:=,<>,IN;
IS NULL 或者 IS NOT NULL;
逻辑符号 AND,OR,NOT;
支持的常量
数值,比如:123,3.1415;
字符,比如:'abc',必须用单引号包裹起来;
NULL,特殊的常量
布尔值,TRUE 或 FALSE
首先我们保证broker.conf文件有enablePropertyFilter=true
这个配置,然后,看一个demo,这是生产端的代码
package com.example.rocketmq.rocketmq.test;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
/**
* @author zy
* @version 1.0.0
* @ClassName TagProducer.java
* @Description TODO
* @createTime 2022/11/13
*/
public class TagProducer {
static Logger logger = LoggerFactory.getLogger(TagProducer.class);
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("TestGroup");
producer.start();
String[]tags = new String[]{"TagA","TagB"};
for (int i=0;i<10;i++){
Message msg = new Message("TEST",tags[i%2],("我是"+tags[i%2]+"的消息...."+i).getBytes(StandardCharsets.UTF_8));
msg.putUserProperty("age",i+"");
producer.send(msg);
}
producer.shutdown();
}
}
然后是消费者
package com.example.rocketmq.rocketmq.test;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.MessageSelector;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.common.message.MessageExt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
/**
* @author zy
* @version 1.0.0
* @ClassName TagConsumer.java
* @Description TODO
* @createTime 2022/11/13
*/
public class TagConsumer {
static Logger logger = LoggerFactory.getLogger(TagConsumer.class);
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("TestGroup");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("TEST", MessageSelector.bySql("age between 2 and 7"));
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
for (MessageExt messageExt : list) {
logger.info(messageExt.getTags()+"*******"+new String(messageExt.getBody()));
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
logger.info("消费者启动成功............");
}
}
启动消费者
然后启动生产者
再看下消费情况
可以看到age在2-7之间的数据被消费了,而且对应的tag也正确。
本文通过demo方式讲解了RocketMQ的SpringBoot配置方式及使用,然后介绍了一些对应API的应用,对于日常工作来说,掌握这些内容可以应付大部分使用MQ的工作场景。