1. 消息队列概述
1.1 消息队列模式
消息队列主要有两种模式:点对点模式和发布/订阅模式。
1.1.1 点对点模式
- 特点:一个消息只能由一个消费者消费。多个生产者可以向同一个队列发送消息,但消息在被一个消费者处理时会被锁住或移除,其他消费者无法处理该消息。
- 失败处理:如果消费者处理消息失败,消息系统通常会将消息放回队列,供其他消费者继续处理。
1.1.2 发布/订阅模式
- 特点:单个消息可以被多个订阅者并发获取和处理。
- 订阅类型:
- 临时订阅:消费者启动时存在,退出后订阅和未处理的消息会丢失。
- 持久订阅:订阅会一直存在,除非主动删除。消费者退出后,消息系统会继续维护该订阅,后续消息可继续处理。
1.2 RabbitMQ 特征
- 消息路由:支持通过不同交换器进行消息路由。
- 消息有序:不支持。消费失败时,消息会被放回队列,可能导致消息无序。
- 消息时序:非常好。支持延时队列、TTL等。
- 容错处理:非常好。通过重试和死信交换器(DLX)处理消息故障。
- 伸缩性:一般。由于只有一个主队列,负载集中,伸缩性较弱。
- 持久化:不太好。未消费的消息支持持久化,但消费后的消息会被删除。
- 消息回溯:不支持。消息不支持永久保存。
- 高吞吐:中等。单机性能达不到十万级标准。
2. RabbitMQ 原理
2.1 基本概念
RabbitMQ 是基于 AMQP 协议的消息队列系统,使用 Erlang 语言开发。以下是 AMQP 协议中的关键概念:
- Server:接收客户端连接,实现 AMQP 实体服务。
- Connection:应用程序与 Server 的网络连接(TCP 连接)。
- Channel:信道,消息读写等操作在信道中进行。客户端可建立多个信道。
- Message:消息,由 Properties 和 Body 组成。
- Virtual Host:虚拟主机,用于逻辑隔离。
- Exchange:交换器,接收消息并根据路由规则将消息路由到队列。
- Binding:交换器和队列之间的虚拟连接。
- RoutingKey:路由键,用于指定路由规则。
- Queue:消息队列,保存消息供消费者消费。
2.2 工作原理
AMQP 协议模型由生产者、消费者和服务端组成,工作流程如下:
- 生产者连接到 Server,建立连接并开启信道。
- 生产者声明交换器和队列,设置属性并通过路由键绑定交换器和队列。
- 消费者建立连接并开启信道以接收消息。
- 生产者发送消息到虚拟主机中的交换器。
- 交换器根据路由键将消息路由到队列。
- 消费者从队列中获取消息并消费。
2.3 常用交换器
- Direct Exchange:直连交换机,要求消息与特定路由键完全匹配,实现一对一的点对点发送。
- Fanout Exchange:将消息转发到与该交换机绑定的所有队列,类似子网广播,实现发布订阅功能。
- Topic Exchange:主题交换机,使用通配符(“*” 匹配一个词,“#” 匹配一个或多个词)进行路由匹配。
- Headers Exchange:根据请求头中携带的键值进行路由,使用相对较少,创建队列需设置绑定的头部信息,有全部匹配和部分匹配两种模式。
3. RabbitMQ 环境搭建
3.1 Windows版
- 下载安装 Erlang:
-
访问Erlang 官方下载页面,根据系统选择合适的版本,如 64 位系统可下载
otp_win64_XX.YY.exe
。
-
运行安装程序,安装路径可自行选择,但不要出现中文或空格。安装完成后,右键点击 “此电脑”,选择 “属性”,进入 “高级系统设置”,点击 “环境变量”。在 “系统变量” 中新建变量,变量名设为 “
ERLANG_HOME
”,变量值为 Erlang 的安装地址。然后找到 “Path” 变量,点击 “编辑”,新建一个路径,输入 “%ERLANG_HOME%\bin
”。 -
按 “Win+R” 键,输入 “cmd”,打开命令提示符,输入 “erl”,若显示版本信息,则说明 Erlang 安装成功。
- 安装 RabbitMQ:
-
前往RabbitMQ 官方下载页面,选择适合 Windows 的版本,如
rabbitmq-server-4.0.8.exe
。
-
运行安装程序,选择安装路径,建议不要使用中文或空格路径,然后一路 “Next” 完成安装。
- 配置 RabbitMQ:
-
打开命令提示符,进入 RabbitMQ 的sbin文件夹,例如 “D:\rabbitMQ\rabbitmq_server-4.0.6\sbin”。输入命令
rabbitmq-plugins enable rabbitmq_management
来启用管理插件。
-
在与
sbin
文件夹同级目录下创建data
文件夹。以管理员身份打开 “RabbitMQ Command Prompt”,依次输入以下命令:rabbitmq-service.bat remove
set RABBITMQ_BASE=D:\rabbitMQ\rabbitmq_server-4.0.8\data
(根据实际安装路径修改)rabbitmq-service.bat install
-
再次执行
rabbitmq - plugins enable rabbitmq_management
命令。
- 启动 RabbitMQ:在命令提示符中输入
rabbitmq-server.bat start
启动 RabbitMQ。也可以通过 “计算机管理” 中的 “服务” 找到 “RabbitMQ” 服务并启动。 - 访问管理界面:打开浏览器,输入 “http://localhost:15672”,使用默认账号 “guest” 和密码 “guest” 登录,即可进入 RabbitMQ 的管理界面。
- 创建管理员账户
-
添加账号, 进入rabbitmq的sbin目录下
-
添加一个账号密码均为admin且身份为超级管理员的用户
rabbitmqctl.bat add_user admin admin rabbitmqctl.bat set_permissions -p "/" admin ".*" ".*" ".*" rabbitmqctl.bat set_user_tags admin administrator
-
查看当前用户组有哪些, 结果如图
rabbitmqctl.bat list_users
3.2 Ubuntu版
在 Ubuntu 上安装 RabbitMQ 需先配置 Erlang 环境(RabbitMQ 基于 Erlang 开发),再安装 RabbitMQ 本身,以下是详细步骤:
-
更新软件包列表
sudo apt-get update
-
安装 Erlang 环境
sudo apt-get install erlang
输入
Y
确认安装,等待安装完成。可通过erl
命令验证(输入halt().
退出 Erlang 命令行)。
-
安装 RabbitMQ 服务器
sudo apt-get install rabbitmq-server
同样输入
Y
确认,安装后服务会自动启动。可通过systemctl status rabbitmq-server
检查状态。
-
启用 RabbitMQ 管理界面
rabbitmq-plugins enable rabbitmq_management
管理界面默认端口为
15672
,若使用云服务器需在安全组开放该端口(同时开放消息通信端口5672
)。
-
创建管理员用户(避免
guest
权限限制)# 添加用户(账号密码自定义,示例为 admin/admin) sudo rabbitmqctl add_user admin admin # 设置用户角色为超级管理员 sudo rabbitmqctl set_user_tags admin administrator # 赋予用户对所有资源的完全权限 sudo rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"
-
访问管理界面
在浏览器输入http://服务器IP:15672
,使用新创建的账号(如admin
)登录,即可管理 RabbitMQ。
-
常用命令
- 启动服务:
sudo service rabbitmq-server start
- 停止服务:
sudo service rabbitmq-server stop
- 重启服务:
sudo service rabbitmq-server restart
- 查看状态:
systemctl status rabbitmq-server
- 启动服务:
4. RabbitMQ 集成
代码仓库:https://github.com/itwanger/paicoding
代码分支:feature/add_rabbitmq_20230506
4.1 前置工作
- 引入依赖:
<dependency>
<groupId>com.rabbitmq</groupId>
<artifactId>amqp-client</artifactId>
<version>5.5.1</version>
</dependency>
- 修改配置:
rabbitmq:
host: 127.0.0.1
port: 5672 # 客户端默认端口:5672
username: admin
passport: admin
virtualhost: /
switch_flag: true # 默认为false,如果需要运行RabbitMQ,改为true
- 在RabbitMQ后台手动添加一个Exchange:
4.2 代码实现
RabbitmqUtil
类:用于管理ConnectionFactory
单例
public class RabbitmqUtil {
// 用于存储每个key对应的连接工厂,保证每个key有自己独立的工厂实例
private static Map<String, ConnectionFactory> executors = new ConcurrentHashMap<>();
/**
* 初始化一个连接工厂
* @param host RabbitMQ服务器主机地址
* @param port RabbitMQ服务器端口
* @param username 用户名
* @param passport 密码
* @param virtualhost 虚拟主机名
* @return 初始化好的ConnectionFactory对象
*/
public static ConnectionFactory init(String host,
Integer port,
String username,
String passport,
String virtualhost) {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost(host);
factory.setPort(port);
factory.setUsername(username);
factory.setPassword(passport);
factory.setVirtualHost(virtualhost);
return factory;
}
/**
* 获取或初始化连接工厂,如果不存在则创建并放入executors中
* @param key 用于标识连接工厂的key
* @param host RabbitMQ服务器主机地址
* @param port RabbitMQ服务器端口
* @param username 用户名
* @param passport 密码
* @param virtualhost 虚拟主机名
* @return 对应的ConnectionFactory对象
*/
public static ConnectionFactory getOrInitConnectionFactory(String key,
String host,
Integer port,
String username,
String passport,
String virtualhost) {
ConnectionFactory connectionFactory = executors.get(key);
if (null == connectionFactory) {
synchronized (RabbitmqUtil.class) {
connectionFactory = executors.get(key);
if (null == connectionFactory) {
connectionFactory = init(host, port, username, passport, virtualhost);
executors.put(key, connectionFactory);
}
}
}
return connectionFactory;
}
}
RabbitmqClient
类:包含获取连接工厂方法
/**
* @author Louzai
* @date 2023/5/10
* 该类用于与RabbitMQ交互,实现消息的发送和消费
*/
@Component
public class RabbitmqClient {
@Autowired
private RabbitmqProperties rabbitmqProperties;
/**
* 创建一个连接工厂
* @param key 用于标识连接工厂的key
* @return 对应的ConnectionFactory对象
*/
public ConnectionFactory getConnectionFactory(String key) {
String host = rabbitmqProperties.getHost();
Integer port = rabbitmqProperties.getPort();
String userName = rabbitmqProperties.getUsername();
String password = rabbitmqProperties.getPassport();
String virtualhost = rabbitmqProperties.getVirtualhost();
return RabbitmqUtil.getOrInitConnectionFactory(key, host, port, userName, password, virtualhost);
}
}
RabbitmqServiceImpl
:包含消息发送和消费的方法
@Component
public class RabbitmqServiceImpl implements RabbitmqService {
@Autowired
private RabbitmqClient rabbitmqClient;
@Autowired
private NotifyService notifyService;
/**
* 发送消息到RabbitMQ
* @param exchange 交换机名称
* @param toutingKey 路由键
* @param message 要发送的消息内容
* @throws IOException 处理IO异常
* @throws TimeoutException 处理连接超时异常
*/
@Override
public void publishMsg(String exchange,
BuiltinExchangeType exchangeType,
String toutingKey,
String message) throws IOException, TimeoutException {
ConnectionFactory factory = getConnectionFactory(toutingKey);
// 创建连接
Connection connection = factory.newConnection();
// 创建消息信道
Channel channel = connection.createChannel();
// 声明交换机
channel.exchangeDeclare(exchange, exchangeType, true, false, null);
// 发布消息
channel.basicPublish(exchange, toutingKey, null, message.getBytes());
System.out.println("Publish msg:" + message);
// 关闭信道和连接
channel.close();
connection.close();
}
/**
* 消费RabbitMQ消息
* @param exchange 交换机名称
* @param queue 队列名称
* @param routingKey 路由键
* @throws IOException 处理IO异常
* @throws TimeoutException 处理连接超时异常
*/
@Override
public void consumerMsg(String exchange,
String queue,
String routingKey) throws IOException, TimeoutException {
ConnectionFactory factory = getConnectionFactory(routingKey);
// 创建连接
Connection connection = factory.newConnection();
// 创建消息信道
Channel channel = connection.createChannel();
// 声明队列
channel.queueDeclare(queue, true, false, false, null);
// 绑定队列到交换机
channel.queueBind(queue, exchange, routingKey);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties,
byte[] body) throws IOException {
String message = new String(body, "UTF-8");
System.out.println("Consumer msg:" + message);
// 获取Rabbitmq消息,并保存到DB
// 说明:这里仅作为示例,如果有多种类型的消息,可以根据消息判定,简单的用if...else处理,复杂的用工厂+策略模式
// 此处notifyService假设为处理消息业务逻辑的服务,实际需根据项目情况实现
notifyService.saveArticleNotify(JsonUtil.toObj(message, UserFootDO.class), NotifyTypeEnum.PRAISE);
// 手动确认消息已消费
channel.basicAck(envelope.getDeliveryTag(), false);
}
};
// 取消自动ack,改为手动ack
channel.basicConsume(queue, false, consumer);
}
/**
* 非阻塞模式消费RabbitMQ消息(目前实现方式较简单粗暴,后续需改进)
*/
@Override
public void processConsumerMsg() {
System.out.println("Begin to processConsumerMsg.");
Integer stepTotal = 1;
Integer step = 0;
// 目前是简单的while循环消费,后续需优化为阻塞I/O模式
while (true) {
step++;
try {
System.out.println("processConsumerMsg cycle.");
consumerMsg(CommonConstants.EXCHANGE_NAME_DIRECT, CommonConstants.QUERE_NAME_PRAISE,
CommonConstants.QUERE_KEY_PRAISE);
if (step.equals(stepTotal)) {
try {
Thread.sleep(10000);
step = 0;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
4.3 调用入口
- 点赞消息走 RabbitMQ:
// 点赞消息走RabbitMQ,其它走Java内置消息机制
if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqProperties.getSwitchFlag()) {
rabbitmqService.publishMsg(
CommonConstants.EXCHANGE_NAME_DIRECT,
BuiltinExchangeType.DIRECT,
CommonConstants.QUERE_KEY_PRAISE,
JsonUtil.toStr(foot));
} else {
Optional.ofNullable(notifyType).ifPresent(notify -> SpringUtil.publishEvent(new NotifyMsgEvent<>(this, notify, foot)));
}
- 启动时消费消息:
@Override
public void run(ApplicationArguments args) {
// 设置类型转换, 主要用于mybatis读取varchar/json类型数据据,并写入到json格式的实体Entity中
JacksonTypeHandler.setObjectMapper(new ObjectMapper());
// 应用启动之后执行
GlobalViewConfig config = SpringUtil.getBean(GlobalViewConfig.class);
if (webPort != null) {
config.setHost("http://127.0.0.1:" + webPort);
}
// 启动RabbitMQ进行消费
if (rabbitmqProperties.getSwitchFlag()) {
taskExecutor.execute(() -> rabbitmqService.processConsumerMsg());
}
log.info("启动成功,点击进入首页: {}", config.getHost());
}
4.4 实际效果
- 对文章多次点赞,触发RabbitMQ发送消息:
- 通过控制台打印的日志,查看发送和消费的消息
4.5 存在问题
查看RabbitMQ后台,会发现大量未关闭的connections和channel,会占用大量内存。
如何解决呢?答案是使用连接池。
以下是整理出的问题:
- Connection 方面:未添加连接池,存在内存持续消耗风险,可能导致机器性能受影响。
- RabbitMQ 消费方式方面:现有的
while + sleep
消费方式简单粗暴,需要改造。 - RabbitMQ 任务处理方面:缺少消费任务挂掉后的重启消费机制。
- 机器故障应对方面:机器重启后,要保证 RabbitMQ 内部消息不丢失。
5. 改进版本:RabbitMQ连接池
- 代码仓库: https://github.com/itwanger/paicoding
- 代码分支:
feature/rabbitmq_connection_pool_20230511
5.1 前置工作
-
修改配置:
rabbitmq: host: 127.0.0.1 port: 5672 username: admin passport: admin virtualhost: / switch_flag: true pool_size: 5 # 连接池大小为5
5.2 加入连接池
之前我们给 ConnectionFactory 加了个单例工厂,不过由于现在有了连接池,这个单例工厂就可以废弃了。
- RabbitMQ 连接类:
public class RabbitmqConnection {
private Connection connection;
public RabbitmqConnection(String host, int port, String userName, String password, String virtualhost) {
ConnectionFactory connectionFactory = new ConnectionFactory();
connectionFactory.setHost(host);
connectionFactory.setPort(port);
connectionFactory.setUsername(userName);
connectionFactory.setPassword(password);
connectionFactory.setVirtualHost(virtualhost);
try {
connection = connectionFactory.newConnection();
} catch (IOException | TimeoutException e) {
e.printStackTrace();
}
}
/**
* 获取链接
*
* @return
*/
public Connection getConnection() {
return connection;
}
/**
* 关闭链接
*
*/
public void close() {
try {
connection.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
- RabbitMQ 连接池类:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class RabbitmqConnectionPool {
private static BlockingQueue<RabbitmqConnection> pool;
public static void initRabbitmqConnectionPool(String host, int port, String userName, String password,
String virtualhost, Integer poolSize) {
pool = new LinkedBlockingQueue<>(poolSize);
for (int i = 0; i < poolSize; i++) {
pool.add(new RabbitmqConnection(host, port, userName, password, virtualhost));
}
}
public static RabbitmqConnection getConnection() throws InterruptedException {
return pool.take();
}
public static void returnConnection(RabbitmqConnection connection) {
pool.add(connection);
}
public static void close() {
pool.forEach(RabbitmqConnection::close);
}
}
5.3 RabbitMQ 发送、消费消息
- RabbitMQ 发送消息的流程为:
从连接池拿到连接 -> 创建通道 -> 声明交换机 -> 发送消息 -> 将连接归还连接池
以下是发送消息的代码:
@Override
public void publishMsg(String exchange,
BuiltinExchangeType exchangeType,
String toutingKey,
String message) {
try {
//创建连接
RabbitmqConnection rabbitmqConnection = RabbitmqConnectionPool.getConnection();
Connection connection = rabbitmqConnection.getConnection();
//创建消息通道
Channel channel = connection.createChannel();
// 声明exchange中的消息为可持久化,不自动删除
channel.exchangeDeclare(exchange, exchangeType, true, false, null);
// 发布消息
channel.basicPublish(exchange, toutingKey, null, message.getBytes());
System.out.println("Publish msg:" + message);
channel.close();
RabbitmqConnectionPool.returnConnection(rabbitmqConnection);
} catch (InterruptedException | IOException | TimeoutException e) {
e.printStackTrace();
}
}
- RabbitMQ 消费消息的流程是:
从连接池拿到连接 -> 创建通道 -> 确定消息队列 -> 绑定队列到交换机 -> 接受并消费消息 -> 将连接归还连接池
消费消息的代码如下:
@Override
public void consumerMsg(String exchange,
String queueName,
String routingKey) {
try {
//创建连接
RabbitmqConnection rabbitmqConnection = RabbitmqConnectionPool.getConnection();
Connection connection = rabbitmqConnection.getConnection();
//创建消息信道
final Channel channel = connection.createChannel();
//消息队列
channel.queueDeclare(queueName, true, false, false, null);
//绑定队列到交换机
channel.queueBind(queueName, exchange, routingKey);
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties,
byte[] body) throws IOException {
String message = new String(body, "UTF-8");
System.out.println("Consumer msg:" + message);
// 获取Rabbitmq消息,并保存到DB
// 说明:这里仅作为示例,如果有多种类型的消息,可以根据消息判定,简单的用 if...else 处理,复杂的用工厂 + 策略模式
notifyService.saveArticleNotify(JsonUtil.toObj(message, UserFootDO.class), NotifyTypeEnum.PRAISE);
channel.basicAck(envelope.getDeliveryTag(), false);
}
};
// 取消自动ack
channel.basicConsume(queueName, false, consumer);
channel.close();
RabbitmqConnectionPool.returnConnection(rabbitmqConnection);
} catch (InterruptedException | IOException | TimeoutException e) {
e.printStackTrace();
}
}
这里要注意,代码存在连接取出后没有归还的问题。因为连接池使用的是 BlockingQueue
,如果连接全部取出且不归还,当新的请求过来时,请求就会卡住,导致界面操作阻塞。
5.4 调用入口
-
点赞消息走 RabbitMQ:
// 点赞消息走 RabbitMQ,其它走 Java 内置消息机制 if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqProperties.getSwitchFlag()) { rabbitmqService.publishMsg( CommonConstants.EXCHANGE_NAME_DIRECT, BuiltinExchangeType.DIRECT, CommonConstants.QUERE_KEY_PRAISE, JsonUtil.toStr(foot)); } else { Optional.ofNullable(notifyType).ifPresent(notify -> SpringUtil.publishEvent(new NotifyMsgEvent<>(this, notify, foot))); }
-
启动时消费消息:
@Override
public void run(ApplicationArguments args) {
// 设置类型转换, 主要用于mybatis读取varchar/json类型数据据,并写入到json格式的实体Entity中
JacksonTypeHandler.setObjectMapper(new ObjectMapper());
// 应用启动之后执行
GlobalViewConfig config = SpringUtil.getBean(GlobalViewConfig.class);
if (webPort != null) {
config.setHost("http://127.0.0.1:" + webPort);
}
// 启动 RabbitMQ 进行消费
if (rabbitmqProperties.getSwitchFlag()) {
String host = rabbitmqProperties.getHost();
Integer port = rabbitmqProperties.getPort();
String userName = rabbitmqProperties.getUsername();
String password = rabbitmqProperties.getPassport();
String virtualhost = rabbitmqProperties.getVirtualhost();
Integer poolSize = rabbitmqProperties.getPoolSize();
RabbitmqConnectionPool.initRabbitmqConnectionPool(host, port, userName, password, virtualhost, poolSize);
taskExecutor.execute(() -> rabbitmqService.processConsumerMsg());
}
log.info("启动成功,点击进入首页: {}", config.getHost());
}
5.5 实际效果
-
对文章多次点赞,触发RabbitMQ发送消息:
-
通过控制台打印的日志,查看发送和消费的消息
-
查看RabbitMQ后台,发现只有5个连接,跟连接池的大小一致
-
再看看channel,每次都会关闭,所以也都没有了
5.6 bug修复
-
问题:报错
AlreadyClosedException: channel is already closed
表示操作时 channel 已被关闭(日志显示为正常关闭clean channel shutdown
),通常由 channel 生命周期管理不当导致。
Consumer
处理消息是一个异步的过程,消息还没处理完,channel
就被关闭了,执行到channel.basicAck(envelope.getDeliveryTag(), false)
会报异常。 -
解决方案:延迟channel的关闭时间。
channel.basicConsume(queueName, false, consumer); Thread.sleep(10000); // 延迟channel关闭,否则会报channel is not open的错误 channel.close(); RabbitmqConnectionPool.returnConnection(rabbitmqConnection);
6 总结
本文文章围绕 RabbitMQ 展开了详细介绍:
- 消息队列模式:介绍了点对点和发布 / 订阅两种模式及其特点和失败处理方式。
- RabbitMQ 原理:阐述了其基于的 AMQP 协议关键概念、工作原理和常用交换器类型。
- 环境搭建:分别说明了 Windows 版和Linux版安装 Erlang、RabbitMQ 及配置、启动和访问管理界面的步骤。
- 集成与问题:展示了集成代码实现,指出存在连接和 channel 未关闭、消费方式简单等问题。
- 改进版本:引入连接池改进,介绍了具体代码及效果,还修复了 channel 关闭相关的 bug。