rocketMQ存储 NO.1

在设计消息队列中间件,最主要功能是解耦业务,填峰削谷,将业务拆分多个步骤,用消息的形式,将整个业务线串联起来,保证了各个功能模块服务能够处理高并发的能力。所以消息队列所要支持的能及时将生产的消息进行消化,保证消息信息及时可靠的送达到消费者手中,那么rocketMQ作为消息中间件,由于消息是需要存储在文件中,才能被消费者消费。在普通文件操作下,平凡的进行io操作,有时候处理的性能并不是很高。那么rocketMQ是通过什么技术方式,提升消息的存储效率,极大提升消息的接受能力?

MappedFile类

在rocketMQ的存储消息中,其中几种数据业务很重要。首先当然是存储基本消息元数据的管理类CommitLog和记录消费组的控制类ConsumeQueue,方便消息查询的索引文件。他们内部最重要的存储类型都是一样的,即MappedFile类。他的初始化代码块:

    private void init(final String fileName, final int fileSize) throws IOException {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.file = new File(fileName);
        this.fileFromOffset = Long.parseLong(this.file.getName());// 文件的名称即为文件的起始偏移量
        boolean ok = false;

        ensureDirOK(this.file.getParent());

        try {
            this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
            this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
            TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
            TOTAL_MAPPED_FILES.incrementAndGet();
            ok = true;
        } catch (FileNotFoundException e) {
            log.error("Failed to create file " + this.fileName, e);
            throw e;
        } catch (IOException e) {
            log.error("Failed to map file " + this.fileName, e);
            throw e;
        } finally {
            if (!ok && this.fileChannel != null) {
                this.fileChannel.close();
            }
        }
    }

1.首先通过文件管道开放读写权限,获取到了mappedByteBuffer子节缓存类,在读写操作时,可以通过该类进行获取数据写入数据功能,以内存方式操作,提升了读写效率
2.由于rocketMQ的设计特点,MappedFile对应的文件名称,其实时一个文件的起始偏移地址。因为每个文件的大小时固定的,即内部设定的fileSize,所以文件充满后,就需要新增新的MapppedFile即创建新的文件。所以文件的文件名称即起始偏移量是固定增长的,这样也能保证文件的顺序性。还有一个好处,因为存储消息是我们需要记住该消息记录文件缓存种的具体位置,加入知道了消息的具体位置,那么怎么快速定位位置获取数据呢?就可以通过于文件的起始偏移量进行比较,然后定位到具体文件,最后通过该文件定位到具体的缓存position位置。

因为MappedFile是存储消息的最终类,了解一下如何将消息内容存储在文件中

    public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
        assert messageExt != null;
        assert cb != null;

        int currentPos = this.wrotePosition.get();

        if (currentPos < this.fileSize) {
            // slice方法,即获取buffer剩余部分(limit-pos部分),其实这里的本意是复制一份byteBuffer。
            // 当对byteBuffer进行操作,例如写入等会改变pos值,但是不会影响原始的buffer。但是内部存储的数组都是同一份的
            ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
            byteBuffer.position(currentPos); // 定位到buffer中pos位置,开始写入就从这个位置开始写
            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);
            }
            // 写入成功后,就会将wrotePosition累加,加上本次写的数据大小
            this.wrotePosition.addAndGet(result.getWroteBytes());
            this.storeTimestamp = result.getStoreTimestamp();
            return result;
        }
        log.error("MappedFile.appendMessage return null, wrotePosition: {} fileSize: {}", currentPos, this.fileSize);
        return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
    }

该方法块中,了解方法入参类,MessageExt 即为消息的主体,AppendMessageCallback时添加消息的回调方法。在调用callBack时之前,先获取ByteBuffer字节缓存对象,该对象其实是复制了一份mappedByteBuffer对象一份,虽然mappedByteBuffer.slice() 本意是截取该缓存剩余没有使用的缓存,但是mappedByteBuffer在MappedFile中没有方法会去改变缓存对象的位置position,limit的控制值。但是slice方法其实内部载体的缓存对象任然指向同一个的,于mappedByteBuffer的载体一样,只是控制属性的position,limit等不一样而已,这样整个系统使用内存并没有增加。那么有什么好处呢?这样在操作子节缓存时,我们不需要修改最原始缓存对象mappedByteBuffer,而是通过修改他的复制品,也能达到间件修改mappedByteBuffer,保证了mappedByteBuffer的可控。
所以在代码中,复制了一份缓存,然后对byteBuffer设置了position位置,currentPos参数是通过wrotePosition属性获取的,该属性含义为已经写入多少字节数量的数据。所以在执行完callBack方法后,得到result结果,都会将result中的写入字节的数量进行增加,下一次消息写入的开始position就能准确。当然消息只是写入到了缓存中,其实并没正在写如到磁盘下,所以wrotePosition只是写入的位置,并不是文件的flush的位置或者commit的位置。

    public int flush(final int flushLeastPages) {
        if (this.isAbleToFlush(flushLeastPages)) {
            if (this.hold()) {
                int value = getReadPosition();

                try {
                    //We only append data to fileChannel or mappedByteBuffer, never both.
                    if (writeBuffer != null || this.fileChannel.position() != 0) {
                        this.fileChannel.force(false);
                    } else {
                        this.mappedByteBuffer.force();
                    }
                } catch (Throwable e) {
                    log.error("Error occurred when force data to disk.", e);
                }

                this.flushedPosition.set(value);
                this.release();
            } else {
                log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
                this.flushedPosition.set(getReadPosition());
            }
        }
        return this.getFlushedPosition();
    }

