【项目篇之消息持久化】仿照RabbitMQ模拟实现消息队列

实现消息持久化

之前将Exchange,MSGQueue,Binding这三个类都存储在了数据库上

但是我们的Message这个消息不适合存储在数据库上面的,为什么呢?

  1. Message消息是不涉及到很复杂的增删改查的,所以一般不会使用到数据库上的很多功能
  2. Message消息的数量可能会非常多,数据库的访问效率并不高

所以Message消息不适合用数据库存储,就把这个Message类存储在文件中,在RabbitMQ上面也是这样做的

消息如何具体如何在文件中存储?

收到消息之后,这些消息都会被交换机给投递到具体的队列中去,

所以消息是依附于队列的,因此存储消息的时候,就把消息按照队列的维度展开,

此处已经有了一个data目录,(meta.db文件就在这个data目录中),

在data目录中去创建一些子目录,每个队列都有一个子目录,子目录的名字就是队列名,

把消息就存储在对应的队列目录中去:

queue_data.txt

而且,对于第一个文件:queue_data.txt文件,这个文件是一个二进制格式的文件,里面存储的是二进制的数据:

在这个二进制文件中具体是如何进行存储的?

在这个文件中做出如下约定:

这个二进制文件中包含若干个消息,每个消息都是以二进制的方式存储的,

同时每个消息都是由两个部分组成:

  1. 消息长度(固定四个字节大小)
  2. 消息的二进制数据(对象序列化之后得到的数据)

如图所示:

我们进一步分析,这个消息的二进制数据里面又包含哪些东西呢?

如图所示:

最后面的那个属性:isValid这个属性是用来标识当前这个消息在文件中是否有效的,那么这个属性的作用是什么呢?

但是随着时间的推移,这个消息文件会越来越大,而且无效消息也会越来越多,此时就需要针对当前数据文件,进行垃圾回收,

总结:

先固定四个字节,去描述整个消息的长度,然后根据消息长度,在这四个字节的后面填上消息的二进制数据。

我们后续在去进行序列化存储和反序列化解析的时候,就是按照上述的格式去展开的

而我们之前在Message对象中定义的offsetBeg和offsetEnd的使用方法如下所示:

如何实现垃圾回收的算法

使用复制算法,针对消息数据文件中的垃圾进行回收

复制算法实现方式:

直接遍历原有的消息数据文件,把所有的有效数据都拷贝到一个新的文件中,再把之前的整个旧的文件都删除掉即可。

但是复制算法适用的前提是:

在当前的空间中,有效的数据不多,大部分都是无效数据的时候,适合使用这个复制算法,

那么这个复制算法什么时候才触发一次呢,什么时候我们知道当前文件有效数据不多,垃圾很多呢

此处做出的约定是:

当总的消息数目超过2000个的时候,并且有效消息的数目低于总消息数据的50%,就触发一次垃圾回收

为什么总数目是2000个,为了避免垃圾回收太频繁,比如总的只有4个消息,两个消息是无效的,此时就触发垃圾回收...

上述的2000和50%是我随便设置的,我觉得这样很合理,那我就这样设置了,你也可以自己去自行设计垃圾回收触发的时机,可以自己根据实际的场景去灵活设置

queue_stat.txt

既然我们约定了这个策略,设置了总消息数目和有效消息数量这两个信息,那么这两个信息我们从哪里去获取到呢?

就是从刚刚的第二个文件:使用queue_stat.txt文件中去获取到总消息数目+有效消息数目

这个文件就是专门用来保存消息的统计信息的,这个文件中只存一行数据,而且是文本格式的数据

这一行数据有两列,第一列是queue_data.txt中总的消息数目,第二列是queue_data.txt中有效消息的数目

两者使用/t来分隔:

2000 /t 500

一共有2000个总消息数目,有效消息数目是500个

文件的拆分与合并

如果某个队列中,消息特别特别多,而且都是有效消息,

此时就会导致整个消息的数据文件特别大,后续针对这个文件的各种操作,成本就很高

如果这个文件的大小是10GB,此时如果触发了一次垃圾回收,整体的GC耗时就会很长

对于RabbitMQ来说,解决方案就是把一个大的文件,拆成多个小的文件,这个就是文件拆分

文件拆分:当单个文件长度达到一定阈值之后,就会拆分成两个文件,如果还是很大,那就一直拆下去

文件合并:每个单独的文件都会进行GC,如果GC之后,文件变小了很多,就可能会和相邻的其他文件合并为一个文件

通过上述的拆分合并,可以保证性能上可以即时响应

这一块的逻辑还是比较复杂的,我们的项目目前就不去实现了,我们只考虑单个文件的情况,如果HR问到了这个问题,就可以给出这个解决方案。

