RocketMQ存储原理 CommitLog解析

RocketMQ作为消息队列中间件,肯定有消息存储的机制,毕竟提供高可靠的消息投递功能,at least once保证每条消息至少被消费一次。rmq的消息存储机制由几个核心的类提供服务,组织起来的。MapFile类是对磁盘文件的抽象管理对象,MapFileQueue类是对一系列同类磁盘文件的,按照文件内第一个数据偏移量为名字排序的MapFile队列。CommitLog类就是对磁盘commitlog文件的抽象管理对象,提供了一系列写入,读取,提交,刷盘的方法保证消息被写入磁盘。本文章会解析CommitLog的几个核心工作原理。

CommitLog的变量

其中比较重要的变量就是刷盘线程flushLogService,提交线程commitLogService,写入回调函数对象appendMessageCallback。在后面的刷盘机制中会重点讲解两个刷盘线程的工作以及区别。

    // Message's MAGIC CODE daa320a7
    public final static int MESSAGE_MAGIC_CODE = -626843481;
    protected static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.STORE_LOGGER_NAME);
    // End of file empty MAGIC CODE cbd43194
    protected final static int BLANK_MAGIC_CODE = -875286124;

    /**
     * commitlog组成的mapfile队列,可以通过它获取正在使用的mapfile或创建新的mapfile
     */
    protected final MappedFileQueue mappedFileQueue;

    /**
     * 消息存储对象,对存储机制的再次抽象,由它统筹msg的存储逻辑。进行msg写入,主从同步,转发给indexService进行索引建立
     */
    protected final DefaultMessageStore defaultMessageStore;

    /**
     * 刷盘线程,对commitlog的mapfile或filechannel刷盘
     */
    private final FlushCommitLogService flushCommitLogService;

    /**
     * If TransientStorePool enabled, we must flush message to FileChannel at fixed periods
     * 异步刷盘时,msg先写入writeBuf,再由commitLogService线程定时提交到commitlog的fileChannel中
     */
    private final FlushCommitLogService commitLogService;

    private final AppendMessageCallback appendMessageCallback;
    private final ThreadLocal batchEncoderThreadLocal;

    /**
     * commitlog承载所有topic的msg存储
     */
    protected HashMap topicQueueTable = new HashMap(1024);

    protected volatile long confirmOffset = -1L;

    private volatile long beginTimeInLock = 0;

    /**
     * 写入msg时要获取同步锁
     */
    protected final PutMessageLock putMessageLock;

CommitLog的初始化