所以flush方法中,有个参数flushLeastPages参数,即至少某页才能刷新。这里就需要了解知识,Page Cache 页缓存技术,简单来说能提升对磁盘上映像和数据的访问详情
https://www.ibm.com/developerworks/cn/linux/l-cache/index.html 该文件有具体的描述。
一页缓存大小为4K,所以当写入的位置于刷新的位置比较至少要大于flushLeastPages*4K的量,才能进行flush操作。所以调用mappedByteBuffer的强制更新方法。并且设置flush的位置。

既然MappedFile提供了写入的方法,那么在读取数据时,也是从该类中读取到数据的,所以需要提供读取数据的位置,和消息大小,才能提供准确的缓存的内容

    public SelectMappedBufferResult selectMappedBuffer(int pos, int size) {
        int readPosition = getReadPosition();
        if ((pos + size) <= readPosition) {
            if (this.hold()) {
                ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
                byteBuffer.position(pos);
                ByteBuffer byteBufferNew = byteBuffer.slice();
                byteBufferNew.limit(size);
                return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
            } else {
                log.warn("matched, but hold failed, request pos: " + pos + ", fileFromOffset: "
                    + this.fileFromOffset);
            }
        } else {
            log.warn("selectMappedBuffer request pos invalid, request pos: " + pos + ", size: " + size
                + ", fileFromOffset: " + this.fileFromOffset);
        }

        return null;
    }

首先,要限制住读取的范围不能超过写入的位置,然后从mappedByteBuffer又复制了一份字节缓存对象,但是byteBuffer对象设置了position位置,并且对byteBuffer进行切割。参考下图


image.png

但是核心还是他们真正执行的内部缓存都是同一部分,不会出现内存的增大,只是控制了position和limit,从而达到读写的控制。
在返回的SelectMappedBufferResult对象中,该对象有个属性startOffset 即起始偏移量,该偏移量是通过文件的fileFromOffset起始偏移量加上了在该文件上的位置。

方法warmMappedFile


    public void warmMappedFile(FlushDiskType type, int pages) {
        long beginTime = System.currentTimeMillis();
        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
        int flush = 0;
        long time = System.currentTimeMillis();
        for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
            byteBuffer.put(i, (byte) 0);
            // force flush when flush disk type is sync
            if (type == FlushDiskType.SYNC_FLUSH) {
                if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
                    flush = i;
                    mappedByteBuffer.force();
                }
            }

            // prevent gc
            if (j % 1000 == 0) {
                log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
                time = System.currentTimeMillis();
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    log.error("Interrupted", e);
                }
            }
        }

        // force flush when prepare load finished
        if (type == FlushDiskType.SYNC_FLUSH) {
            log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",
                this.getFileName(), System.currentTimeMillis() - beginTime);
            mappedByteBuffer.force();
        }
        log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),
            System.currentTimeMillis() - beginTime);

        this.mlock();
    }

