Hadoop源码分析_namenode.FSDirectory


来源 http://blog.csdn.net/shirdrn/article/details/4631518

这里对与org.apache.hadoop.hdfs.server.namenode.FSDirectory类相关的类进行阅读分析。

  • INodeDirectoryWithQuota类

该类org.apache.hadoop.hdfs.server.namenode.INodeDirectoryWithQuota的继承层次关系如下所示:

◦org.apache.hadoop.hdfs.server.namenode.INode(implements java.lang.Comparable<byte[]>)
     ◦org.apache.hadoop.hdfs.server.namenode.INodeDirectory
          ◦org.apache.hadoop.hdfs.server.namenode.INodeDirectoryWithQuota

INode抽象类

该类是一个保存在内存中的file/block层次结构,一个基本的INode包含了文件和目录inode的通用域(Field) 。下面看INode类定义的属性:

  protected byte[] name; // 名称
  protected INodeDirectory parent; // 所在目录
  protected long modificationTime; // 修改时间
  protected long accessTime; // 访问时间
  private long permission; // 权限,只能调用updatePermissionStatus方法设置权限

INode类提供的构造方法如下所示:

  protected INode() { // 受保护构造方法,子类可以继承(初始化INode的属性值)
    name = null;
    parent = null;
    modificationTime = 0;
    accessTime = 0;
  }

  INode(PermissionStatus permissions, long mTime, long atime) { // 根据指定权限创建INode
    this.name = null;
    this.parent = null;
    this.modificationTime = mTime;
    setAccessTime(atime);
    setPermissionStatus(permissions); // 设置权限:包括用户名、组、FsPermission信息
  }

  protected INode(String name, PermissionStatus permissions) { // 受保护构造方法
    this(permissions, 0L, 0L);
    setLocalName(name);
  }
  
  /** 
   * 通过拷贝构造INode实例
   */
  INode(INode other) {
    setLocalName(other.getLocalName());
    this.parent = other.getParent();
    setPermissionStatus(other.getPermissionStatus());
    setModificationTime(other.getModificationTime());
    setAccessTime(other.getAccessTime());
  }

INode类主要就是针对一个INode的名称、所在目录、修改时间、访问时间、权限这些属性来实现操作的,该类中的方法无非实现对这些属性的操作。另外还包括删除该INode,其它几个抽象方法,如下所示:

  /**
   * 检查该INode是否是一个目录
   */
  public abstract boolean isDirectory();

  /**
   * 收集该INode的所有块(包括该INode的孩子结点),并清除全部对该INode的引用
   */
  abstract int collectSubtreeBlocksAndClear(List<Block> v);

  /**
   * 计算摘要信息,返回数组包含如下内容:
   * 0: 内容摘要长度, 1: 文件数量, 2: 目录数量 3: 磁盘空间
   */
  abstract long[] computeContentSummary(long[] summary);

  /**
   * 获取以该INode为根的磁盘空间与命名空间统计信息
   * 其中,DirCounts包含nsCount (namespace consumed)和dsCount (diskspace consumed)两个计数器变量
   */
  abstract DirCounts spaceConsumedInTree(DirCounts counts);

2、INodeDirectory类

INodeDirectory类是一个目录INode,因此该类内部定义了一个INode列表。该类定义的属性如下:

  protected static final int DEFAULT_FILES_PER_DIRECTORY = 5; // 一个目录INode中默认可以存储5个文件
  private List<INode> children; // 以该目录INode为根的INode实例列表

可以想象得到,作为一个目录,应该提供从目录中检索得到指定的INode的操作,还有就是对引用该INode的INode进行的一些基本操作。这里就不过多阐述了,可以阅读该类的源代码。

3、INodeDirectoryWithQuota类

INodeDirectoryWithQuota类继承自INodeDirectory类,INodeDirectoryWithQuota类表示具有配额限制的目录INode实现类。我们通过该类中定义的一些与配额有关的属性就能了解到这样一种目录INode有什么样的特点:

  private long nsQuota; // 命名空间配额限制
  private long nsCount; // 命名空间大小
  private long dsQuota; // 磁盘空间配额限制
  private long diskspace; // 磁盘空间大小

  • INodeFileUnderConstruction类

