什么是 RocketMQ?
RocketMQ 是众多 MQ 中的一种,属于Alibaba旗下,使用JAVA语言开发的一款消息中间件,具有高性能、高可靠、高实时、分布式特点。MQ的全称是 Message Queue 消息队列,使用较多的消息队列有ActiveMQ,RabbitMQ,ZeroMQ,Kafka,MetaMQ,RocketMQ。
RocketMQ的重要组成部分
现实生活中的邮政系统要正常运行,离不开下面这四个角色, 一是发信者, 二是收信者, 三是负责暂存、传输的邮局, 四是负责协调各个地方邮局的管理机构。对应到 RocketMQ 中,这四个角色就是Producer、Consumer、Broker和NameServer。
启动 RocketMQ的顺序是先启动 NameServer ,再启动 Broker ,这时候消息队列已可以提供服务了,想发送消息就使用 Producer 来发送,想接收消息 就使用 Consumer 来接收。
NameServer可以看做注册中心,它主要建立起了Producer、Consumer和Broker之间的连接。
Broker可以看做暂存、传输消息的容器,它启动时会向NameServer注册自己的信息。
Producer就是发信者了,它需要先连接NameServer,然后从上面获取到Broker的信息并发送消息到Broker,如果存在多个Broker,NameServer会通过负载均衡的拉取Broker的信息。
Consumer是收信者,它同样需要先连接NameServer,然后从上面获取到Broker的信息并消费容器中的消息。
轮询
轮询是按照某种算法进行顺序触发,轮询时会保存下一个待分配任务的索引,以便于下次执行时可以拿到开始索引位置,以达到负载均衡的目的。
轮询算法是一种无状态的负载均衡策略,它不需要了解每个节点的当前负载或性能状态。这意味着即使某些节点可能比其他节点更繁忙或性能更低,轮询算法仍然会按照固定的顺序分配任务。
比如有 1,2,3,4四个数值,现在轮询输出这四个数值,具体从哪一个开始是随机的,可能是1也可能是3,但是一旦开始以后就是按照顺序执行了。
代码实现:
public static void main(String[] args) {
polling(); // 简单的轮询
}
private static void polling() {
int[] ints = {1,2,3,4};
// index 的区间[0,4)
int index = new Random().nextInt(4);
// 这里为了测试写死总共轮询10次
for (int i = index; i < 10; i++) {
// 下一个轮询索引(开始位置)
int nextIndex = (index + i) % 4; // 取模后nextIndex的区间只会在[0,4)
System.out.println("依次输出:" + ints[nextIndex]);
}
}
负载均衡
这里主要通俗易懂的讲一下负载均衡的概念,可以实现负载均衡的方式有很多,这里不做赘述。
假如你现在只有一个服务器运行着项目,本来没有什么问题,但是因为用户量不停的增加,导致服务器承受不住了,再这样下去项目要垮了。这个时候就可以多买几台服务器分别部署项目,分别部署了以后还要保证用户的请求可以均匀的分发到每一台服务器上。
这个时候就有人发明了负载均衡机制,它可以通过某种算法将用户的请求均匀的发布到每一台服务器上面,并且如果其中某台服务器宕机了,它会在尝试发送几次无果以后跳过该服务器发送到还在存活的服务器上。
比如Nginx就是专门处理负载均衡的软件,在Nginx上面可以部署多个服务器,用户发送的请求会先到Nginx服务,然后Nginx再通过负载均衡算法请求到对应服务器上面去。
NameServer/Broker
启动RocketMQ时需要先启动NameServer,它相当于一个Broker平台,生产者和消费者想要发送或者消费消息时都需要到Broker平台上面获取Broker容器的IP地址建立连接。
NameServer主要是通过负载均衡算法去获取Broker的信息, NameServer 与每台 Broker 服务保持长连接,并间隔 30S 检查 Broker 是否存活,如果检测到 Broker 宕机,则从路由注册表中将其移除。
Topic(主题)
标识一类消息的逻辑名字,消息的逻辑管理单位。无论消息生产还是消费,都需要指定 Topic。 区分消息的种类;一个发送者可以发送消息给一个或者多个 Topic;一个消息的接收者可以订阅一个或者多个 Topic 消息 。
Topic 保存在 Broker 容器内,而每个Topic下都会有一个或多个队列,队列的数量可以通过客户端进行配置,这个队列就是用来存放消息的。
Message Queue(消息队列)
简称 Queue 或 Q。消息物理管理单位。一个 Topic 将有若干个 Q。若一个 Topic 创建在不同的 Broker,则不同的 broker 上都有若干 Q,消息将物理地 存储落在不同 Broker 结点上,具有水平扩展的能力。
无论生产者还是消费者,实际的生产和消费都是针对 Q 级别。例如 Producer 发送消息的时候,会预先选择(默认轮询)好该 Topic 下面的某一条 Q 发送;Consumer 消费的时候也会负载均衡地分配若干个 Q,只拉取对应 Q 的消息。 每一条 message queue 均对应一个文件,这个文件存储了实际消息的索引信息。并且即使文件被删除,也能通过实际纯粹的消息文件(commit log) 恢复回来
commit log文件
生产者发送消息并不是直接发送到消息队列中去的,而是以顺序写入的方式发送到commit log文件,commit log文件是MQ创建的一个大概1G的连续内存空间,如果一个commit log文件满了以后会再次分配1G创建一个commit log文件。
生产者发送消息到commit log文件,commit log文件保存消息的具体内容,然后把消息的offset、msgSize、tags给到消息队列。当像消费者投递消息时Broker会根据offset找到commit log文件对应消息的具体位置将消息投递给消费者。
- offset:是消息在Commit Log中的起始位置,用于定位消息。
- msgSize:表示消息的大小,用于读取消息时确定读取的长度。
- tags:是消息的标签,用于消息的过滤和查询。
Tags(标签)
虽然消息可以根据不同Topic进行了分类,但是有时候业务可能还需要根据同一个Topic下的消息再进行更细化的分类,这个时候就可以使用Tags属性,Tags可以理解为为每个Topic下的消息设一个标签,生产者和消费者可以订阅某个主题下的某个Tags下的所有消息,而不是某个主题下的全部消息。
Producer(生产者)
生产者也称为消息发布者,负责生产并发送消息至 RocketMQ。
下面是一个生产者发送消息的过程图解:
Consumer(消费者)
消费者也称为消息订阅者,负责从 RocketMQ 拉取消息。
当只有一个消费者订阅某个主题的消息进行消费时,这个消费者会消费这个主题下所有队列的消息,如下图:
当有两个消费者订阅某个主题的消息进行消费时,Broker会采用负载均衡的方式给消费者重新分配队列,如下图:
可以看到,broker会均匀的分配队列给消费者,Consumer1只消费0、2队列里面的消息,Consumer2只消费1、3队列里面的消息,这也会导致0、2队列中的消息Consumer2一直也消费不到。
如果再新增一个消费者那么就总会有一个消费者只能消费一个队列中的消息,以此类推。这里需要注意,假如现在的队列是四个,而同一个消费组里面的消费者有五个,那么会有一个队列被两个消费者消费吗?答案并不会。第五个消费者会一直都无法消费到消息。所以同一消费者组下的消费者的数量要小于等于消费主题下队列的数量。
ConsumerGroup(消费者组)
当想要创建一个消费者的时候,必须要给消费者定义一个组名(生产者也一样),多个消费者可以使用同一个组名进行消费。同一个消费者组中的Consumer和不同消费者组的Consumer消费消息的方式是不太一样的。
从上面图示可以看到,同一消费者组下的Consumer消费方式是负载均衡的,它还有一个名称叫做集群模式,一条消息只能被一个Consumer消费,而不是投递给每一个Consumer。
在面对不同消费组订阅同一个Topic时,Broker会把消息分别投送给消费者组,然后再根据消费者组内Consumer的分配关系进行消息分配,每一个消费者组都会消费全量消息。
集群模式和广播模式
消费者可以分为两种消费模式,分别是集群模式和广播模式,默认情况下统一消费者组下的消费者采取的是集群模式,消费者分担消费消息。
- 集群模式:我们可以通过以下代码来设置采用集群模式,RocketMQ Push Consumer默认为集群模式,同一个消费组内的消费者分担消费。
consumer.setMessageModel(MessageModel.CLUSTERING);
- 广播模式:通过以下代码来设置采用广播模式,广播模式下,消费组内的每一个消费者都会消费全量消息。
consumer.setMessageModel(MessageModel.BROADCASTING);
生产者代码:
@Test
void contextLoads() throws Exception {
// 创建生产者并设置组名
DefaultMQProducer mqProducer = new DefaultMQProducer("rocketmq-producer");
// 连接 name server
mqProducer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 启动生产者
mqProducer.start();
List<Order> orders = createOrder();
for (Order order : orders) {
// 创建消息
Message message = new Message("test-topic", order.toString().getBytes());
message.setKeys(String.valueOf(order.getOrderId()));
// 发送携带Key值的消息
SendResult send = mqProducer.send(message);
System.out.println("发送消息状态:" + send.getSendStatus());
}
// 关闭生产者
mqProducer.shutdown();
}
public List<Order> createOrder(){
List<Order> orders = Arrays.asList(
new Order(1001, "新增订单"),
new Order(1002, "新增订单"),
new Order(1003, "新增订单")
);
return orders;
}
/**
* 订单类
**/
class Order{
private Integer orderId; // 订单号
private String orderDesc; // 订单描述
public Order(Integer orderId, String orderDesc) {
this.orderId = orderId;
this.orderDesc = orderDesc;
}
public Integer getOrderId() {
return orderId;
}
public void setOrderId(Integer orderId) {
this.orderId = orderId;
}
public String getOrderDesc() {
return orderDesc;
}
public void setOrderDesc(String orderDesc) {
this.orderDesc = orderDesc;
}
@Override
public String toString() {
return "Order{" +
"orderId=" + orderId +
", orderDesc='" + orderDesc + '\'' +
'}';
}
}
消费者设置为集成模式,在相同消费者组内的多个消费者场景:
@Test
void consumer1() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 消费者模式:集群模式
mqPushConsumer.setMessageModel(MessageModel.CLUSTERING);
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
String body = new String(messageExt.getBody()); // 获取消息体
System.out.println("consumer1 消费到的消息:=> " + body);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
控制台输出:
consumer1 消费到的消息:=> Order{orderId=1003, orderDesc='新增订单'}
@Test
void consumer2() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 消费者模式:集群模式
mqPushConsumer.setMessageModel(MessageModel.CLUSTERING);
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
msgs.stream().forEach(messageExt -> {
System.out.println("consumer2 消费到的消息 => " + new String(messageExt.getBody()));
});
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
控制台输出:
consumer2 消费到的消息 => Order{orderId=1001, orderDesc='新增订单'}
consumer2 消费到的消息 => Order{orderId=1002, orderDesc='新增订单'}
消费者设置为广播模式,在相同消费者组内的多个消费者场景:
@Test
void consumer1() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 消费者模式:广播模式
mqPushConsumer.setMessageModel(MessageModel.BROADCASTING);
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
String body = new String(messageExt.getBody()); // 获取消息体
System.out.println("consumer1 消费到的消息:=> " + body);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
控制台输出:
consumer1 消费到的消息:=> Order{orderId=1001, orderDesc='新增订单'}
consumer1 消费到的消息:=> Order{orderId=1002, orderDesc='新增订单'}
consumer1 消费到的消息:=> Order{orderId=1003, orderDesc='新增订单'}
@Test
void consumer2() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 消费者模式:广播模式
mqPushConsumer.setMessageModel(MessageModel.BROADCASTING);
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
msgs.stream().forEach(messageExt -> {
System.out.println("consumer2 消费到的消息 => " + new String(messageExt.getBody()));
});
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
控制台输出:
consumer2 消费到的消息 => Order{orderId=1001, orderDesc='新增订单'}
consumer2 消费到的消息 => Order{orderId=1002, orderDesc='新增订单'}
consumer2 消费到的消息 => Order{orderId=1003, orderDesc='新增订单'}
订阅关系一致
订阅关系:一个消费者组订阅一个 Topic 的某一个 Tag,这种记录被称为订阅关系。
订阅关系一致:同一个消费者组下所有消费者实例所订阅的Topic、Tag必须完全一致。如果订阅关系(消费者组名-Topic-Tag)不一致,会导致消费消息紊乱,甚至消息丢失。
RocketMQ 可以解决什么问题?
- 削峰限流
比如我们把项目部署到Tomcat服务上启动,假如Tomcat最大并发量只有四百,这个时候如果有上千个请求并发过来,那么超过服务器限制的请求就会被放弃,也就是我们常见的浏览器抛出503提示服务不可用。
那么使用RocketMQ就可以设置一个阈值,将超出的请求缓存起来,等后面服务器可以接受新的请求后再进行处理。
- 异步
在复杂的业务逻辑中,有些操作可能不需要立即返回结果,或者它们的执行时间较长,可能会阻塞主流程的执行。
通过RocketMQ,我们可以将这些耗时操作以异步的方式发送到消息队列中,让后台线程或独立的服务去处理它们。这样,主流程可以迅速返回,提高系统的响应速度。同时,异步处理还可以提高系统的吞吐量,因为多个操作可以并行执行,而不是串行等待。
- 解耦
在微服务架构或分布式系统中,服务之间的依赖关系可能非常复杂。当某个服务出现故障时,可能会影响到其他服务的正常运行。RocketMQ通过消息队列实现服务之间的松耦合通信。
发送方将消息发送到队列,而不需要关心接收方的具体实现和状态。接收方可以根据自己的需求订阅相应的队列,并独立处理接收到的消息。这种方式降低了服务之间的耦合度,提高了系统的灵活性和可维护性。
生产者
同步消息
同步发送是最常用的方式,是指消息发送方发出一条消息后,会在收到服务端同步响应之后才发下一条消息的通讯方式,可靠的同步传输被广泛应用于各种场景,如重要的通知消息、短消息通知等。
生命周期:
- 初始化:消息被生产者构建并完成初始化,待发送到服务端的状态。
- 待消费:消息被发送到服务端,对消费者可见,等待消费者消费的状态。
- 消费中:消息被消费者获取,并按照消费者本地的业务逻辑进行处理的过程。 此时服务端会等待消费者完成消费并提交消费结果,如果一定时间后没有收到消费者的响应,Apache RocketMQ会对消息进行重试处理。具体信息,请参见消费重试。
- 消费提交:消费者完成消费处理,并向服务端提交消费结果,服务端标记当前消息已经被处理(包括消费成功和失败)。 Apache RocketMQ默认支持保留所有消息,此时消息数据并不会立即被删除,只是逻辑标记已消费。消息在保存时间到期或存储空间不足被删除前,消费者仍然可以回溯消息重新消费。
- 消息删除:Apache RocketMQ按照消息保存机制滚动清理最早的消息数据,将消息从物理文件中删除。更多信息,请参见消息存储和清理机制。
使用示例:
@Test
void producer() throws Exception{
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息并设置主题和消息体
Message message = new Message("test-topic", "测试消息".getBytes());
// 5.同步发送消息
for (int i = 0; i < 3; i++) {
System.out.println("开始发送第"+i+"条消息");
SendResult send = defaultMQProducer.send(message);
// 等待发送结果才能继续向下执行......
System.out.println("第"+i+"条消息发送状态:" + send.getSendStatus());
}
// 等待发送结果才能继续向下执行......
System.out.println("发送消息状态:" + send.getSendStatus());
System.out.println("继续下面业务逻辑...");
// 6.关闭生产者
defaultMQProducer.shutdown();
}
控制台输出:
发送原理:
- 生产者向MQ服务器发送消息
- MQ服务器收到消息后返回处理结果
- 生产者继续下一步…
❗备注 同步发送方式请务必捕获发送异常,并做业务侧失败兜底逻辑,如果忽略异常则可能会导致消息未成功发送的情况。
异步消息
异步发送是指发送方发出一条消息后,不等服务端返回响应,接着发送下一条消息的通讯方式。
❗备注 异步发送需要实现异步发送回调接口(SendCallback)。
消息发送方在发送了一条消息后,不需要等待服务端响应即可发送第二条消息,发送方通过回调接口接收服务端响应,并处理响应结果。异步发送一般用于链路耗时较长,对响应时间较为敏感的业务场景。例如,视频上传后通知启动转码服务,转码完成后通知推送转码结果等。
使用示例:
@Test
void producer() throws Exception{
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 在这里定义一个程序计数器,初始值为线程的数量
int messageCount = 3;
final CountDownLatch countDownLatch = new CountDownLatch(messageCount);
// 4.创建消息并设置主题和消息体
Message message = new Message("test-topic", "测试消息".getBytes());
// 5.异步发送
for (int i = 0; i < messageCount; i++) {
defaultMQProducer.send(message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println("线程["+Thread.currentThread().getName()+"]发送消息成功:" + sendResult.getSendStatus());
countDownLatch.countDown();
}
@Override
public void onException(Throwable throwable) {
System.out.println("线程["+Thread.currentThread().getName()+"]条消息发送失败:" + throwable);
countDownLatch.countDown();
}
});
}
System.out.println("线程["+Thread.currentThread().getName()+"] 继续下面业务逻辑...");
// 6.关闭生产者,异步发送,如果要求可靠传输,必须要等回调接口返回明确结果后才能结束逻辑,否则立即关闭Producer可能导致部分消息尚未传输成功
countDownLatch.await(5, TimeUnit.SECONDS);
defaultMQProducer.shutdown();
}
控制台输出:
这里用到了CountDownLatch
,快速了解请到:CountDownLatch详解以及用法示例-CSDN博客
发送原理:
- 生产者异步发送消息,不用等待MQ返回结果再继续执行
- 发送后继续后面业务逻辑
- 等待MQ回调响应,处理回调逻辑。
❗备注 异步发送与同步发送代码唯一区别在于调用send接口的参数不同,异步发送不会等待发送返回,取而代之的是send方法需要传入
SendCallback 的实现,SendCallback 接口主要有onSuccess 和 onException
两个方法,表示消息发送成功和消息发送失败。
单向消息
发送方只负责发送消息,不等待服务端返回响应且没有回调函数触发,即只发送请求不等待应答。此方式发送消息的过程耗时非常短,一般在微秒级别。适用于某些耗时非常短,但对可靠性要求并不高的场景,例如日志收集。
使用示例:
@Test
void producer() throws Exception{
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息并设置主题和消息体
Message message = new Message("test-topic", "测试消息".getBytes());
// 5.单向发送
for (int i = 0; i < 3; i++) {
System.out.println("开始发送第"+i+"条消息");
defaultMQProducer.sendOneway(message);
}
System.out.println("继续下面业务逻辑...");
// 6.关闭生产者
defaultMQProducer.shutdown();
}
控制台输出:
可以看出,单向消息除了不关心发送结果以外,也属于同步消息。
发送原理:
- 生产者发送消息到MQ
- 继续下面业务逻辑
延时消息
使用示例:
@Test
void producer() throws Exception{
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息并设置主题和消息体
Message message = new Message("test-topic", "测试消息".getBytes());
// 5.设置延迟时间 Apache RocketMQ 一共支持18个等级的延迟投递,具体时间可参考官方文档
message.setDelayTimeLevel(3); // level 3 代表延迟 10s发送
// 6.延迟发送
SendResult send = defaultMQProducer.send(message);
System.out.println("发送消息返回状态:"+ send.getSendStatus());
System.out.println("继续下面业务逻辑...");
// 7.关闭生产者
defaultMQProducer.shutdown();
}
控制台输出:
💡提示 这里最重要的是message中设置延迟等级,例子中设置的等级是3,也就是发送者发送后,10s后消费者才能收到消息。
💡提示
延时消息的实现逻辑需要先经过定时存储等待触发,延时时间到达后才会被投递给消费者。因此,如果将大量延时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。
顺序消息
顺序消息指的是(FIFO),是单词 First In First Out 的缩写,表示先先出原则。故而保持顺序消息的前提是生产者和消费者都要以有序的方式进行发送和接收。既然要保持顺序,那么就不可以使用异步的方式进行发送消息和处理消息。
下面是使用顺序消费需要遵循的原则:
- **单一生产者:**消息生产的顺序性仅支持单一生产者,不同生产者分布在不同的系统,即使设置相同的消息组,不同生产者之间产生的消息也无法判定其先后顺序。
- **串行发送和消费:**生产者和消费者都应该保持同步状态。
- **有限重试:**需要设置合理的重试次数,因为如果某条消息一直重试消费失败,将会跳过这条消息。
代码实战:
比如现在有一个电商业务,我们需要发送按照订单的支付、创建订单、新增物流这样的一个顺序进行发送和消费。
如果按照异步发送消费的方式,将会出现下面结果:
可以看到消费者先执行了订单1003的创建订单逻辑,又走了支付下单逻辑,最后走了新增物流,执行顺序明显不是我们想要的。
接下来实现顺序发送和消费的代码,来看看效果:
/**
* 订单类
**/
class Order{
private Integer orderId; // 订单号
private String orderDesc; // 订单描述
public Order(Integer orderId, String orderDesc) {
this.orderId = orderId;
this.orderDesc = orderDesc;
}
public List<Order> createOrder(){
List<Order> orders = Arrays.asList(
new Order(1001, "支付下单"),
new Order(1001, "创建订单"),
new Order(1001, "新增物流"),
new Order(1002, "支付下单"),
new Order(1002, "创建订单"),
new Order(1002, "新增物流"),
new Order(1003, "支付下单"),
new Order(1003, "创建订单"),
new Order(1003, "新增物流")
);
return orders;
}
public Integer getOrderId() {
return orderId;
}
public void setOrderId(Integer orderId) {
this.orderId = orderId;
}
public String getOrderDesc() {
return orderDesc;
}
public void setOrderDesc(String orderDesc) {
this.orderDesc = orderDesc;
}
@Override
public String toString() {
return "Order{" +
"orderId=" + orderId +
", orderDesc='" + orderDesc + '\'' +
'}';
}
}
@Test
void contextLoads() throws Exception {
// 创建生产者并设置组名
DefaultMQProducer mqProducer = new DefaultMQProducer("rocketmq-producer");
// 连接 name server
mqProducer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 启动生产者
mqProducer.start();
List<Order> orders = createOrder();
for (Order order : orders) {
// 创建消息
Message message = new Message("test-topic", order.toString().getBytes());
// 发送顺序消息,第二个参数需要实现MessageQueueSelector的select方法,通过该方法可以获取到该主题下的消息队列信息
SendResult send = mqProducer.send(message, new MessageQueueSelector() {
/**
* @param mqs test-topic主题下的队列信息
* @param msg 发送的消息对象
* @param arg 对应send的第三个参数,也就是orderId
**/
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
System.out.println("msg:"+new String(msg.getBody()));
System.out.println("arg:"+ arg);
System.out.println("mqs 消息队列数量:"+ mqs.size());
// 发送顺序消息需要保证同一批订单都在同一个队列中,这里利用hashCode和取模完成
int i = arg.hashCode(); // 得到orderId的哈希值
System.out.println("orderId:"+arg+" => "+i);
int index = i % mqs.size(); // 取模运算的一个关键特点是,其值的范围在0到除数(不包括除数本身)之间,也就是[0,3)
System.out.println("orderId:"+arg+" 存放在第"+index+"个队列中");
return mqs.get(index); // 返回要存放的消息队列
}
}, order.getOrderId());
System.out.println("发送消息状态:" + send.getSendStatus());
}
// 关闭生产者
mqProducer.shutdown();
}
public List<Order> createOrder(){
List<Order> orders = Arrays.asList(
new Order(1001, "支付下单"),
new Order(1001, "创建订单"),
new Order(1001, "新增物流"),
new Order(1002, "支付下单"),
new Order(1002, "创建订单"),
new Order(1002, "新增物流"),
new Order(1003, "支付下单"),
new Order(1003, "创建订单"),
new Order(1003, "新增物流")
);
return orders;
}
@Test
void consumer1() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer1");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> list, ConsumeOrderlyContext consumeOrderlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("消费到的消息 => " + new String(messageExt.getBody()));
return ConsumeOrderlyStatus.SUCCESS;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
输出结果:
批量消息
上述我们举的例子都属于单个消息的创建和发送,而批量消息就是将消息打包成集合进行发送,使用批量消息需要注意两点:
- 需要注意的是批量消息的大小不能超过 1MiB(否则需要自行分割)
- 同一批 batch 中 topic 必须相同。
使用示例:
@Test
void producer() throws Exception {
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息集合,注意:①同一批消息的topic必须相同否则会报错。②批量消息的大小不能超过 1MiB(否则需要自行分割)
String topic = "test-topic";
List<Message> messages = Arrays.asList(
new Message(topic, "Hello world 0".getBytes()),
new Message(topic, "Hello world 1".getBytes()),
new Message(topic, "Hello world 2".getBytes()),
new Message(topic, "Hello world 4".getBytes())
);
// 5.批量发送
SendResult send = defaultMQProducer.send(messages);
System.out.println("发送消息状态:" + send.getSendStatus());
System.out.println("继续下面业务逻辑...");
// 6.关闭生产者
defaultMQProducer.shutdown();
}
控制台输出:
发送原理:
- 生产者将消息打包成集合发送到MQ
- MQ将收到的消息集合统一放进同一个消息队列中
- 消费者根据消息队列中的顺序进行消费
为啥什么要使用批量消息呢?将数据打包成集合放到一个消息中不就好了?
其实一条消息中所包含的内容大小是有限制的,一般是4MB(4194304字节),所以当我们发送的消息超过了这个限制的时候,MQ就会抛错了,比如下面列子,为了测试我们循环了一百万次创建了一个超过4MB的集合进行发送:
@Test
void producer() throws Exception {
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息
String topic = "test-topic";
ArrayList<String> strings = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
strings.add("Hello world"+i);
}
Message message = new Message(topic, strings.toString().getBytes());
// 5.同步发送
SendResult send = defaultMQProducer.send(message);
System.out.println("发送消息状态:" + send.getSendStatus());
System.out.println("继续下面业务逻辑...");
// 6.关闭生产者
defaultMQProducer.shutdown();
}
执行上面代码,控制台会直接抛出错误:org.apache.rocketmq.client.exception.MQClientException: CODE: 13 DESC: the message body size over max value, MAX: 4194304
意思是消息正文大小超过最大值,最大值:4194304。
所以为了解决这种问题,我们就可以采取分批的形式进行发送。
消费者
Push消费和Pull消费
- 消息获取方式:
- Push消费:在这种模式下,消息服务器会主动将消息推送给消费者。消费者无需主动向服务器请求消息,只需在连接建立后等待消息的推送。这种方式的实时性较强,但可能会因为推送速度过快而导致消费者处理不过来,造成消息堆积。
- Pull消费:与Push消费相反,Pull消费模式下,消费者需要主动向消息服务器发送请求,拉取消息进行消费。消费者可以根据自己的处理能力,控制拉取消息的频率和数量,从而避免消息堆积的风险。但这也可能导致消费者在处理完一批消息后,需要等待一段时间才能再次拉取新的消息,从而影响实时性。
- 实现机制:
- 虽然从表面上看,Push消费是消息服务器主动推送消息,但实际上,RocketMQ的Push消费在底层仍然是通过Pull机制实现的。RocketMQ采用长轮询的方式,模拟了Push的效果。当消费者与服务器建立连接后,消费者会不断向服务器发送拉取请求,但服务器只有在有新消息时才会返回数据。这种方式既保证了消息的实时性,又避免了无效的网络请求。
- Pull消费则完全依赖于消费者的主动请求。消费者需要定期或根据一定的策略向服务器发送拉取请求,获取新的消息。
- 适用场景:
- Push消费适用于对实时性要求较高,且消费者处理能力较强的场景。例如,一些实时性要求高的在线系统或实时数据分析场景。
- Pull消费则更适用于消费者处理能力有限,或需要更精细地控制消息处理速度的场景。例如,一些后台处理任务或批量处理场景。
以为Pull模式在MQ中已经不推荐使用了,所以这里只举例Push模式的使用,Pull模式的使用请参照官方文档:Pull消费 | RocketMQ
Push消费举例:
@Test
void consumer() throws Exception{
// 1.创建 Push 消费,并设置组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test-consumer-group");
// 2.连接 name server
consumer.setNamesrvAddr("localhost:9876");
// 3.订阅主题 "*" 表示订阅这个主题的全部消息
consumer.subscribe("test-topic","*");
// 4.MessageListenerConcurrently 异步监听消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("收到的消息内容:" + new String(messageExt.getBody()));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
// 挂在jvm,可以让线程一直处在运行状态
System.in.read();
}
同步消费
使用MessageListenerOrderly
可以实现同步消费,具体代码举例:
@Test
void consumer() throws Exception{
// 1.创建 Push 消费,并设置组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test-consumer-group");
// 2.连接 name server
consumer.setNamesrvAddr("localhost:9876");
// 3.订阅主题 "*" 表示订阅这个主题的全部消息
consumer.subscribe("test-topic","*");
// 4.同步监听消息
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> list, ConsumeOrderlyContext consumeOrderlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("收到的消息内容:" + new String(messageExt.getBody()));
return ConsumeOrderlyStatus.SUCCESS;
}
});
// 5.启动消费者
consumer.start();
// 挂在jvm
System.in.read();
}
异步消费
使用MessageListenerConcurrently
可以实现异步消费,上面的Push消费模式举例代码使用的就是异步消费模式。
@Test
void consumer() throws Exception{
// 1.创建 Push 消费,并设置组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test-consumer-group");
// 2.连接 name server
consumer.setNamesrvAddr("localhost:9876");
// 3.订阅主题 "*" 表示订阅这个主题的全部消息
consumer.subscribe("test-topic","*");
// 4.异步监听
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("收到的消息内容:" + new String(messageExt.getBody()));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 5.启动消费者
consumer.start();
// 挂在jvm
System.in.read();
}
过滤消息
消息过滤是指消息生产者向Topic中发送消息时,设置消息属性对消息进行分类,消费者订阅Topic时,根据消息属性设置过滤条件对消息进行过滤,只有符合过滤条件的消息才会被投递到消费端进行消费。
消费者订阅Topic时若未设置过滤条件,无论消息发送时是否有设置过滤属性,Topic中的所有消息都将被投递到消费端进行消费。
RocketMQ支持的消息过滤方式有两种,Tag过滤和SQL92过滤。
过滤方式 | 说明 | 场景 |
---|---|---|
Tag过滤 | 消费者订阅的Tag和发送者设置的消息Tag相互匹配,则消息被投递给消费端进行消费。 | 简单过滤场景。一条消息支持设置一个Tag,仅需要对Topic中的消息进行一级分类并过滤时可以使用此方式。 |
SQL92过滤 | 发送者设置Tag或消息属性,消费者订阅满足SQL92过滤表达式的消息被投递给消费端进行消费。 | 复杂过滤场景。一条消息支持设置多个属性,可根据SQL语法自定义组合多种类型的表达式对消息进行多级分类并实现多维度的过滤。 |
订阅关系一致
在讲过滤消费之前,我们必须先了解什么是订阅关系一致,同一个消费者组中的消费者必须要保持订阅关系一致,不然会导致消息紊乱的问题出现。那么什么是订阅关系一致呢?
**订阅关系:**一个消费者组订阅一个 Topic 的某一个 Tag,这种记录被称为订阅关系。
**订阅关系一致:**同一个消费者组下所有消费者实例所订阅的Topic、Tag必须完全一致。如果订阅关系(消费者组名-Topic-Tag)不一致,会导致消费消息紊乱,甚至消息丢失。
Tag 过滤
以电商交易场景为例,从客户下单到收到商品这一过程会生产一系列消息,以如下消息为例:
- 订单消息
- 支付消息
- 物流消息
这些消息会发送到MQ中,被各个不同的系统所订阅,以如下系统为例:
- 支付系统:只需订阅支付消息。
- 物流系统:只需订阅物流消息。
- 实时计算系统:需要订阅所有和交易相关的消息。
- 交易成功率分析系统:需订阅订单和支付消息。
生产者代码示例:
@Test
void producer() throws Exception {
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息
String topic = "test-topic";
List<Message> messages = Arrays.asList(
new Message(topic, "TagB", "TagB Hello world!".getBytes()),
new Message(topic, "TagA", "TagB Hello world!".getBytes())
);
// 5.同步发送
SendResult send = defaultMQProducer.send(messages);
System.out.println("发送消息状态:" + send.getSendStatus());
// 6.关闭生产者
defaultMQProducer.shutdown();
}
控制台输出:
消费者代码示例:
@Test
void consumer() throws Exception{
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test-consumer-group");
// 连接 name server
consumer.setNamesrvAddr("localhost:9876");
// 订阅消息
consumer.subscribe("test-topic", "TagB");
// MessageListenerConcurrently 异步监听消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("收到的消息内容:" + new String(messageExt.getBody()));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
// 挂在jvm
System.in.read();
}
**控制台输出:**
可以看到,只消费了TagB的消息,TagA的消息并没有被消费,那如果TagA和TagB的消息都需要消费呢?可以使用 || 符号隔开,比如: consumer.subscribe("test-topic", "TagB || TagA");
SQL92过滤
SQL属性过滤是 Apache RocketMQ 提供的高级消息过滤方式,通过生产者为消息设置的属性(Key)及属性值(Value)进行匹配。生产者在发送消息时可设置多个属性,消费者订阅时可设置SQL语法的过滤表达式过滤多个属性。
💡信息
Tag是一种系统属性,所以SQL过滤方式也兼容Tag标签过滤。在SQL语法中,Tag的属性名称为TAGS。开启属性过滤首先要在Broker端设置配置enablePropertyFilter=true,该值默认为false。
以电商交易场景为例,从客户下单到收到商品这一过程会生产一系列消息,按照类型将消息分为订单消息和物流消息,其中给物流消息定义地域属性,按照地域分为杭州和上海:
- 订单消息
- 物流消息
- 物流消息且地域为杭州
- 物流消息且地域为上海
这些消息会发送到MQ中,被各个不同的系统所订阅:
- 物流系统1:只需订阅物流消息且消息地域为杭州。
- 物流系统2:只需订阅物流消息且消息地域为杭州或上海。
- 订单跟踪系统:只需订阅订单消息。
- 实时计算系统:需要订阅所有和交易相关的消息。
携带Key值发送消息
@Test
void contextLoads() throws Exception {
// 创建生产者并设置组名
DefaultMQProducer mqProducer = new DefaultMQProducer("rocketmq-producer");
// 连接 name server
mqProducer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 启动生产者
mqProducer.start();
List<Order> orders = createOrder();
for (Order order : orders) {
// 创建消息
Message message = new Message("test-topic", order.toString().getBytes());
// 设置消息的key值,尽量使用唯一值,可以设置多个值当做key值
message.setKeys(String.valueOf(order.getOrderId()));
// 发送携带Key值的消息
SendResult send = mqProducer.send(message);
System.out.println("发送消息状态:" + send.getSendStatus());
}
// 关闭生产者
mqProducer.shutdown();
}
public List<Order> createOrder(){
List<Order> orders = Arrays.asList(
new Order(1001, "新增订单"),
new Order(1002, "新增订单"),
new Order(1003, "新增订单")
);
return orders;
}
/**
* 订单类
**/
class Order{
private Integer orderId; // 订单号
private String orderDesc; // 订单描述
public Order(Integer orderId, String orderDesc) {
this.orderId = orderId;
this.orderDesc = orderDesc;
}
public Integer getOrderId() {
return orderId;
}
public void setOrderId(Integer orderId) {
this.orderId = orderId;
}
public String getOrderDesc() {
return orderDesc;
}
public void setOrderDesc(String orderDesc) {
this.orderDesc = orderDesc;
}
@Override
public String toString() {
return "Order{" +
"orderId=" + orderId +
", orderDesc='" + orderDesc + '\'' +
'}';
}
}
// 这里为了做演示,使用map进行去重,实际开发中可以使用redis分布式锁,或者数据库去重表等方式。
HashMap map = new HashMap<String, String>();
@Test
void consumer1() throws Exception {
// 创建一个消费者并设置组名
DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("rocketmq-consumer1");
// 连接 name server
mqPushConsumer.setNamesrvAddr(MQConstants.NAME_SERVER);
// 订阅主题 "*" 表示订阅所有消息
mqPushConsumer.subscribe("test-topic", "*");
// 设置监听器用来监听消息
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
String keys = messageExt.getKeys(); // 获取消息唯一标识
// 根据这个keys值可以解决重复消费的问题
System.out.println("keys:"+ keys);
String body = new String(messageExt.getBody()); // 获取消息体
System.out.println("获取到的消息:=> " + body);
if (map.containsKey(keys)) { // 如果重复了,就签收消息不做任何操作
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
System.out.println("新增订单数据:=> " + body);
map.put(keys,"");
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者服务
mqPushConsumer.start();
// jvm 挂载
System.in.read();
}
输出结果:
这里为了演示key值的使用,生产者将数据发送了两次使数据重复,然后再通过key值完成去重操作,解决重复消费的问题。
重复消费问题
什么情况会导致重复消费?
- **生产者重复投递消息:**比如生产者本身投递的数据有问题,或者投递的数据还没被消费就又投递一次等等很多bug场景都有可能导致消息重复。
- **消费者扩容导致rebalance(重平衡):**增加消费者会触发MQ的重平衡机制,或者新增消费者组并且和已有消费者订阅了相同主题,那么MQ会把这个主题的所有消息再发一遍,即使它已经被别的消费者组中的消费者和消费过。
- **消费者代码逻辑问题:**假如一个订单在被消费时新增数据成功之后走后面业务逻辑出错,代码捕获异常并触发重试消费,这样就会导致该订单在一段时间后再次被投递消费。
如何解决重复消费?
- 数据库层面,建立去重表:
- 第一步,在数据库建立一个去重表,保存消息相关数据,并在消息唯一字段建立唯一索引。
- 第二步,消费者在消费消息时,先对去重表进行插入操作,因为插入操作只会出现两种情况,插入成功和抛异常,插入成功执行后面业务逻辑,插入失败就捕获异常并判断是否是
SQLIntegrityConstraintViolationException
异常,如果是就不做任何处理并签收消息。 - 第三步,有时候需要在合适的地方删除去重表的数据,比如消费一条订单数据时,第一次消费插入去重表成功,继续业务逻辑,但是在执行业务逻辑时出错,比如在插入订单表的时候出错了,消息就会进行重试消费,但是这个时候去重表已经存在这条数据,所以会导致该条消息不能再被消费。
缺点:使用去重表的方式会将压力都给到数据库,当数据量过大时会导致数据库不稳定甚至宕机。
- Redis 分布式锁
消息丢失和消息堆积问题
什么情况会导致消息丢失?
- 发送消息到MQ后从缓存中获取消息异步刷盘出现问题,导致消息丢了
- MQ将消息正常写入磁盘,但是磁盘坏了又没有备份,导致消息全丢。
- 消费消息时出现异常,但又没对异常进行处理,MQ误以为已经消费成功了,导致消息丢了。
如何解决消息丢失问题
- 首先需要将异步刷盘策略改为同步刷盘,这一步需要修改Broker的配置文件,将
flushDiskType
改为SYNC_FLUSH
同步刷盘策略,默认的是ASYNC_FLUSH
异步刷盘。一旦同步刷盘返回成功,那么就一定保证消息已经持久化到磁盘中了; - 对MQ做集群部署,这样一个磁盘故障了,还有另一个磁盘,磁盘间的数据要及时同步;
- 在消费消息时如果出现异常要做相应的处理,比如让MQ进行消息重试并且打印到日志文件或者写表的方式将异常的消息记录下来。
什么情况会导致消息堆积?
- 新上线的消费者功能有BUG,消息无法被消费。
- 生产者短时间内推送大量消息至Broker,消费者消费能力不足。
- 生产者未感知Broker消费堆积持续向Broker推送消息。
如何解决消息堆积问题?
- 前期做好测试工作,保证测试环境没有问题再上线。
- 调整消费者的并发数或者增加消费者的数量,但是需要保证同一消费组的消费者数量小于等于消息队列数量,同时也可以增加消息队列的数量。
- 要做到 熔断与隔离。当一个Broker的队列出现消息积压时,要对其熔断,将其隔离,将新消息发送至其它队列,过一定的时间,再解除其隔离。
消息重试和死信队列
消息重试
首先要知道为什么会消息重试,哪些情况会导致消息重试?
- 消费消息时出现异常导致消息没有被正常签收。
- 手动在消费者设置返回状态为重试状态
通过代码来实现第一种情况的例子:
@Test
void producer() throws Exception {
// 1.创建生产者
DefaultMQProducer defaultMQProducer = new DefaultMQProducer("test-producer-group");
// 2.连接 name server
defaultMQProducer.setNamesrvAddr("localhost:9876");
// 3.启动生产者
defaultMQProducer.start();
// 4.创建消息
String topic = "test-topic1";
Message message = new Message(topic, "TagC", "TagC-c Hello world!".getBytes());
// 5.同步发送
SendResult send = defaultMQProducer.send(message);
System.out.println("发送消息状态:" + send.getSendStatus());
// 6.关闭生产者
defaultMQProducer.shutdown();
}
@Test
void consumer() throws Exception{
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("test-consumer-group");
// 连接 name server
consumer.setNamesrvAddr("localhost:9876");
// 订阅消息
consumer.subscribe("test-topic1", "TagC");
// 设置重试次数为3次
consumer.setMaxReconsumeTimes(3);
// MessageListenerConcurrently 异步监听消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
MessageExt messageExt = list.get(0);
System.out.println("收到的消息内容:" + new String(messageExt.getBody()) + "时间" + DateUtil.now());
int i = 10/0; // 异常代码
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
// 挂载jvm
System.in.read();
}
控制台输出:
生产者发送一条消息到test-topic1
主题的TagC
,消费者订阅这个主题的消息,第一次消费时遇到了异常,没有正常返回CONSUME_SUCCESS
给到MQ,过了一段时间后这条消息再次被消费(这里同样出现了重复消费的问题),这就是第一种情况下出现的消息重试。
第二种情况也很简单,只需要把上面的int i = 10/0;
代码用try-catch
包裹起来,在捕获异常时记录日常信息并返回ConsumeConcurrentlyStatus.RECONSUME_LATER;
表示该消息消费失败,需要过一会重试。
消息重试并不是在消息失败时立马重试,而是过一会儿才进行重试,具体过多久,官网有明确说明。
**顺序消费和并发消费的重试机制不同:**顺序消费和并发消费的重试机制并不相同,顺序消费消费失败后会先在客户端本地重试直到最大重试次数,这样可以避免消费失败的消息被跳过,消费下一条消息而打乱顺序消费的顺序,而并发消费消费失败后会将消费失败的消息重新投递回服务端,再等待服务端重新投递回来,在这期间会正常消费队列后面的消息。并发消费失败后并不是投递回原Topic,而是投递到一个特殊Topic,其命名为%RETRY%ConsumerGroupName,集群模式下并发消费每一个ConsumerGroup会对应一个特殊Topic,并会订阅该Topic。 两者参数差别如下:
死信队列
当一条消息初次消费失败,RocketMQ会自动进行消息重试,达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息。此时,该消息不会立刻被丢弃,而是将其发送到该消费者对应的特殊队列中,这类消息称为死信消息(Dead-Letter Message),存储死信消息的特殊队列称为死信队列(Dead-Letter Queue),死信队列是死信Topic下分区数唯一的单独队列。如果产生了死信消息,那对应的ConsumerGroup的死信Topic名称为%DLQ%ConsumerGroupName,死信队列的消息将不会再被消费。可以利用RocketMQ Admin工具或者RocketMQ Dashboard上查询到对应死信消息的信息。
在上面代码例子中对test-topic1
主题的TagC
的这条消息设置了最大重试次数为3次,所以当消费者重试了三次仍然无法正常消费这条消息时,就会被放进死信队列中去,而TagC
下的那条消息目前就被放到了死信队列,主题为%DLQ%test-consumer-group
。
SpringBoot集成RocketMq实战
相关配置
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.1</version> <!-- 版本号需要根据自己的rocketmq版本设置-->
</dependency>
# rocketmq 相关配置
rocketmq:
name-server: localhost:9876
producer:
group: test-producer-group
rocketmq:
name-server: localhost:9876
# 消费者组名如果配置在yml文件,那么该项目的所有消费者都在同一个消费者组内。
# consumer:
# group:
同步发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
SendResult result = rocketMQTemplate.syncSend("test-topic", "同步测试消息");
System.out.println("发送消息成功:"+result.getSendStatus());
}
需要编写一个消费者监听器,并且必须满足下面条件:
- 监听类必须是一个Bean
- 必须加上相关注解,比如
@RocketMQMessageListener
注解 - 必须实现
RocketMQMessageListener
接口
@Service
@RocketMQMessageListener(
consumerGroup = "boot-consumer-group", // 消费者组名
topic = "test-topic", // 消费主题
consumeMode = ConsumeMode.CONCURRENTLY, // 异步方式,默认异步消费
)
public class RocketMQConsumerService implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody());
System.out.println("监听主题:["+messageExt.getTopic() + "]得到的消息:" + body);
}
}
异步发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
CountDownLatch downLatch = new CountDownLatch(3); // 线程计数器
for (int i = 0; i < 3; i++) {
rocketMQTemplate.asyncSend("test-topic", "异步测试消息", new SendCallback() {
@Override
public void onSuccess(SendResult result) {
System.out.println("线程[" + Thread.currentThread().getName() + "]发送消息成功:" + result.getSendStatus());
downLatch.countDown();
}
@Override
public void onException(Throwable throwable) {
System.out.println("发送消息异常:" + throwable.getMessage());
downLatch.countDown();
}
});
}
System.out.println("线程[" + Thread.currentThread().getName() + "]执行业务逻辑...");
try {
downLatch.await(1,TimeUnit.SECONDS); // 等待所有线程执行完毕
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
消费者仍然是同步发送中的消费者代码。
延时发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
System.out.println("当前时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
Message<String> msg = MessageBuilder.withPayload("延迟消息").build();
// 第三个参数表示发送超时时间,第四个参数是延迟级别,级别3代表延迟10s发送
SendResult result = rocketMQTemplate.syncSend("test-topic", msg, 3000, 3);
System.out.println("消费发送结果:" + result.getSendStatus());
}
@RocketMQMessageListener(
consumerGroup = "boot-consumer-group", // 消费者组名
topic = "test-topic", // 消费主题
consumeMode = ConsumeMode.CONCURRENTLY // 异步方式,默认异步消费
)
@Service
public class RocketMQConsumerService implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody());
System.out.println("监听主题:["+messageExt.getTopic() + "]得到的消息:" + body);
System.out.println("消息接收时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
}
}
单向发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
// 发送单向消息,无返回值,不关注发送结果,一般用来发送不重要的消息
rocketMQTemplate.sendOneWay("test-topic", "单向消息");
// System.out.println("消费发送结果:" + result.getSendStatus());
}
批量发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
System.out.println("当前时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
List<Message<String>> messages = Arrays.asList(
MessageBuilder.withPayload("消息1").build(),
MessageBuilder.withPayload("消息2").build(),
MessageBuilder.withPayload("消息3").build(),
MessageBuilder.withPayload("消息4").build()
);
// 发送批量消息,不保证消息的顺序
SendResult result = rocketMQTemplate.syncSend("test-topic", messages);
System.out.println("消费发送结果:" + result.getSendStatus());
}
顺序发送
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
List<Order> orders = createOrder(); // 创建一个订单集合
for (Order order : orders) {
Integer orderId = order.getOrderId(); // 取唯一标识作为hashKey
// 发送顺序消息
SendResult result = rocketMQTemplate.syncSendOrderly("test-topic", order, String.valueOf(orderId));
System.out.println("消息发送状态:"+result.getSendStatus());
}
}
public List<Order> createOrder(){
List<Order> orders = Arrays.asList(
new Order(1001, "支付下单"),
new Order(1001, "创建订单"),
new Order(1001, "新增物流"),
new Order(1002, "支付下单"),
new Order(1002, "创建订单"),
new Order(1002, "新增物流"),
new Order(1003, "支付下单"),
new Order(1003, "创建订单"),
new Order(1003, "新增物流")
);
return orders;
}
/**
* 订单类
**/
class Order{
private Integer orderId; // 订单号
private String orderDesc; // 订单描述
public Order(Integer orderId, String orderDesc) {
this.orderId = orderId;
this.orderDesc = orderDesc;
}
public Integer getOrderId() {
return orderId;
}
public void setOrderId(Integer orderId) {
this.orderId = orderId;
}
public String getOrderDesc() {
return orderDesc;
}
public void setOrderDesc(String orderDesc) {
this.orderDesc = orderDesc;
}
@Override
public String toString() {
return "Order{" +
"orderId=" + orderId +
", orderDesc='" + orderDesc + '\'' +
'}';
}
}
@RocketMQMessageListener(
consumerGroup = "boot-consumer-group", // 消费者组名
topic = "test-topic", // 消费主题
consumeMode = ConsumeMode.ORDERLY // 同步消费才能保证顺序
)
@Service
public class RocketMQConsumerService implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody());
System.out.println(Thread.currentThread().getName() + ":监听主题:["+messageExt.getTopic() + "]得到的消息:" + body);
}
}
Tag过滤
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Test
void contextLoads() {
for (int i = 0; i < 3; i++) {
String tags = "Tag-"+i;
// RocketMQTemplate发送带Tag的消息的方式和原生MQ的方式不一样.
// syncSend 方法是这样说明的 destination – formats: `topicName:tags`
SendResult result = rocketMQTemplate.syncSend("test-topic:"+tags, "携带Tag的消息:" + tags);
System.out.println("发送消息成功:"+result.getSendStatus());
}
}
@Service
@RocketMQMessageListener(
consumerGroup = "boot-consumer-group", // 消费者组名
topic = "test-topic", // 消费主题
consumeMode = ConsumeMode.CONCURRENTLY, // 异步方式,默认异步消费
selectorExpression = "Tag-1" // 过滤条件,默认 * 消费该主题下的所有消息
)
public class RocketMQConsumerService implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt messageExt) {
String body = new String(messageExt.getBody());
System.out.println("监听主题:["+messageExt.getTopic() + "]得到的消息:" + body);
}
}
秒杀系统
如何设计秒杀系统?
首先需要清楚秒杀的核心其实就是扣减库存和新增订单,其他的业务逻辑都为了更好的完成这两步操作。
下面是写一个秒杀系统都需要做哪些操作:
- 查询该用户是否已经买过商品了(一般一个商品用户只能购买一次),如果买过了直接return。
- 查询库存数量,判断库存是否充足,如果库存不足直接return。
- 扣减当前库存。
- 生成订单。
首先是第一步,如何在高并发场景下保证一个商品只能被一个用户购买一次?这里需要做到去重的操作,那么去重我们就需要一个唯一标记,既然需要保证一个商品只能被一个用户购买一次,我们就可以使用商品ID和用户ID(前提是它们各自都是唯一的)组合起来当做唯一标记。
那么使用什么技术进行去重呢?有两种方案,分别是:
- 数据库,创建去重表利用唯一索引进行去重。
- Redis,使用setnx进行去重。
在高并发场景下,很显然使用数据库不如使用Redis来完成这个操作更合适,利用Redis的setnx方法,如果用户第一次购买就会返回true,重复购买就会返回false,并且setnx还是一个原子性的操作,可以很好的保证在高并发场景下的线程安全。
第二步和第三步,查询库存数量和做库存扣减都属于IO操作,需要去数据库里查询库存的数量然后再再更新库存。那么我们如果想要提升接口的响应速度就尽可能的减少数据库操作,所以这一步也可以使用Redis替代,我们可以先把库存数量同步到Redis,然后在Redis里面做一个库存的预扣减。至于什么时间将数据库的库存同步到Redis取决于秒杀活动的开始时间,可以写一个定时任务来处理。
第四步生成订单,上面的库存预扣减主要是用来判断库存是否充足,最终还是要在数据库进行扣减库存操作和生成订单操作的,所以如果不把这两步给抽出来,最终还是无法提升接口的响应速度。
解决方法:再创建一个应用,比如叫做scekill-service,上面的叫做seckill-web,那么我们就可以将对数据库做扣减库存和生成订单的操作放到scekill-service上,然后seckill-web做完判断以后将用户和商品信息发送到RocketMq,而scekill-service进行监听,一旦有消息就根据消息进行扣减库存和生成订单操作,这样就实现了业务逻辑的解耦,并且也提高了跟用户做交互的接口响应速度,至此,一个简单的秒杀系统就设计完毕了。
秒杀系统结构图:
代码实战
- 创建一个seckill-web项目
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
spring:
application:
name: seckill-web
redis:
host: localhost
port: 6379
database: 0
server:
port: 9090
tomcat:
threads:
max: 400
rocketmq:
name-server: localhost:9876
producer:
group: seckill-web-group
@Configuration // @Configuration 让SpringBoot知道这是一个配置类
public class RedisConfiguration {
@Bean // @Bean 声明这是一个bean,bean的名称和方法名对应
public JedisConnectionFactory connectionFactory(){
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName("localhost");
configuration.setPort(6379);
JedisConnectionFactory connectionFactory = new JedisConnectionFactory(configuration);
return connectionFactory;
}
@Bean
public RedisTemplate<Object,Object> redisTemplate(JedisConnectionFactory connectionFactory){
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key采用String的序列化方式
template.setKeySerializer(new StringRedisSerializer());
// value序列化方式采用jackson
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// hash的key也采用String的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
// hash的value序列化方式采用jackson
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
@RestController
@RequestMapping("seckill")
@Log4j2
public class SeckillController {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private RocketMQTemplate rocketMQTemplate;
// CAS 原子计数器,是线程安全的
AtomicInteger userIdAt = new AtomicInteger(0);
/**
* 秒杀方法
* @param prodId 商品ID
* @param userId 用户ID,正常都是从token中解析,不会直接进行传参
* @return
*/
@RequestMapping("doSeckill")
public String doSeckill(String prodId){
int userId = userIdAt.incrementAndGet(); // 为了方便测试,这里自动生成自增用户ID
// 根据用户ID和商品ID生成 uniqueKey
String uk = prodId+"_"+userId;
try {
// 根据 uniqueKey 判断是否第一次购买,这里可以保证相同优惠卷每个用户只能抢一次
Boolean b = redisTemplate.opsForValue().setIfAbsent("uk:" + uk, "");
if (!b) { // 有值就会返回false,无值返回true
return "抱歉,您已购买过该商品哦";
}
// 判断库存是否充足
Long quantity = redisTemplate.opsForValue().decrement("prods:" + prodId);
if (quantity < 0) {
return "抱歉,库存不足!";
}
rocketMQTemplate.asyncSend("seckill-topic", uk, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("订单信息:{},发送到MQ响应状态:{} ",uk, sendResult.getSendStatus());
}
@Override
public void onException(Throwable throwable) {
// 这里思考一个问题,如果Redis预扣减成功,MQ发送消息异常了,会不会导致商品库存错误扣减
log.error("订单信息:{},发送MQ出现异常:{} ",uk, throwable.getMessage());
redisTemplate.delete(uk); // 如果出现异常就代表该用户没成功抢到,就删除锁
}
});
}catch (Exception e) {
redisTemplate.delete(uk); // 如果出现异常就代表该用户没成功抢到,就删除锁
}
return "正在拼命抢购,请时刻关注订单信息";
}
}
seckill-web完成!
- 创建一个seckill-service项目,项目中的实体类、mapper、mapper.xml、service、impl层都是使用mybatisX插件一键生成的。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- MySQL Connector/J -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.27</version> <!-- 使用适合你MySQL版本的驱动 -->
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.25</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
spring:
application:
name: seckill-service
redis:
host: localhost
port: 6379
database: 0
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/sql_study?serverTimezone=GMT%2B8&useSSL=false
name: root
password: 123456
server:
port: 9091
rocketmq:
name-server: localhost:9876
mybatis-plus:
mapper-locations: classpath:/mapper/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
@SpringBootApplication
@MapperScan(basePackages = "com.fan.seckillservice.mapper")
public class SeckillServiceApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillServiceApplication.class, args);
}
}
/**
* 数据同步
*/
@Component
@Log4j2
public class DataSyncConfig {
@Autowired
private ProductsDTOMapper productsDTOMapper;
@Autowired
private RedisTemplate redisTemplate;
/**
* DataSyncConfig 初始化前执行,也可以使用定时任务进行实现,比如秒杀活动开始前同步
*/
@PostConstruct
public void initMethod(){
try {
List<ProductsDTO> productsDTOS = productsDTOMapper.selectList(new LambdaQueryWrapper<ProductsDTO>()
.select(ProductsDTO::getProdId, ProductsDTO::getProdQuantity));
if (CollectionUtils.isEmpty(productsDTOS)) {
log.info("数据库未查到商品信息!");
return;
}
for (ProductsDTO product : productsDTOS) {
redisTemplate.opsForValue().set("prods:" + product.getProdId(), product.getProdQuantity());
}
}catch (Exception e) {
log.info("同步商品数据到 Redis 异常:" + e.getMessage());
}
}
}
@Configuration // @Configuration 让SpringBoot知道这是一个配置类
public class RedisConfiguration {
@Bean // @Bean 声明这是一个bean,bean的名称和方法名对应
public JedisConnectionFactory connectionFactory(){
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName("localhost");
configuration.setPort(6379);
JedisConnectionFactory connectionFactory = new JedisConnectionFactory(configuration);
return connectionFactory;
}
@Bean
public RedisTemplate<Object,Object> redisTemplate(JedisConnectionFactory connectionFactory){
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// key采用String的序列化方式
template.setKeySerializer(new StringRedisSerializer());
// value序列化方式采用jackson
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
// hash的key也采用String的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
// hash的value序列化方式采用jackson
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
@Log4j2
@Service
@RocketMQMessageListener(
topic = "seckill-topic", // 消息主题
consumerGroup = "seckill-service-consumer", // 消费者组名
consumeMode = ConsumeMode.CONCURRENTLY // 异步消费
)
public class ConsumerListen implements RocketMQListener<MessageExt> {
@Autowired
private ProductsDTOService productsDTOService;
@Autowired
private RedisTemplate redisTemplate;
private Integer ZX_TIME = 30000; // 默认自旋时间30秒
/**
* 监听MQ订单消息,扣减库存并创建订单
* @param messageExt
*/
@Override
public void onMessage(MessageExt messageExt) {
// 获取 uniqueKey
String uk = new String(messageExt.getBody());
log.info("监听到订单信息,uk:{}",uk);
// 根据 _ 切割拿到用户ID和商品ID
String prodId = uk.split("_")[0];
String userId = uk.split("_")[1];
try {
// 扣减库存和创建订单这两个操作要保证原子性
// synchronized (this) { // 使用synchronized保证原子性
// 利用Redis分布式锁 + 自旋锁完成秒杀
int currentTime = 0;
while (ZX_TIME > currentTime) {
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock:" + prodId, "");
if (lock) { // 获取到锁继续向下执行
productsDTOService.realSeckill(userId, prodId);
return;
} else { // 获取不到锁的线程让它自旋等待30秒
currentTime += 1000;
Thread.sleep(1000);
}
}
// }
}catch (Exception e) {
log.info("执行秒杀操作异常:" + e.getMessage());
}finally {
// 一个线程执行完就主动释放锁
redisTemplate.delete("lock:" + prodId);
}
}
}
public interface ProductsDTOService extends IService<ProductsDTO> {
void realSeckill(String userId, String prodId);
}
/**
* @author 23610
* @description 针对表【products】的数据库操作Service实现
* @createDate 2024-04-04 19:45:42
*/
@Service
@Log4j2
public class ProductsDTOServiceImpl extends ServiceImpl<ProductsDTOMapper, ProductsDTO>
implements ProductsDTOService{
@Autowired
private ProductsDTOMapper prodMapper;
@Autowired
private OrderitemsDTOMapper orderitemsMapper;
@Autowired
private OrdersDTOMapper ordersMapper;
/**
* 为缓解数据库压力,利用 Redis 分布式锁 + 自旋锁 + Spring事务来完成秒杀操作。
* @param userId
* @param prodId
*/
@Override
@Transactional(rollbackFor = Exception.class) // 开启事务,如果遇到异常就进行回滚
public void realSeckill(String userId, String prodId) {
// 先查询数据库库存是否充足
ProductsDTO productsDTO = prodMapper.selectById(prodId);
Integer newQuantity = productsDTO.getProdQuantity() - 1;
if (newQuantity < 0) { // 库存不足直接return
log.info("商品:{},库存不足!", prodId);
return;
}
int update = prodMapper.updateProdQuantity2(prodId, newQuantity);
if (update <= 0) {
log.info("商品:{},扣减库存失败!", prodId);
throw new RuntimeException("商品:"+ prodId + ",扣减库存失败!");
}
log.info("商品:{},扣减库存成功!", prodId);
// 创建订单
OrdersDTO ordersDTO = new OrdersDTO();
ordersDTO.setCustId(userId);
ordersDTO.setOrderDate(new Date());
ordersMapper.insert(ordersDTO);
log.info("商品:{},用户:{},创建订单成功!", prodId, userId);
}
/**
* 利用 mysql 行锁 + Spring事务来完成秒杀操作。
* 缺点:这样会将所有压力都给数据库,如果并发量过大可能会导致数据库不稳定。
* @param userId
* @param prodId
*/
// @Override
// @Transactional(rollbackFor = Exception.class) // 开启事务,如果遇到异常就进行回滚
// public void realSeckill(String userId, String prodId) {
// // 利用 mysql 行锁保证更新产品表的线程安全
// // UPDATE products SET prod_quantity = prod_quantity-1 WHERE prod_id = #{prodId} AND prod_quantity > 0;
// int update = prodMapper.updateProdQuantity(prodId);
// if (update <= 0) {
// log.info("商品:{},扣减库存失败!", prodId);
// throw new RuntimeException("商品:"+ prodId + ",扣减库存失败!");
// }
// log.info("商品:{},扣减库存成功!", prodId);
// // 创建订单
// OrdersDTO ordersDTO = new OrdersDTO();
// ordersDTO.setCustId(userId);
// ordersDTO.setOrderDate(new Date());
// ordersMapper.insert(ordersDTO);
// log.info("商品:{},用户:{},创建订单成功!", prodId, userId);
// }
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.fan.seckillservice.mapper.ProductsDTOMapper">
<resultMap id="BaseResultMap" type="com.fan.seckillservice.dto.ProductsDTO">
<id property="prodId" column="prod_id" jdbcType="CHAR"/>
<result property="vendId" column="vend_id" jdbcType="CHAR"/>
<result property="prodName" column="prod_name" jdbcType="CHAR"/>
<result property="prodPrice" column="prod_price" jdbcType="DECIMAL"/>
<result property="prodDesc" column="prod_desc" jdbcType="VARCHAR"/>
<result property="prodQuantity" column="prod_quantity" jdbcType="INTEGER"/>
</resultMap>
<sql id="Base_Column_List">
prod_id,vend_id,prod_name,
prod_price,prod_desc,prod_quantity
</sql>
<update id="updateProdQuantity">
UPDATE products SET prod_quantity = prod_quantity-1 WHERE prod_id = #{prodId} AND prod_quantity > 0;
</update>
<update id="updateProdQuantity2">
UPDATE products SET prod_quantity = #{quantity} WHERE prod_id = #{prodId};
</update>
</mapper>
/**
* @author 23610
* @description 针对表【products】的数据库操作Mapper
* @createDate 2024-04-04 19:45:42
* @Entity com.fan.seckillservice.dto.ProductsDTO
*/
public interface ProductsDTOMapper extends BaseMapper<ProductsDTO> {
int updateProdQuantity(String prodId);
int updateProdQuantity2(String prodId, Integer quantity);
}
mybatisX插件
可以参考这篇文章:csdn
数据库脚本
后续补充