在实现消息队列时,消息持久化是保证数据可靠性的核心环节 ------ 即使服务重启,已接收的消息也不会丢失。本文将结合代码和设计思路,详细讲解如何通过文件存储实现消息的写入、删除、加载与垃圾回收(GC)。
一、整体设计思路
我们的持久化方案围绕文件存储展开,核心目标是:
- 消息写入文件后可持久留存,服务重启后能重新加载
- 支持逻辑删除消息,避免频繁修改文件导致性能损耗
- 定期清理无效消息
- 保证多线程下的文件操作安全
核心文件结构
每个队列对应一个独立目录,包含两类文件:
queue_data.txt:存储消息的二进制数据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)只能顺序读写,无法在文件中间修改数据。而我们的逻辑删除消息需要:
- 定位到消息在文件中的起始位置
- 读取消息数据
- 修改
isValid标记为0x0 - 写回原位置
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
写入流程:
- 校验队列文件存在
- 序列化消息为二进制数组
- 计算消息在文件中的
offsetBeg和offsetEnd(用于后续定位) - 追加写入文件:先写 4 字节长度,再写消息本体
- 更新统计文件(总消息数 + 1,有效消息数 + 1)
重点:
- 用
synchronized (queue)加锁,保证多线程下写入安全 offsetBeg = 当前文件长度 + 4,offsetEnd = 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,保留数据在文件中,避免频繁移动文件数据导致性能损耗。
删除流程:
- 加锁保证线程安全
- 用
RandomAccessFile定位到消息起始位置 - 读取消息数据,反序列化为
Message对象 - 修改
isValid = 0x0 - 序列化后写回原位置
- 更新统计文件(有效消息数 - 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
服务启动时,需要从文件中恢复所有有效消息到内存。
加载流程:
- 循环读取文件:先读 4 字节长度,再读消息本体
- 反序列化为
Message对象 - 若
isValid == 0x1(有效),则计算offsetBeg/offsetEnd并加入链表 - 若
isValid == 0x0(无效),则跳过,仅更新光标位置 - 捕获
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 流程:
- 加锁,避免并发修改
- 创建新文件
queue_data_new.txt - 加载旧文件中所有有效消息
- 将有效消息写入新文件
- 删除旧文件,将新文件重命名为
queue_data.txt - 更新统计文件(总消息数 = 有效消息数)
触发条件:
- 总消息数 > 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;
}
四、多线程安全设计
所有涉及文件修改的操作(sendMessage、deleteMessage、gc)都通过 synchronized (queue) 加锁:
- 锁对象是队列本身,不同队列之间互不影响
- 保证同一队列的文件操作串行执行,避免并发写入导致数据损坏
java
synchronized (queue) {
// 文件操作逻辑...
}
五、总结
本次实现的消息持久化方案,核心亮点可以概括为四点:
- 格式清晰:采用「4 字节长度 + 消息本体」的存储结构,读写逻辑简单直观,也为后续扩展字段、兼容新协议留下了空间。
- 高效删除:通过逻辑删除标记无效消息,配合定期 GC 清理冗余数据,既避免了频繁修改文件带来的性能损耗,又能控制文件体积不会无限膨胀。
- 线程安全:以队列对象为粒度加锁,让不同队列的操作互不阻塞,同时保证同一队列的文件读写串行执行,避免多线程并发导致的数据损坏。
- 可靠恢复:服务重启后能完整加载所有有效消息,配合统计文件的校验,确保数据在异常场景下也能安全恢复。
这套方案完整覆盖了消息持久化的核心场景,既保证了数据可靠性,又兼顾了性能与可维护性。