从字面含义可以知道预热作用,主要在分配文件或者创建文件时使用,我们知道在读取写入缓存时,操作系统是通过应用所需分布部分的缓存,进行保存文件的一部分数据,假如没有命中,任然需要读取文件,然后存储到缓存中。所以他并没有将所有文件的大小全部映射到缓存中。该方法先时初始化mappedByteBuffer,保证缓存的清洁干净,如果是同步刷新,则需要在一定的缓存页数下进行刷新。最后又执行了mlock方法

    public void mlock() {
        final long beginTime = System.currentTimeMillis();
        final long address = ((DirectBuffer) (this.mappedByteBuffer)).address();
        Pointer pointer = new Pointer(address);
        {
            int ret = LibC.INSTANCE.mlock(pointer, new NativeLong(this.fileSize));
            log.info("mlock {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
        }

        {
            int ret = LibC.INSTANCE.madvise(pointer, new NativeLong(this.fileSize), LibC.MADV_WILLNEED);
            log.info("madvise {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
        }
    }

调用了操作系统的内部函数方法,该方法采用了mmap技术 即memory map 内存映射文件地址技术。
先将Libc方法中mlock,将锁住指定的内存区域避免被操作系统调到swap空间中。
然后方法madvise,将文件数据一次性全部写入到映射内存区域中。
该文章具体描述方法原理
https://www.jianshu.com/p/eaad6ec3f87c

AllocateMappedFileService类

既然熟悉了MappedFile的具体存储,具体的读写操作后。为了MappedFile的统一创建管理,引入了AllocateMappedFileService服务类。如果有需要创建新的文件,都需要经过该服务类负责,创建新的MappedFile,那么该服务类有什么优势呢?

    public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) {
        int canSubmitRequests = 2;
        if (this.messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            if (this.messageStore.getMessageStoreConfig().isFastFailIfNoBufferInStorePool()
                && BrokerRole.SLAVE != this.messageStore.getMessageStoreConfig().getBrokerRole()) { //if broker is slave, don't fast fail even no buffer in pool
                canSubmitRequests = this.messageStore.getTransientStorePool().availableBufferNums() - this.requestQueue.size();
            }
        }

        AllocateRequest nextReq = new AllocateRequest(nextFilePath, fileSize);
        // 尝试将nextreq对象放入到table中,如果失败?说明之前已经创建过了
        boolean nextPutOK = this.requestTable.putIfAbsent(nextFilePath, nextReq) == null;

        if (nextPutOK) {
            // 如果放置成功,就将nextReq放入到queue中。
            if (canSubmitRequests <= 0) {
                log.warn("[NOTIFYME]TransientStorePool is not enough, so create mapped file error, " +
                    "RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
                this.requestTable.remove(nextFilePath);
                return null;
            }
            boolean offerOK = this.requestQueue.offer(nextReq);
            if (!offerOK) {
                log.warn("never expected here, add a request to preallocate queue failed");
            }
            canSubmitRequests--;
        }

        AllocateRequest nextNextReq = new AllocateRequest(nextNextFilePath, fileSize);
        // 下个文件
        boolean nextNextPutOK = this.requestTable.putIfAbsent(nextNextFilePath, nextNextReq) == null;
        if (nextNextPutOK) {
            if (canSubmitRequests <= 0) {
                log.warn("[NOTIFYME]TransientStorePool is not enough, so skip preallocate mapped file, " +
                    "RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
                this.requestTable.remove(nextNextFilePath);
            } else {
                // 下个文件,放入成功,放入到queue中
                boolean offerOK = this.requestQueue.offer(nextNextReq);
                if (!offerOK) {
                    log.warn("never expected here, add a request to preallocate queue failed");
                }
            }
        }

        if (hasException) {
            log.warn(this.getServiceName() + " service has exception. so return null");
            return null;
        }

        AllocateRequest result = this.requestTable.get(nextFilePath);
        try {
            if (result != null) {
                // result需要通过countDownLatch通知,如果为0则可以继续执行。
                // 如果当前拿到的result,countDownLatch为0无需等待,说明在上一次创建的时候,已经创建成功了
                boolean waitOK = result.getCountDownLatch().await(waitTimeOut, TimeUnit.MILLISECONDS);
                if (!waitOK) {
                    log.warn("create mmap timeout " + result.getFilePath() + " " + result.getFileSize());
                    return null;
                } else {
                    this.requestTable.remove(nextFilePath);
                    return result.getMappedFile();
                }
            } else {
                log.error("find preallocate mmap failed, this never happen");
            }
        } catch (InterruptedException e) {
            log.warn(this.getServiceName() + " service has exception. ", e);
        }

        return null;
    }

这段代码是最为核心的创建文件部分
从参数中,包含了下个文件地址nextFilePath,下下个文件地址nextNextFilePath,并且文件的大小fileSize。
首先将nextFilePath进行封装成AllocateRequest对象,可以理解为发起一次分配请求,并尝试将对象放入到requestTable中,如果成功,说明nextFilePath之前是没有存放过的。如果放置失败说明,之前已经有申请相同的路径请求创建文件了。为什么会出现相同文件地址呢?因为该方法中提供了nextNextFilePath参数,也会将这个地址对应的文件预先创建好。
nextPutOK=true。说明放置成功了,然后他只是将请求放入到一个requestQueue请求队列中,并且尝试申请创建以nextNextFilePath为路径的文件了请求了。该请求也是放入到requestQueue队列中。在这块代码中,并没有说明如何创建文件的。继续下面的代码。
从requestTable中取出了nextFilePath对应的分配请求,然后对内部属性countDownLatch进行等待,如果等待成功后,就会从request中拿到了MappedFile对象了。那么从该段代码中可以了解到,该等待过程就是在等待其他线程将MappedFile创建完成,然后将countDownLatch进行倒计时,然后当前线程就能唤醒,返回结果,并且移除了requestTable中的nextFilePath为key的数据。那么nextNextFilePath为地址的文件也去申请创建了,他什么时候返回成功呢?很简单,等下次同一个业务又需要申请创建文件的时候,此时的nextFilePath即为上一次的nextNextFilePath的路径了,如果上一次nextNextFilePath的路径申请完成了,此次就能很快的返回结果了。这就是为什么需要预创建下下个文件的原因了。
那么真正创建文件的线程做什么呢?
由于AllocateMappedFileService是继承于ServiceThread类,自身就是需要实现线程的run()方法

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

        while (!this.isStopped() && this.mmapOperation()) {

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

实现的线程很简单,最核心的是调用mmapOperation方法

    private boolean mmapOperation() {
        boolean isSuccess = false;
        AllocateRequest req = null;
        try {
            req = this.requestQueue.take();
            AllocateRequest expectedRequest = this.requestTable.get(req.getFilePath());
            if (null == expectedRequest) {
                log.warn("this mmap request expired, maybe cause timeout " + req.getFilePath() + " "
                    + req.getFileSize());
                return true;
            }
            if (expectedRequest != req) {
                log.warn("never expected here,  maybe cause timeout " + req.getFilePath() + " "
                    + req.getFileSize() + ", req:" + req + ", expectedRequest:" + expectedRequest);
                return true;
            }

            if (req.getMappedFile() == null) {
                long beginTime = System.currentTimeMillis();

                MappedFile mappedFile;
                if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
                    try {
                        mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
                        mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
                    } catch (RuntimeException e) {
                        log.warn("Use default implementation.");
                        mappedFile = new MappedFile(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
                    }
                } else {
                    mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
                }

                long elapsedTime = UtilAll.computeElapsedTimeMilliseconds(beginTime);
                if (elapsedTime > 10) {
                    int queueSize = this.requestQueue.size();
                    log.warn("create mappedFile spent time(ms) " + elapsedTime + " queue size " + queueSize
                        + " " + req.getFilePath() + " " + req.getFileSize());
                }

                // pre write mappedFile
                if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig()
                    .getMappedFileSizeCommitLog()
                    &&
                    this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
                    mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
                        this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
                }

                req.setMappedFile(mappedFile);
                this.hasException = false;
                isSuccess = true;
            }
        } catch (InterruptedException e) {
            log.warn(this.getServiceName() + " interrupted, possibly by shutdown.");
            this.hasException = true;
            return false;
        } catch (IOException e) {
            log.warn(this.getServiceName() + " service has exception. ", e);
            this.hasException = true;
            if (null != req) {
                requestQueue.offer(req);
                try {
                    Thread.sleep(1);
                } catch (InterruptedException ignored) {
                }
            }
        } finally {
            if (req != null && isSuccess)
                req.getCountDownLatch().countDown();
        }
        return true;
    }

首先通过监听队列,通过take方法,取出分配请求AllocateRequest对象,然后于requestTable对象中的请求对象进行校验。再对MappedFile创建,并初始化,通过配置属性,确定是否可以进行文件预热,就是调用warmMappedFile方法。最后看一下finally方法,如果创建成功,会对内置的countDownLatch进行倒计时,唤醒申请分配的请求线程。
基本工具已经准备好了,就等着上层如何使用了。
首先有了MappedFile对象了,不能因为创建一个文件而把之前的对象随意丢弃了,肯定任然需要使用,那么需要有个类对MappedFile对象进行管理了。

MappedFileQueue

首先了解他的属性

    private final String storePath;

    private final int mappedFileSize;

    private final CopyOnWriteArrayList mappedFiles = new CopyOnWriteArrayList();

    private final AllocateMappedFileService allocateMappedFileService;

    private long flushedWhere = 0;
    private long committedWhere = 0;

    private volatile long storeTimestamp = 0;

mappedFileSize,定义了文件的大小,文件存储因不同业务所需的文件大小是不一致的
storePath,文件的路径,不同业务存储文件数据需要区分,因为MappedFile文件名其实就是一串数字
mappedFiles,管理存储的映射文件集合
flushedWhere和committedWhere 刷新和提交的位置点。
那么该类如何来管理MappedFile的,首先是加载旧的文件数据

    public boolean load() {
        File dir = new File(this.storePath);
        File[] files = dir.listFiles();
        if (files != null) {
            // ascending order
            Arrays.sort(files);
            for (File file : files) {

                if (file.length() != this.mappedFileSize) {
                    log.warn(file + "\t" + file.length()
                        + " length not matched message store config value, please check it manually");
                    return false;
                }

                try {
                    // load时数据,将wrote,flush,commit位置都设置成文件大小
                    // 那么如何修正?通过方法truncateDirtyFiles(),外部调用可以修正
                    MappedFile mappedFile = new MappedFile(file.getPath(), mappedFileSize);

                    mappedFile.setWrotePosition(this.mappedFileSize);
                    mappedFile.setFlushedPosition(this.mappedFileSize);
                    mappedFile.setCommittedPosition(this.mappedFileSize);
                    this.mappedFiles.add(mappedFile);
                    log.info("load " + file.getPath() + " OK");
                } catch (IOException e) {
                    log.error("load file " + file + " error", e);
                    return false;
                }
            }
        }

        return true;
    }

再加载的过程中,通过文件夹dir得到所有的文件files,然后对文件进行排序,因为文件名称是数字组成的,可以进行正序排列,然后遍历所有的文件。通过文件的具体路径创建了MappedFile对象,并且内部初始化,然后设定了wrote,flushed,commited的位置,目前设定的位置都是最大值,即文件的默认大小。那么知道,文件是有可能没有写完的,不可能全部是最大的值,该怎么修正呢?
那么MappedFileQueue提供了删除脏数据的方法,去修正写入,刷新,提交的位置。

    public void truncateDirtyFiles(long offset) {
        List willRemoveFiles = new ArrayList();

        for (MappedFile file : this.mappedFiles) {
            long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize;
            if (fileTailOffset > offset) {
                // 当文件最大的偏移量大于了需要修正的offset
                if (offset >= file.getFileFromOffset()) {
                    // offset大于了文件起始的偏移量,需要重置wrote,commit,flush的位置
                    // 为什么这些位置只要offset % this.mappedFileSize,
                    // 因为一个文件大小都是mappedFileSize,offset是多个文件累加起来的
                    file.setWrotePosition((int) (offset % this.mappedFileSize));
                    file.setCommittedPosition((int) (offset % this.mappedFileSize));
                    file.setFlushedPosition((int) (offset % this.mappedFileSize));
                } else {
                    // offset小于了 文件的起始偏移量,说明该文件需要删除,因为都是脏数据
                    file.destroy(1000);
                    willRemoveFiles.add(file);
                }
            }
        }

        this.deleteExpiredFile(willRemoveFiles);
    }

可以通过给定 offset参数,将比offset大的数据都要重置的。那么如何重置呢,如果一个文件的起始偏移量都大于了offset,那么该文件就要删除销毁。如果offset存储某个文件的位置中,那么就可以通过设置更新wrote,flushed,committed位置就可以达到数据重置效果了,因为数据的写入和读取,都与这三点位置点有关。从而就修正了文件的几个重要的位点了。
在写入消息的时候,往往是获取最近的一个MappedFile,然后往后写入。那么如果文件不存在,或者文件写满了,该如何处理呢?该类也进行了对获取lastMappedFile方法进行封装

    public MappedFile getLastMappedFile(final long startOffset, boolean needCreate) {
        long createOffset = -1;
        MappedFile mappedFileLast = getLastMappedFile();

        if (mappedFileLast == null) {
            // 修正创建的偏移量,该偏移量提现出来是mappedFileSize倍数
            createOffset = startOffset - (startOffset % this.mappedFileSize);
        }

        if (mappedFileLast != null && mappedFileLast.isFull()) {
            // 最后一个文件满了,需要创建文件
            createOffset = mappedFileLast.getFileFromOffset() + this.mappedFileSize;
        }

        if (createOffset != -1 && needCreate) {
            // 文件名就是偏移量,该偏移量是这个文件起始偏移量
            String nextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset);
            // 下一个文件,预先创建
            String nextNextFilePath = this.storePath + File.separator
                + UtilAll.offset2FileName(createOffset + this.mappedFileSize);
            MappedFile mappedFile = null;

            if (this.allocateMappedFileService != null) {
                // 通过allocateMappedFileService负责创建
                mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
                    nextNextFilePath, this.mappedFileSize);
            } else {
                try {
                    mappedFile = new MappedFile(nextFilePath, this.mappedFileSize);
                } catch (IOException e) {
                    log.error("create mappedFile exception", e);
                }
            }

            if (mappedFile != null) {
                if (this.mappedFiles.isEmpty()) {
                    // 标识第一个文件
                    mappedFile.setFirstCreateInQueue(true);
                }
                this.mappedFiles.add(mappedFile);
            }

            return mappedFile;
        }

        return mappedFileLast;
    }

该方法时获取最近的一个映射文件,但是如果获取不到,或者文件满了,都可以创建新的映射文件。在创建的时候,定义了nextFilePath,并且也定义了nextNextFilePath,其中nextNextFilePath的文件名称刚好比nextFilePath 大mappedFileSize,所以也能间接反映出文件是固定大小累计增长起来的。然后可以通过allocateMappedFileService分配映射文件服务去创建MappedFile对象。

在查询消息的过程中,往往是给定了一个物理偏移量offset,然后在MappedFile集合中查找出符合条件的映射文件,首先MappedFile的文件大小是固定的,都是mappedFileSize,并且每个MappedFile都有起始的偏移量fileFromOffset。所以也能很快定位到符合要求的映射文件

    public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound) {
        try {
            MappedFile firstMappedFile = this.getFirstMappedFile();
            MappedFile lastMappedFile = this.getLastMappedFile();
            if (firstMappedFile != null && lastMappedFile != null) {
                if (offset < firstMappedFile.getFileFromOffset() || offset >= lastMappedFile.getFileFromOffset() + this.mappedFileSize) {
                    LOG_ERROR.warn("Offset not matched. Request offset: {}, firstOffset: {}, lastOffset: {}, mappedFileSize: {}, mappedFiles count: {}",
                        offset,
                        firstMappedFile.getFileFromOffset(),
                        lastMappedFile.getFileFromOffset() + this.mappedFileSize,
                        this.mappedFileSize,
                        this.mappedFiles.size());
                } else {
                    // 得到offset是处于哪个文件
                    int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
                    MappedFile targetFile = null;
                    try {
                        targetFile = this.mappedFiles.get(index);
                    } catch (Exception ignored) {
                    }

                    if (targetFile != null && offset >= targetFile.getFileFromOffset()
                        && offset < targetFile.getFileFromOffset() + this.mappedFileSize) {
                        return targetFile;
                    }

                    for (MappedFile tmpMappedFile : this.mappedFiles) {
                        if (offset >= tmpMappedFile.getFileFromOffset()
                            && offset < tmpMappedFile.getFileFromOffset() + this.mappedFileSize) {
                            return tmpMappedFile;
                        }
                    }
                }

                if (returnFirstOnNotFound) {
                    return firstMappedFile;
                }
            }
        } catch (Exception e) {
            log.error("findMappedFileByOffset Exception", e);
        }

        return null;
    }

