手写消息队列(一):从零搭建Spring Boot + MyBatis + SQLite

你是否好奇过: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.场景:没有交换机

生产者 → 直接发消息 → 队列

问题:

  1. 生产者要知道队列的名字
  2. 一个消息要发给多个队列,生产者要发多次
  3. 增加新队列,要改生产者代码

B.场景:有交换机

生产者 → 发给交换机 → 交换机决定 → 发给哪些队列

优势:

  1. 生产者只认识交换机,不认识队列
  2. 交换机自动复制消息到多个队列
  3. 增加队列,只需改绑定规则,不改生产者

问:交换机在消息队列中扮演什么角色?

交换机是消息队列中核心的路由组件 ,相当于交通指挥中心

它的主要职责是:

  1. 接收生产者发送的消息,但自己不存储
  2. 根据消息的 routingKey 和自身的类型(DIRECT/FANOUT/TOPIC),决定消息应该去哪些队列
  3. 通过 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存储)
}

设计思考

  • argumentsMap<String, Object> 存储,扩展性强
  • 存入数据库时需要转成 JSON(用 Jackson 处理)

交换机类型枚举

ruby 复制代码
public enum ExchangeType {
    DIRECT(0),   // 精确匹配  --"routingKey"必须完全等于 "bindingKey"
    FANOUT(1),   // 广播      --不管 "routingKey",发给所有绑定的队列
    TOPIC(2);    // 通配符    --"routingKey" 匹配通配符(`*`、`#`)
}

2. 队列(MSGQueue)

交换机 vs 队列(核心区别)
对比维度 交换机(Exchange) 队列(Queue)
比喻 大脑/指挥中心 身体/仓库
作用 决定消息去哪 真正存储消息
存消息吗 ❌ 不存 ✅ 存
数量 少(几个到几十个) 多(上百甚至上千个)
生命周期 长期存在 可长期可临时
消费者关心吗 不关心 关心(从这取消息)

消息和队列

  • 队列里存在多个消息,生产着把消息存放进去,消费者从中拿走消息,并且没有队列,消费者请求过多易压倒数据库,如果有队列,则可以把请求发给队列,在从队列中按照自己的节奏取请求。
  • 在消费者程序挂了的场景下,没有队列则消息直接丢失,若是有队列,则会存取起来,等消费者恢复后取走

问:队列在消息队列中扮演什么角色?

队列是消息队列中真正存储消息的地方 ,相当于仓库身体

它的核心职责有三个:

  1. 存储:消息发来后存在队列里,等消费者来取
  2. 缓冲:生产者和消费者速度不一致时,队列做缓冲
  3. 可靠性:消费者挂了没关系,消息还在队列里

和交换机的区别是:交换机是"大脑"(负责决策),队列是"身体"(负责存储)。没有交换机,消息不知道去哪;没有队列,消息没地方放。

在我的项目中,队列设计了 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无法工作

问:绑定在消息队列中扮演什么角色?

绑定是连接交换机和队列的桥梁 ,相当于一份路由规则合同

它告诉交换机三件事:

  1. 要连接哪个队列(queueName
  2. 什么条件的消息走这条路(bindingKey
  3. 属于哪个交换机(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 保证可重复执行。

问:有什么好处?

  1. 零人工干预:不需要手动执行 SQL
  2. 幂等性:多次执行不会报错
  3. 可移植性:任何电脑都能一键运行
  4. 版本一致:代码即数据库定义,不会出现 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 启动成功 ==========

九、小结

本文完成了:

  1. ✅ Spring Boot + MyBatis + SQLite 项目搭建
  2. ✅ 交换机、队列、绑定实体类设计
  3. ✅ 数据库表结构设计
  4. ✅ MyBatis Mapper 编写
  5. ✅ JSON 扩展参数处理
  6. ✅ 自动建表和初始化
  7. ✅ 单元测试

下一篇预告:实现交换机、队列、绑定的增删改查,封装 DataBaseManager,编写完整的单元测试。

相关推荐
Oo_行者_oO1 小时前
Spring Schedule + ShedLock + RabbitMQ 生产级落地方案 - 云楼(中国)
java·后端
Hical611 小时前
百万 TCP 长连接内存实测:50 万点回归,R²=1.0000,每连接 7.58 KB
后端·github
Mahir081 小时前
HashMap 底层原理深度解密:从数据结构到 JDK1.7/1.8 演进全解
java·后端·面试·hashmap
uhakadotcom1 小时前
get_event_loop(),和 get_running_loop() + ThreadPoolExecutor 有啥区别
后端·面试·github
小马爱打代码1 小时前
Spring Boot 自动装配流程
java·spring boot·后端
Cosolar2 小时前
72小时生死时速:一文读懂引爆Fable模型禁令的越狱技术风暴
人工智能·后端·程序员
砍材农夫2 小时前
python环境|pip|uv|venv|Conda区别
后端·python·conda·pip·uv
Csvn2 小时前
Linux 网络配置与排查命令实战
后端
IT_陈寒2 小时前
Redis主从切换把我坑惨了,这份血泪史你最好看看
前端·人工智能·后端