RocketMQ5.0.0消息存储<一>_存储文件及内存映射

目录

一、存储文件

1. Commitlog文件

2. ConsumeQueue文件

3. Index索引文件

4. checkpoint文件

二、内存映射文件

1. 内存映射文件队列MappedFileQueue

2. 内存映射文件MappedFile

        1):MappedFile初始化(init)

        2):MappedFile提交(commit)

        3):MappedFile刷盘(flush)

        4):MappedFile销毁(destory)

3. 堆外缓存TransientStorePool

三、参考资料


一、存储文件

        RcoketMQ存储路径为${ROCKETMQ_HOME}/store,主要存储文件,如下图所示。

存储文件描述
commitlog

1. 消息存储目录,按消息发送时顺序写入

2. 每个文件默认大小1G,文件名:以文件中第一个消息偏移量命名。

config

1. Broker运行期间配置信息;

2. consumerFilter.json:主题消息过滤信息;

    consumerOffset.json:集群模式下的消费进度;

    consumerOrderInfo.json:消费顺序消息信息;

    delayOffset.json:延时消息队列拉取进度;

    subscriptionGroup.json:消息消费组配置信息;

    topics.json:topic配置属性。

consumequeue

1. 消息消费队列存储目录,二级:一级topic,二级消费队列ID(默认4个);

2. 单个文件默认存储30万个条目,每个条目长度20字节。

index消息索引文件目录,目的:根据索引可快速检索定位commillog文件中的消息
timerlog定时消息日志文件目录
abort

1. 存在该文件说明Broker非正常关闭

2. 该文件默认Broker启动时创建,正常关闭前删除。

checkpoint

1. 文件检查点文件;

2. 存储:commitlog、consumequeue、index文件最后一次刷盘的时间戳

         RocketMQ将所有主题的消息存储在Commitlog同一文件中,且按发送顺序写入文件,这样尽最大的能力确保消息发送的高性能与高吞吐量。但是按照消息主题检索消息带来了极大的不便,RocketMQ引入了ConsumeQueue消息队列文件,每个消息主题包含多个消息消费队列(内容相同),每一个消息队列有一个消息文件,其专门为消息订阅构建的索引文件,提高根据主题与消息队列检索消息的速度。

        IndexFile索引文件,其主要设计理念为了加速消息的检索性能,根据消息的属性快速从Commitlog文件中检索消息。

        RocketMQ是高性能的消息中间件,存储部分的设计是核心,存储的核心是IO访问性能。下图是消息的数据流向。

1. Commitlog文件

         RcoketMQ的${ROCKETMQ_HOME}/store/commilog文件目录下每个文件默认大小1G,文件命名方式是:以文件中第一个消息偏移量命名。其中,每条消息的长度不相同。如下图所示,每条消息的前面4个字节是该消息的总长度

        Commitlog文件对应实体类是org.apache.rocketmq.store.CommitLog。获取消息的总长度方法是org.apache.rocketmq.store.CommitLog#calMsgLength,如下代码所示。

/**
 * 获取当前消息的总长度(不定长,总长度存储在前4个字节)
 * 消息体长度、topic长度、消息扩展属性长度 按消息存储格式 计算该消息的总长度
 * @param sysFlag 系统Flag
 * @param bodyLength 消息体长度
 * @param topicLength topic长度
 * @param propertiesLength 消息扩展属性长度
 * @return 当前消息的总长度
 */
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 魔数,固定值0xdaa320a7
            + 4 //BODYCRC 消息体CRC验证码
            + 4 //QUEUEID 消息队列ID
            + 4 //FLAG 用户消息flag,RocketMQ不处理
            + 8 //QUEUEOFFSET 该消息在当前消费队列的偏移量
            + 8 //PHYSICALOFFSET 该消息在Commitlog文件中的偏移量
            + 4 //SYSFLAG 系统Flag,如:是否压缩、是否是事务消息等
            + 8 //BORNTIMESTAMP 生产者调用消息发送API的时间戳
            + bornhostLength //BORNHOST 消息发送者IP、端口号(IPV4共8字节;IPV6共20字节)
            + 8 //STORETIMESTAMP 存储消息时间戳
            + storehostAddressLength //STOREHOSTADDRESS Broker服务器地址 + 端口号
            + 4 //RECONSUMETIMES 消息重试次数
            + 8 //Prepared Transaction Offset 事务消息Prepare阶段的偏移量
            + 4 //bodyLength 消息体长度
            + (bodyLength > 0 ? bodyLength : 0) //BODY 消息体
            + 1 //topicLength 主题长度
            + topicLength //TOPIC 主题
            + 2 //propertiesLength 消息扩展属性长度
            + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength 消息扩展属性内容
            + 0;
    return msgLen;
}