该方法首先去验证offset是否符合条件,不可能比第一个文件小,或者比最后一个文件大。然后通过offset偏移量通过与mappedFileSize比较得到比例值,可以定位到文件集合中的位置。如果任然没有符合条件,则就遍历所有的mappedFile,查找符合条件的映射文件。

既然都MappedFileQueue负责查询,创建MappedFile,那肯定少不了删除过期的MappedFile的功能。他提供了可以根据偏移量删除文件,也可以根据文件的有效期删除

    public int deleteExpiredFileByTime(final long expiredTime,
        final int deleteFilesInterval,
        final long intervalForcibly,
        final boolean cleanImmediately) {
        Object[] mfs = this.copyMappedFiles(0);

        if (null == mfs)
            return 0;

        int mfsLength = mfs.length - 1;
        int deleteCount = 0;
        List files = new ArrayList();
        if (null != mfs) {
            for (int i = 0; i < mfsLength; i++) {
                MappedFile mappedFile = (MappedFile) mfs[i];
                // 每个文件都有一个过期时间,进行删除
                long liveMaxTimestamp = mappedFile.getLastModifiedTimestamp() + expiredTime;
                if (System.currentTimeMillis() >= liveMaxTimestamp || cleanImmediately) {
                    if (mappedFile.destroy(intervalForcibly)) {
                        files.add(mappedFile);
                        deleteCount++;

                        if (files.size() >= DELETE_FILES_BATCH_MAX) {
                            break;
                        }

                        if (deleteFilesInterval > 0 && (i + 1) < mfsLength) {
                            try {
                                Thread.sleep(deleteFilesInterval);
                            } catch (InterruptedException e) {
                            }
                        }
                    } else {
                        break;
                    }
                } else {
                    //avoid deleting files in the middle
                    break;
                }
            }
        }

        deleteExpiredFile(files);

        return deleteCount;
    }

