消息队列持久化:文件存储设计与实现全解析

在实现消息队列时,消息持久化是保证数据可靠性的核心环节 ------ 即使服务重启,已接收的消息也不会丢失。本文将结合代码和设计思路,详细讲解如何通过文件存储实现消息的写入、删除、加载与垃圾回收(GC)。

一、整体设计思路

我们的持久化方案围绕文件存储展开,核心目标是:

  • 消息写入文件后可持久留存,服务重启后能重新加载
  • 支持逻辑删除消息,避免频繁修改文件导致性能损耗
  • 定期清理无效消息
  • 保证多线程下的文件操作安全

核心文件结构

每个队列对应一个独立目录,包含两类文件:

  1. queue_data.txt:存储消息的二进制数据
  2. queue_stat.txt:存储队列消息统计信息(总消息数、有效消息数)
java 复制代码
./data/
└── [队列名]/
    ├── queue_data.txt    # 消息数据文件
    └── queue_stat.txt    # 消息统计文件

二、关键技术选型:为什么用这两个类?

1. DataOutputStream:解决 "写 4 字节长度" 的问题

Java 原生的 OutputStream.write(int) 方法,虽然参数是 int,但实际只会写入低 8 位 (1 个字节)。而我们的消息长度需要用4 字节(32 位) 存储,直接用原生方法会导致数据截断。

DataOutputStream 提供了 writeInt(int) 方法,能完整写入 4 字节整数,完美匹配我们的消息格式:

规定的消息存储格式:[4 字节长度] + [消息二进制本体]

java 复制代码
// 写入消息长度(4字节)
dataOutputStream.writeInt(messageBinary.length);
// 写入消息本体
dataOutputStream.write(messageBinary);

2. RandomAccessFile:支持随机读写与修改

普通的文件流(FileInputStream/FileOutputStream)只能顺序读写,无法在文件中间修改数据。而我们的逻辑删除消息需要:

  1. 定位到消息在文件中的起始位置
  2. 读取消息数据
  3. 修改 isValid 标记为 0x0
  4. 写回原位置

RandomAccessFile 提供了 seek() 方法,可以在对文件进行操作是把光标跳转到任意位置,实现 "随机读写",这是实现逻辑删除的关键。

java 复制代码
// 跳转到消息起始位置
randomAccessFile.seek(message.getOffsetBeg());
// 读取消息数据
randomAccessFile.read(bufferSrc);
// 修改后写回原位置
randomAccessFile.seek(message.getOffsetBeg());
randomAccessFile.write(bufferDest);

三、核心功能实现细节

1. 队列文件的创建与销毁

  • 创建 :生成队列目录、数据文件和统计文件,并初始化统计数据为 0\t0
  • 销毁:先删除数据文件和统计文件,再删除队列目录
  • 校验 :提供 checkFileExits() 方法,确保后续操作前文件存在
java 复制代码
public void createQueueFiles(String queueName) throws IOException {
        //1.先创建队列所对应的消息目录
        File baseDir = new File(getQueueDir(queueName));
        if (!baseDir.exists()) {
            //如果这个目录不存在就创建这个目录
            boolean ok = baseDir.mkdirs();
            if (!ok) {
                throw new IOException("创建目录失败!baseDir=" + baseDir.getAbsoluteFile());
            }
        }
        //2.创建队列的数据文件
        File queueDataFile = new File(getQueueDataPath(queueName));
        if (!queueDataFile.exists()) {
            boolean ok = queueDataFile.createNewFile();
            if (!ok) {
                throw new IOException("创建文件失败! queueDataFile=" + queueDataFile.getAbsolutePath());
            }
        }
        //3.创建消息统计文件
        File queueStatFile = new File(getQueueStatPath(queueName));
        if (!queueStatFile.exists()) {
            boolean ok = queueStatFile.createNewFile();
            if (!ok) {
                throw new IOException("创建文件失败! queueStatFile=" + queueStatFile.getAbsolutePath());
            }
        }

        //4. 给消息统计文件, 设定初始值. 0\t0
        Stat stat = new Stat();
        stat.validCount = 0;
        stat.totalCount = 0;
        writeStat(queueName, stat);
    }