2. ConsumeQueue文件

        RcoketMQ的${ROCKETMQ_HOME}/store/consumequeue文件目录是消费队列存储,根据主题进行存储,如下图所示。第一级目录是消息主题,第二级目录是消费队列。

         为了加速消息检索速度和节省磁盘空间,每个ConsumeQueue文件中每个条目不会存储消息的全量信息,如下图所示是消息条目的存储格式。

         每个条目共20字节(8字节的消息物理偏移量 + 4字节的消息总长度 + 8字节的Tag的哈希码),而每个ConsumeQueue文件存储30万条,因此单个文件大小为:30w × 20字节。

        单个ConsumeQueue文件可以作为一个数组(元素是存储条目),其下标为ConsumeQueue的逻辑偏移量,消息消费进度存储的偏移量即逻辑偏移量。ConsumeQueue即为Commitlog文件的索引文件,其构建机制是当消息提交到Commitlog文件的内存映射后,由专门的线程产生消息转发任务,从而构建消息消费队列文件与下文提到的索引文件

        ConsumeQueue文件对应的实体类org.apache.rocketmq.store.ConsumeQueue,关键属性如下代码所示。

// ConsumeQueue存储条目大小:20字节 = 8字节的Commitlog offset + 4字节的消息大小 + 8字节的Tag的哈希码
public static final int CQ_STORE_UNIT_SIZE = 20;
// 消息存储
private final MessageStore messageStore;
// Commitlog文件队列
private final MappedFileQueue mappedFileQueue;
// 主题
private final String topic;
// 队列ID
private final int queueId;
// 单个条目的缓冲
private final ByteBuffer byteBufferIndex;

// ConsumeQueue存储路径
private final String storePath;
// ConsumeQueue文件大小
private final int mappedFileSize;
// 最大物理偏移量
private long maxPhysicOffset = -1;

// 最小逻辑偏移量
private volatile long minLogicOffset = 0;
// 扩展属性
private ConsumeQueueExt consumeQueueExt = null; 

3. Index索引文件

        RcoketMQ的${ROCKETMQ_HOME}/store/index是消息索引存储目录。其结构是Hash结构:Hash槽 + Hash冲突的链表结构。如下图所示。

         注意,hash槽存储的是最新Index条目的索引位置Index条目结构是:hash码 + Commitlog偏移量 + 存储时间差值 + 当前hash槽的值(待存储条目的前一个条目的偏移量),其中timedif指该消息存储时间与第一条消息的时间戳的差值,小于0表示该消息无效

        Index索引文件的实体类是org.apache.rocketmq.store.index.IndexFile。其索引头部实体类是org.apache.rocketmq.store.index.IndexHeader,关键属性如下。

// 头部总长度为40字节
public static final int INDEX_HEADER_SIZE = 40;

/*
	各内容存储偏移量
 */
private static int beginTimestampIndex = 0;
private static int endTimestampIndex = 8;
private static int beginPhyoffsetIndex = 16;
private static int endPhyoffsetIndex = 24;
private static int hashSlotcountIndex = 32;
private static int indexCountIndex = 36;
private final ByteBuffer byteBuffer;

// 文件中消息的最小时间戳
private final AtomicLong beginTimestamp = new AtomicLong(0);
// 文件中消息的最大时间戳
private final AtomicLong endTimestamp = new AtomicLong(0);
// 文件中消息的最小偏移量(Commitlog的物理偏移量)
private final AtomicLong beginPhyOffset = new AtomicLong(0);
// 文件中消息的最大偏移量(Commitlog的物理偏移量)
private final AtomicLong endPhyOffset = new AtomicLong(0);
// 哈希槽个数,注意不是使用个数
private final AtomicInteger hashSlotCount = new AtomicInteger(0);
// 条目列表中使用的个数,按顺序存储
private final AtomicInteger indexCount = new AtomicInteger(1);

4. checkpoint文件

        RcoketMQ的${ROCKETMQ_HOME}/store目录下的checkpoint文件,是commitlog、consumequeue、index文件最后一次刷盘的时间戳。该文件固定长度为4k,其中只用该文件的前面24个字节,其存储格式下图。

二、内存映射文件

        RocketMQ通过使用内存映射文件来提高IO访问性能,CommitLog、ConsumeQueue、IndexFile单个文件都被设计为固定长度,如果一个文件写满以后再创建一个新文件,文件名为该文件第一条消息对应的全局物理偏移量。

        MappedFileQueue是MappedFile管理容器,MappedFileQueue是对存储目录的封装,例如CommitLog文件的存储路径${ROCKETMQ_HOME}/store/commitlog,该目录下存在多个内存映射文件(MappedFile)

1. 内存映射文件队列MappedFileQueue

        org.apache.rocketmq.store.MappedFileQueue主要属性如下代码所示。

// 存储目录
protected final String storePath;
// 单个MappedFile的存储大小
protected final int mappedFileSize;
// MappedFile文件集合
protected final CopyOnWriteArrayList<MappedFile> mappedFiles = new CopyOnWriteArrayList<MappedFile>();
// 创建MappedFile的服务类
protected final AllocateMappedFileService allocateMappedFileService;
// 当前刷盘指针
protected long flushedWhere = 0;
// 当前数据提交指针,即:内存中ByteBuffer的当前写指针
protected long committedWhere = 0;
// 存储时间戳
protected volatile long storeTimestamp = 0;

        主要注意的是,数据提交操作(commit操作的committedWhere)只是内存缓存写入到文件内存映射,或是直接写入到文件内存映射;而数据刷盘操作(flush操作的flushedWhere)是文件内存映射写入到磁盘

        以下代码是MappedFileQueue类的主要方法:

  • MappedFile getMappedFileByTime(final long timestamp):存储时间查找MappedFile;
  • MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound):消息偏移量查找MappedFile,若returnFirstOnNotFound为true时,没有找到则返回第一个文件(注意:第一个文件不一定是从0开始的文件,原因是文件过期机制)
  • long getMinOffset() :获取最小偏移量;
  • long getMaxOffset():获取最大偏移量(最后一个文件的偏移量 + 最后一个文件的写指针);
  • long getMaxWrotePosition():获取写指针(最后一个文件的偏移量 + 最后一个文件的写指针)。

2. 内存映射文件MappedFile

        org.apache.rocketmq.store.logfile.MappedFile是接口,其默认实现类是org.apache.rocketmq.store.logfile.DefaultMappedFile,其主要属性如下代码。

// OS系统默认每页大小,4K
public static final int OS_PAGE_SIZE = 1024 * 4;

// 当前JVM实例中的MappedFile虚拟内存
protected static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);
// 当前JVM实例中的MappedFile对象个数
protected static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);

// 原子更新DefaultMappedFile的属性:wrotePosition
protected static final AtomicIntegerFieldUpdater<DefaultMappedFile> WROTE_POSITION_UPDATER;
// 原子更新DefaultMappedFile的属性:committedPosition
protected static final AtomicIntegerFieldUpdater<DefaultMappedFile> COMMITTED_POSITION_UPDATER;
// 原子更新DefaultMappedFile的属性:flushedPosition
protected static final AtomicIntegerFieldUpdater<DefaultMappedFile> FLUSHED_POSITION_UPDATER;

// 写指针
protected volatile int wrotePosition;
/**
 * 提交指针
 * transientStorePoolEnable为true时,
 * 首先消息存储在ByteBuffer writeBuffer,
 * 其次根据Commit线程提交到mappedByteBuffer
 * 最后根据Flush线程刷写到磁盘
 */
protected volatile int committedPosition;
// 刷写磁盘的指针
protected volatile int flushedPosition;
// 文件大小
protected int fileSize;
// 文件通道:用于读取、写入文件的通道
protected FileChannel fileChannel;
/**
 * 堆内存ByteBuffer
 * 该缓存不为空(transientStorePoolEnable为true时不为空),则首先将该缓存提交到MappedByteBuffer中
 * Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
 */
protected ByteBuffer writeBuffer = null;
/**
 * 堆内存池,transientStorePoolEnable为true启用
 */
protected TransientStorePool transientStorePool = null;
// 文件名
protected String fileName;
// 该文件的初始偏移量,该文件第一个消息的起始位置
protected long fileFromOffset;
// 对应的物理文件Commitlog
protected File file;
/**
 * 物理文件Commitlog对应的内存映射缓存
 */
protected MappedByteBuffer mappedByteBuffer;
// 文件最后写入的时间戳
protected volatile long storeTimestamp = 0;
// 该文件是否是MappedFileQueue队列中的第一个文件
protected boolean firstCreateInQueue = false;
// 最后刷盘的时间戳
private long lastFlushTime = -1L;

        1):MappedFile初始化(init)

        初始化分两种情况:是否开启transientStorePoolEnable,默认false不开启,如下代码所示。

  • true(开启):先存储在堆外内存,然后通过Commit线程将数据提交到内存映射Buffer中,再通过Flush线程将内存映射Buffer中的数据持久化到磁盘中。
  • false(未开启):直接存储到内存映射Buffer中,再通过Flush线程将内存映射Buffer中的数据持久化到磁盘中。