这段删除文件逻辑很简单,通过文件的最近修改时间加上有效期时间,并且与当前时间比较,是否过期而后进行删除。

CommitLog 存储消息元数据的管理类

构造器

    public CommitLog(final DefaultMessageStore defaultMessageStore) {
        this.mappedFileQueue = new MappedFileQueue(defaultMessageStore.getMessageStoreConfig().getStorePathCommitLog(),
            defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog(), defaultMessageStore.getAllocateMappedFileService());
        this.defaultMessageStore = defaultMessageStore;

        if (FlushDiskType.SYNC_FLUSH == defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
            this.flushCommitLogService = new GroupCommitService();
        } else {
            this.flushCommitLogService = new FlushRealTimeService();
        }

        this.commitLogService = new CommitRealTimeService();

        this.appendMessageCallback = new DefaultAppendMessageCallback(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
        batchEncoderThreadLocal = new ThreadLocal() {
            @Override
            protected MessageExtBatchEncoder initialValue() {
                return new MessageExtBatchEncoder(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
            }
        };
        this.putMessageLock = defaultMessageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ? new PutMessageReentrantLock() : new PutMessageSpinLock();

    }

mappedFileQueue,声明了MappedFileQueue对象,
flushCommitLogService通过数据刷新方式配置,根据同步刷新或者异步刷新方式,创建了刷新的工作任务。
commitLogService 消息提交的定时任务
appendMessageCallback 消息的存放逻辑回调方法,与MappedFile中appendMessage方法结合
putMessageLock 添加消息时的锁

该类最重要的功能是添加具体消息,方法
public PutMessageResult putMessage(final MessageExtBrokerInner msg),当然这里需要熟悉rocketMQ中消息的结构体是如何设计的,这个后面再聊
先看添加功能部分代码

        // Set the storage time
        msg.setStoreTimestamp(System.currentTimeMillis());
        // Set the message body BODY CRC (consider the most appropriate setting
        // on the client)
        msg.setBodyCRC(UtilAll.crc32(msg.getBody()));
        // Back to Results
        AppendMessageResult result = null;

        StoreStatsService storeStatsService = this.defaultMessageStore.getStoreStatsService();

        String topic = msg.getTopic();
        int queueId = msg.getQueueId();

        final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag());
        if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE
            || tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
            // Delay Delivery
            if (msg.getDelayTimeLevel() > 0) {
                // 延迟消息,将topic进行转换
                if (msg.getDelayTimeLevel() > this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel()) {
                    msg.setDelayTimeLevel(this.defaultMessageStore.getScheduleMessageService().getMaxDelayLevel());
                }

                topic = ScheduleMessageService.SCHEDULE_TOPIC;
                queueId = ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel());

                // Backup real topic, queueId
                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);
            }
        }

        long elapsedTimeInLock = 0;
        MappedFile unlockMappedFile = null;
        MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();