初始化动作会做一些成员变量的赋值,对刷盘策略选择不同的实现类。除此之外,在rmq刚启动时还需要将磁盘的log文件加载到内存中,此时不会真正将数据加载到内存,会做文件抽象对象的创建和虚拟内存的映射。

    /**
     * 在Broker的JVM启动时,创建BrokerController对象,创建MessageStore对象,创建CommitLog
     */
    public CommitLog(final DefaultMessageStore defaultMessageStore) {
        // 专门管理commitlog的mapfile的对象,mapfile队列
        // 像consume queue文件也有自己的mapfile队列
        this.mappedFileQueue = new MappedFileQueue(
                defaultMessageStore.getMessageStoreConfig().getStorePathCommitLog(),
                defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog(),
                // 创建mapfile文件的线程,commitlog用来做文件提前创建和预热
                defaultMessageStore.getAllocateMappedFileService());

        this.defaultMessageStore = defaultMessageStore;
        // 根据commitlog的刷盘策略,选择不同的刷盘线程实现
        if (FlushDiskType.SYNC_FLUSH == defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            this.flushCommitLogService = new GroupCommitService();
        } else {
            this.flushCommitLogService = new FlushRealTimeService();
        }
        // 异步刷盘使用到的提交线程
        this.commitLogService = new CommitRealTimeService();

        // commitlog写入msg的实现逻辑,mapfile是抽象的文件管理对象,mapfile写入数据时只做统筹逻辑,具体的文件写入逻辑由appendMessageCallback对象回调实现
        this.appendMessageCallback = new DefaultAppendMessageCallback(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
        batchEncoderThreadLocal = new ThreadLocal() {
            @Override
            protected MessageExtBatchEncoder initialValue() {
                return new MessageExtBatchEncoder(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
            }
        };
        // 因为commitlog保存所有topic的消息,Broker接收msg是多线程并行的,存在并发写入,这里选择同步锁的实现策略,悲观锁或乐观锁
        this.putMessageLock = defaultMessageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ?
                new PutMessageReentrantLock() :
                new PutMessageSpinLock();
    }

rmq刚启动时加载磁盘文件到内存

    /**
     * Broker启动后,MessageStore初始化阶段调用commitlog加载磁盘路径上所有log文件变为mapfile对象
     * log文件并没有立即被读取到内存中,只是封装为mapfile对象构建mapfile队列,方便管理
     */
    public boolean load() {
        boolean result = this.mappedFileQueue.load();
        log.info("load commit log " + (result ? "OK" : "Failed"));
        return result;
    }

启动刷盘线程和提交线程,在JVM正常退出时关闭线程

    /**
     * Broker启动,同时启动commitlog对象,让它做些事情
     * 例如启动刷盘线程,启动提交线程
     */
    public void start() {
        this.flushCommitLogService.start();
        if (defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            this.commitLogService.start();
        }
    }

    /**
     * 同样的在Broker正常关闭时做资源回收的动作,让commitlog优雅关闭线程
     */
    public void shutdown() {
        if (defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            this.commitLogService.shutdown();
        }
        this.flushCommitLogService.shutdown();
    }

CommitLog的Msg消息体序列化协议

我们从calMsgLength函数可以看出一条消息被写入log文件时它的序列化协议,每个字段的字节长度以及顺序。对于properties的处理值得我们借鉴,在设计序列化协议时对不定长的数据,我们可以把这部分数据放在字节数组的尾部,在redis的sds结构体中也是对字符串这么处理,实际的字符内容封装为char数组放在尾部,称为柔性数组。

protected static int calMsgLength(int sysFlag, int bodyLength, int topicLength, int propertiesLength) {
        int bornhostLength = (sysFlag & MessageSysFlag.BORNHOST_V6_FLAG) == 0 ? 8 : 20;
        int storehostAddressLength = (sysFlag & MessageSysFlag.STOREHOSTADDRESS_V6_FLAG) == 0 ? 8 : 20;
        final int msgLen = 4 //TOTALSIZE
            + 4 //MAGICCODE
            + 4 //BODYCRC
            + 4 //QUEUEID
            + 4 //FLAG
            + 8 //QUEUEOFFSET
            + 8 //PHYSICALOFFSET
            + 4 //SYSFLAG
            + 8 //BORNTIMESTAMP
            + bornhostLength //BORNHOST
            + 8 //STORETIMESTAMP
            + storehostAddressLength //STOREHOSTADDRESS
            + 4 //RECONSUMETIMES
            + 8 //Prepared Transaction Offset
            + 4 + (bodyLength > 0 ? bodyLength : 0) //BODY 4个字节的消息体长度 + 实际的消息体字节
            + 1 + topicLength //TOPIC 1个字节的topic长度 + 实际的topic字节
            + 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength 2个字节的消息属性长度 + 实际的消息属性字节
            + 0;
        return msgLen;
    }

写入消息的原理

Producer发送消息时会根据发送消息封装一个RemotingCommand对象,这个对象包含了命令编码code,消息内容body。发送消息的code=SEND_MESSAGE=10,并且在Broker启动时向RemotingServer对象注册SendMessageProcessor对象处理消息写入的逻辑。SendMessageProcessor对象也是一个NettyProcuessor,rmq是用netty框架进行网络通信的。

最终在DefaultMessageStore.asyncPutMessage函数中调用commitlog对象的asyncPutMessage函数进行消息保存。其实看到这里会有疑问为什么是async开头的,不是同步写入消息吗?rmq在这里是采用异步阻塞式的写入消息,根据刷盘策略可能同步刷盘或异步刷盘,但是对于消息的写入到内存,是异步阻塞式的写入。

  • 异步是指当前工作线程将消息的刷盘和主从同步的工作交给另一个线程去做,这里会交给ForkJoinPoo的线程。
  • 阻塞是指工作线程被挂起,等待异步线程的唤醒。

可以思考下这里为什么要做成异步阻塞式的,会导致线程上下文切换,不是比同步更加慢吗?其实主要在刷盘逻辑和主从同步逻辑可以并行执行,提高效率所以才做成异步阻塞式。asyncPutMessage函数对比putMessage函数,主要区别就在于最后的消息刷盘逻辑和主从同步逻辑是并行还是串行。

延迟消息的处理

TRANSACTION_NOT_TYPE是指普通消息。延迟的msg处理会将真实topic和queueId备份到properties字段中,替换成SCHEDULE_TOPIC_XXXX,是一个专门的延迟TOPIC,这个Top[ic下有每个延迟级别的queue,由DeliverDelayedMessageTimerTask类定时调度,到时间就会将消息取出替换回真实的topic和queueId,重新写入到commitlog,再转发到对应consumeQueue和Index中。

        StoreStatsService storeStatsService =     this.defaultMessageStore.getStoreStatsService();

        String topic = msg.getTopic();
        // producer在发送端就会根据轮询策略选择topic下的队列
        int queueId = msg.getQueueId();
        // msg的sysFlag是一个4字节长度的系统属性标识,通过二进制位判断msg是否事务消息
        final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag());
        if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
                || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
            // Delay Delivery
            if (msg.getDelayTimeLevel() > 0) {
                // 社区版的rmq延迟消息只支持特定级别的延迟时间,所以是delay level
                if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
                }

                // 对于延迟msg要替换真实的topic&queue,然后投递到专门的延迟topic去
                topic = TopicValidator.RMQ_SYS_SCHEDULE_TOPIC;
                queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

                // 真实的topic&queue备份到msg字节数组的最后(看看msg的序列化协议就知道了)
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
                MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
                msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));

                msg.setTopic(topic);
                msg.setQueueId(queueId);
            }
        }