2. 消息写入文件:sendMessage

写入流程:

  1. 校验队列文件存在
  2. 序列化消息为二进制数组
  3. 计算消息在文件中的 offsetBegoffsetEnd(用于后续定位)
  4. 追加写入文件:先写 4 字节长度,再写消息本体
  5. 更新统计文件(总消息数 + 1,有效消息数 + 1)

重点

  • synchronized (queue) 加锁,保证多线程下写入安全
  • offsetBeg = 当前文件长度 + 4offsetEnd = offsetBeg + 消息长度,这两个字段是后续定位消息的关键
java 复制代码
public void sendMessage(MSGQueue queue, Message message) throws MqException, IOException {
        //1.先要进行检查队列的目录和文件是否存在
        if (!checkFileExits(queue.getName())) {
            throw new MqException("[MessageFileManager] 队列对应的文件不存在! queueName=" + queue.getName());
        }

        synchronized (queue) {
            //2.在把消息写入文件之前,要先把消息进行序列化
            byte[] messageBinary = BinaryTool.toBytes(message);

            //3.先获取到当前队列的数据文件的长度,然后计算出该Message对象的offsetBeg和offsetEnd
            //我们需要把这个信息写到当前队列数据文件的末尾,此时Message对象的offsetBeg就是 当前文件的长度 + 4
            //offsetEnd就是 当前文件长度 + 4 +message自身的长度

            File queueDateFile = new File(getQueueDataPath(queue.getName()));
    //      通过queueDateFile.length()可以获取到当前消息文件的长度,单位字节
            message.setOffsetBeg(queueDateFile.length() + 4);
            message.setOffsetEnd(queueDateFile.length() + 4 + messageBinary.length);

            //4.把消息写入到数据文件,是追加到数据文件的末尾
            try (OutputStream outputStream = new FileOutputStream(queueDateFile, true)) {
                try (DataOutputStream dataOutputStream = new DataOutputStream(outputStream)) {
                    //先要把消息的长度写进去,这个长度是占据四个字节的
                    dataOutputStream.write(messageBinary.length);
                    //接下来写入消息本体
                    dataOutputStream.write(messageBinary);
                }
            }

            //接下来需要更新消息统计文件
            Stat stat = readStat(queue.getName());
            stat.totalCount++;
            stat.validCount++;
            writeStat(queue.getName(), stat);
        }
    }

3. 逻辑删除消息:deleteMessage

我们采用逻辑删除 而非物理删除:只修改消息的 isValid 标记为 0x0,保留数据在文件中,避免频繁移动文件数据导致性能损耗。

删除流程:

  1. 加锁保证线程安全
  2. RandomAccessFile 定位到消息起始位置
  3. 读取消息数据,反序列化为 Message 对象
  4. 修改 isValid = 0x0
  5. 序列化后写回原位置
  6. 更新统计文件(有效消息数 - 1)

重点

  • 读取后必须重新 seek 到原位置,因为 read() 会移动文件指针
  • 只有 isValid 一个字节被修改