首先又设置了msg的部分属性,例如存储的时间storeTimestamp,bodyCRC等值,其实从该片段中,从消息中获取到了topic和queueId,说明put消息时,已经确定好了消息存放的具体队列了。tranType是消息的事务类型,并且事务是提交状态,或者没有事务状态,但是消息存在delayTimeLevel>0,说明消息需要延迟发送。在rocketMQ中,支持消息延迟发送,但是有固定的延迟时间,每个延迟等级对应延迟的时间。在确实是延迟发送消息时,他将消息真实的topic,queueId存放在msg的property中,并且又重新更新了该消息的topic和queueId,目前该topic和queueId与延迟的发送有关系,就等于标记了消息是延迟消息,并且是对应了具体的队列。对消息进行调整后,通过mappedFileQueue得到了最近的也就是最后一个映射文件。

        putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
        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()) {
                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 new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, null);
            }

            result = mappedFile.appendMessage(msg, this.appendMessageCallback);
            switch (result.getStatus()) {
                case PUT_OK:
                    break;
                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 new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, result);
                    }
                    result = mappedFile.appendMessage(msg, this.appendMessageCallback);
                    break;
                case MESSAGE_SIZE_EXCEEDED:
                case PROPERTIES_SIZE_EXCEEDED:
                    beginTimeInLock = 0;
                    return new PutMessageResult(PutMessageStatus.MESSAGE_ILLEGAL, result);
                case UNKNOWN_ERROR:
                    beginTimeInLock = 0;
                    return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result);
                default:
                    beginTimeInLock = 0;
                    return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, result);
            }

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