消息序列化写入内存

从代码逻辑可以看出来写消息到内存里的时候是串行的,虽然会影响并发度但是这是必须的。因为commitlog文件保存所有的topic的消息,在进行写入时为了避免字节数据的彼此覆盖造成错乱需要互斥。

这里还可以看到MapFile的文件预热功能,主要是为了避免创建新文件时导致某些消息等待过久导致Producer发送超时。这时候应该会看见Producer发送消息的尖刺抖动

        long elapsedTimeInLock = 0;
        MappedFile unlockMappedFile = null;
        // 最后一个mapfile是正在使用的mapfile
        MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();

        putMessageLock.lock(); // 涉及并发存储消息
        try {
            long beginLockTimestamp = this.defaultMessageStore.getSystemClock().now();
            this.beginTimeInLock = beginLockTimestamp;

            // Here settings are stored timestamp, in order to ensure an orderly
            // global
            msg.setStoreTimestamp(beginLockTimestamp);

            if (null == mappedFile || mappedFile.isFull()) {
                // 创建新的commitlog,如果没有开启文件预热功能,创建文件要发生磁盘IO恰好磁盘繁忙时,可能导致producer端网络超时
                mappedFile = this.mappedFileQueue.getLastMappedFile(0); // Mark: NewFile may be cause noise
            }
            if (null == mappedFile) {
                log.error("create mapped file1 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString());
                beginTimeInLock = 0;
                return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, null));
            }
            // 追加msg到log尾部,序列化的操作在callback做
            result = mappedFile.appendMessage(msg, this.appendMessageCallback);
            switch (result.getStatus()) {
                case PUT_OK:
                    break;
                // msg长度大于log的可用空间,创建新文件重新写入
                case END_OF_FILE:
                    unlockMappedFile = mappedFile;
                    // Create a new file, re-write the message
                    mappedFile = this.mappedFileQueue.getLastMappedFile(0);
                    if (null == mappedFile) {
                        // XXX: warn and notify me
                        log.error("create mapped file2 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString());
                        beginTimeInLock = 0;
                        return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, result));
                    }
                    result = mappedFile.appendMessage(msg, this.appendMessageCallback);
                    break;
                // msg或属性长度过长了
                case MESSAGE_SIZE_EXCEEDED:
                case PROPERTIES_SIZE_EXCEEDED:
                    beginTimeInLock = 0;
                    return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.MESSAGE_ILLEGAL, result));
                case UNKNOWN_ERROR:
                    beginTimeInLock = 0;
                    return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result));
                default:
                    beginTimeInLock = 0;
                    return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result));
            }

            elapsedTimeInLock = this.defaultMessageStore.getSystemClock().now() - beginLockTimestamp;
            beginTimeInLock = 0;
        } finally {
            putMessageLock.unlock();
        }

        if (elapsedTimeInLock > 500) {
            // 写入消息耗时超过500ms告警,由此可见rmq的设计者对写消息到内存中,最坏阈值时间500ms内,一般来说肯定是低于阈值的
            log.warn("[NOTIFYME]putMessage in lock cost time(ms)={}, bodyLength={} AppendMessageResult={}", elapsedTimeInLock, msg.getBody().length, result);
        }

        if (null != unlockMappedFile && this.defaultMessageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
            // 倒数第2个mapfile满了,且因为文件预热对它虚拟内存锁定了,这里对它解锁内存,使操作系统可以对这部分虚拟内存有必要时进行置换到磁盘的处理
            this.defaultMessageStore.unlockMappedFile(unlockMappedFile);
        }