// 使用堆缓存池
@Override
public void init(final String fileName, final int fileSize,
                 final TransientStorePool transientStorePool) throws IOException {
    // 未使用堆缓存池
    init(fileName, fileSize);
    // 从堆缓存池获取writeBuffer
    this.writeBuffer = transientStorePool.borrowBuffer();
    this.transientStorePool = transientStorePool;
}

/**
 * 未使用堆缓存池
 * step1:通过RandomAccessFile创建读、写文件访问通道
 * step2:将文件映射为mappedByteBuffer(内存映射缓存)
 * @param fileName 文件名
 * @param fileSize 文件大小
 * @throws IOException
 */
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;

    UtilAll.ensureDirOK(this.file.getParent());

    try {
        // 通过RandomAccessFile创建读、写文件访问通道
        this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
        // 将文件映射为mappedByteBuffer
        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();
        }
    }
}

        2):MappedFile提交(commit)

        Commit操作是将消息直接保存到文件映射内存(transientStorePoolEnable为false)或消息从缓存保存到文件映射内存(transientStorePoolEnable为true)。下面代码可以看出,Commit操作的对象是writeBuffer,目的:将MappedFile.writeBuffer数据提交到该文件通道内(FileChannel),即:提交到文件内存映射

// commit操作的对象的writeBuffer,目的:将MappedFile.writeBuffer数据提交到该文件通道内(FileChannel)
@Override
public int commit(final int commitLeastPages) {
    // writeBuffer为空,则直接返回写指针
    if (writeBuffer == null) {
        //no need to commit data to file channel, so just regard wrotePosition as committedPosition.
        return WROTE_POSITION_UPDATER.get(this);
    }
    // 是否可提交,本次提交页数小于commitLeastPages,则不可提交,待下次提交
    if (this.isAbleToCommit(commitLeastPages)) {
        if (this.hold()) { // 获取锁
            // 提交数据目的:将MappedFile.writeBuffer数据提交到该文件通道内(FileChannel)
            commit0();
            this.release(); // 释放锁
        } else {
            log.warn("in commit, hold failed, commit offset = " + COMMITTED_POSITION_UPDATER.get(this));
        }
    }

    // All dirty data has been committed to FileChannel.
    if (writeBuffer != null && this.transientStorePool != null && this.fileSize == COMMITTED_POSITION_UPDATER.get(this)) {
        this.transientStorePool.returnBuffer(writeBuffer);
        this.writeBuffer = null;
    }

    return COMMITTED_POSITION_UPDATER.get(this);
}

// 提交数据目的:将MappedFile.writeBuffer数据提交到该文件通道内(FileChannel)
protected void commit0() {
    // 当前写位置
    int writePos = WROTE_POSITION_UPDATER.get(this);
    // 上次提交位置
    int lastCommittedPosition = COMMITTED_POSITION_UPDATER.get(this);

    if (writePos - lastCommittedPosition > 0) {
        try {
            // slice():创建与writeBuffer的共享缓冲区,维护一套独立的(position、limit、mark)
            ByteBuffer byteBuffer = writeBuffer.slice();
            byteBuffer.position(lastCommittedPosition);
            byteBuffer.limit(writePos);

            // 文件通道的位置是上次提交位置
            this.fileChannel.position(lastCommittedPosition);
            // 把上次提交位置 到 当前写位置的数据写入文件通道内
            this.fileChannel.write(byteBuffer);

            // 更新写位置
            COMMITTED_POSITION_UPDATER.set(this, writePos);
        } catch (Throwable e) {
            log.error("Error occurred when commit data to FileChannel.", e);
        }
    }
}

        3):MappedFile刷盘(flush)

        Flush操作将文件通道内(FileChannel)的消息写入磁盘,永久性存储在磁盘。如下代码所示。

/*
    刷盘操作
    writeBuffer不为空时,刷fileChannel的数据(commit的操作数据)
                为空时,直接从物理文件映射内存mappedByteBuffer刷
 */