该类的继承层次关系如下所示:

◦org.apache.hadoop.hdfs.server.namenode.INode(implements java.lang.Comparable<byte[]>)
     ◦org.apache.hadoop.hdfs.server.namenode.INodeFile
          ◦org.apache.hadoop.hdfs.server.namenode.INodeFileUnderConstruction

其中,INode类前面已经介绍了,它是一个目录或者文件的INode的抽象。

1、INodeFile类

该类表示一个文件INode,正好与目录INode相对应。因为我们已经阅读分析过目录INode的实现,对该文件INode的实现就比较简单了。看该类的属性:

  static final FsPermission UMASK = FsPermission.createImmutable((short)0111); // 文件INode默认权限

  protected BlockInfo blocks[] = null; // 块的元数据信息实体的数组
  protected short blockReplication; // 块副本数
  protected long preferredBlockSize; // 块大小

该类定义了如下几个基本的操作: 

  /**
   * 将块加入到列表blocks中
   */
  void addBlock(BlockInfo newblock);

  /**
   * 为该文件设置块副本数 
   */
  void setReplication(short replication);

  /**
   * 根据索引位置和块(属于该文件)设置该文件中该块
   */
  void setBlock(int idx, BlockInfo blk);

对应的set方法,也存在get实现。

可见,一个INodeFile类实例是不持有任何客户端或者Datanode信息的,就是一个基本的实在的文件。因为在HDFS集群中需要执行计算任务,这要涉及到块的复制等操作,而某些块需要由Namenode调度分派给指定的进程去执行,这就需要一种实体类,既能够包含INodeFile的基本信息,又能够包含与在该INodeFile上执行操作的进程,所以,Hadoop实现了一个INodeFileUnderConstruction类,并在INodeFile类中实现了由INodeFile到INodeFileUnderConstruction的转换,如下所示:

  INodeFileUnderConstruction toINodeFileUnderConstruction(String clientName, String clientMachine, DatanodeDescriptor clientNode) throws IOException {
    if (isUnderConstruction()) { // 如果该INodeFile已经被创建
      return (INodeFileUnderConstruction)this; // 转换为INodeFileUnderConstruction实例
    }
    // 如果该INodeFile没有创建,则直接构造一个INodeFileUnderConstruction实例
    return new INodeFileUnderConstruction(name,
        blockReplication, modificationTime, preferredBlockSize,
        blocks, getPermissionStatus(),
        clientName, clientMachine, clientNode);
  }

2、INodeFileUnderConstruction类待研究,流程怎么回事

该类所含有的信息包括与执行计算任务相关的一些属性,如下所示:

  final String clientName;         // 租约(lease)持有者
  private final String clientMachine; // 客户端主机
  private final DatanodeDescriptor clientNode; // 如果客户端同时也是HDFS集群中的Datanode

  private int primaryNodeIndex = -1; // 客户端结点激活租约(lease)
  private DatanodeDescriptor[] targets = null;   // 文件最后一个块的存储位置信息
  private long lastRecoveryTime = 0;

通过上面属性信息可以知道,一个INodeFileUnderConstruction文件具有持有操作该文件的进程(客户端)的一些信息,如果客户端进程同时也是HDFS集群中Datanode,它就能够根据租约的有效性来执行与该文件相关的操作,例如复制等。

下面介绍个主要方法:

1)assignPrimaryDatanode方法

INodeFileUnderConstruction类中实现的assignPrimaryDatanode方法,能够将该INodeFileUnderConstruction文件分配给指定的客户端进程,也就是执行租约恢复的操作,并通过setLastRecoveryTime更新最后租约恢复时间lastRecoveryTime。下面是assignPrimaryDatanode方法的实现:

  /**
   * 为该文件初始化租约的恢复的处理(存储选择的主Datanode所激活的块列表)
   */
  void assignPrimaryDatanode() {
    // 指派第一个活跃的为主Datanode结点
    if (targets.length == 0) {
      NameNode.stateChangeLog.warn("BLOCK*"  + " INodeFileUnderConstruction.initLeaseRecovery:"
 + " No blocks found, lease removed.");
    }

    int previous = primaryNodeIndex;
    // 从索引previous开始查找到一个活跃的Datanode进程
    for(int i = 1; i <= targets.length; i++) {
      int j = (previous + i)%targets.length;
      if (targets[j].isAlive) { // 保证第j个Datanode处于活跃状态
        DatanodeDescriptor primary = targets[primaryNodeIndex = j]; 
        primary.addBlockToBeRecovered(blocks[blocks.length - 1], targets); // 存储被主Datanode激活的块,实际存储到该Datanode的块队列中
        NameNode.stateChangeLog.info("BLOCK* " + blocks[blocks.length - 1] + " recovery started, primary=" + primary);
        return;
      }
    }
  }