消息刷盘和主从同步

在消息数据写入到内存中后,如果开启了内存池会写入writeBuf,正常是写到mappedByteBuf,前者是一块堆外直接内存并且通过系统调用进行内存锁定。后者是commitlog磁盘文件通过mmap函数映射到虚拟内存的一块零拷贝内存空间。

在现代操作系统中进程看到的所有内存都是虚拟内存,虚拟内存也就是虚拟的内存地址空间,进行内存寻址时需要OS或硬件将虚拟地址转换成真实物理地址。所以每个进程可以拥有比物理内存大很多的内存地址空间,原理就是当物理内存不够时,OS采用淘汰脏页的方式将部分虚拟内存页刷写回磁盘,也就是虚拟内存在物理内存和磁盘之间会发生置换。内存锁定之后的虚拟内存不会被OS置换到磁盘中,可以保证对这块虚拟内存的高效读写。

在写入writeBuf或mapBuf后,可能需要将数据写入磁盘,并且开启了主从同步后也需要将数据同步给broker的slave节点,达到高可用。这2件事是不相干的可以并行执行,rmq使用CompletableFuture并行执行。CompletableFuture使用JDK中默认的ForkJoinPool线程池,ForkJoinPool线程池使用makeCommonPool函数创建,通过java.util.concurrent.ForkJoinPool.common.parallelism指定线程数,默认是cpu-1。

        // Statistics
        storeStatsService.getSinglePutMessageTopicTimesTotal(msg.getTopic()).incrementAndGet();
        storeStatsService.getSinglePutMessageTopicSizeTotal(topic).addAndGet(result.getWroteBytes());

        // 提交刷盘请求,如果commitlog配置是同步刷盘则在下面会阻塞等待异步线程的刷盘结果
        CompletableFuture flushResultFuture = submitFlushRequest(result, msg);
        // 提交同步slave请求,如果broker被配置为同步推送msg到slave则在下面会等待异步线程的推送结果
        CompletableFuture replicaResultFuture = submitReplicaRequest(result, msg);
        // 并行执行刷盘和主从同步,并在匿名函数中处理2个请求的执行结果(2个都正常执行完毕的话)
        return flushResultFuture.thenCombine(replicaResultFuture, (flushStatus, replicaStatus) -> {
            if (flushStatus != PutMessageStatus.PUT_OK) {
                putMessageResult.setPutMessageStatus(flushStatus);
            }
            if (replicaStatus != PutMessageStatus.PUT_OK) {
                putMessageResult.setPutMessageStatus(replicaStatus);
                if (replicaStatus == PutMessageStatus.FLUSH_SLAVE_TIMEOUT) {
                    log.error("do sync transfer other node, wait return, but failed, topic: {} tags: {} client address: {}",
                            msg.getTopic(), msg.getTags(), msg.getBornHostNameString());
                }
            }
            return putMessageResult;
        });

提交刷盘请求就是对刷盘逻辑的封装为CompletableFuture,从broker读取配置的刷盘策略,同步刷盘如果要确保broker把消息写入到磁盘了就要把message的waitStoreMsgOk属性设置true,这个属性是在producer发送时设置的。如果waitStoreMsgOk=false或异步刷盘都是直接返回PUT_OK的CompletableFuture,此时工作线程不会阻塞,可立即拿到future的结果。

同步刷盘是构造个request丢给GroupCommitService的请求队列,然后返回future,工作线程就会阻塞等待future的结果。GroupCommitService前面提过是负责同步刷盘的线程,它有2个请求队列,write和read队列都用volatile修饰。每次线程只从read队列拿请求处理,并且处理完read队列后立即交换2个队列,这样可避免读写并发的问题,实现无锁的读写并发。

public CompletableFuture submitFlushRequest(AppendMessageResult result, MessageExt messageExt) {
        // Synchronization flush
        if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
            if (messageExt.isWaitStoreMsgOK()) {
                GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                        this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                service.putRequest(request);
                return request.future();
            } else {
                service.wakeup();
                return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
            }
        }
        // Asynchronous flush
        else {
            if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                flushCommitLogService.wakeup();
            } else  {
                commitLogService.wakeup();
            }
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    }

初探主从同步原理

关于主从复制机制的代码解析,会在另一篇文章解析。

