RabbitMQ 消息队列入门
前言
中间件
消息队列
- 异步处理,注册完发短信
- 应用解耦,订单接口调用扣库存接口,失败了怎么办?
- 流量削峰,大量请求到达业务接口,这不行!
- 日志处理,每个业务代码都调用一下写日志的方法吗?结合AOP思想,业务程序为什么要关心写日志的事情?
- 消息通讯等,ABC处在聊天室里面,一起聊天?foreach吗?
官网有7个入门教程,过了一遍,做个笔记。
正文
HelloWorld
概述
RabbitMQ,是个消息代理人message broker。它接收,存储,转发消息。
几个常用的术语:
- 生产者Producer,生产发送消息。
- 消费者Consumer,接收消息。
- 队列Queue,只受系统内存和硬盘大小限制。存储消息,生产者往队列里面发送,消费者监听读取。
这几个对象可以分布在不同的机器。
使用Client
P和C的角色。maven仓库包为amqp-client
和slf4j-nop
<dependencies>
<!-- https://mvnrepository.com/artifact/com.rabbitmq/amqp-client -->
<dependency>
<groupId>com.rabbitmq</groupId>
<artifactId>amqp-client</artifactId>
<version>5.8.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-nop -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-nop</artifactId>
<version>1.7.30</version>
</dependency>
<endencies>
发送
也就是Producer.java
public class Send {
private static final String QUEUE_NAME = "hello1";
public static void main(String[] args) {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("xxx.xxx.xxx.xxx");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
String message = "Hello world";
channel.basicPublish("", QUEUE_NAME, null, message.getBytes(Charset.forName("utf-8")));
System.out.println(" [x] Sent '" + message + "'");
} catch (TimeoutException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
Connection
和Channel
都实现了ICloseable接口,所以可以使用try(...)接口自动释放资源。Channel
是我们要经常使用的API对象。channel.queueDeclare
是幂等的,只有在没有的情况下才会创建。然后调用basicPublish
方法,往队列发送字节数组消息。
接收
Consumer,Receiver.java
public class Receiver {
private final static String QUEUE_NAME = "hello";
public static void main(String[] args) throws IOException, TimeoutException {
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("xxx.xxx.xxx.xxx");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
DeliverCallback deliverCallback = ((consumerTag, message) -> {
String msg = new String(message.getBody(), "UTF-8");
System.out.println(" [x] Received [" + msg + "]");
});
channel.basicConsume(QUEUE_NAME, false, deliverCallback, consumerTag -> {
});
}
}
connection
和 channel
对象没有使用try-with-resource自动释放,factory.newConnection()
之后程序就会保持运行,调用basicConsume
方法来消费得到的消息。该方法第二个autoAck参数写了false,这样,消息就属于未确认的状态,每次启动都会重复收到。
Work Queue 工作队列
消息产生的速度大于消费的速度,该怎么办?
每个http请求的时间不宜过长,所以可以把内部耗时的方法做成异步,然后用回调callback的方式实现。换个角度说就是consumer里面有比较耗时的任务,可以用thread.sleep()
模拟一下。
DeliverCallback deliverCallback = ((consumerTag, message) -> {
String msg = new String(message.getBody(), "UTF-8");
int i = new Random().nextInt(5);
try {
Thread.sleep(i * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(msg + "休眠了" + i + "秒");
});
不过这不是这节的重点,这里的重点是几个参数。
消息确认
首先设置一下每次接收的消息数,每次一个channel.basicQos(1);
。在客户端没有确认之前不会接收新的消息。channel.basicConsume
方法的第二个参数autoAck表示自动确认。消息有两种状态,ready和unacked的。消息发送到queue→ready→consumer消费,但不确认→unacked→确认→结束,等待下一个。
public class NewTask {
private final static String QUEUE_NAME = "hello";
public static void main(String[] args) throws IOException, TimeoutException {
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.queueDeclare(QUEUE_NAME, false, false, false, null);
System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
channel.basicQos(1);//一次接收一个消息
DeliverCallback deliverCallback = (consumerTag, message) -> {
String msg = new String(message.getBody(), "UTF-8");
int i = new Random().nextInt(2);
try {
Thread.sleep(i * 1000);
channel.basicAck(message.getEnvelope().getDeliveryTag(), false);//只确认这个tag对应的消息
System.out.println(msg + "执行了" + i + "秒,consumerTag=" + consumerTag + "并发送了确认");
} catch (InterruptedException e) {
e.printStackTrace();
}
};
boolean autoAck = false;//不自动确认
String str = channel.basicConsume(QUEUE_NAME, autoAck, deliverCallback, consumerTag -> {
System.out.println(consumerTag);
});
}
}
Message Durablity
Redis类似。
确保消息不丢。也就是确保未消费的消息在服务器意外宕机重启之后消息不丢。RabbitMQ会以一定间隔把消息写入磁盘,但不是实时【所以还是有一个短的时间间隔会产生消息的丢失情况】。为了解决这个问题,需要两个配置
-
定义queue的时候设置durable参数为true。rabbitmq不允许queue name相同其他参数不同的两个队列,所以可以先删以前的。
boolean durable = true; channel.queueDeclare("hello", durable, false, false, null);
-
发送的时候设置
MessageProperties
属性。channel.basicPublish("", "hello", MessageProperties.PERSISTENT_TEXT_PLAIN,//持久化为文本 message.getBytes());
Fair Dispatch 公平分发
RabbitMQ的默认推送策略是把第N个消息推送给第N个客户端,他不会管一个客户端是否还有没确认的消息,所以可能会导致某个客户端非常的忙。解决方案:
调用basicOps设置prefetchCount
为1,这样一个客户端在没有确认当前消息之前不会收到下一个消息。
Publish/Subscribe 发布/订阅
一次性给所有的Consumer发送消息
回顾一下前面的例子,基本的代码流程是
- 创建ConnectionFactory→设置参数→创建Connection→创建Channel
- Producer声明QueueName,往Exchanges=”“发送消息
- Consumer指定相同的QueueName,设置消息处理函数,读取数据,发送确认。
Exchanges
RabbitMQ中有Exchange的概念。消息实际上不会直接发送给Queue,而是给Exchange,然后通过exchange转发给queue,然后给Consumer消费。exchange为空字符表示系统内部默认的exchange。
[root@test]~# rabbitmqctl list_exchanges -p test_host1
Listing exchanges for vhost test_host1 ...
name type
amq.fanout fanout
amq.direct direct
amq.match headers
amq.rabbitmq.trace topic direct
amq.headers headers
amq.topic topic
amp.*
为系统自带的exchange。
管理界面查看
ExchangeType
Type决定一个Exchange怎么处理接收到的消息,广播到所有队列或者推送到特定的队列或者直接丢弃消息。
内置的ExchangeType枚举
public enum BuiltinExchangeType {
DIRECT("direct"), FANOUT("fanout"), TOPIC("topic"), HEADERS("headers");
//...省略其他
}
fanout
顾名思义,是一种广播的处理方式,会发送到所有的queue。看个demo。
先看Send
//Send.java
public class Send {
private static final String EXCHANGE_NAME = "logs";
public static void main(String[] args) {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
try (final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel()) {
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.FANOUT);//Exchange声明
final String msg = String.valueOf(LocalDateTime.now());
channel.basicPublish(EXCHANGE_NAME, "", null, msg.getBytes(Charset.defaultCharset()));//第二个routingKey留空待定
System.out.println("发送" + msg);
} catch (TimeoutException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
定义一个FANOUT
类型的Exchange,没有定义Queue。调用basicPublish发送消息。
再看Receiver.java
//Receiver.java
public class Receiver {
private static final String EXCHANGE_NAME = "logs";
public static void main(String[] args) throws IOException, TimeoutException {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.FANOUT);
final String queueName = channel.queueDeclare().getQueue();//获取自动生成的queue,
channel.queueBind(queueName, EXCHANGE_NAME, "");//绑定,最后一个参数待定
channel.basicConsume(queueName, true, (consumerTag, message) -> {
final String s = new String(message.getBody(), Charset.defaultCharset());
System.out.println("收到" + s);
}, consumerTag -> { });
}
}
定义一个和Send相同的Exchange。获取创建的channel对应的系统自动生成的queue(结束之后会自动删除,避免系统有太多队列)。绑定queue和exchange。RabbitMQ会丢弃消息如果这个exchange下面没有绑定queue的话。
运行多个Receiver实例。因为ExchangeType是fanout,所以,每个实例都会收到广播的消息。
对比前面例子中的默认Exchange,一个消息,发送到一个Exchange(默认的空字符串),因为queuename指定了是同一个,所以,只会有一个client收到消息。
而这个例子中,queue是自动生成的,所以会有多个自动删除的queue,一个queue对应一个client。ExchangeType是fanout,所以,每个client都会收到。
Routing
有选择性的接收消息
前面例子使用了fanout
广播的方式来发布消息,一条消息会被推送到所有的队列,又因为队列是自动生成的,一个队列对应一个consumer,所以所有的consumer都会收到所有的消息。这无法实现某个consumer只关心某种类型的消息的需求。所以,这里引入exchangetype=direct的例子。
name 相同,type不同的exchange不合法,可以先在rabbitmq的管理平台界面删除原先的exchange。
Binding
回顾前面的代码。publish和queue绑定的时候都留空了routingKey参数。
Send.java
Consumer.java
Consumer的queueBind 和 Producer的basicPublish中routingKey需要匹配。fanout
类型的exchange会忽略routingKey参数,所以我们直接留空。
direct Exchange
fanout的消息分发不太灵活,所以这里使用direct的Exchange。看下图,如果Producer产生的routingKey为orange,那么只会发送给Q1,那么只有C1会收到消息。如果routingKey为black或者green,那么C2会收到消息。
Multiple Bindings
多个队列绑定同一个routingKey也是合理。下面的例子Q1和Q2都会收到black的消息,这种绑定本质上就退化成了一种前面的fanout Exchange。
Demo
场景:Producer
产生3种routingKey的message,Info,Error,Fault。定义两个Consumer,C1接收Info的message,C2接收Error和Fault。
//Send.java
public class Send {
private static final String EXCHANGE_NAME = "logs";
public static void main(String[] args) {
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");//省略其他设置
try (final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel()) {
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);//指定exchange的类型
String messageType = args[0];//传入routingKey
String msg = messageType + " message" + LocalDateTime.now();
channel.basicPublish(EXCHANGE_NAME, messageType, null, msg.getBytes(StandardCharsets.UTF_8));
System.out.println("发送了" + msg);
} catch (TimeoutException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//Receiver.java
public class Receive {
private static final String EXCHANGE_NAME = "logs";
public static void main(String[] args) throws IOException, TimeoutException {
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
final String queueName = channel.queueDeclare().getQueue();
for (String arg : args) {//遍历所有的routingKey,绑定所有当前queue
System.out.println("绑定routingKey:" + arg);
channel.queueBind(queueName, EXCHANGE_NAME, arg);
}
channel.basicConsume(queueName, true, ((consumerTag, message) -> {
final String msg = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("收到" + msg);
}), consumerTag -> {
});
}
}
创建5个启动配置,3个为Send,分别发送Info,Fault,Error消息;2个Receiv,第一个接收Info,第二个接收Error和Fault。
最终效果
Topic
通过pattern模式来指定接收的消息。
前面例子使用的ExchangeType为direct
,相对于fanout
,是灵活了一些,但是还是有一些缺点,比如无法组合条件。比如有个consumer关心所有的error
消息以及和a相关的info
消息。这里就可以使用Topic的Exchange。然后都是通过routingKey参数来指定。
通配符
* 星号,代表一个词
# 井号,代表0个或多个词(包括一个)
以点分隔,组成routingKey。比如*.a.b.#
如果设置BuiltinExchangeType.TOPIC
的exchangeType,但是没有使用通配符,那么就和BuiltinExchangeType.DIRECT
是一样的。
未匹配任何模式的消息会被丢弃。
关键代码
声明exchange
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
发送消息
if (args[0].equals("info")) {
s = "a's info message";
channel.basicPublish(EXCHANGE_NAME, "a.info", null, s.getBytes(StandardCharsets.UTF_8));
} else {
s = "xxx.yyy's error message";
channel.basicPublish(EXCHANGE_NAME, "xxx.yyy.error", null, s.getBytes(StandardCharsets.UTF_8));
}
使用通配符接收消息
channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
final String queueName = channel.queueDeclare().getQueue();//临时的queue
String routingKey = args[0];//传入的参数 比如*.info 或 #.error来匹配
channel.queueBind(queueName, EXCHANGE_NAME, routingKey);
System.out.println("绑定" + routingKey + "的队列");
channel.basicConsume(queueName, true, ((consumerTag, message) -> {
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("收到消息" + msg);
}), consumerTag -> {});
RPC 远程调用
远程过程调用。
Client 调用 Server的服务。Client发送消息,Server消费消息。Server计算结果,发布一个消息到对应的队列。Client消费队列里面的消息。这一个过程Client和Server都是双重身份。这个是和其他最主要的区别。
关于RPC
RPC是一种常见的模式,但也存在一些争议,这主要体现在如果开发者有意或无意的不去注意这是一个本地的方法还是比较耗时的远程方法。RPC也增加了系统的调试复杂度。
开发RPC的几个建议:
- 确保方法容易辨识是远程还是本地
- 做好文档
- 处理调用时候的异常
回调队列
Client需要Server的计算结果,所以需要在消息里面带上CallbackQueueName。根据AMQP 0-9-1
协议,定义了14个属性,除了4个比较常用,其他都很少用。
- deliveryMode 设置消息的持久化,第二个例子中用过。
- contentType 设置内容的mime-type ,建议application/json
- replyTo 回复队列名
- correlationId 关联id 因为消息是异步的,所以可以给每个消息带上个id,用来关联发送的消息。
final AMQP.BasicProperties basicProperties = new AMQP.BasicProperties.Builder()
.correlationId("uuid")
.replyTo("xxx")
.build();
channel.basicPublish("", "rpc_queue", props, message.getBytes());
简易版Client
public class Client {
private static final String RPC_QUEUE_NAME = "rpc_queue";//rpc调用的queue,往里面发rpc调用参数
public static void main(String[] args) throws IOException, TimeoutException {
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.xx.x.x");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
String msg = String.valueOf(3);//模拟调用参数
final String replyQueueName = channel.queueDeclare().getQueue();
String corrId = UUID.randomUUID().toString();
final AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
.replyTo(replyQueueName)//回复的队列
.correlationId(corrId)//当前消息的uuid
.build();
channel.basicPublish("",
RPC_QUEUE_NAME,
properties,
msg.getBytes(StandardCharsets.UTF_8));//广播的方式往rpc queue发布消息
System.out.println("发送计算[" + msg + "]的消息");
//等待消息回复
channel.basicConsume(replyQueueName, true, (consumerTag, message) -> {
String revCorrId = message.getProperties().getCorrelationId();
if (corrId.equals(revCorrId)) {//拿到了回复
final String result = new String(message.getBody(), StandardCharsets.UTF_8);
System.out.println("发送" + msg + "得到回复" + result);
} else {
System.out.println("收到correlationId:" + revCorrId);
}
}, consumerTag -> {
});
}
}
channel.queueDeclare()
用来声明一个临时队列,为接收返回结果的队列。代码中只发布了一个计算请求,所以basicConsume
中corrId判断其实没有必要。正常情况下可以在当前临时队列发布多个计算请求,每个的计算结果都传入到当前的临时队列,所以需要判断corrId的匹配情况。
简易版Server
public class Server {
private static final String RPC_QUEUE_NAME = "rpc_queue";
public static void main(String[] args) throws IOException, TimeoutException {
System.out.println("hello from server");
final ConnectionFactory factory = new ConnectionFactory();
factory.setHost("x.x.x.x");
factory.setPort(5672);
factory.setUsername("full_access");
factory.setPassword("111111");
factory.setVirtualHost("test_host1");
final Connection connection = factory.newConnection();
final Channel channel = connection.createChannel();
channel.queueDeclare(RPC_QUEUE_NAME,false,false,false,null);//声明非排他的队列,用来消费rpc_queue里面的计算请求。
channel.basicConsume(RPC_QUEUE_NAME,
true,//自动回复,后面就不需要手动ack
(consumerTag, message) -> {
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
String replyMsg = new String(msg + "Result");//简单模拟计算结果。
System.out.println("收到" + msg + "开始计算,计算完成结果为:[" + replyMsg + "]");
//拿到需要回复properties
String replyQueueName = message.getProperties().getReplyTo();
String correlationId = message.getProperties().getCorrelationId();
AMQP.BasicProperties replyProps = new AMQP.BasicProperties.Builder()
.correlationId(correlationId)//correlationId返回去。
.build();
// 把计算结果发回去
channel.basicPublish("", replyQueueName, replyProps, replyMsg.getBytes(StandardCharsets.UTF_8));
}, consumerTag -> {
});
}
}
消费RPC_QUEUE_NAME
的计算请求,然后根据消息里面带的getReplyTo()
的值返回给客户端。
Publisher Confirm 发布确认
可靠发布
启用生产者确认
根据AMQP 0.9.1
协议,这个确认默认是没有启用的,可以通过confirmSelect
方法启用。
Channel channel = connection.createChannel();
channel.confirmSelect();
这个方法是针对channel的,不是针对每个消息,所以,只要 在开启channel之后调用一次就好。
确认每个消息
先是一个简单的例子,每发完一个消息,都让系统确认等待一下。
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
// 5秒超时
channel.waitForConfirmsOrDie(5_000);
}
每次发完一个消息,都等待最多5秒钟的一遍确认。这个很明显会极大的影响系统的吞吐率。
批量确认
发送一个确认一个明显会比较low,所以这里引入一种批量确认的方式。不过这只是一种自己业务代码的确认机制,不是rabbitmq提供的。
int batchSize = 100;//
int outstandingMessageCount = 0;
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
outstandingMessageCount++;//发送一个加1
if (outstandingMessageCount == batchSize) {
ch.waitForConfirmsOrDie(5_000);//到达batchSize之后确认
outstandingMessageCount = 0;
}
}
if (outstandingMessageCount > 0) {
ch.waitForConfirmsOrDie(5_000);//确认剩下的
}
这种确认吞吐量是上来了,不过最大的问题是当confirm
出问题了之后是无法定位到具体哪个有问题。
ConcurrentSkipListMap 和 channel.getNextPublishSeqNo()
channel.getNextPublishSeqNo
可以获取发布的消息的下一个序号,有序递增。ConcurrentSkipListMap有一个heapMap
方法,可以返回key小于等于param的map子集。在发布消息之前先获取序号,作为key放到map里面。
map.put(nextPublishSeqNo, byteMsg);
channel.basicPublish("", queueName, null, msgStr.substring(i, i + 1).getBytes(StandardCharsets.UTF_8));
异步确认
Producer只管发消息,然后注册一个异步回调函数。rabbitmq提供了两个回调函数。一个是发送成功的回调,一个是发送失败的回调。两个函数的参数是一样的,两个。
- sequence number 序号。表示成功/失败的消息编号
- multiple 布尔值。false表示只有一个被确认。true表示小于等于当前序号的消息发送成功/失败
channel.confirmSelect();//启用消息确认
channel.addConfirmListener(
(deliveryTag, multiple) -> {
if (multiple) {
System.out.println("序号" + deliveryTag + "的信息发送成功");
map.remove(deliveryTag);
} else {
System.out.println("序号小于" + deliveryTag + "的信息发送成功");
final ConcurrentNavigableMap<Long, Byte> confirmed = map.headMap(deliveryTag, true);
confirmed.clear();
}
},
(deliveryTag, multiple) -> {
if (!multiple) {
System.out.println("发送失败的信息sequence number:" + deliveryTag);
} else {
System.out.println("序号小于" + deliveryTag + "的消息发送失败");
}
});