这里在put消息时,通过putMessageLock锁进行锁定,说明在多线程请求存放消息时,只能线程竞争获取锁,那么在消息的生产存储时瓶颈在这里提现了,需要提高单线程下存储消息的效率了,所以才会有对MappedFile的处理,运用mmap,page cache等技术,用来提升存储速度。又重置了msg的storeTimestamp时间,然后判断mappedFile是否可用,如果mappedFile不存在,或者已经写满了,就需要创建新的映射文件。然后通过mappedFile中的方法appendMessage,将appendMessageCallback参数带入进去得到result结果。在消息如何转换成byte存储在mappedFile中的MappedByteBuffer,都在appendMessageCallback中实现逻辑。再考虑一个问题,一个MappedFile比较存储数据是有限的,那么在存储前MappedFile是没有满的,但是此时消息的大小并没有能存储进剩余MappedFile的容量,该如何处理呢?所以在result中出现了status 添加时的状态信息,其中PUT_OK 说明放置成功了,如果是END_OF_FILE状态 处理逻辑是又重新创建了MappedFile,然后继续调用mappedFile.appendMessage方法。这里要考虑清楚,上一个文件如何处理的,剩余部分的容量是如何处理的呢?
那么了解一下AppendMessageCallback中的具体实现了。

DefaultAppendMessageCallback 消息存储回调类

看一下 AppendMessageResult doAppend(final long fileFromOffset, final ByteBuffer byteBuffer, final int maxBlank, final MessageExtBrokerInner msgInner) 方法

            // 物理偏移量的起始位置
            long wroteOffset = fileFromOffset + byteBuffer.position();

            this.resetByteBuffer(hostHolder, 8);
            // msgid 可以分析出,地址+物理偏移量组成,16个子节
            String msgId = MessageDecoder.createMessageId(this.msgIdMemory, msgInner.getStoreHostBytes(hostHolder), wroteOffset);

            // Record ConsumeQueue information
            keyBuilder.setLength(0);
            keyBuilder.append(msgInner.getTopic());
            keyBuilder.append('-');
            keyBuilder.append(msgInner.getQueueId());
            String key = keyBuilder.toString();
            // 当前topic与queue组成key,得到队列逻辑便宜位置
            Long queueOffset = CommitLog.this.topicQueueTable.get(key);
            if (null == queueOffset) {
                queueOffset = 0L;
                CommitLog.this.topicQueueTable.put(key, queueOffset);
            }
            // Transaction messages that require special handling
            // 存在事务消息
            final int tranType = MessageSysFlag.getTransactionValue(msgInner.getSysFlag());
            switch (tranType) {
                // Prepared and Rollback message is not consumed, will not enter the
                // consumer queuec
                case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
                case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
                    queueOffset = 0L;
                    break;
                case MessageSysFlag.TRANSACTION_NOT_TYPE:
                case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
                default:
                    break;
            }

wroteOffset是消息写入的起始位置,他是有文件的起始位置加上了byteBuffer.position()的值,在MappedFile中已经说明过了这里的byteBuffer是通过复制得来的,但是设定了position位置,该位置就是MappedFile当前已经wrote的数量。所以wroteOffset值就是文件的起始偏移量加上当前MappedFile写入的数量
msgId 生成一个消息的编号,每个消息都有唯一的标识,他是有broker服务的ip(8个字节)与写入的偏移量wroteOffset(8个字节)组合起来,占用16个字节
queueOffset 是消息队列的偏移量,他存储在topicQueueTable中,key是由topic和queueId组合而成的,初始值为0,然后通过消息的事务类型,如果事务准备或者回滚下,重置queueOffset,说明这个参数也很重要。接下来时计算消息是如何转换成byte数组的,那么先要确定消息的是如何组成的。看一段计算消息的长度方法

    protected static int calMsgLength(int bodyLength, int topicLength, int propertiesLength) {
        /**
         * 该方法可以得知,一个消息存储的组成,需要多少属性组成的
         */
        final int msgLen = 4 //TOTALSIZE
            + 4 //MAGICCODE
            + 4 //BODYCRC
            + 4 //QUEUEID
            + 4 //FLAG
            + 8 //QUEUEOFFSET
            + 8 //PHYSICALOFFSET
            + 4 //SYSFLAG
            + 8 //BORNTIMESTAMP
            + 8 //BORNHOST
            + 8 //STORETIMESTAMP
            + 8 //STOREHOSTADDRESS
            + 4 //RECONSUMETIMES
            + 8 //Prepared Transaction Offset
            + 4 + (bodyLength > 0 ? bodyLength : 0) //BODY
            + 1 + topicLength //TOPIC
            + 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength
            + 0;
        return msgLen;
    }

其中消息有些属性固定长度,有些是无法固定的。其中消息体bodyLength,topicLength,propertiesLength这三个属性值是不固定的。因为topic,消息体内容body,还有消息中设置的properties是根据业务信息,确定内容,所以肯定是变化的。假设给定一个字符串的值,那么如何从byte[]中写入。首先字符串可用转换成byte数组,但是字符串的长度是变化的,导致byte数组长度不固定。所以我们要先确定字符串的长度(假设长度 int 类型 占4个字节),然后再写入byte数组。所以一个字符串存储需要记录长度,再是字符串数据。在读取的时候,就能够知道先读取4个字节,判别字符串长度,然后通过长度取出具体byte数组,这些数据就可用转换成字符串了。所以不确定长度的属性,是如何添加就能了解了。在计算长度有一些固定的属性,例如总消息的大小TOTALSIZE,MAGICCODE是什么呢?待会再将

你可能感兴趣的:(rocketMQ存储 NO.1)