submitReplicaRequest函数只是对每个消息提交一个主从同步的请求给GroupTransferService线程的请求队列。每个GroupCommitRequest对象是一个消息的物理偏移量,只有主从同步的成功偏移量 > 这个request的偏移量,就算这条消息被传输给了slave。实际上GroupTransferService线程不做实际的消息数据的传输工作,只是对请求队列中的每个消息偏移量判断是否 < push2SlaveMaxOffset,如果小于代表此条请求的消息已被同步到至少一个slave中。可以唤醒挂起的工作线程返回。

RocketMQ的主从同步设计的比较复杂,本身副本数据的冗余就需要比较多的代码量去做。HAService是主从同步的逻辑实现类,既充当master的角色也是slave的角色。

每个broker启动后都有一个角色身份,是master或slave,然后在HAService构造时同步创建一个HAClient对象,在broker启动时将master地址传递给HAClient,HAClient线程在循环工作体中建立master链接。HAClient是代表slave角色的broker对master建立的链接,不断从master和slave的socketChannel中读取传输过来的消息写入commitlog。HAClient线程同时会每隔5秒向master报告自己的主从复制偏移量,算是心跳检测。

HAConnection是代表master对slave的链接,同时也是一个近实时传输commitlog数据给slave的工作线程。从HAConnection的推送代码逻辑来看,rmq的主从复制机制还是采取的推模式,由master控制推送的频率。

所以总的来说,submitReplicaRequest函数只是提交主从复制的请求给GroupTransferService判断这条Msg有没有被传输给至少一个slave,真正的传输动作是在每个HAConnection对象做的,每个HAConnection对象近乎实时的推送commitlog数据给slave。而slave的HAClient线程也会在收到复制数据写入commitlog后立即给master报告已经收到的偏移量,在master的HAConnection的ReadSocketService线程监听来自slave的ACK offset,更新push2SlaveMaxOffset变量。GroupTransferService线程就可以立即读到更新的push2SlaveMaxOffset,判断msg是否已完成主从复制。

public CompletableFuture submitReplicaRequest(AppendMessageResult result, MessageExt messageExt) {
        // 检查broker是否同步推送msg到slave
        if (BrokerRole.SYNC_MASTER == this.defaultMessageStore.getMessageStoreConfig().getBrokerRole()) {
            // 高可用服务被封装为对象,高可用指将数据冗余在多个异地节点,防止当前broker损坏导致丢数据
            HAService service = this.defaultMessageStore.getHaService();
            if (messageExt.isWaitStoreMsgOK()) {
                // 检查高可用服务是否正常:1.有slave长连接存在 2.master和slave的字节同步差异在可接受范围内,代表主从同步是正常工作的
                if (service.isSlaveOK(result.getWroteBytes() + result.getWroteOffset())) {
                    GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                            this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                    // 提交请求到HAservice,有groupTransferService线程专门推送给slave
                    service.putRequest(request);
                    service.getWaitNotifyObject().wakeupAll();
                    return request.future();
                }
                else {
                    return CompletableFuture.completedFuture(PutMessageStatus.SLAVE_NOT_AVAILABLE);
                }
            }
        }
        return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
    }

异步刷盘策略

Broker的刷盘策略配置是针对CommitLog、ConsumeQueue、IndexFile等所有磁盘文件的配置。异步刷盘策略是指消息被同步写入到writeBuf或mapBuf内存中,写入线程立即返回。同步到磁盘的动作交由异步线程去做。根据是否开启了内存池有一点小区别。

  • 如果开启了内存池,消息先写到writeBuf,由CommitRealTimeService线程近实时将writeBuf数据提交到fileChannel中,再由FlushRealTimeService线程近实时将fileChannel存在OS缓冲区的数据刷写到磁盘。
  • 如果没有开内存池,消息直接写到mapBuf,由FlushRealTimeService线程近实时将mapBuf数据刷写到磁盘。

但无论有无开启内存池,对异步刷盘策略来说,在写入消息到内存时,写完之后都是立即返回,不会像同步刷盘一样等待数据同步到磁盘之后才返回。

 在mapfile的写入代码逻辑可以看到是优先选择writeBuf写入数据的。MappedFile类appendMessageInner函数

public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
            ...

            // 根据刷盘策略不同,选择写到writeBuf还是mapBuf
            ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
            // 调整buf的写入指针
            byteBuffer.position(currentPos);
            AppendMessageResult result;
            // 回调具体文件对象的写入消息实现函数
            if (messageExt instanceof MessageExtBrokerInner) {
                result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
            } else if (messageExt instanceof MessageExtBatch) {
                result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
            } else {
                return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
            }
            
            ...
    }

 线程写入消息到内存中后,在提交刷盘请求的代码中可以明显看出同步刷盘和异步刷盘的区别。一个是要等待刷盘线程处理完刷盘请求唤醒自己,一个是唤醒异步线程直接返回。

