1. 消息存储
目前的MQ中间件从存储模型来,分为需要持久化和不需要持久化的两种模型,现在大多数的是支持持久化存储的,比如ActiveMQ、RabbitMQ、Kafka、RocketMQ,ZeroMQ却不需要支持持久化存储而业务系统也大多需要MQ有持久存储的能力,这样可以大大增加系统的高可用性。
从存储方式和效率来看,文件系统高于KV存储,KV存储又高于关系型数据库,直接操作文件系统肯定是最快的,但如果从可靠性的角度出发直接操作文件系统是最低的,而关系型数据库的可靠性是最高的。
1.1 存储介质类型和对比
常用的存储类型分为关系型数据库存储、分布式KV存储 和 文件系统存储
目前的高性能磁盘,顺序写速度可以达到 600MB/s,足以满足一般网卡的传输速度,而磁盘随机读写的速度只有约 100KB/s,与顺序写的性能相差了 6000 倍,故好的消息队列系统都会采用顺序写的方式
关系型数据库存储 | 分布式KV存储 | 文件系统存储 | |
---|---|---|---|
简介 | 选用 JDBC 方式实现消息持久化,只需要简单地配置 xml 即可实现 JDBC 消息存储 | kv存储即 Key-Value 型存储中间件,如 Redis 和 RocksDB,将消息存储到这些中间件中 | 将消息存储到文件系统中 |
性能 | 存在性能瓶颈,如mysql在单表数据量达到千万级别的情况下,IO读写性能下降 | 通过高并发的中间件存储和处理消息,速度必然优于数据库存储方式 | 将消息刷盘至所部属虚拟化/物理机的文件系统来实现消息持久化,效率更高 |
可靠性 | 该方案十分依赖DB,一旦DB出现故障,MQ消息无法落盘存储,从而导致线上故障 | 相较DB来说更加安全可靠 | 除非部署 MQ 的机器本身或是本地磁盘挂了,否则一般不会出现无法持久化的问题 |
项目使用 | ActiveMQ | Redis、RockDB | RocketMQ、Kafaka、RabbitMQ |
存储效率:文件系统 > 分布式KV存储 > 关系型数据库DB
开发难度和集成:文件系统> 分布式KV存储 > 关系型数据库DB
1.2 顺序读写和随机读写
顺序读写和随机读写对于机械硬盘来说为什么性能差异巨大
顺序读写 | 随机读写 | |
---|---|---|
文件数目 | 读写一个大文件 | 读写个小文件 |
比较 | 顺序读写只读取一个大文件,耗时更少 | 随机读写需要打开多个文件,写进行多次的寻址和旋转延迟,速率远低于顺序读写 |
文件预读 | 顺序读写时磁盘会预读文件,即在读取的起始地址连续读取多个页面,若被预读的页面被使用,则无需再去读取 | 由于数据不在一起,无法预读 |
比较 | 在大并发的情况下,磁盘预读能够免去大量的读操作,处理速度肯定更快 | 磁盘需要不断的寻址,效率很低 |
写入数据 | 写入新文件时,需要寻找磁盘可用空间 | 写入新文件时,需要寻找磁盘可用空间,但由于一个文件的存储量更小,这个操作触发频率更多 |
比较 | 顺序读写创建新文件,只需要创建一个大文件就可以用很久 | 随机读写1 |
1.3 消息存储机制
由于消息队列有高可靠性的要求,故要对队列中的数据进行持久化存储。
- 消息生产者先向 MQ 发送消息
- MQ 收到消息,将消息进行持久化,并在存储系统中新增一条记录
- 返回ACK(确认字符)给生产者
- MQ 推送消息给对应的消费者,等待消费者返回ACK(确认字符,确认消费)
- 若这条消息的消费者在等待时间内成功返回ACK,则 MQ 认为消息消费成功,删除存储中的消息
- 若 MQ 在指定时间内没有收到ACK,则认为消息消费失败,会尝试重新推送消息
1.4 消息存储设计
RocketMQ采用了单一的日志文件,即把同1台机器上面所有topic的所有queue的消息,存放在一个文件里面,从而避免了随机的磁盘写入。
所有消息都存在一个单一的CommitLog文件里面,然后有后台线程异步的同步到ConsumeQueue,再由Consumer进行消费。
这里之所以可以用“异步线程”,也是因为消息队列天生就是用来“缓冲消息”的,只要消息到了CommitLog,发送的消息也就不会丢,只要消息不丢,那就有了“充足的回旋余地”,用一个后台线程慢慢同步到ConsumeQueue,再由Consumer消费。
1.5 消息存储结构
消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的
RocketMQ 采取一些机制,尽量向 CommitLog 中顺序写,但是随机读,单个文件大小默认1G ,可通过在 broker 置文件中设置 mapedFileSizeCommitLog 属性来改变默认大小。
1.5.1 CommitLog
CommitLog是存储消息内容的存储主体,Producer发送的消息都会顺序写入CommitLog文件
CommitLog 以物理文件的方式存放,每台 Broker 上的 CommitLog 被本机器所有 ConsumeQueue 共享,文件地址:$ {user.home} \store$ { commitlog} \ $ { fileName}。
文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推,消息主要是顺序写入日志文件,当文件满了,写入下一个文件。
1.5.2 ConsumeQueue
consumequeue文件可以看成是基于topic的commitlog索引文件。
RocketMQ基于主题订阅模式实现消息的消费,消费者关心的是主题下的所有消息,但是由于不同的主题的消息不连续的存储在commitlog文件中,如果只是检索该消息文件可想而知会有多慢,为了提高效率,对应的主题的队列建立了索引文件,为了加快消息的检索和节省磁盘空间,每一个consumequeue条目存储了消息的关键信息commitog文件中的偏移量、消息长度、tag的hashcode值。
1.5.3 IndexFile
index 存的是索引文件,用于为生成的索引文件提供访问服务,这个文件用来加快消息查询的速度,通过消息Key值查询消息真正的实体内容
消息消费队列 RocketMQ 专门为消息订阅构建的索引文件 ,提高根据主题与消息检索 消息的速度 ,使用 Hash 索引机制,具体是 Hash 槽与 Hash 冲突的链表结构。
在实际的物理存储上,文件名则是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引。
1.5.4 Config
config 文件夹中 存储着 Topic 和 Consumer 等相关信息,主题和消费者群组相关的信息就存在在此。
- topics.json : topic 配置属性
- subscriptionGroup.json :消息消费组配置信息。
- delayOffset.json :延时消息队列拉取进度。
- consumerOffset.json :集群消费模式消息消进度。
- consumerFilter.json :主题消息过滤信息。
2. 消费进度管理
业务实现消费回调的时候,当且仅当此回调函数返回
ConsumeConcurrentlyStatus.CONSUME_SUCCESS
,RocketMQ才会认为这批消息(默认是1条)是消费完成的
为了保证消息是肯定被至少消费成功一次,RocketMQ会把这批消费失败的消息重发回Broker(topic不是原topic而是这个消费组的RETRY topic),在延迟的某个时间点(默认是10秒,业务可设置)后,再次投递到这个ConsumerGroup,而如果一直这样重复消费都持续失败到一定次数(默认16次),就会投递到DLQ死信队列,应用可以监控死信队列来做人工干预。
2.1 从哪里开始消费
当新实例启动的时候,PushConsumer会拿到本消费组broker已经记录好的消费进度,如果这个消费进度在Broker并没有存储起来,证明这个是一个全新的消费组,这时候客户端有几个策略可以选择:
CONSUME_FROM_LAST_OFFSET //默认策略,从该队列最尾开始消费,即跳过历史消息CONSUME_FROM_FIRST_OFFSET //从队列最开始开始消费,即历史消息(还储存在broker的)全部消费一遍CONSUME_FROM_TIMESTAMP//从某个时间点开始消费,和setConsumeTimestamp()配合使用,默认是半个小时以前
2.2 消息ACK机制
RocketMQ是以consumer group+queue为单位是管理消费进度的,以一个consumer offset标记这个这个消费组在这条queue上的消费进度
如果某已存在的消费组出现了新消费实例的时候,依靠这个组的消费进度,就可以判断第一次是从哪里开始拉取的。
每次消息成功后,本地的消费进度会被更新,然后由定时器定时同步到broker,以此持久化消费进度,但是每次记录消费进度的时候,只会把一批消息中最小的offset值为消费进度值,如下图:
2.3 重复消费问题
这定时方式和传统的一条message单独ack的方式有本质的区别,性能上提升的同时,会带来一个潜在的重复问题——由于消费进度只是记录了一个下标,就可能出现拉取了100条消息如 2101-2200的消息,后面99条都消费结束了,只有2101消费一直没有结束的情况。
在这种情况下,RocketMQ为了保证消息肯定被消费成功,消费进度职能维持在2101,直到2101也消费结束了,本地的消费进度才能标记2200消费结束了(注:consumerOffset=2201)。
在这种设计下,就有消费大量重复的风险,如2101在还没有消费完成的时候消费实例突然退出(机器断电,或者被kill),这条queue的消费进度还是维持在2101,当queue重新分配给新的实例的时候,新的实例从broker上拿到的消费进度还是维持在2101,这时候就会又从2101开始消费,2102-2200这批消息实际上已经被消费过还是会投递一次。
对于这个场景,RocketMQ暂时无能为力,所以业务必须要保证消息消费的幂等性,这也是RocketMQ官方多次强调的态度。
2.4 重复消费验证
2.4.1 查看当前消费进度
检查队列消费的当前进度
cat consumerOffset.json
{
"offsetTable": {
"topicTest@rocket_test_consumer_group": {
0: 33,
1: 32,
2: 32,
3: 33
},
"%RETRY%rocket_test_consumer_group@rocket_test_consumer_group": {
0: 6
}
}
}
通过consumerOffset.json我们可以知道当前topicTest主题的queue0消费到偏移量为28
2.4.2 消费者发送消息
消费者发送消息,并查看各个队列消息的偏移量
发送queueId:[2],偏移量offset:[32],发送状态:[SEND_OK]
发送queueId:[3],偏移量offset:[33],发送状态:[SEND_OK]
发送queueId:[0],偏移量offset:[33],发送状态:[SEND_OK]
发送queueId:[1],偏移量offset:[32],发送状态:[SEND_OK]
发送queueId:[2],偏移量offset:[33],发送状态:[SEND_OK]
发送queueId:[3],偏移量offset:[34],发送状态:[SEND_OK]
发送queueId:[0],偏移量offset:[34],发送状态:[SEND_OK]
发送queueId:[1],偏移量offset:[33],发送状态:[SEND_OK]
发送queueId:[2],偏移量offset:[34],发送状态:[SEND_OK]
发送queueId:[3],偏移量offset:[35],发送状态:[SEND_OK]
我们发现队列2的偏移量最小为29
消费的时候最小偏移量不提交,其他都正常
//队列2的偏移量为29的数据在等待
if (ext.getQueueId() == 2 && ext.getQueueOffset() == 29) {
System.out.println("消息消费耗时较厂接收queueId:[" + ext.getQueueId() + "],偏移量offset:[" + ext.getQueueOffset() + "]");
//等待 模拟假死状态
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
运行查看日志
接收queueId:[3],偏移量offset:[34],接收时间:[1608880793658],消息=[Hello Java demo RocketMQ 5]
接收queueId:[3],偏移量offset:[33],接收时间:[1608880793658],消息=[Hello Java demo RocketMQ 1]
接收queueId:[3],偏移量offset:[35],接收时间:[1608880793658],消息=[Hello Java demo RocketMQ 9]
接收queueId:[1],偏移量offset:[32],接收时间:[1608880794684],消息=[Hello Java demo RocketMQ 3]
接收queueId:[0],偏移量offset:[34],接收时间:[1608880794687],消息=[Hello Java demo RocketMQ 6]
接收queueId:[1],偏移量offset:[33],接收时间:[1608880794685],消息=[Hello Java demo RocketMQ 7]
接收queueId:[2],偏移量offset:[33],接收时间:[1608880794689],消息=[Hello Java demo RocketMQ 4]
接收queueId:[0],偏移量offset:[33],接收时间:[1608880794685],消息=[Hello Java demo RocketMQ 2]
模拟服务宕机无法确认消息,接收queueId:[2],偏移量offset:[32]
接收queueId:[2],偏移量offset:[34],接收时间:[1608880794691],消息=[Hello Java demo RocketMQ 8]
我们发现只有队列2的偏移量为29的消息消费超时,其他都已经正常消费
我们再查看下consumerOffset.json
{
"offsetTable": {
"topicTest@rocket_test_consumer_group": {
0: 33,
1: 32,
2: 32,
3: 33
},
"%RETRY%rocket_test_consumer_group@rocket_test_consumer_group": {
0: 6
}
}
}
我们发现rocketMQ 整个消费记录都没有被提交,所以下次消费会全部再次消费
2.4.3 再次消费
去掉延时代码继续消费
接收queueId:[3],偏移量offset:[35],接收时间:[1608880958530],消息=[Hello Java demo RocketMQ 9]
接收queueId:[3],偏移量offset:[33],接收时间:[1608880958530],消息=[Hello Java demo RocketMQ 1]
接收queueId:[3],偏移量offset:[34],接收时间:[1608880958530],消息=[Hello Java demo RocketMQ 5]
接收queueId:[2],偏移量offset:[32],接收时间:[1608880959539],消息=[Hello Java demo RocketMQ 0]
接收queueId:[2],偏移量offset:[33],接收时间:[1608880959560],消息=[Hello Java demo RocketMQ 4]
接收queueId:[0],偏移量offset:[33],接收时间:[1608880959561],消息=[Hello Java demo RocketMQ 2]
接收queueId:[2],偏移量offset:[34],接收时间:[1608880959561],消息=[Hello Java demo RocketMQ 8]
接收queueId:[1],偏移量offset:[32],接收时间:[1608880959564],消息=[Hello Java demo RocketMQ 3]
接收queueId:[1],偏移量offset:[33],接收时间:[1608880959564],消息=[Hello Java demo RocketMQ 7]
接收queueId:[0],偏移量offset:[34],接收时间:[1608880959566],消息=[Hello Java demo RocketMQ 6]
我们发现消息被重复消费了一遍