如果真的要实现,大致的思路如下所示:

需要专门的数据结构,去存储当前队列中有多少个数据文件,每个文件的大小是多少,消息数目是多少,无效消息数目是多少,

同时设计好策略,什么时候触发文件的拆分,什么时候触发文件的合并

创建MessageFileManager类

创建一个类叫做MessageFileManager针对硬盘上面的消息进行管理,

也就是针对文件进行管理,也就是针对存储消息的内容进行管理

而刚刚的DataBaseManager类是针对数据库操作进行管理的

后续我们需要在代码中操作这几个文件,那就直接操作下面的方法即可:就不必通过硬编码的方式去获取消息文件的路径了:

java 复制代码
//这个方法:用来获取到指定队列对应的消息文件所在的路径:  
private String getQueueDir(String queueName){  
    return "./data/" + queueName;  
}  
  
//这个方法用来获取该队列的消息数据文件路径:  
private String getQueueDataPath(String queueName){  
    return getQueueDir(queueName) + "/queue_data.txt";  
}  
  
//这个方法用来获取到该队列的消息统计文件路径:  
private String getQueueStatPath(String queueName){  
    return getQueueDir(queueName) + "/queue_stat.txt";  
              
}

同时定义一个内部类表示统计信息:

java 复制代码
  
//定义一个内部类来表示该队列的统计信息:  
static public class Stat{  
    public int totalCount;   //总消息数量  
    public int validCount;  //有效消息数量  
}

实现消息统计文件读写

统计文件的读写比较简单,消息数据文件的读写比较复杂,我们柿子先挑软的捏

第一个方法:读统计文件的方法:

这个方法大体步骤如下所示:

  1. 创建一个新的内部类Stat来存放读取的统计信息
  2. 使用Scanner来进行读取操作
  3. 使用nextInt方法先读取总的消息数量,然后读取有效消息数量
  4. 把读取到的总消息数量和有效消息数量放到新的Stat中
  5. 最后返回这个新的Stat

因为统计文件是第一个文本文件,我们直接使用Scanner来读取即可:

java 复制代码
//使用这个方法来读统计文件:  
private Stat readStat(String queueName) throws FileNotFoundException {  
    Stat stat1 = new Stat();  
    InputStream inputStream = new FileInputStream(getQueueStatPath(queueName));  
    Scanner scanner = new Scanner(inputStream);  
    //第一个整数是总的消息数量  
    stat1.totalCount = scanner.nextInt();  
    //第二个整数是有效消息数量:  
    stat1.validCount = scanner.nextInt();  
    return stat1;  
}

第二个方法:创建一个写统计文件的方法:

这个方法的大体步骤如下所示:

  1. 使用OutputStream来操作
  2. 使用PrintWriter来写入Stat的内容
  3. 最后刷新缓冲区

代码如下所示:

java 复制代码
//这个方法用来写统计文件:  
private void  writeStat(String queueName, Stat stat) throws FileNotFoundException {  
    //使用PrintWrite来写文件:  
    OutputStream outputStream = new FileOutputStream(getQueueStatPath(queueName));  
    PrintWriter printWriter = new PrintWriter(outputStream);  
    printWriter.write(stat.totalCount + "/t" + stat.validCount);  
    //保证数据从缓冲区刷新到硬盘上去:  
    printWriter.flush();  
}

注意:OutputStream 打开文件会默认将源文件都清空,所以使用OutputStream每次去在文件中写入新的值的时候,旧的值都会被清空,新的数据覆盖了旧的数据

创建消息目录和文件:

刚刚文件的读写方法编写完毕,下面来编写文件的创建方法:

这个方法一共分为下面的四个步骤:

  1. 创建队列对应的消息目录
  2. 创建队列数据文件
  3. 创建消息统计文件
  4. 给消息统计文件设置一个初始值:0或者/t0

1:先创建队列对应的消息目录:

java 复制代码
//1.先创建队列对应的消息目录  
File baseDir = new File(getQueueDir(queueName));  
if(!baseDir.exists()){  
    //不存在,就创建这个目录:  
    boolean ok = baseDir.mkdirs();  
    if(!ok){  
        throw new IOException("创建目录失败!baseDir="+baseDir.getAbsolutePath());  
    }  
}

2:创建队列数据文件

java 复制代码
//2.创建队列数据文件:  
File queueDataFile = new File(getQueueDataPath(queueName));  
if(!queueDataFile.exists()){  
    boolean ok = queueDataFile.createNewFile();  
    if(!ok){  
        throw new IOException("创建队列数据文件失败:queueDataFile="+queueDataFile.getAbsolutePath());  
    }  
}

3:创建消息统计文件:

