RabbitMQ&AMQP
同步请求&异步请求
同步请求
以下是微服务间使用同步请求调用的示意图:
缺点:
- 性能低下:支付服务的服务是它所调用的所有服务的服务时间之和
- 资源浪费:支付服务在等待其它服务时占用系统资源,但实际不工作
- 紧耦合:当支付动作发生后又要扩展其它的业务时(比如新增赠送优惠券服务),需要更改支付服务的代码
- 故障传递:当支付服务所依赖的一个服务发生故障,支付服务也无法完成,故障会逐级传递
优点:
- 时效性强:用户可以通过支付服务的返回结果知道请求是否已经完全成功
- 支付服务可以得到每个调用服务的结果
异步请求
以下是微服务间使用异步调用的示意图:
异步请求的原理就是支付服务将支付成功的消息发送给Broker,然后立即返回,一些服务需要在支付后完成其它工作,所以它们会主动订阅Broker中的支付事件,完成对应的工作。
优点:
- 吞吐量提升:由于支付服务不再等待其它服务的串行执行,带来的直接好处就是,系统的吞吐量提升,每秒钟处理的请求数增加
- 松耦合:支付服务可以完全不了解谁在订阅支付的事件
- 故障不会传递:当订阅事件的微服务故障,也不会影响到支付服务,稍后它重启后会再次收到事件
- 流量削峰:当瞬时流量峰值突然变高时,微服务依然以自己的频率处理事件,虽然处理的慢一些,但系统不至于不可用
缺点:
- 时效性弱:用户没法通过支付服务的返回结果知道支付操作产生的副作用(那些订阅支付事件的服务)是否都已经执行成功
- 依赖Broker:系统的可靠性和稳定性完全依赖Broker,所以需要一个稳健的Broker
- 业务之间没有明显流程线
综上所述,在实际业务的一些场景下,尤其是高并发场景,我们会用到微服务间的异步通信机制
消息队列
消息队列是异步通信的一种实现,是某个系统将生产的消息放到队列中,其它的系统来订阅这个消息,队列也就是Broker的角色。
下面是常用的消息队列产品:
RabbitMQ是AMQP的一个实现,AMQP是“高级消息队列协议”,它是跨语言的一种通用消息队列协议,所以RabbitMQ具有很多语言的客户端。
简单队列模型
简单队列模型中有一个生产者和一个消费者,还有一个队列,生产者生产消息,投放到队列中,消费者监听队列,当队列中有消息时会得到通知。
回想异步模型,这样不就是生产者与消费者之间的通信通过队列由同步变成了异步吗。
现在,建立两个项目,一个是生产者一个是消费者:
我建立了一个父项目,然后consumer
和producer
都作为它的子项目,这让我可以在父pom中配置版本、dependencyManagement
和共同导入的dependency
。
你当然可以不这样做,甚至你可以只在一个项目里写两个主类。
导入RabbitMQ依赖
<dependencies>
<dependency>
<groupId>com.rabbitmq</groupId>
<artifactId>amqp-client</artifactId>
<version>${rabbit.mq.version}</version>
<!-- <version>5.14.2</version> -->
</dependency>
</dependencies>
编写Producer:
public class ProducerApplication {
public static void main(String[] args) throws IOException, TimeoutException {
// 创建一个连接工厂,并配置连接信息
ConnectionFactory factory = new ConnectionFactory(); factory.setHost("localhost");
factory.setPort(5672);
factory.setUsername("guest");
factory.setPassword("guest");
// 获取连接并创建通道
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
// 定义队列,发布消息
String queueName = "simple.queue";
String message = "Hello, rabbitmq!";
channel.queueDeclare(queueName, false, false, false, null);
channel.basicPublish("", queueName, null, message.getBytes());
}
}
这里面有一些角色:
- 通道:代表一个客户端连接和RabbitMQ服务器交互的通道
- 队列:消息队列
编写Consumer:
public class ConsumerApplication {
public static void main(String[] args) throws IOException, TimeoutException {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
factory.setPort(5672);
factory.setUsername("guest");
factory.setPassword("guest");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);
channel.basicConsume(queueName, true, new DeliverCallback() {
@Override
public void handle(String consumerTag, Delivery message) throws IOException {
System.out.println("consumer received an message: " + new String(message.getBody()));
}
}, t -> {});
}
}
消费者的代码和生产者没有啥区别,区别在于最后它是提供了一个用于消费消息的监听器。不过这里也定义了同名的队列,这是因为生产者和消费者并不一定是谁先启动,所以都要定义一下,在定义队列时如果RabbitMQ服务器中已经有了对应队列,就不会再次定义。
多工作者模式
你可以创建多个工作者来监听同一个队列,不过一个消息只会被发向一个工作者,消息是发布后就销毁的
SpringBoot提供了一个简单的AMQP消息队列规范,下面我们使用这套规范来操作RabbitMQ。
导入依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
配置RabbitMQ:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
Producer:
@SpringBootTest
public class ProducerTest {
@Autowired
private RabbitTemplate rabbitTemplate;
private static final String QUEUE_NAME = "simple.queue";
@Test
void testProduceMessage() {
VerifyCodeMessage message = new VerifyCodeMessage(1, "159xxxx2044", "091241");
rabbitTemplate.convertAndSend(QUEUE_NAME, message);
}
}
Spring-AMQP完成了通道、队列的定义,你只需要使用RabbitTemplate
直接进行队列操作就可以了,而且,我们可以将一个对象作为消息发布,该对象会被序列化成字节数组。
Consumer:
@Slf4j
@Component
public class VerifyCodeMessageListener {
AtomicInteger worker1Cnt = new AtomicInteger(0);
AtomicInteger worker2Cnt = new AtomicInteger(0);
@RabbitListener(queues = "simple.queue")
public void worker1(VerifyCodeMessage verifyCodeMessage) throws InterruptedException {
log.info("Worker1 received an message: " + verifyCodeMessage + ", totcnt = " + worker1Cnt.incrementAndGet());
}
@RabbitListener(queues = "simple.queue")
public void worker2(VerifyCodeMessage verifyCodeMessage) throws InterruptedException {
log.info("Worker2 received an message: " + verifyCodeMessage + ", totcnt = " + worker2Cnt.incrementAndGet());
}
}
消费者的代码更加简单,你只需要提供一个监听类,并且将它注册成一个组件,在其中提供@RabbitListener
注解标注的方法,并且在注解中注明要监听的队列,消息的消费者就被定义了,当有消息被接收时,消费者方法将被调用。
下面,我们让生产者连续发送50条消息:
@Test
void testProduceMessage() throws InterruptedException {
for (int i=0; i<50; i++) {
VerifyCodeMessage message = new VerifyCodeMessage(1, "159xxxx2044", "091241");
rabbitTemplate.convertAndSend(QUEUE_NAME, message);
Thread.sleep(20);
}
}
可以看到,两个工作者者轮流接到消息:
消息预取
现实场景中可能有的工作者性能较差,一秒钟只能处理10个消息,有的性能较好,一秒钟能处理50个消息,那么此时我们期待的是性能较强的处理较多的消息,性能较差的处理较少的消息。
我们通过Thread.sleep
模拟这种情况:
@RabbitListener(queues = "simple.queue")
public void worker1(VerifyCodeMessage verifyCodeMessage) throws InterruptedException {
Thread.sleep(20);
log.info("Worker1 received an message: " + verifyCodeMessage + ", totcnt = " + worker1Cnt.incrementAndGet());
}
@RabbitListener(queues = "simple.queue")
public void worker2(VerifyCodeMessage verifyCodeMessage) throws InterruptedException {
Thread.sleep(50);
log.info("Worker2 received an message: " + verifyCodeMessage + ", totcnt = " + worker2Cnt.incrementAndGet());
}
可我们发现,它们几乎还是处理了同样多的消息:
这是因为RabbitMQ有消息预取机制,就是消费者先拿消息,然后再慢慢处理,所以消息队列才没有感知到Worker2的处理速度较慢,但实际上,由于Worker2的处理速度较慢,所以影响了50个任务的总处理时间
通过配置将消息预取的数量设置成1,也就是消费一个拿一个:
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
listener:
simple:
prefetch: 1
这样的话,可以明显看到Worker1的消费次数多了,而且当我们使用原子变量进行计数时,我们发现Worker2的消费次数确实变少了:
发布订阅模式
即使多工作者模型可以允许我们针对一个消息建立多个实际的消费者,但我们还是没有办法满足之前的异步微服务间调用模型
由于一个消息被一个消费者处理后,消息就立刻被销毁了,所以没法完成订单服务、仓储服务和短信服务都接到支付服务发来的消息的需求。
RabbitMQ提供了发布订阅模式来解决这一问题,如下图所示:
在上图中,生产者不再直接传递消息到队列,它不了解它的消息都被传递到了什么队列之中,取而代之的是,生产者将消息传递给交换机(Exchange),交换机根据某些规则将生产者的消息路由到某个或者某些队列中,如Fanout
交换机将生产者的消息广播到每一个队列中。这样,就能实现前面说的那个模型了。
FanoutExchange
配置
@Configuration
public class FanoutExchangeConfig {
// 订单服务的队列
@Bean
public Queue orderQueue() {
return new Queue("order.queue");
}
// 短信服务的队列
@Bean
public Queue smsQueue() {
return new Queue("sms.queue");
}
// 支付服务作为生产者,连接到的交换机
@Bean
public FanoutExchange paymentExchange() {
return new FanoutExchange("payment.exchange");
}
// 将payment交换机和订单队列绑定
@Bean
public Binding paymentOrderBinding(FanoutExchange paymentExchange, Queue orderQueue) {
return BindingBuilder.bind(orderQueue).to(paymentExchange);
}
// 将payment交换机和短信队列绑定
@Bean
public Binding paymentSmsBinding(FanoutExchange paymentExchange, Queue smsQueue) {
return BindingBuilder.bind(smsQueue).to(paymentExchange);
}
}
这样,支付服务无需知道它需要将消息发布到哪些队列中,交换机将它和它要发布的队列解耦。
消费者:
@Slf4j
@Component
public class PaymentListener {
@RabbitListener(queues = "order.queue")
public void handleOrder(String paymentId) {
log.info("Order service handle order, paymentid => " + paymentId);
}
@RabbitListener(queues = "sms.queue")
public void handleSMS(String paymentId) {
log.info("SMS service handle sms, paymentid => " + paymentId);
}
}
生产者:
@SpringBootTest
public class FanoutExchangeTest {
@Autowired
private RabbitTemplate template;
@Test
void testSendPaymentId () {
String exchangeID = "payment.exchange";
String paymentID = "01230jfj1k24k1l4120";
template.convertAndSend(exchangeID, "", paymentID);
}
}
运行,Order和SMS的相关服务都能接到消息:
总结:FanoutExchange是一种广播的交换机制,它将消息广播到每个绑定到它的队列
DirectExchange
DirectExchange允许消息根据规则(RoutingKey)被路由到指定的队列上,这被官方称作路由模式
- 每个队列可以与交换机绑定一个或多个RoutingKey
- 交换机将根据RoutingKey来匹配要发送到的队列
下面,我们使用@RabbitListener
注解来完成交换机和队列的绑定,而不是通过定义Bean的繁琐方式。
@Slf4j
@Component
public class PaymentListener {
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "order.queue"),
exchange = @Exchange(name = "payment.direct.exchange", type = ExchangeTypes.DIRECT),
key = {"order", "all"}
))
public void handleOrder(String paymentId) {
log.info("Order service handle order, paymentid => " + paymentId);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "sms.queue"),
exchange = @Exchange(name = "payment.direct.exchange", type = ExchangeTypes.DIRECT),
key = {"sms", "all"}
))
public void handleSMS(String paymentId) {
log.info("SMS service handle sms, paymentid => " + paymentId);
}
}
上面的代码声明了一个DirectExchange和两个队列,还有三个RoutingKey,order
绑定到order.queue
上,sms
绑定到sms.queue
上,all
同时绑定到两者。
测试:
@Test
void testSendPaymentId () {
String exchangeID = "payment.direct.exchange";
template.convertAndSend(exchangeID, "order", "order payment id");
template.convertAndSend(exchangeID, "sms", "sms payment id");
template.convertAndSend(exchangeID, "all", "all payment id");
}
需要注意的是,这里我们新的exchange的id和以前的有所不同,如果和以前的同名但是不同类型,程序启动会报错
请注意,在使用FanoutExchange时,我们没有使用RoutingKey这个参数,也就是说有些Exchange不用这个参数,有些Exchange需要用到这个参数进行额外的鉴别工作
总结:DirectExchange将消息转换到那些RoutingKey直接匹配的队列上,但是一个队列可以绑定多个RoutingKey,如果你给所有队列都绑定了同一个RoutingKey,再用这个RoutingKey发送消息,那么此时它就相当于FanoutExchange
TopicExchange
TopicExchange也用到RoutingKey,但它的RoutingKey命名应该是点分字符串
,并且其中可以有通配符。这导致它的使用比DirectExchange
更加灵活。
应用场景:
假设有这么四个RoutingKey,如果使用DirectExchange
,此时你想单独监听中国的新闻和天气你需要写两个RoutingKey,而当你使用Topic时,你可以使用通配符:china.*
通配符:
#
:代表0个或多个单词*
:代表1个单词
定义消费者:
@Component
public class TopicListener {
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.china.queue"),
exchange = @Exchange(name = "topic.exchange", type = ExchangeTypes.TOPIC),
key = {"china.*"}
))
public void chinaListener(String message) {
System.out.println("China message Listener => " + message);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.japanese.queue"),
exchange = @Exchange(name = "topic.exchange", type = ExchangeTypes.TOPIC),
key = {"japanese.*"}
))
public void japaneseListener(String message) {
System.out.println("Japanese message Listener => " + message);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.news.queue"),
exchange = @Exchange(name = "topic.exchange", type = ExchangeTypes.TOPIC),
key = {"*.news"}
))
public void newsListener(String message) {
System.out.println("News message Listener => " + message);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.weather.queue"),
exchange = @Exchange(name = "topic.exchange", type = ExchangeTypes.TOPIC),
key = {"*.weather"}
))
public void weatherListener(String message) {
System.out.println("Weather message Listener => " + message);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "topic.all.queue"),
exchange = @Exchange(name = "topic.exchange", type = ExchangeTypes.TOPIC),
key = {"#"}
))
public void allListener(String message) {
System.out.println("All message Listener => " + message);
}
}
定义生产者:
@Autowired
private RabbitTemplate template;
@Test
void testSendTopicMessage() {
String exchangeName = "topic.exchange";
template.convertAndSend(exchangeName, "china.news", "China news 1");
template.convertAndSend(exchangeName, "china.weather", "China weather 1");
template.convertAndSend(exchangeName, "japanese.news", "Japanese news 1");
template.convertAndSend(exchangeName, "japanese.weather", "Japanese weather 1");
}
结果:
消息转换器
我们向消息队列中传递对象都是通过Converter
来将对象转成字节数组的,默认情况下工作的Converter
使用的是JDK的序列化机制。
但JDK的序列化性能一直是被人诟病的,而且它平台相关。即使AMQP能够跨语言,但你的对象通过JDK序列化机制序列化,别的语言还是无法反序列化的。
除此之外我们还有这么多的实现:
下面我们将它替换成Jackson的Converter实现,在此之前,你必须要引入Jackson库。
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
然后,定义MessageConverter:
@Configuration
public class MessageConverterConfig {
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
一定要注意,这里的接口和实现都是
org.springframework.amqp.support.converter
下的,Spring和其它框架都有很多类似的类,别导错包
现在,消息都是通过json来序列化反序列化的了: