关于消息持久化
rocketMq通过broker节点中转和持久化数据。首先我们先看一下msg的数据结构当然看这玩意没啥用,没有关联的后续
broker所接受到的消息都会存储到commitLog中完成消息持久化。简单来说mq采用顺序写入,将新消息不断append到文件中从而提升I/O效率。具体来说,commitLog实际上是由MappedFile组成的MappedFileQueue,而MappedFile又是通过mmap命令将文件映射到进程的地址空间。mmap每次划出1GB的空间用做commitLog,消息则不断append到MappedFile上
关于mmap这里还要再一提linux中的zero-copy(零拷贝)
从用户态看,linux所提供的read和write方法无非是将数据从内存写入到磁盘,并不涉及copy动作。但实际上系统会先从用户态(user space)转换为内核态(kernel space)最终写入磁盘。这不仅涉及数据的多次copy还有cpu用户态和内核态的切换。以socket连接为例
而zero-copy就是为了解决这一问题,让数据的传输不需要经过user space。mmap命令将kernel space内存与应用程序共享,从而避免user space到kernel space的切换。
但是mmap也不是万能的,当你的程序map了一个文件,而这个文件又被另一个进程truncate,那么write会因为访问到非法地址而被SIGBUS信号终止,从而导致进程终止。关于这点就不详细说明了。到这里还是没有达成零拷贝,因为数据还可能在kernel space中移动。关于这点可以直接传递文件描述符而不是文件本身,使其指向磁盘的同一文件,避免数据拷贝,这部分也不进一步展开了。
broker中并不只有一个topic,但是broker中只维护一个commitLog,所以broker接收到的所有消息都会写入commitLog文件中,并不会根据topic做区分。这样显然会提高消息写入的速度,也会带来两个问题:
- 不同的topic在分发消息时,如何从commitLog中找到消息
- 如何保证高并发下消息的顺序写入
我们先来看第二个问题。对于并发控制自然少不了锁的参与,直观的想法就是对写入动作加独占锁保护,即同一时刻只允许一个线程加锁成功,那么该选什么样的锁实现才合适呢?RocketMQ 目前实现了两种方式。1. 基于 AQS 的 ReentrantLock 2. 基于 CAS 的 SpinLock。 对于 ReentrantLock,底层 AQS 抢不到锁的话会休眠,但是 SpinLock 会一直抢锁,造成明显的 CPU 占用。SpinLock 在 trylock 失败时,可以预期持有锁的线程会很快退出临界区,死循环的忙等待很可能要比进程挂起等待更高效。这也是为什么在高并发下为了保持 CPU 平稳占用而采用方式一,单次请求响应时间短的场景下采用方式二能够减少 CPU 开销。
这里顺带提一下rocketMq的顺序消息
rocketMq只能保证单机有序,相同消息组的消息按照先后顺序被存储在同一个队列(实现
MessageQueueSelector
接口从而自定义从生产者到queue的路由逻辑,保证消息都发送到同一个队列)。为了保持消息生产的顺序性仅支持单一生产者,并且串行发送消息(这里建议采用同步消息发送,即发送网络请求后会同步等待 Broker 服务器返回结果,避免消息缺失)。为了保证消费消息的顺序性,还需要消费者顺序消费以及合理设定重试次数,避免超过重试次数丢失或一直重试阻塞
关于问题一,则是rocketMq的索引管理,这个在下文会讲,这里继续说commitLog的持久化。rocketMq并不是一接收到消息就马上持久化到磁盘,实际上大部分系统都是首先缓存到内存中再批量写入的。
引入批量写入在提高I/O效率的同时也会带来数据丢失的问题。不同于数据库先写入redo log再写入缓存,rocketMq直接写入缓存,如果此时断电那么缓存中的数据就会丢失。
这里rocketMq提供了三种策略
-
同步持久化,使用
GroupCommitService
。写入线程仅仅负责唤醒落盘线程,将消息转交给存储线程,而不会等待消息存储完成之后就立刻返回了。落盘线程每隔 10 ms 会检查一次,如果有数据未持久化,便将 page cache 中的数据刷入磁盘。即使断电,但是生产者此时仍未收到成功的响应,所以生产者会重试,消息不会丢失
-
异步持久化且未开启
TransientStorePool
缓存,使用FlushRealTimeService
。消息写入page cache就会响应ack,然后由后台线程异步将page cache里的内容持久化到磁盘。
-
固定频率刷盘
不响应中断,每隔 500ms(可配置)flush 一次,如果发现未落盘数据不足(默认 16K),直接进入下一个循环。如果超过10s没有写入,也会触发消息持久化
-
非固定频率刷盘
每次有新的消息到来的时候,都会发送唤醒信号
-
-
异步持久化且开启
TransientStorePool
缓存,使用CommitRealService
。将消息存储到
DirectByteBuffer
,越过page cache,通过NIO的FileChannel
直接操作文件?。此时一旦进程重启数据就会丢失,数据可靠性降为最低级别。关于堆外内存
ByteBuffer.allocate() vs. ByteBuffer.allocateDirect() & 一文搞懂堆外内存(模拟内存泄漏)
由于I/O操作必须要求地址连续,但是jvm中byte数组并不能保证,并且即使同一个对象,因为GC对碎片的整理也会导致地址的变动。所以在写入文件时,实际会
- 开辟一块临时的堆外内存
- 将jvm中不连续的数据copy到地址连续的堆外内存中
- 使用堆外内存为数据源进行I/O操作
- 完成后释放内存
所以直接使用堆外内存可以减少数据copy次数,进而提升I/O效率。但是堆外内存也不是万能的,自然也存在着弊端,堆外内存泄漏显然比普通的oom更加难以解决Netty堆外内存泄露排查盛宴
关于引入缓冲区的读写分离
类似再内存层面做了读写分离,写数据直接走内存,读数据走page cache。因为当page cache上积累了大量脏页后会触发flush动作,造成在刷脏页时磁盘压力较高,从而出现写入时的毛刺现象。
消息的删除策略
- 消息文件过期(默认72h),且到达清理时间(默认凌晨4点),删除过期文件
- 消息文件过期(默认72h),且磁盘空间达到了水位线(默认75%),删除过期文件
- 磁盘已经到达必须释放的上线(85%)的时候,则开始批量清理文件(无论是否过期),直到空间充足
需要注意的是,如果磁盘空间达到危险水位线(默认90%),此时的broker会拒绝写入服务
消息索引
消息不仅需要可靠的存储,还需要查找的到。每一个queue都对应一个consumeQueue文件用于记录queue中msg在commitLog中的位置。具体来说记录了msg在commitLog中offset和msg的长度以及hashcode用于单条文消息索引。所以rocketMq模型下,消息本身存在的逻辑队列称为MessageQueue,而对应的物理索引文件称为ConsumeQueue,MessageQueue = ConsumeQueue索引文件 + CommitLog 文件
关于消费点位(consumer offset)的持久化
在broker中维护每个消费组的消费进度,每5秒checkpoint,记录当下消费情况。所以在checkpoint之间发生的意外(重启扩容等)就会造成消息的重复。
ps: kafka则是使用内部队列(__consumer_offsets)记录消费进度,采用差分的方式,记录每次变动的增量。借助自身消息持久化的能力,从而更好的避免checkpoint方案所造成的消息重复问题
rocketMq会通过dispatch线程根据consumeQueue在commitLog中读取消息,分发到对应的消费队列。
故障恢复
broker在启动时创建一个临时文件abort,当broker正常退出,通过JVM的hook函数会将abort文件删除;如果异常退出,那么abort就会保留。所以broker在启动时首先判断是否需要进入故障恢复流程
负载均衡
rocketMq支持两种消息模式:集群消费(clustering)和广播消费(broadcasting)
- 集群消费是指同一topic下的一条消息只会被同一消费组中的一个消费者消费。消息被负载均衡到了同一个消费组的多个消费者实例上
- 广播消费是指每条消息都会推送到集群内所有的消费者,保证每条消息至少被每个消费者消费一次
消息粒度的负载均衡机制,是基于内部的单条消息确认语义实现的。消费者获取某条消息后,服务端会将该条消息加锁,保证这条消息对其他消费者不可见,直到消息消费成功或消费超时。因此即使多个消费者同时从一个queue获取消息,服务端也可以保证消息不会被多个消费者消费。
rocketMq和其他中间件一样,也提供了不同的负载均衡算法,这里不进步阐述。以后可能会补?
事务消息
首先来看rocketmq如何实现事务消息
- 生产者发送消息到broker,该消息是prepare消息,且事务消息的发送是同步发送的方式。
- broker接收到消息后,会将该消息进行转换,所有的事务消息统一写入Half Topic,该Topic默认是RMQ_SYS_TRANS_HALF_TOPIC ,写入成功后会给生产者返回成功状态。
- 本地生产获取到该消息的事务Id,进行本地事务处理。
- 本地事务执行成功提交Commit,失败则提交Rollback,超时提交或提交Unknow状态则会触发broker的事务回查。
- 若提交了Commit或Rollback状态,Broker则会将该消息写入到Op Topic,该Topic默认是RMQ_SYS_TRANS_OP_HALF_TOPIC,该Topic的作用主要记录已经Commit或Rollback的prepare消息,Broker利用Half Topic和Op Topic计算出需要回查的事务消息。如果是commit消息,broker还会将消息从Half取出来存储到真正的Topic里,从而消费者可以正常进行消费,如果是Rollback则不进行其他操作
- 如果本地事务执行超时或返回了Unknow状态,则broker会进行事务回查。若生产者执行本地事务超过6s则进行第一次事务回查,总共回查15次,后续回查间隔时间是60s,broker在每次回查时会将消息再在Half Topic写一次。回查次数和时间间隔都是可配置的。
- 执行事务回查时,生产者可以获取到事务Id,检查该事务在本地执行情况,返回状态同第一次执行本地事务一样。
事务消息的成功投递是需要经历三个Topic的,分别是:
- Half Topic:用于记录所有的prepare消息
- Op Half Topic:记录已经提交了状态的prepare消息
- Real Topic:事务消息真正的Topic,在Commit后会才会将消息写入该Topic,从而进行消息的投递
那么如何借助rocketmq实现分布式事务?首先在消费逻辑中实现消息的幂等。rocketMq是可靠的中间件,保证发送成功的消息不会丢失,但是可靠不代表我们收到的消息不会重复。
- 发送时的消息重复 当一条消息已经被成功发送到broker并持久化,但是此时由于网络波动或者服务器断电导致broker未能成功响应生产者,此时触发生产者失败重试,从而产生两条内容相同但是message id不同的消息。所以msg id作为幂等key不能解决生产者重复,需要从业务角度出发实现幂等
- 投递时消息重复 消息消费的场景下,消息已投递到消费者并完成业务处理,但是消费者在应答时出现问题导致broker认为消息发送失败,为了保证消息至少被消费一次,所以broker重新将消息发送给消费者,从而造成整个消费组收到了两天msg id一致的消息
- 负载均衡时消息重复 当rocketMq重启或扩缩容时,由于checkpoint的实现方式,导致有秒级的消息重复
解决消息幂等需要注意两点
-
多条消息串行幂等 当接收到msgId0的消息后完成消费逻辑,且事务均提交,又接收到msgId0的消息
-
多条消息并行幂等
当接收到msgId0的消息后,未完成消费逻辑时,又接收到msgId0的消息
即使完全抛开业务逻辑,我们可以借助关系型数据库主键实现消息幂等。以msgId作为主键,在事务开启前首先向幂等表新增该条消息的记录,如果可以新增成功,则继续执行;如果不能新增成功,需要判断该条记录的状态来确定返回给broker的状态,如果该记录状态为成功,则直接返回成功;如果失败,则需要稍后重试。这里为了避免消息第一次执行失败导致后续消息一直重试,还需要给这条记录加上过期时间。
参考文献:
[1]: 官方文档
[2]: RocketMQ 系列(四) 消息存储
[4]: 浅析Linux中的零拷贝技术
[5]: 深度解读 RocketMQ 存储机制
[6]: RocketMQ架构原理解析:消息存储(CommitLog)
[7]: RocketMQ - 如何实现顺序消息
[8]: 消息幂等(去重)通用解决方案
[9]: RocketMQ是如何实现事务消息的