java 复制代码
 public void deleteMessage(MSGQueue queue, Message message) throws IOException, ClassNotFoundException {
        synchronized (queue) {
            //后一个参数rw表示打开这个文件之后既可以读也可以写
            try (RandomAccessFile randomAccessFile = new RandomAccessFile(getQueueDataPath(queue.getName()), "rw")) {
                //1.先从文件中读取到message数据
                // 2.计算当前消息占用的字节长度,并创建对应长度的空字节数组
                byte[] bufferSrc = new byte[(int) (message.getOffsetEnd() - message.getOffsetBeg())];
                // 3. 将RandomAccessFile的文件指针(光标)移动到该消息在文件中的起始位置
                randomAccessFile.seek(message.getOffsetBeg());

                //seek方法是移动光标,从数据的开始位置开始读取数据
                // 4.从光标位置开始,读取文件中的字节数据,填充到bufferSrc数组中
                randomAccessFile.read(bufferSrc);
                int readBytes = randomAccessFile.read(bufferSrc);
                if (readBytes != bufferSrc.length) {
                    throw new IOException("读取消息数据不完整,预期读取" + bufferSrc.length + "字节,实际读取" + readBytes + "字节");
                }

                //5.把读取出来的数据转为一个message对象
                Message diskMessage = (Message) BinaryTool.fromBytes(bufferSrc);
                diskMessage.setIsValid((byte) 0x0);

                //6.再把上述的数据重新写回文件
                byte[] bufferDest = BinaryTool.toBytes(diskMessage);
                //接下来还需要再次移动光标到该段数据的起始位置,因为上一次移动光标到起始位置之后进行了读取文件的操作
                //这会让光标移动到下一个消息的起始位置,但是我们写入这段数据还是需要在之前的起始位置进行写入
                //因此需要再次移动光标
                randomAccessFile.seek(message.getOffsetBeg());
                randomAccessFile.write(bufferDest);
                //上述的操作对于这个文件来说,只有一个字节发生了改变
            }
            //上述操作进行完之后,还要更新消息统计文件
            Stat stat = readStat(queue.getName());
            if (stat.totalCount > 0) {
                stat.validCount -= 1;
            }
            writeStat(queue.getName(), stat);
        }
    }

4. 加载所有有效消息:loadAllMessageFromQueue

服务启动时,需要从文件中恢复所有有效消息到内存。

加载流程:

  1. 循环读取文件:先读 4 字节长度,再读消息本体
  2. 反序列化为 Message 对象
  3. isValid == 0x1(有效),则计算 offsetBeg/offsetEnd 并加入链表
  4. isValid == 0x0(无效),则跳过,仅更新光标位置
  5. 捕获 EOFException 表示文件读取完毕

重点

  • 手动维护 currentOffset 变量,因为 DataInputStream 无法直接获取文件光标位置
  • 只加载有效消息,避免内存浪费
java 复制代码
 public LinkedList<Message> loadAllMessageFromQueue(String queueName) throws IOException, MqException, ClassNotFoundException {
        LinkedList<Message> messages = new LinkedList<>();
        try (InputStream inputStream = new FileInputStream(getQueueDataPath(queueName))) {
            try (DataInputStream dataInputStream = new DataInputStream(inputStream)) {
                // 这个变量记录当前文件光标.
                long currentOffset = 0;
                //由于一个文件中包含了多个信息,所以需要用循环来读取
                while (true) {
                    //1.先要读取当前消息的长度
                    int messageSize = dataInputStream.readInt();
                    //2.接下来按照这个消息的长度来读取消息的内容
                    byte[] buffer = new byte[messageSize];
                    int actualSize = dataInputStream.read(buffer);
                    if (messageSize != actualSize) {
                        //如果长度不匹配,说明文件有问题
                        throw new MqException("[MessageFileManager] 文件格式错误! queueName=" + queueName);
                    }
                    //3.接下来需要把这个读到的二进制数据反序列化一个message对象
                    Message message = (Message) BinaryTool.fromBytes(buffer);
                    //4.接下来要判断这个消息对象是不是无效对象
                    if (message.getIsValid() != 0x1) {
                        //无效数据直接跳过
                        //跳过这个数据之前也需要先更新offest
                        currentOffset += (4 + messageSize);
                        continue;
                    }
                    // 5. 有效数据, 则需要把这个 Message 对象加入到链表中. 加入之前还需要填写 offsetBeg 和 offsetEnd
                    //    进行计算 offset 的时候, 需要知道当前文件光标的位置的. 由于当下使用的 DataInputStream 并不方便直接获取到文件光标位置
                    //    因此就需要手动计算下文件光标.
                    message.setOffsetBeg(currentOffset + 4);
                    message.setOffsetEnd(currentOffset + 4 + messageSize);
                    currentOffset += (4 + messageSize);
                    messages.add(message);
                }
            } catch (EOFException e) {
                // 这个 catch 并非真是处理 "异常", 而是处理 "正常" 的业务逻辑. 文件读到末尾, 会被 readInt 抛出该异常.
                // 这个 catch 语句中也不需要做啥特殊的事情
                System.out.println("[MessageFileManager] 恢复 Message 数据完成!");
            }
        }
        return messages;
    }