2)removeBlock方法

该类的removeBlock方法从该文件的块列表中删除一个块,并且只能删除列表中的最后一个块。实现如下所示: 

  void removeBlock(Block oldblock) throws IOException {
    if (blocks == null) {
      throw new IOException("Trying to delete non-existant block " + oldblock);
    }
    int size_1 = blocks.length - 1;
    if (!blocks[size_1].equals(oldblock)) {
      throw new IOException("Trying to delete non-last block " + oldblock);
    }

    BlockInfo[] newlist = new BlockInfo[size_1]; // 创建一个新的块列表(比原来的块列表小1)
    System.arraycopy(blocks, 0, newlist, 0, size_1); // 将原来的块列表中除去最后一个块以外的全部块,拷贝到新的块列表中
    blocks = newlist; // 修改当前文件的块列表
    targets = null; // 因为最后一个块删除了,该块对应的存储位置信息也不存在了
  }

3)convertToInodeFile方法

该方法将一个INodeFileUnderConstruction文件转化为INodeFile文件,如下所示:

  INodeFile convertToInodeFile() {
    INodeFile obj = new INodeFile(getPermissionStatus(),
                                  getBlocks(),
                                  getReplication(),
                                  getModificationTime(),
                                  getModificationTime(),
                                  getPreferredBlockSize());
    return obj;    
  }

  • FSDirectory类

该类org.apache.hadoop.hdfs.server.namenode.FSDirectory用来存储文件系统目录的状态。它处理向磁盘中写入或加载数据,并且对目录中的数据发生的改变记录到日志中。它保存了一个最新的filename->blockset的映射表,并且将它写入到磁盘中。

该类定义的属性如下所示:

  final FSNamesystem namesystem; // 文件系统命名空间系统实例
  final INodeDirectoryWithQuota rootDir; // 具有配额限制的目录INode,这里即是根目录
  FSImage fsImage;  // FSImage映像
  private boolean ready = false; // 该目录是否准备好处理writing/loading到磁盘
  private MetricsRecord directoryMetrics = null; // 目录元数据记录实体

该类构造方法如下所示:

  FSDirectory(FSNamesystem ns, Configuration conf) {
    this(new FSImage(), ns, conf);
    fsImage.setCheckpointDirectories(FSImage.getCheckpointDirs(conf, null), FSImage.getCheckpointEditsDirs(conf, null));
  }

  FSDirectory(FSImage fsImage, FSNamesystem ns, Configuration conf) {
    rootDir = new INodeDirectoryWithQuota(INodeDirectory.ROOT_NAME, ns.createFsOwnerPermissions(new FsPermission((short)0755)), Integer.MAX_VALUE, -1); // 目录的权限设为755(drwxrw-rw-)
    this.fsImage = fsImage; // 后面会详细分析FSImage映像类的
    namesystem = ns;
    initialize(conf); // 调用,根据配置类实例conf初始化directoryMetrics
  }


 

通过上面的FSDirectory的构造可以看出,通过FSNamesystem ns访问一个已经存在的DFS的命名空间系统目录,为FSDirectory的根目录rootDir设置访问权限。

下面介绍FSDirectory类的方法,选择几个重要的方法详细分析:

1、加载FSImage映像