public CompletableFuture submitFlushRequest(AppendMessageResult result, MessageExt messageExt) {
        // Synchronization flush
        if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
            if (messageExt.isWaitStoreMsgOK()) {
                // 同步刷盘策略 且 producer要求等这条msg落盘后才返回,就会提交请求给GroupCommitService线程处理刷盘,然后当前线程等待落盘后被唤醒
                GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                        this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
                service.putRequest(request);
                return request.future();
            } else {
                service.wakeup();
                return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
            }
        }
        // Asynchronous flush
        else {
            // 异步刷盘策略,开启了内存池就唤醒提交线程,否则直接唤醒刷盘线程,好处是当前线程不用等待可立即返回
            if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                flushCommitLogService.wakeup();
            } else  {
                commitLogService.wakeup();
            }
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    }

从上面我们知道如果把commitlog的存储配置成开启内存池且把broker刷盘策略配置为异步刷盘,broker是master角色。这时候commitlog创建的MapFile对象是带有writeBuf缓冲的,当我们发送消息时会先写入到writeBuf中。接着看看CommitRealTimeService线程如何工作,把writeBuf数据提交到fileChannel中。

CommitRealTimeService线程run函数,虽然有个interval变量200ms的间隔,在waitForRunning(interval)函数中会睡眠200ms,但实际上waitForRunning函数会优先判断如果hasNotified标志位被改成true代表有新消息被写入,需要立即处理,就不会进入睡眠。而在消息写入的最后submitFlushRequest函数我们知道异步刷盘策略,会调用CommitRealTimeService.wakeup函数将hasNotified改为true。所以将消息从writeBuf提交到fileChannel的动作是近实时的。不单只提交线程是近实时设计,异步刷盘的FlushRealTimeService线程和同步刷盘的GroupCommitService线程也是近实时设计,主要目的是提高CPU的使用率,有新的请求时立即处理。

@Override
public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");
    while (!this.isStopped()) {
        // 提交writeBuf的间隔,默认200ms
        int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitIntervalCommitLog();
        // 一次提交的脏页阈值,默认4页
        int commitDataLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitCommitLogLeastPages();
        // 两次提交的最大间隔,如果超过了阈值就表示距离上次提交已有一段时间,忽略脏页阈值限制,立即提交一次
        int commitDataThoroughInterval =
            CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitCommitLogThoroughInterval();

        long begin = System.currentTimeMillis();
        if (begin >= (this.lastCommitTimestamp + commitDataThoroughInterval)) {
            this.lastCommitTimestamp = begin;
            commitDataLeastPages = 0;
        }

        try {
            // writeBuf数据写到fileChannel内存,此时数据存在OS的fd缓冲区中
            boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
            long end = System.currentTimeMillis();
            if (!result) {
                this.lastCommitTimestamp = end; // result = false means some data committed.
                // 唤醒FlushRealTimeService线程对fileChannel刚写入的数据刷盘
                flushCommitLogService.wakeup();
            }

            if (end - begin > 500) {
                log.info("Commit data to file costs {} ms", end - begin);
            }
            this.waitForRunning(interval);
        } catch (Throwable e) {
            CommitLog.log.error(this.getServiceName() + " service has exception. ", e);
        }
    }

    boolean result = false;
    // Broker正常关闭,对剩余mapfile数据进行提交,尝试提交10次
    for (int i = 0; i < RETRY_TIMES_OVER && !result; i++) {
        result = CommitLog.this.mappedFileQueue.commit(0);
        CommitLog.log.info(this.getServiceName() + " service shutdown, retry " + (i + 1) + " times " + (result ? "OK" : "Not OK"));
    }
    CommitLog.log.info(this.getServiceName() + " service end");
}

FlushRealTimeService异步刷盘线程的run函数。逻辑上和提交线程很相似,有间隔,最少刷盘的脏页数。最后调mapFile.flush函数写入磁盘。