java 复制代码
//3.创建消息统计文件:  
File queueStatFile = new File(getQueueDataPath(queueName));  
if(!queueStatFile.exists()){  
    boolean ok = queueStatFile.createNewFile();  
    if(!ok){  
        throw new IOException("创建消息文件失败:queueStatFile="+queueStatFile.getAbsolutePath());  
    }  
}

4:给消息统计文件设置一个初始值:0或者/t0

java 复制代码
Stat stat = new Stat();  
stat.totalCount = 0;  
stat.validCount = 0;  
writeStat(queueName,stat);

总结,下面是这个创建消息目录文件的汇总代码:

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.getAbsolutePath());  
        }  
    }  
    //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(getQueueDataPath(queueName));  
    if(!queueStatFile.exists()){  
        boolean ok = queueStatFile.createNewFile();  
        if(!ok){  
            throw new IOException("创建消息文件失败:queueStatFile="+queueStatFile.getAbsolutePath());  
        }  
    }  
  
    //4.给消息统计文件设置一个初始值:0或者/t0  
    Stat stat = new Stat();  
    stat.totalCount = 0;  
    stat.validCount = 0;  
    writeStat(queueName,stat);  
  
}

删除队列的目录和文件

这个方法的大体逻辑如下所示:
先删除文件,再删除目录

  1. 删除数据文件

  2. 删除统计文件

  3. 删除队列的目录

  4. 检测是否删除成功了

队列也是可以被删除的,当队列被删除之后,对应的消息文件之类的自然也要被删除的

java 复制代码
//删除队列的文件和目录:  
public void destroyQueueFiles(String queueName) throws  IOException{  
    //先删除文件,再删除目录:  
    //第一步:删除数据文件:  
    File queueDataFile = new File(getQueueDataPath(queueName));  
    boolean ok1 = queueDataFile.delete();  
    //第二步:删除统计文件:  
    File queueStatFile = new File(getQueueStatPath(queueName));  
    boolean ok2  = queueStatFile.delete();  
    //第三步:删除队列的目录:  
    File baseDir = new File(getQueueDir(queueName));  
    boolean ok3 = baseDir.delete();  
    if(!ok1 || !ok2 || !ok3){  
        //有任意一个删除失败,抛出异常:  
        throw  new IOException("删除队列和目录失败:baseDir = "+baseDir.getAbsolutePath());  
    }  
}

检查队列的目录和文件是否存在:

判定队列的数据文件和统计文件是否都存在:

如果有任何一个不存在,那么就判定这个消息是损坏的消息:

这个方法的使用场景:

后续生产者给brokerServer生产消息了,那么这个消息就可能需要被记录在文件上,取决于消息是否要持久化,

java 复制代码
//检查队列的目录和文件是否存在:  
public boolean checkFilesExits(String  queueName){  
    //判定队列的数据文件和统计文件是否都存在:  
    File queueDataFile = new File(getQueueDataPath(queueName));  
    if(!queueDataFile.exists()){  
        //数据文件不存在  
        return false;  
    }  
    File queueStatFile = new File(getQueueStatPath(queueName));  
    if(!queueStatFile.exists()){  
        //统计文件不存在:  
        return false;  
    }  
    return true;  
}
相关推荐
小钊(求职中)4 分钟前
ElasticSearch从入门到精通-覆盖DSL操作和Java实战
java·大数据·elasticsearch·搜索引擎·全文检索
yuanlaile27 分钟前
Go全栈_Golang、Gin实战、Gorm实战、Go_Socket、Redis、Elasticsearch、微服务、K8s、RabbitMQ全家桶
linux·redis·golang·k8s·rabbitmq·gin
极客智谷29 分钟前
深入理解Java线程池:从原理到实战的完整指南
java·后端
代码不行的搬运工37 分钟前
HTML快速入门-4:HTML <meta> 标签属性详解
java·前端·html
Databend1 小时前
大数据是不是凉了?
数据库
mask哥1 小时前
详解最新链路追踪skywalking框架介绍、架构、环境本地部署&配置、整合微服务springcloudalibaba 、日志收集、自定义链路追踪、告警等
java·spring cloud·架构·gateway·springboot·skywalking·链路追踪
XU磊2601 小时前
javaWeb开发---前后端开发全景图解(基础梳理 + 技术体系)
java·idea
学也不会1 小时前
雪花算法
java·数据库·oracle
晓华-warm2 小时前
国产免费工作流引擎star 5.9k,Warm-Flow版本升级1.7.0(新增大量好用功能)
java·中间件·流程图·开源软件·flowable·工作流·activities
凭君语未可2 小时前
介绍 IntelliJ IDEA 快捷键操作
java·ide·intellij-idea