你是否好奇过:RocketMQ 的消息是怎么存到硬盘的?RabbitMQ 的交换机和队列是怎么绑定的?
本文带你从零手写一个简化版消息队列,揭开源码背后的秘密。
什么是消息队列?
消息队列是一种异步通信机制 ,核心是生产者生产消息,消费者消费消息,两者通过队列解耦。
就像寄快递:你把包裹放到快递柜,朋友有空再去取。你不需要等朋友来拿,朋友也不受你时间限制。
消息队列主要解决异步处理、流量削峰、系统解耦、可靠性四个问题。比如秒杀场景,瞬时流量很大,用MQ缓冲一下,不会压垮数据库。
我手写了一个简化版MQ,包括交换机(路由)、队列(存储)、绑定(关联)三个核心组件,用SQLite存储元数据,实现网络通信层。
一、为什么手写消息队列?
消息队列(MQ)是后端开发的核心中间件,RabbitMQ、RocketMQ、Kafka 各有千秋。但只停留在"会用"是不够的,只有自己动手写一遍,才能真正理解:
- 消息怎么存储才能保证不丢失?
- 交换机和队列是怎么绑定的?
- 网络通信怎么设计?
本文将从零开始 ,用 Spring Boot + MyBatis + SQLite 搭建一个轻量级消息队列的存储层。
二、项目技术栈
| 技术 | 作用 | 理由 |
|---|---|---|
| Spring Boot 2.7 | 项目基础框架 | 简化配置,IoC 容器 |
| MyBatis | 数据库操作 | 灵活编写 SQL |
| SQLite | 元数据存储 | 零配置,单文件,适合嵌入式 |
| Jackson | JSON 处理 | 扩展参数序列化 |
| JUnit 5 | 单元测试 | 保证代码质量 |
三、核心实体类设计
消息队列有三个核心概念:交换机,队列,绑定
1. 交换机(Exchange)
为什么要先有交换机?
A.场景:没有交换机
生产者 → 直接发消息 → 队列
问题:
- 生产者要知道队列的名字
- 一个消息要发给多个队列,生产者要发多次
- 增加新队列,要改生产者代码
B.场景:有交换机
生产者 → 发给交换机 → 交换机决定 → 发给哪些队列
优势:
- 生产者只认识交换机,不认识队列
- 交换机自动复制消息到多个队列
- 增加队列,只需改绑定规则,不改生产者
问:交换机在消息队列中扮演什么角色?
交换机是消息队列中核心的路由组件 ,相当于交通指挥中心。
它的主要职责是:
- 接收生产者发送的消息,但自己不存储
- 根据消息的 routingKey 和自身的类型(DIRECT/FANOUT/TOPIC),决定消息应该去哪些队列
- 通过 Binding 规则和队列解耦,生产者不需要知道队列的存在
这种设计的好处是:
- 解耦:生产者和队列不直接依赖
- 灵活:改变路由规则不影响生产者
- 复用:一个消息可以发给多个队列
交换机的实体
typescript
public class Exchange {
private String name; // 交换机名称
private ExchangeType type; // 类型:DIRECT、FANOUT、TOPIC
private boolean durable; // 是否持久化
private boolean autoDelete; // 是否自动删除
private Map<String, Object> arguments; // 扩展参数(JSON存储)
}
设计思考:
arguments用Map<String, Object>存储,扩展性强- 存入数据库时需要转成 JSON(用 Jackson 处理)
交换机类型枚举
ruby
public enum ExchangeType {
DIRECT(0), // 精确匹配 --"routingKey"必须完全等于 "bindingKey"
FANOUT(1), // 广播 --不管 "routingKey",发给所有绑定的队列
TOPIC(2); // 通配符 --"routingKey" 匹配通配符(`*`、`#`)
}
2. 队列(MSGQueue)
交换机 vs 队列(核心区别)
| 对比维度 | 交换机(Exchange) | 队列(Queue) |
|---|---|---|
| 比喻 | 大脑/指挥中心 | 身体/仓库 |
| 作用 | 决定消息去哪 | 真正存储消息 |
| 存消息吗 | ❌ 不存 | ✅ 存 |
| 数量 | 少(几个到几十个) | 多(上百甚至上千个) |
| 生命周期 | 长期存在 | 可长期可临时 |
| 消费者关心吗 | 不关心 | 关心(从这取消息) |
消息和队列
- 队列里存在多个消息,生产着把消息存放进去,消费者从中拿走消息,并且没有队列,消费者请求过多易压倒数据库,如果有队列,则可以把请求发给队列,在从队列中按照自己的节奏取请求。
- 在消费者程序挂了的场景下,没有队列则消息直接丢失,若是有队列,则会存取起来,等消费者恢复后取走
问:队列在消息队列中扮演什么角色?
队列是消息队列中真正存储消息的地方 ,相当于仓库 或身体。
它的核心职责有三个:
- 存储:消息发来后存在队列里,等消费者来取
- 缓冲:生产者和消费者速度不一致时,队列做缓冲
- 可靠性:消费者挂了没关系,消息还在队列里
和交换机的区别是:交换机是"大脑"(负责决策),队列是"身体"(负责存储)。没有交换机,消息不知道去哪;没有队列,消息没地方放。
在我的项目中,队列设计了
durable(持久化)、exclusive(排他)、autoDelete(自动删除)等属性,参考了 RabbitMQ 的设计。
队列的实体
typescript
public class MSGQueue {
private String name; // 队列名称
private boolean durable; // 是否持久化
private boolean exclusive; // 是否排他
private boolean autoDelete; // 是否自动删除
private Map<String, Object> arguments; // 扩展参数
}
队列的存储结构(硬盘)
c
./data/
├── order.queue/ ← 队列名称
│ ├── queue_data.txt ← 消息数据文件
│ └── queue_stat.txt ← 统计信息
├── user.queue/
│ ├── queue_data.txt
│ └── queue_stat.txt
├── log.queue/
│ ├── queue_data.txt
│ └── queue_stat.txt
3. 绑定(Binding)
没有绑定会怎样?
交换机 ──???──→ 队列
- 交换机不知道要把消息发送给谁,
问题:
- 交换机和队列是隔离的,无法通信
- 消息到了交换机,不知道去哪个队列
- 整个MQ无法工作
问:绑定在消息队列中扮演什么角色?
绑定是连接交换机和队列的桥梁 ,相当于一份路由规则合同。
它告诉交换机三件事:
- 要连接哪个队列(
queueName)- 什么条件的消息走这条路(
bindingKey)- 属于哪个交换机(
exchangeName)没有绑定,交换机和队列就是隔离的,消息到了交换机也不知道该去哪。
绑定的核心价值是解耦和灵活:改变路由规则只需要修改绑定,不需要改交换机或生产者的代码。一个交换机可以绑定多个队列(一对多),一个队列也可以绑定多个交换机(多对一)。
绑定的实体
arduino
public class Binding {
private String exchangeName; // 交换机名称
private String queueName; // 队列名称
private String bindingKey; // 绑定键(路由规则)
}
四、数据库设计(自动建表)
SQLite 建表语句
sql
-- 交换机表
CREATE TABLE IF NOT EXISTS exchange (
name TEXT PRIMARY KEY,
type INTEGER NOT NULL,
durable INTEGER NOT NULL DEFAULT 0,
auto_delete INTEGER NOT NULL DEFAULT 0,
arguments TEXT
);
-- 队列表
CREATE TABLE IF NOT EXISTS msg_queue (
name TEXT PRIMARY KEY,
durable INTEGER NOT NULL DEFAULT 0,
exclusive INTEGER NOT NULL DEFAULT 0,
auto_delete INTEGER NOT NULL DEFAULT 0,
arguments TEXT
);
-- 绑定表
CREATE TABLE IF NOT EXISTS binding (
exchange_name TEXT NOT NULL,
queue_name TEXT NOT NULL,
binding_key TEXT,
PRIMARY KEY (exchange_name, queue_name)
);
为什么用 SQLite?
- 嵌入式数据库,不需要单独安装
- 单文件存储,数据迁移方便
- 零配置,适合单机版 MQ```
问:为什么要做自动化建表?
自动化建表的核心目的是提升项目的可移植性和开发效率。
如果没有自动化建表:
- 新同事 clone 项目后,需要找 SQL 文件,手动执行建表
- 部署到服务器时,要登录服务器执行 SQL
- 多人协作时,可能出现 SQL 版本不一致的问题
有了自动化建表:
- 项目启动时自动检查并建表,使用
CREATE TABLE IF NOT EXISTS保证幂等性- 自动创建
./data目录- 自动插入默认数据(如
default.direct交换机)最终效果:git clone 后直接运行,一键启动。
问:用了什么技术实现?
用了 Spring Boot 的
@PostConstruct,在 Bean 初始化完成后自动执行建表逻辑。建表 SQL 用了IF NOT EXISTS保证可重复执行。
问:有什么好处?
- 零人工干预:不需要手动执行 SQL
- 幂等性:多次执行不会报错
- 可移植性:任何电脑都能一键运行
- 版本一致:代码即数据库定义,不会出现 SQL 文件过期的问题
MetaMapper 接口设计(以交换机为例子)
less
@Mapper
public interface MetaMapper {
// 建表
void createExchangeTable();
void createQueueTable();
void createBindingTable();
// 交换机 CRUD
void insertExchange(Exchange exchange);
void deleteExchange(@Param("name") String name);
Exchange selectExchangeByName(@Param("name") String name);
List<Exchange> selectAllExchanges();
// 队列 CRUD(类似)
// 绑定 CRUD(类似)
}
XML Mapper 实现
建表语句(幂等设计)
sql
<update id="createExchangeTable">
CREATE TABLE IF NOT EXISTS exchange (
name TEXT PRIMARY KEY,
type INTEGER NOT NULL,
durable INTEGER NOT NULL DEFAULT 0,
auto_delete INTEGER NOT NULL DEFAULT 0,
arguments TEXT
)
</update>
DataBaseManager 设计
scss
@Component
public class DataBaseManager {
@Autowired
private MetaMapper metaMapper;
@PostConstruct // Spring 启动时自动执行
public void init() {
// 1. 确保 data 目录存在
ensureDataDirectoryExists();
// 2. 检查数据库是否存在
if (!checkDBExists()) {
// 3. 建表
createTable();
// 4. 创建默认数据
createDefaultData();
}
}
private void ensureDataDirectoryExists() {
File dataDir = new File("./data");
if (!dataDir.exists()) {
dataDir.mkdirs();
}
}
private void createTable() {
metaMapper.createExchangeTable();
metaMapper.createQueueTable();
metaMapper.createBindingTable();
}
private void createDefaultData() {
Exchange defaultExchange = new Exchange();
defaultExchange.setName("default.direct");
defaultExchange.setType(ExchangeType.DIRECT);
defaultExchange.setDurable(true);
metaMapper.insertExchange(defaultExchange);
}
}
流程图
kotlin
MqApplication.main()
↓
Spring 容器启动
↓
扫描到 @Component 的 DataBaseManager
↓
注入 MetaMapper(@Autowired)
↓
执行 @PostConstruct 的 init()
↓
┌─────────────────────────────────────┐
│ ① 检查 ./data 目录 → 不存在则创建 │
│ ② 检查 meta.db → 不存在则初始化 │
│ ③ 执行建表 SQL(IF NOT EXISTS) │
│ ④ 插入默认数据 │
└─────────────────────────────────────┘
↓
数据库就绪 ✅
六、JSON 扩展参数处理
6.1 问题
arguments 字段在 Java 中是 Map<String, Object>,但数据库只能存字符串。
6.2 解决方案
在实体类的 getter/setter 中自动转换:
typescript
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// MyBatis 写数据库时调用
public String getArguments() {
if (arguments == null || arguments.isEmpty()) {
return "{}";
}
try {
return OBJECT_MAPPER.writeValueAsString(arguments);
} catch (JsonProcessingException e) {
return "{}";
}
}
// MyBatis 读数据库时调用
public void setArguments(String argumentsJson) {
if (argumentsJson == null || argumentsJson.isEmpty()) {
this.arguments = new HashMap<>();
return;
}
try {
this.arguments = OBJECT_MAPPER.readValue(argumentsJson,
new TypeReference<Map<String, Object>>() {});
} catch (JsonProcessingException e) {
this.arguments = new HashMap<>();
}
}
为什么能自动调用?
MyBatis 看到 getArguments() 返回 String,就用它写入数据库;看到 setArguments(String),就用它读取数据库。
七、单元测试
less
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class DataBaseManagerTest {
@Autowired
private DataBaseManager dataBaseManager;
@Test
@Order(1)
void testDatabaseInit() {
// 验证数据库文件存在
File dbFile = new File("./data/meta.db");
assertTrue(dbFile.exists());
// 验证默认交换机存在
Exchange defaultExchange = dataBaseManager.selectExchangeByName("default.direct");
assertNotNull(defaultExchange);
assertEquals(ExchangeType.DIRECT, defaultExchange.getType());
}
@Test
@Order(2)
void testCreateAndQueryExchange() {
Exchange exchange = new Exchange();
exchange.setName("test.exchange");
exchange.setType(ExchangeType.FANOUT);
dataBaseManager.insertExchange(exchange);
Exchange found = dataBaseManager.selectExchangeByName("test.exchange");
assertNotNull(found);
assertEquals("test.exchange", found.getName());
}
}
八、运行效果
csharp
[DataBaseManager] 检查目录: ./data → 不存在,创建
[DataBaseManager] 数据库文件: ./data/meta.db → 不存在
[DataBaseManager] 数据库不存在,开始初始化...
[DataBaseManager] 开始建表...
[DataBaseManager] 建表完成
[DataBaseManager] 默认交换机创建成功: default.direct
[DataBaseManager] 数据库初始化完成
========== MQ Server 启动成功 ==========
九、小结
本文完成了:
- ✅ Spring Boot + MyBatis + SQLite 项目搭建
- ✅ 交换机、队列、绑定实体类设计
- ✅ 数据库表结构设计
- ✅ MyBatis Mapper 编写
- ✅ JSON 扩展参数处理
- ✅ 自动建表和初始化
- ✅ 单元测试
下一篇预告:实现交换机、队列、绑定的增删改查,封装 DataBaseManager,编写完整的单元测试。