public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");

    while (!this.isStopped()) {
        // 是否有计划的刷盘,默认是false,FlushRealTimeService的实时原来是指fileChannel有数据就刷的意思
        boolean flushCommitLogTimed = CommitLog.this.defaultMessageStore.getMessageStoreConfig().isFlushCommitLogTimed();
        // 刷盘间隔
        int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushIntervalCommitLog();
        // 刷盘的脏页阈值
        int flushPhysicQueueLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogLeastPages();
        // 最大的刷盘间隔阈值,超过间隔忽略脏页阈值限制,立即刷一次
        int flushPhysicQueueThoroughInterval =
            CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogThoroughInterval();

        boolean printFlushProgress = false;

        // Print flush progress
        long currentTimeMillis = System.currentTimeMillis();
        if (currentTimeMillis >= (this.lastFlushTimestamp + flushPhysicQueueThoroughInterval)) {
            this.lastFlushTimestamp = currentTimeMillis;
            flushPhysicQueueLeastPages = 0;
            printFlushProgress = (printTimes++ % 10) == 0;
        }

        try {
            if (flushCommitLogTimed) {
                Thread.sleep(interval);
            } else {
                this.waitForRunning(interval);
            }

            if (printFlushProgress) {
                // 日志打印磁盘字节数和内存写入字节数的差异比例,但是现在注释了
                this.printFlushProgress();
            }

            long begin = System.currentTimeMillis();
            // 对fileChannel中在OS缓冲区的数据刷盘
            CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages);
            long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
            if (storeTimestamp > 0) {
                CommitLog.this.defaultMessageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimestamp);
            }
            long past = System.currentTimeMillis() - begin;
            if (past > 500) {
                log.info("Flush data to disk costs {} ms", past);
            }
        } catch (Throwable e) {
            CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
            this.printFlushProgress();
        }
    }

    // Normal shutdown, to ensure that all the flush before exit
    boolean result = false;
    for (int i = 0; i < RETRY_TIMES_OVER && !result; i++) {
        result = CommitLog.this.mappedFileQueue.flush(0);
        CommitLog.log.info(this.getServiceName() + " service shutdown, retry " + (i + 1) + " times " + (result ? "OK" : "Not OK"));
    }

    this.printFlushProgress();

    CommitLog.log.info(this.getServiceName() + " service end");
}

同步刷盘策略

把broker.conf文件的flushDiskType配置为SYNC_FLUSH,开启同步刷盘。每条写入消息的线程把消息同步写入mapBuf,等待消息落盘后返回。写入磁盘由GroupCommitService线程去做,看名字像提交线程,实际干的是刷写磁盘的工作。run函数

public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");

    while (!this.isStopped()) {
        try {
            // 每次处理完read队列的刷盘请求,如果没有新的请求到达就会休息10毫秒
            this.waitForRunning(10);
            // 一次性处理read队列的请求,执行刷盘
            this.doCommit();
        } catch (Exception e) {
            CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
        }
    }

    // Under normal circumstances shutdown, wait for the arrival of the
    // request, and then flush
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        CommitLog.log.warn("GroupCommitService Exception, ", e);
    }

    synchronized (this) {
        this.swapRequests();
    }

    this.doCommit();

    CommitLog.log.info(this.getServiceName() + " service end");
}

对requestRead队列提交的刷盘请求做处理。每个请求代表一条msg的磁盘写入位置偏移量,判断这条msg是否落盘依靠当前刷盘偏移量是否超过了msg的写入偏移量。对一条请求最多执行两次2刷盘,是防止一条msg被截断写到2个MapFile文件中,但从CommitLog的写入消息代码逻辑看一条msg不可能被拆分写入。理论上这里mapFileQueue.flush只会调一次就把所有数据写入磁盘了,后续对所有的刷盘请求偏移量都是大于等于的。

private void doCommit() {
    synchronized (this.requestsRead) {
        if (!this.requestsRead.isEmpty()) {
            // 一个GroupCommitRequest对象代表一条msg写入
            for (GroupCommitRequest req : this.requestsRead) {
                boolean flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
                // 通常只需要一次刷盘,就可将read队列所有请求的数据写入磁盘,但这里对一条msg却要最多刷两次盘,一条msg可能被分段写入2个mapfile?
                // 至少commitlog是不会的,在commitlog的回调写入逻辑中会判断剩余空间不够msg写入时会填充0,创建新commitlog写入msg
                for (int i = 0; i < 2 && !flushOK; i++) {
                    CommitLog.this.mappedFileQueue.flush(0);
                    flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
                }
                // 唤醒阻塞等待消息刷盘的线程,返回结果给producer
                req.wakeupCustomer(flushOK ? PutMessageStatus.PUT_OK : PutMessageStatus.FLUSH_DISK_TIMEOUT);
            }

            long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
            if (storeTimestamp > 0) {
                CommitLog.this.defaultMessageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimestamp);
            }

            this.requestsRead.clear();
        } else {
            // Because of individual messages is set to not sync flush, it
            // will come to this process
            CommitLog.this.mappedFileQueue.flush(0);
        }
    }
}