5. 垃圾回收(GC):清理无效消息

逻辑删除会导致文件中积累大量无效数据,因此需要定期执行 GC,用复制算法清理无效数据:

GC 流程:

  1. 加锁,避免并发修改
  2. 创建新文件 queue_data_new.txt
  3. 加载旧文件中所有有效消息
  4. 将有效消息写入新文件
  5. 删除旧文件,将新文件重命名为 queue_data.txt
  6. 更新统计文件(总消息数 = 有效消息数)

触发条件

  • 总消息数 > 2000
  • 有效消息占比 < 50%
java 复制代码
 public boolean checkGC(String queueName) {
        //判断是否要GC 需要拿到消息的总数和有效的消息数,这两个值都是在消息统计文件当中的
        Stat stat = readStat(queueName);
        if (stat != null && stat.totalCount > 2000 && (double) stat.validCount / (double) stat.totalCount < 0.5) {
            return true;
        }
        return false;
    }

四、多线程安全设计

所有涉及文件修改的操作(sendMessagedeleteMessagegc)都通过 synchronized (queue) 加锁:

  • 锁对象是队列本身,不同队列之间互不影响
  • 保证同一队列的文件操作串行执行,避免并发写入导致数据损坏
java 复制代码
synchronized (queue) {
    // 文件操作逻辑...
}

五、总结

本次实现的消息持久化方案,核心亮点可以概括为四点:

  1. 格式清晰:采用「4 字节长度 + 消息本体」的存储结构,读写逻辑简单直观,也为后续扩展字段、兼容新协议留下了空间。
  2. 高效删除:通过逻辑删除标记无效消息,配合定期 GC 清理冗余数据,既避免了频繁修改文件带来的性能损耗,又能控制文件体积不会无限膨胀。
  3. 线程安全:以队列对象为粒度加锁,让不同队列的操作互不阻塞,同时保证同一队列的文件读写串行执行,避免多线程并发导致的数据损坏。
  4. 可靠恢复:服务重启后能完整加载所有有效消息,配合统计文件的校验,确保数据在异常场景下也能安全恢复。

这套方案完整覆盖了消息持久化的核心场景,既保证了数据可靠性,又兼顾了性能与可维护性。

相关推荐
sg_knight1 小时前
设计模式实战:策略模式(Strategy)
java·开发语言·python·设计模式·重构·架构·策略模式
踩着两条虫1 小时前
去“AI味儿”实操手册:从“机器脸”到“高级脸”,只差这三步!
前端·vue.js·ai编程
麦麦鸡腿堡1 小时前
JavaWeb_SpringBootWeb,HTTP协议,Tomcat快速入门
java·开发语言
qq_417695051 小时前
内存对齐与缓存友好设计
开发语言·c++·算法
2301_816651221 小时前
实时系统下的C++编程
开发语言·c++·算法
一然明月1 小时前
Qt QML 锚定(Anchors)全解析
java·数据库·qt
晓纪同学1 小时前
EffctiveC++_02第二章
java·jvm·c++
2401_831824961 小时前
C++与Python混合编程实战
开发语言·c++·算法