方法loadFSImage实现如下所示: 

  void loadFSImage(Collection<File> dataDirs, Collection<File> editsDirs, StartupOption startOpt) throws IOException {
     // 根据Hadoop servers启动选项进行操作
    if (startOpt == StartupOption.FORMAT) { // 如果启动选项类型为FORMAT(格式化),在启动之前需要进行格式化
      fsImage.setStorageDirectories(dataDirs, editsDirs); // 设置FSImage映像文件文件的存储目录
      fsImage.format(); // 对FSImage执行格式化操作
      startOpt = StartupOption.REGULAR; // 动态修改启动选项REGULAR(正常启动)
    }
    try {
      if (fsImage.recoverTransitionRead(dataDirs, editsDirs, startOpt)) { // 根据启动选项及其对应存储目录,分析存储目录,必要的话从先前的事务恢复过来
        fsImage.saveFSImage(); // 保存FSImage映像文件内容,并创建一个空的edits文件
      }
      FSEditLog editLog = fsImage.getEditLog(); // 获取到存FSImage映像对应的EditLog文件
      assert editLog != null : "editLog must be initialized";
      if (!editLog.isOpen())
        editLog.open(); // 打开EditLog文件
      fsImage.setCheckpointDirectories(null, null); // 设置检查点存储目录
    } catch(IOException e) {
      fsImage.close();
      throw e;
    }
    synchronized (this) {
      this.ready = true; // 设置当前FSDirectory状态
      this.notifyAll(); // 通知阻塞在该FSDirectory对象上的全部其它线程
    }
  }


通过该方法,我们可以看到加载一个FSImage映像的过程:首先需要对内存中的FSImage对象进行格式化;然后从将指定存储目录中的EditLog日志文件作用到格式化完成的FSImage内存映像上;最后需要再创建一个空的EditLog日志准备记录对命名空间进行修改的操作,以备检查点进程根据需要将EditLog内容作用到FSImage映像上,保持FSImage总是最新的,保证EditLog与FSImage同步。

2、更新INode文件计数

实现的方法为updateCount,如下所示:

  /** 
   * 批量更新:更新具有配额限制的每一个INode的计数
   * 
   * @param inodes 某个Path下的INode数组
   * @param numOfINodes 需要更新的INode的数量(从数组inodes的索引0开始计数)
   * @param nsDelta 文件系统命名空间大小的改变量
   * @param dsDelta 磁盘空间大小的改变量
   */
  private void updateCount(INode[] inodes, int numOfINodes, long nsDelta, long dsDelta) throws QuotaExceededException {
    if (!ready) { 
      return;
    }
    if (numOfINodes>inodes.length) { // 检查numOfINodes,当大于inodes数组大小时,设置为inodes数组大小
      numOfINodes = inodes.length;
    }
    // check existing components in the path  
    int i=0;
    try {
      for(; i < numOfINodes; i++) {
        if (inodes[i].isQuotaSet()) { // 如果是一个具有配额限制的目录INode
          INodeDirectoryWithQuota node =(INodeDirectoryWithQuota)inodes[i]; // 转换,得到INodeDirectoryWithQuota实例
          node.updateNumItemsInTree(nsDelta, dsDelta); // 更新node目录树的大小
        }
      }
    } catch (QuotaExceededException e) {
      e.setPathName(getFullPathName(inodes, i));
      // 发生异常,执行回滚上述更新操作
      for( ; i-- > 0; ) {
        try {
          if (inodes[i].isQuotaSet()) { 
            INodeDirectoryWithQuota node =(INodeDirectoryWithQuota)inodes[i]; 
            node.updateNumItemsInTree(-nsDelta, -dsDelta);
          }
        } catch (IOException ingored) {
        }
      }
      throw e;
    }
  }


文件系统中的INode(目录或文件)可能因为在执行计算任务过程中,某个INode(树)的内容发生变化,为保证HDFS中文件管理的一致性,在必要的时候需要更新INode的统计数据。

3、向该目录中添加一个孩子INode

实现方法为addChild,如下所示:

  private <T extends INode> T addChild(INode[] pathComponents, int pos, T child, long childDiskspace, boolean inheritPermission) throws QuotaExceededException {
    INode.DirCounts counts = new INode.DirCounts();
    child.spaceConsumedInTree(counts); // 更新counts对象(该对象包含child在目录树中INode名字的数量与占用磁盘空间)
    if (childDiskspace < 0) {
      childDiskspace = counts.getDsCount(); // 获取child的磁盘空间大小
    }
    updateCount(pathComponents, pos, counts.getNsCount(), childDiskspace); // 更新pathComponents数组从0到pos-1位置的每一个INode的统计计数
    T addedNode = ((INodeDirectory)pathComponents[pos-1]).addChild(child, inheritPermission); // 将child添加到((INodeDirectory)pathComponents[pos-1])目录中,并返回child结点
    if (addedNode == null) { // 如果((INodeDirectory)pathComponents[pos-1])中已经存在child结点
      updateCount(pathComponents, pos, -counts.getNsCount(), -childDiskspace); // 回滚上述更新操作
    }
    return addedNode; // 返回添加到该目录中的INode
  }


4、向namespace中添加一个INode

实现的方法为addNode,如下所示:

  /** 
   * 将node添加到namespace中,node的完整路径为src,如果该node磁盘空间childDiskspace未知则应该为-1
   */
  private <T extends INode> T addNode(String src, T child, long childDiskspace, boolean inheritPermission) throws QuotaExceededException {
    byte[][] components = INode.getPathComponents(src); // 将路径src转换为UTF-8编码的字节数组
    child.setLocalName(components[components.length-1]); // 为child设置本地文件名称
    INode[] inodes = new INode[components.length]; // 分配一个components.length大小的INode[]
    synchronized (rootDir) {
      rootDir.getExistingPathINodes(components, inodes);
      return addChild(inodes, inodes.length-1, child, childDiskspace, inheritPermission);
    }
  }


上面调用了INodeDirectory类的getExistingPathINodes方法,这里说明一下该方法。

例如,给定一个路径/c1/c2/c3,其中只有/c1/c2是存在的,而/c3不存在,则得到这样一个字节数组["","c1","c2","c3"]。

如果想要执行调用getExistingPathINodes(["","c1","c2"], [?]),则应该使用[c2]填充占位数组;

如果想要执行调用getExistingPathINodes(["","c1","c2","c3"], [?]),则应该使用[null]填充占位数组;

如果想要执行调用getExistingPathINodes(["","c1","c2"], [?,?]),则应该使用[c1,c2]填充占位数组;

如果想要执行调用getExistingPathINodes(["","c1","c2","c3"], [?,?]),则应该使用[c2,null]填充占位数组;

如果想要执行调用getExistingPathINodes(["","c1","c2"], [?,?,?,?]),则应该使用[rootINode,c1,c2,null]填充占位数组;

如果想要执行调用getExistingPathINodes(["","c1","c2","c3"], [?,?,?,?]),则应该使用[rootINode,c1,c2,null]填充占位数组。

对应于上面方法中,对getExistingPathINodes方法的调用,指定一个完整路径components(例如上面的src转化后得到的components数组),执行调用后,会根据上述举例中的规则来对inodes数组进行填充。得到一个inodes数组以后,就可以调用addChild方法向该目录FSDirectory中添加一个child。

5、向文件系统中添加一个文件

实现的方法为addFile,如下所示:

  INodeFileUnderConstruction addFile( 
                String path,
                PermissionStatus permissions,
                short replication,
                long preferredBlockSize,
                String clientName,
                String clientMachine,
                DatanodeDescriptor clientNode,
                long generationStamp)  throws IOException {
    waitForReady(); // 等待该目录已经准备好,能够被使用
    long modTime = FSNamesystem.now(); // 取当前时间
    if (!mkdirs(new Path(path).getParent().toString(), permissions, true, modTime)) { // 创建path的父目录
      return null;
    }
    INodeFileUnderConstruction newNode = new INodeFileUnderConstruction(
                                 permissions,replication,
                                 preferredBlockSize, modTime, clientName, 
                                 clientMachine, clientNode); // 创建一个新的INode文件
    synchronized (rootDir) {
      newNode = addNode(path, newNode, -1, false); // 将newNode加入到namespace中去
    }
    if (newNode == null) { // 添加失败
      NameNode.stateChangeLog.info("DIR* FSDirectory.addFile: " +"failed to add "+path
 +" to the file system");
      return null;
    }
    fsImage.getEditLog().logOpenFile(path, newNode); // 将namespace中新添加INode的事务写入到FSImage对应的EditLog日志文件中
    NameNode.stateChangeLog.debug("DIR* FSDirectory.addFile: "  +path+" is added to the file system");
    return newNode;
  }