@Override
public int flush(final int flushLeastPages) {
    // 是否可刷盘,刷盘页数小于flushLeastPages,则不可刷盘,待下次刷盘
    if (this.isAbleToFlush(flushLeastPages)) {
        if (this.hold()) { // 获取锁
            /*
                获取当前写位置
                writeBuffer为null时,获取当前写指针
                         不为null时,获取当前提交指针
             */
            int value = getReadPosition();

            try {
                this.mappedByteBufferAccessCountSinceLastSwap++;

                //We only append data to fileChannel or mappedByteBuffer, never both.
                // writeBuffer不为空时,刷fileChannel的数据(commit的操作数据)
                if (writeBuffer != null || this.fileChannel.position() != 0) {
                    this.fileChannel.force(false);
                }
                // writeBuffer为空时,直接从物理文件映射内存mappedByteBuffer刷
                else {
                    this.mappedByteBuffer.force();
                }
                this.lastFlushTime = System.currentTimeMillis();
            } catch (Throwable e) {
                log.error("Error occurred when force data to disk.", e);
            }

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

        4):MappedFile销毁(destory)

        Destory操作是将过期的文件,如:Commitlog、ConsumeQueue、IndexFile等文件内存映射清空并删除其物理文件。如下代码所示。

/**
 * 销毁MappedFile文件
 * 注意:关闭MappedFile、删除MappedFile对应的物理文件Commitlog
 * @param intervalForcibly true时,拒绝被销毁的最大存活时间
 * @return true if success; false otherwise.
 */
@Override
public boolean destroy(final long intervalForcibly) {
    // 关闭MappedFile
    this.shutdown(intervalForcibly);

    // 是否需要被清除
    if (this.isCleanupOver()) {
        try {
            long lastModified = getLastModifiedTimestamp();
            // 关闭文件通道
            this.fileChannel.close();
            log.info("close file channel " + this.fileName + " OK");

            long beginTime = System.currentTimeMillis();
            // 删除MappedFile对应的物理文件
            boolean result = this.file.delete();

            log.info("delete file[REF:" + this.getRefCount() + "] " + this.fileName
                    + (result ? " OK, " : " Failed, ") + "W:" + this.getWrotePosition() + " M:"
                    + this.getFlushedPosition() + ", "
                    + UtilAll.computeElapsedTimeMilliseconds(beginTime)
                    + "," + (System.currentTimeMillis() - lastModified));
        } catch (Exception e) {
            log.warn("close file channel " + this.fileName + " Failed. ", e);
        }

        return true;
    } else {
        log.warn("destroy mapped file[REF:" + this.getRefCount() + "] " + this.fileName
                + " Failed. cleanupOver: " + this.cleanupOver);
    }

    return false;
}

3. 堆外缓存TransientStorePool

        org.apache.rocketmq.store.TransientStorePool是堆外缓存池,只有transientStorePoolEnable为true时,才能创建该对象(参考MappedFile初始化章节)。RocketMQ会单独创建一个写缓存,然后通过Commit线程将数据提交到内存映射Buffer中,再通过Flush线程将内存映射Buffer中的数据持久化到磁盘中。

        RocketMQ引入该机制主要原因是提供一种内存锁定,将当前堆外内存一直锁定在内存中,避免被进程将内存交换置换到交换区,提高存储性能,主要针对Commitlog文件服务。如下代码是其关键属性。

// 配置availableBuffers的个数,可通过/store/broker.conf的transientStorePoolSize配置,默认5(MessageStoreConfig.transientStorePoolSize)
private final int poolSize;
// 单个ByteBuffer的大小(即:单个Commitlog),可通过/store/broker.conf的mappedFileSizeCommitLog配置,默认1G(MessageStoreConfig.mappedFileSizeCommitLog)
private final int fileSize;
// ByteBuffer的容器,双端队列
private final Deque<ByteBuffer> availableBuffers;
// 配置属性
private final MessageStoreConfig storeConfig;
/**
 * It's a heavy init method.
 * 初始化poolSize个堆内存,通过com.sun.jna.Library将该批内存锁定,避免被置换到交换区,提高消息存储性能
 */
public void init() {
    for (int i = 0; i < poolSize; i++) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);

        final long address = ((DirectBuffer) byteBuffer).address();
        Pointer pointer = new Pointer(address);
        // 内存锁定,避免被置换到交换区,提高消息存储性能
        LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));

        availableBuffers.offer(byteBuffer);
    }
}

        创建poolSize个堆外内存,并利用com.sun.jna.Library类库将该批内存锁定,避免被置换到交换区,提高存储性能。

三、参考资料

Rocket Mq消息持久化_飞科-程序人生的博客-CSDN博客

庖丁解牛 | 图解 RocketMQ 核心原理_liqiuman180688的博客-CSDN博客

RocketMQ4.X - 简书

RocketMQ5.0.0消息发送_爱我所爱0505的博客-CSDN博客 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值