同步刷盘真的能做到不丢消息吗

丢消息的场景对MQ来说是老生常谈,通常分为3个可能场景。

  • Producer端发送消息给Broker,可能网络超时导致丢消息
  • Broker端收到消息,未来得及落盘,此时宕机、进程崩溃、掉电导致丢消息
  • Consumer端拉取到消息,由于业务逻辑不正确,导致消费异常,丢消息

对于使用rmq需要高可靠的场景,我们通常把Broker刷盘策略配置为同步刷盘,且消息的property改为waitStoreOk=true。这样就能保证消息在Broker端存储时不丢失吗?

MapFileQueue.flush函数最终调到Mapfile.flush函数,实际上看代码逻辑在极端情况下是有可能发生丢失消息可能。先看看MappedFileQueue.flush函数,调用mapFile.flush刷盘后返回此mapFile中刷盘指针的位置,加上mapfile起始偏移量算出flushedWhere指针。这个flushedWhere变量是GroupCommitService线程判断msg是否落盘的依据。

/**
     * flushedWhere当前mapfile队列中刷盘指针的位置
     * committedWhere当前mapfile队列中提交指针的位置
     * 这2个指针都由mapfileQueue对象维护,因为mapfileQueue负责这类mapfile的刷盘和提交工作
     */
    public boolean flush(final int flushLeastPages) {
        boolean result = true;
        MappedFile mappedFile = this.findMappedFileByOffset(this.flushedWhere, this.flushedWhere == 0);
        if (mappedFile != null) {
            long tmpTimeStamp = mappedFile.getStoreTimestamp();
            int offset = mappedFile.flush(flushLeastPages);
            long where = mappedFile.getFileFromOffset() + offset;
            result = where == this.flushedWhere;
            this.flushedWhere = where;
            if (0 == flushLeastPages) {
                this.storeTimestamp = tmpTimeStamp;
            }
        }
        return result;
    }

 再看看MappedFile.flush函数,无非就是对fileChannel或mapBuf滞留在内存中的脏页进行刷盘,但是对这2个动作做了catch,这就比较显眼。

如果刷盘报错了,只是打个错误日志,下面又把flushedPosition更新到写入指针位置,相当于这部分数据并未落盘,直到下次新数据写入同步刷盘时才会连带把这部分数据落盘,如果这时候发生断电,就导致这部分数据丢失。但是这里把flushedPosition指针更新后,在GroupCommitLogService刷盘逻辑中判断flushedPosition >= msg的写入位置了,就唤醒被挂起的写入线程,返回PUT_OK。producer收到ok,以为msg被落盘了,实际msg此时在OS的缓冲区中,如果突然断电或操作系统崩溃了,消息就丢失了。

从mappedByteBuffer.force()来看并没有声明抛出异常,但是在JVM的本地方法和OS层面是否有运行时异常,甚至磁盘硬件层面是否有异常导致刷盘不成功呢?

这个问题尝试给社区提了issue https://github.com/apache/rocketmq/issues/5235

/**
     * @return 当前已经刷盘的位置指针,指针之前的数据已落盘
     */
    public int flush(
            // 至少需要刷盘的页数
            final int flushLeastPages) {
        // 这里去计算当前内存累计的脏页是否到了最低脏页刷盘的阈值
        if (this.isAbleToFlush(flushLeastPages)) {
            // 检查当前mapfile没有被销毁,destroy函数会将refCount置为0,hold同时会将refCount++代表此时有至少一个线程在访问mapfile
            if (this.hold()) {
                int value = getReadPosition();
                try {
                    // 开启内存池,CommitRealTimeService从writeBuf写到fileChannel,直接将fileChannel数据刷盘
                    if (writeBuffer != null || this.fileChannel.position() != 0) {
                        this.fileChannel.force(false);
                    } else {
                        // 同步刷盘的数据每一次都会直接写入mapBuf,异步刷盘但没有开启内存池的数据也会写入mapBuf
                        this.mappedByteBuffer.force();
                    }
                } catch (Throwable e) {
                    log.error("Error occurred when force data to disk.", e);
                }
                // 更新当前刷盘指针为当前写入指针的位置
                this.flushedPosition.set(value);
                // 将refCount--,当refCount=0时会释放mapBuf的内存
                this.release();
            } else {
                log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
                this.flushedPosition.set(getReadPosition());
            }
        }
        return this.getFlushedPosition();
    }

CommitLog过期文件的删除机制

待补充

你可能感兴趣的:(RocketMQ,rocketmq,java)