通过该方法,我们了解到,当向namespace中添加一个文件的时候,需要通过FSImage映像获取到其所对应的EditLog日志文件,将对使namespace发生改变的事务记录下来。只要当对namespace执行的操作生效的时候,才会被记录到EditLog日志文件中,如果失败的话是不会登陆日志的。

6、向指定文件中写入块(Block)

如下所示:

  Block addBlock(String path, INode[] inodes, Block block) throws IOException {
    waitForReady();
    synchronized (rootDir) {
      INodeFile fileNode = (INodeFile) inodes[inodes.length-1]; // inodes数组中最后一个INodeFile
      // 检查配额限制,更新空间用量
      updateCount(inodes, inodes.length-1, 0, fileNode.getPreferredBlockSize()*fileNode.getReplication());
      
      // associate the new list of blocks with this file
      namesystem.blocksMap.addINode(block, fileNode); // 将该块block加入到namesystem所维护的映射表blocksMap中的fileNode文件中去
      BlockInfo blockInfo = namesystem.blocksMap.getStoredBlock(block); // 获取到namesystem所维护的blocksMap映射表中,block块的信息blockInfo
      fileNode.addBlock(blockInfo); // 将blockInfo信息添加到fileNode文件中

      NameNode.stateChangeLog.debug("DIR* FSDirectory.addFile: "
                                    + path + " with " + block
                                    + " block is added to the in-memory "
                                    + "file system");
    }
    return block;
  }


 

可见,每当需要向目录中写入块(Block)的时候,都需要向FSNamesystem的blocksMap映射表中登记,同时通过从FSNamesystem的blocksMap映射表中获取待写入块已经存在的信息,一同写入到该目录中该块所属的文件中去。

7、需要写入EditLog日志文件的操作

这里,对FSDirectory类中实现的,与namespace相关的需要写入到EditLog日志文件的事务进行总结,给出具体的操作说明。

一共涉及到12个操作,执行这些操作的时候,需要登录到EditLog日志中,如下所示:

  /**
   * 将文件添加到文件系统中
   */
  INodeFileUnderConstruction addFile(
                String path, 
                PermissionStatus permissions,
                short replication,
                long preferredBlockSize,
                String clientName,
                String clientMachine,
                DatanodeDescriptor clientNode,
                long generationStamp) throws IOException;

  /**
   * 将一个文件对应的块列表持久化到文件系统
   */
  void persistBlocks(String path, INodeFileUnderConstruction file) IOException;

  /**
   * 关闭文件
   */
  void closeFile(String path, INodeFile file) throws IOException;

  /**
   * 删除指定文件的某个块
   */
  boolean removeBlock(String path, INodeFileUnderConstruction fileNode, Block block) throws IOException;

  /**
   * 文件重命名
   */
  boolean renameTo(String src, String dst) throws QuotaExceededException;

  /**
   * 为指定文件设置副本因子
   */
  Block[] setReplication(String src, short replication, int[] oldReplication) throws IOException;

  /**
   * 为指定文件设置权限
   */
  void setPermission(String src, FsPermission permission) throws IOException;

  /**
   * 设置文件属主
   */
  void setOwner(String src, String username, String groupname) throws IOException;

  /**
   * 删除文件
   */
  INode delete(String src);

  /**
   * 创建目录
   */
  boolean mkdirs(String src, PermissionStatus permissions, boolean inheritPermission, long now) throws FileNotFoundException, QuotaExceededException;

  /**
   * 为指定目录设置配额 
   */
  void setQuota(String src, long nsQuota, long dsQuota) throws FileNotFoundException, QuotaExceededException;

  /**
   * 设置一个文件的访问时间
   */
  void setTimes(String src, INodeFile inode, long mtime, long atime, boolean force) throws IOException;

 

对于FSDirectory类,我们就分析这么多。通过上面分析,我们知道了FSDirectory类主要是管理对于属于一个FSDirectory类目录实例的文件的基本操作,而一个FSDirectory类是位于文件系统中的,对于指定的文件进行的操作都由FSDirectory类来管理维护,并对特定的事务写入到EditLog日志文件中。

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值